From f94d6136266f0e8caba11eba07ca5d3ff396cc55 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 10:03:00 -0700 Subject: [PATCH 01/87] fix(net): decode PUBLISH_DONE Unauthorized and skip a dead source's standing route (#4232) Co-authored-by: GPT-6 Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 2 +- quest/m1/dropped-sources.md | 32 +++++++++++++++++--------------- rs/moq-net/src/ietf/publish.rs | 8 ++++++++ rs/moq-net/src/model/front.rs | 12 +++++++++--- rs/moq-net/src/model/origin.rs | 21 +++++++++++++++++++++ 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/quest/m1/README.md b/quest/m1/README.md index bf58cf9600..5b22901327 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -20,7 +20,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [BBR classic ECN](/quest/m1/bbr-classic-ecn.md) - Startup and bandwidth probing respond to CE marks before the bottleneck drops packets - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached -- [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` +- [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` - [JS group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection - [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` - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine diff --git a/quest/m1/dropped-sources.md b/quest/m1/dropped-sources.md index 9fc495174f..9e29b46896 100644 --- a/quest/m1/dropped-sources.md +++ b/quest/m1/dropped-sources.md @@ -2,26 +2,28 @@ ## Goal -A track or broadcast that ends because its source ended reports the source's -own error to every consumer, locally and across a relay. `Dropped` means only -that a handle was dropped without an end, which a correct producer never does. -#4179 fixed one path (a revoked upstream subscription now reads -`Unauthorized`); the rest still surface `Dropped`. +A track that ends because its source ended reports the source's own error to +every consumer, locally and across a relay. `Dropped` means only that a handle +was dropped without an end, which a correct producer never does. A broadcast +end carries no cause since #4031, so only track errors are in scope. ## Plan -- Known sources, from #4179: a source closing, a route leaving the origin's - table, and a withdrawn source broadcast. Find each place a consumer can - observe `Dropped` and make the ending side carry its real error (an explicit - `abort` or a preserved cause), at the source rather than by remapping at the - consumer. -- moq-transport: a `PUBLISH_DONE` carrying Unauthorized arrives as - `Error::Remote(1)`. Map it to the same error lite reports. -- Regression tests per path, each failing on `Dropped` today, in-process and - over a mock session. +- Rust already maps IETF `PUBLISH_DONE` Unauthorized to `Error::Unauthorized`, + and a closed source's standing route no longer re-requests it. +- #4179 fixes revoked upstream subscriptions and #4120 preserves session death + and resumed-track errors. After #4179 reaches `main`, verify the remaining + track paths (source close, route removal, broadcast withdrawal) locally and + over a mock session, and map JS `PUBLISH_DONE` Unauthorized to #4179's shared + error. Preserve causes at the source rather than remapping `Dropped` at + consumers. Public API: none expected; error values consumers observe change. Wire: none. +## Required + +- [Unauthorized](/quest/m1/auth/unauthorized.md) - #4179 supplies shared Unauthorized errors and revoked-stream handling + ## Related -- [#4179](https://github.com/moq-dev/moq/pull/4179) - fixed the revoked-upstream path +- [#4179](https://github.com/moq-dev/moq/pull/4179) - owns the revoked-upstream path diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index 787496a600..cf93727fd1 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -124,6 +124,8 @@ use super::Version; pub(crate) enum PublishDoneStatus { /// An implementation-specific failure ended the subscription. InternalError, + /// The subscriber is no longer authorized for the track. + Unauthorized, /// The track is no longer being published. TrackEnded, } @@ -144,6 +146,7 @@ impl PublishDoneStatus { | Version::Draft21 | Version::Draft22 => match self { Self::InternalError => 0x0, + Self::Unauthorized => 0x1, Self::TrackEnded => 0x2, }, } @@ -164,6 +167,7 @@ impl PublishDone<'_> { pub(crate) fn end(&self, version: Version) -> Result<(), crate::Error> { match self.status_code { code if code == PublishDoneStatus::TrackEnded.code(version) => Ok(()), + code if code == PublishDoneStatus::Unauthorized.code(version) => Err(crate::Error::Unauthorized), // SUBSCRIPTION_ENDED: the subscription reached the end its filter asked for. // Draft-20 removed it and left 0x3 unassigned. 0x3 if matches!( @@ -731,6 +735,10 @@ mod tests { for version in [Version::Draft14, Version::Draft19, Version::Draft20, Version::Draft22] { assert!(done(0x2).end(version).is_ok(), "{version:?}"); + assert!( + matches!(done(0x1).end(version), Err(crate::Error::Unauthorized)), + "{version:?}" + ); assert!( matches!(done(0x0).end(version), Err(crate::Error::Remote(0x0))), "{version:?}" diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index df6a582a65..b56136b02a 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -202,7 +202,8 @@ pub(super) struct Front { serving_closing: bool, /// The route an upstream request is in flight through. upstream: Option, - /// Routes that refused the path while another source was serving. + /// Routes excluded from selection: they refused the path while another + /// source was serving, or their source ended while still advertised. refused: HashSet, /// Why the last candidate fell through, reported if the front ends unresolved. last_err: Option, @@ -244,7 +245,7 @@ impl Front { self.identity.pin() } - /// The routes that refused the path; the driver skips them when selecting. + /// The routes excluded from selection; the driver skips them. pub(super) fn refused_routes(&self) -> &HashSet { &self.refused } @@ -429,12 +430,16 @@ impl Front { } fn source_closed(&mut self, source: u64, actions: &mut Vec) { - let Some((serving, _)) = self.serving else { + let Some((serving, route)) = self.serving else { return; }; if serving != source { return; } + // A standing route can outlive the source it produced. Asking it again + // would re-request the broadcast that just ended; another route to the + // same publisher may still resume it. + self.refused.insert(route); self.serving = None; self.serving_closing = false; actions.push(Action::Detach { source }); @@ -813,6 +818,7 @@ mod tests { front.step(Event::SourceClosed { source: 100 }), &[Action::Detach { source: 100 }, Action::Reselect], ); + assert!(front.refused_routes().contains(&1)); assert_actions( front.step(Event::Selected { best: Some(remote(3, 10)), diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index ae1c473d21..dfa1089b61 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -6225,6 +6225,27 @@ mod tests { assert!(end.is_none(), "a group followed the final one"); } + /// A standing route outlives the source it produced: the front ends instead of + /// asking that route for the broadcast that just closed. + #[tokio::test] + async fn a_closed_source_is_not_requested_again_from_its_standing_route() { + let producer = origin(1).produce(); + let server = producer + .dynamic("room", Route::default().with_hops(hops(&[10]))) + .unwrap(); + let pending = producer.consume().request_broadcast("room/alice"); + let source = broadcast::Info::new().produce(); + queued(&server).await.accept(&source); + let resolved = pending.await.unwrap(); + + source.close(); + settle(|| resolved.is_closed()).await; + assert!( + server.poll_requested_broadcast(&kio::Waiter::noop()).is_pending(), + "the closed source was requested again" + ); + } + /// An origin front drops the source track as soon as its last reader leaves, /// so the publisher's `unused()` resolves far below `TRACK_IDLE_LINGER`. /// Cached groups stay on the front for the linger; a returning reader From 7de2a7b2b39884f3a5473ee1aca91bcbc23105e3 Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:10:00 +0000 Subject: [PATCH 02/87] chore: release (#4222) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 92 +++++++++++++++++------------------ Cargo.toml | 40 +++++++-------- rs/hang/CHANGELOG.md | 6 +++ rs/hang/Cargo.toml | 2 +- rs/kio/CHANGELOG.md | 10 ++++ rs/kio/Cargo.toml | 2 +- rs/libmoq/CHANGELOG.md | 11 +++++ rs/libmoq/Cargo.toml | 2 +- rs/moq-archive/CHANGELOG.md | 6 +++ rs/moq-archive/Cargo.toml | 2 +- rs/moq-audio/CHANGELOG.md | 6 +++ rs/moq-audio/Cargo.toml | 2 +- rs/moq-auth/CHANGELOG.md | 6 +++ rs/moq-auth/Cargo.toml | 2 +- rs/moq-binary/CHANGELOG.md | 6 +++ rs/moq-binary/Cargo.toml | 2 +- rs/moq-boy/CHANGELOG.md | 6 +++ rs/moq-boy/Cargo.toml | 2 +- rs/moq-cli/CHANGELOG.md | 6 +++ rs/moq-cli/Cargo.toml | 2 +- rs/moq-e2ee/CHANGELOG.md | 6 +++ rs/moq-e2ee/Cargo.toml | 2 +- rs/moq-ffi/CHANGELOG.md | 11 +++++ rs/moq-ffi/Cargo.toml | 2 +- rs/moq-gst/CHANGELOG.md | 7 +++ rs/moq-gst/Cargo.toml | 2 +- rs/moq-hls/CHANGELOG.md | 7 +++ rs/moq-hls/Cargo.toml | 2 +- rs/moq-json/CHANGELOG.md | 6 +++ rs/moq-json/Cargo.toml | 2 +- rs/moq-loc/CHANGELOG.md | 6 +++ rs/moq-loc/Cargo.toml | 2 +- rs/moq-msf/CHANGELOG.md | 6 +++ rs/moq-msf/Cargo.toml | 2 +- rs/moq-mux/CHANGELOG.md | 11 +++++ rs/moq-mux/Cargo.toml | 2 +- rs/moq-net/CHANGELOG.md | 19 ++++++++ rs/moq-net/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 16 ++++++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-room/CHANGELOG.md | 6 +++ rs/moq-room/Cargo.toml | 2 +- rs/moq-rtc/CHANGELOG.md | 6 +++ rs/moq-rtc/Cargo.toml | 2 +- rs/moq-rtmp/CHANGELOG.md | 6 +++ rs/moq-rtmp/Cargo.toml | 2 +- rs/moq-srt/CHANGELOG.md | 6 +++ rs/moq-srt/Cargo.toml | 2 +- rs/moq-stats/CHANGELOG.md | 6 +++ rs/moq-stats/Cargo.toml | 2 +- rs/moq-tokio/CHANGELOG.md | 10 ++++ rs/moq-tokio/Cargo.toml | 2 +- rs/moq-transcode/CHANGELOG.md | 6 +++ rs/moq-transcode/Cargo.toml | 2 +- rs/moq-uring/CHANGELOG.md | 6 +++ rs/moq-uring/Cargo.toml | 2 +- rs/moq-video/CHANGELOG.md | 10 ++++ rs/moq-video/Cargo.toml | 2 +- 58 files changed, 314 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73b7247c95..43ba79c076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2835,13 +2835,13 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.6" +version = "0.21.7" dependencies = [ "anyhow", "bytes", "derive_more 2.1.1", "hex", - "kio 0.6.0", + "kio 0.6.1", "lazy_static", "moq-json", "moq-mux", @@ -3802,7 +3802,7 @@ dependencies = [ [[package]] name = "kio" -version = "0.6.0" +version = "0.6.1" dependencies = [ "criterion", "futures", @@ -3884,13 +3884,13 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.6" +version = "0.6.7" dependencies = [ "anyhow", "bytes", "cbindgen", "hang", - "kio 0.6.0", + "kio 0.6.1", "moq-audio", "moq-json", "moq-loc", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.6" +version = "0.0.7" dependencies = [ "async-trait", "bytes", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.5" +version = "0.1.6" dependencies = [ "block2 0.6.2", "bytes", @@ -4180,7 +4180,7 @@ dependencies = [ "dispatch2", "fixed-resample", "hang", - "kio 0.6.0", + "kio 0.6.1", "moq-mux", "moq-net", "objc2 0.6.4", @@ -4203,14 +4203,14 @@ dependencies = [ [[package]] name = "moq-auth" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "aws-lc-rs", "axum", "base64 0.23.1", "jsonwebtoken", - "kio 0.6.0", + "kio 0.6.1", "moq-auth", "moq-pattern", "p256", @@ -4251,10 +4251,10 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.5" +version = "0.1.6" dependencies = [ "bytes", - "kio 0.6.0", + "kio 0.6.1", "moq-flate", "moq-net", "thiserror 2.0.21", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.6" +version = "0.5.7" dependencies = [ "anyhow", "boytacean", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.6" +version = "0.12.7" dependencies = [ "anyhow", "axum", @@ -4321,14 +4321,14 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.6" +version = "0.0.7" dependencies = [ "aws-lc-rs", "base64 0.23.1", "bytes", "criterion", "hex", - "kio 0.6.0", + "kio 0.6.1", "moq-net", "serde_json", "thiserror 2.0.21", @@ -4340,12 +4340,12 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.6" +version = "0.4.7" dependencies = [ "bytes", "getrandom 0.4.3", "hang", - "kio 0.6.0", + "kio 0.6.1", "moq-audio", "moq-json", "moq-mux", @@ -4374,7 +4374,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.6" +version = "0.4.7" dependencies = [ "anyhow", "bytes", @@ -4393,13 +4393,13 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.6" +version = "0.5.7" dependencies = [ "axum", "bytes", "hang", "humantime", - "kio 0.6.0", + "kio 0.6.1", "m3u8-rs", "moq-mux", "moq-net", @@ -4417,12 +4417,12 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.5.3" +version = "0.5.4" dependencies = [ "bytes", "criterion", "json-patch", - "kio 0.6.0", + "kio 0.6.1", "moq-flate", "moq-net", "serde", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.14" +version = "0.2.15" dependencies = [ "bytes", "moq-net", @@ -4443,7 +4443,7 @@ dependencies = [ [[package]] name = "moq-msf" -version = "0.5.0" +version = "0.5.1" dependencies = [ "serde", "serde_json", @@ -4452,7 +4452,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.6" +version = "0.10.7" dependencies = [ "anyhow", "base64 0.23.1", @@ -4460,7 +4460,7 @@ dependencies = [ "futures", "h264-parser", "hang", - "kio 0.6.0", + "kio 0.6.1", "memchr", "moq-binary", "moq-json", @@ -4489,14 +4489,14 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.5" +version = "0.3.6" dependencies = [ "arrayvec", "bytes", "criterion", "futures", "getrandom 0.4.3", - "kio 0.6.0", + "kio 0.6.1", "loom", "moq-pattern", "num_enum", @@ -4606,7 +4606,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.6" +version = "0.15.7" dependencies = [ "anyhow", "axum", @@ -4649,9 +4649,9 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.6" +version = "0.2.7" dependencies = [ - "kio 0.6.0", + "kio 0.6.1", "moq-auth", "moq-json", "moq-net", @@ -4663,7 +4663,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.6" +version = "0.3.7" dependencies = [ "aws-lc-rs", "axum", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.6" +version = "0.3.7" dependencies = [ "anyhow", "byteorder", @@ -4731,7 +4731,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.6" +version = "0.3.7" dependencies = [ "bytes", "futures", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.6" +version = "0.2.7" dependencies = [ "futures", "moq-json", @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.17" +version = "0.19.18" dependencies = [ "anyhow", "bytes", @@ -4815,12 +4815,12 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "bytes", "hang", - "kio 0.6.0", + "kio 0.6.1", "moq-mux", "moq-net", "moq-tokio", @@ -4834,14 +4834,14 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.7" +version = "0.0.8" dependencies = [ "anyhow", "bytes", "criterion", "http", "io-uring", - "kio 0.6.0", + "kio 0.6.1", "libc", "moq-net", "moq-noq-proto", @@ -4891,7 +4891,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "ash", @@ -7833,9 +7833,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -7854,9 +7854,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 5f9dcd4431..b6da7a249e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,14 +121,14 @@ dispatch2 = "0.3.1" flate2 = { version = "1.1", default-features = false } futures = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } -hang = { version = "0.21.6", path = "rs/hang" } +hang = { version = "0.21.7", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" humantime = "2.3" io-uring = "0.7.14" jsonwebtoken = "11" -kio = { version = "0.6.0", path = "rs/kio" } +kio = { version = "0.6.1", path = "rs/kio" } lazy_static = "1.5" libc = "0.2" libloading = "0.9" @@ -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.5", path = "rs/moq-audio", default-features = false } -moq-auth = { version = "0.1.3", path = "rs/moq-auth" } -moq-binary = { version = "0.1.5", path = "rs/moq-binary" } +moq-audio = { version = "0.1.6", path = "rs/moq-audio", default-features = false } +moq-auth = { version = "0.1.4", path = "rs/moq-auth" } +moq-binary = { version = "0.1.6", path = "rs/moq-binary" } moq-flate = { version = "0.2.0", path = "rs/moq-flate" } -moq-hls = { version = "0.5.6", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.5.3", path = "rs/moq-json" } -moq-loc = { version = "0.2.14", path = "rs/moq-loc" } -moq-msf = { version = "0.5.0", path = "rs/moq-msf" } -moq-mux = { version = "0.10.6", path = "rs/moq-mux" } -moq-net = { version = "0.3.5", path = "rs/moq-net" } +moq-hls = { version = "0.5.7", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.5.4", path = "rs/moq-json" } +moq-loc = { version = "0.2.15", path = "rs/moq-loc" } +moq-msf = { version = "0.5.1", path = "rs/moq-msf" } +moq-mux = { version = "0.10.7", path = "rs/moq-mux" } +moq-net = { version = "0.3.6", 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.1", default-features = false } @@ -158,23 +158,23 @@ moq-noq-udp = "1.3.1" # used by moq-video on Linux. moq-nvenc = { version = "0.1.2", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.6", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.6", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.6", path = "rs/moq-rtmp" } +moq-relay = { version = "0.15.7", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.7", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.7", path = "rs/moq-rtmp" } moq-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.6", path = "rs/moq-srt" } -moq-stats = { version = "0.2.6", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.17", path = "rs/moq-tokio", default-features = false } +moq-srt = { version = "0.3.7", path = "rs/moq-srt" } +moq-stats = { version = "0.2.7", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.18", path = "rs/moq-tokio", default-features = false } # Default features off on moq-transcode and moq-video so each workspace consumer # chooses native codecs, OpenH264, and rendering explicitly. Both crates still # provide working native plus software defaults when depended on directly. # VAAPI is opt-in everywhere; its decoder is hardware-validated, while its # encoder is not yet. -moq-transcode = { version = "0.1.5", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.6", path = "rs/moq-transcode", default-features = false } # default-features off (the noq backend) so the consumer picks which QUIC # stack the io_uring path compiles; cargo features are additive, so a default-on # backend could not be opted out of. -moq-uring = { version = "0.0.7", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.8", 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.5", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.6", 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/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 8eb3f06886..cb6915e4f3 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.7](https://github.com/moq-dev/moq/compare/hang-v0.21.6...hang-v0.21.7) - 2026-09-26 + +### Added + +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + ## [0.21.6](https://github.com/moq-dev/moq/compare/hang-v0.21.5...hang-v0.21.6) - 2026-09-26 ### Other diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 3a91b6cc15..0f8fc27b8d 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.21.6" +version = "0.21.7" edition = "2024" rust-version.workspace = true diff --git a/rs/kio/CHANGELOG.md b/rs/kio/CHANGELOG.md index 35f0c0a375..7ddc2611bb 100644 --- a/rs/kio/CHANGELOG.md +++ b/rs/kio/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1](https://github.com/moq-dev/moq/compare/kio-v0.6.0...kio-v0.6.1) - 2026-09-26 + +### Fixed + +- *(moq-net)* serve only the lite routes a request woke, and bound kio waiter lists ([#4216](https://github.com/moq-dev/moq/pull/4216)) + +### Other + +- *(kio)* keep a parked waiter that quiet lists still hold ([#4240](https://github.com/moq-dev/moq/pull/4240)) + ## [0.6.0](https://github.com/moq-dev/moq/compare/kio-v0.5.9...kio-v0.6.0) - 2026-09-23 ### Added diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index aed9d6849d..9ad60223b5 100644 --- a/rs/kio/Cargo.toml +++ b/rs/kio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.6.0" +version = "0.6.1" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index 729971df95..deb91a00f9 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.7](https://github.com/moq-dev/moq/compare/libmoq-v0.6.6...libmoq-v0.6.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) +- *(mux)* forward importer discontinuities through publishers ([#4239](https://github.com/moq-dev/moq/pull/4239)) + +### Fixed + +- *(libmoq)* write moq.h and moq.pc into OUT_DIR only ([#4243](https://github.com/moq-dev/moq/pull/4243)) + ## [0.6.6](https://github.com/moq-dev/moq/compare/libmoq-v0.6.5...libmoq-v0.6.6) - 2026-09-26 ### Other diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 1333199898..f175436a0e 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.6.6" +version = "0.6.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-archive/CHANGELOG.md b/rs/moq-archive/CHANGELOG.md index 32ca63360b..131e05651e 100644 --- a/rs/moq-archive/CHANGELOG.md +++ b/rs/moq-archive/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.7](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.6...moq-archive-v0.0.7) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.0.6](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.5...moq-archive-v0.0.6) - 2026-09-26 ### Other diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index 99608653d3..855a28d3b3 100644 --- a/rs/moq-archive/Cargo.toml +++ b/rs/moq-archive/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.6" +version = "0.0.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index 1f1528d686..dcc8e8dd67 100644 --- a/rs/moq-audio/CHANGELOG.md +++ b/rs/moq-audio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.6](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.5...moq-audio-v0.1.6) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net, hang, moq-mux + ## [0.1.5](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.4...moq-audio-v0.1.5) - 2026-09-26 ### Other diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 8e2dd4c426..f6126c6414 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-auth/CHANGELOG.md b/rs/moq-auth/CHANGELOG.md index b765d35fab..c8a00c9909 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.4](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.3...moq-auth-v0.1.4) - 2026-09-26 + +### Fixed + +- *(auth)* keep accepted grants on fixed expiry deadlines ([#4237](https://github.com/moq-dev/moq/pull/4237)) + ## [0.1.3](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.2...moq-auth-v0.1.3) - 2026-09-26 ### Other diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 4ca32b17dc..71db9a0d87 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.3" +version = "0.1.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-binary/CHANGELOG.md b/rs/moq-binary/CHANGELOG.md index 74f69c0200..0ede8f673a 100644 --- a/rs/moq-binary/CHANGELOG.md +++ b/rs/moq-binary/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.6](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.5...moq-binary-v0.1.6) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net + ## [0.1.5](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.4...moq-binary-v0.1.5) - 2026-09-26 ### Other diff --git a/rs/moq-binary/Cargo.toml b/rs/moq-binary/Cargo.toml index f4b4ea44fb..a57c90cf24 100644 --- a/rs/moq-binary/Cargo.toml +++ b/rs/moq-binary/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index bedc2bcc32..141ad2a5d5 100644 --- a/rs/moq-boy/CHANGELOG.md +++ b/rs/moq-boy/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.7](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.6...moq-boy-v0.5.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.5.6](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.5...moq-boy-v0.5.6) - 2026-09-26 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index d2407abfc2..3609644d0d 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" keywords = ["moq", "gameboy", "streaming", "emulator", "live"] categories = ["multimedia::video", "emulators", "network-programming"] -version = "0.5.6" +version = "0.5.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 55370f5586..7dda0a76c0 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.7](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.6...moq-cli-v0.12.7) - 2026-09-26 + +### Fixed + +- *(cli)* keep delayed playback at the live edge ([#4241](https://github.com/moq-dev/moq/pull/4241)) + ## [0.12.6](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.5...moq-cli-v0.12.6) - 2026-09-26 ### Other diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 6065077ddd..020952cb52 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.12.6" +version = "0.12.7" 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-e2ee/CHANGELOG.md b/rs/moq-e2ee/CHANGELOG.md index 4172aa7f0b..f72b7acdce 100644 --- a/rs/moq-e2ee/CHANGELOG.md +++ b/rs/moq-e2ee/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.7](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.6...moq-e2ee-v0.0.7) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net + ## [0.0.6](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.5...moq-e2ee-v0.0.6) - 2026-09-26 ### Other diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index e87619e285..faabbf35f5 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.6" +version = "0.0.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index 9d52f99f9e..ba27f82e16 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.7](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.6...moq-ffi-v0.4.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) +- *(mux)* forward importer discontinuities through publishers ([#4239](https://github.com/moq-dev/moq/pull/4239)) + +### Other + +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) + ## [0.4.6](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.5...moq-ffi-v0.4.6) - 2026-09-26 ### Other diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 7b822275cc..8c1d26b37b 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.6" +version = "0.4.7" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 5461b5432c..2d816fb588 100644 --- a/rs/moq-gst/CHANGELOG.md +++ b/rs/moq-gst/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.7](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.6...moq-gst-v0.4.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) +- *(mux)* forward importer discontinuities through publishers ([#4239](https://github.com/moq-dev/moq/pull/4239)) + ## [0.4.6](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.5...moq-gst-v0.4.6) - 2026-09-26 ### Other diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 40439c0c0b..9aaca91417 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.6" +version = "0.4.7" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index 967b0c349e..b3af4e69f6 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.7](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.6...moq-hls-v0.5.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + ## [0.5.6](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.5...moq-hls-v0.5.6) - 2026-09-26 ### Other diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 090727281d..4b07c4b955 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.6" +version = "0.5.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index eb07288e02..3500ad22c6 100644 --- a/rs/moq-json/CHANGELOG.md +++ b/rs/moq-json/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.4](https://github.com/moq-dev/moq/compare/moq-json-v0.5.3...moq-json-v0.5.4) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net + ## [0.5.3](https://github.com/moq-dev/moq/compare/moq-json-v0.5.2...moq-json-v0.5.3) - 2026-09-26 ### Other diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 62944fcdb3..6d79a3f605 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.3" +version = "0.5.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-loc/CHANGELOG.md b/rs/moq-loc/CHANGELOG.md index 247d789404..b2f1a45f9b 100644 --- a/rs/moq-loc/CHANGELOG.md +++ b/rs/moq-loc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.15](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.14...moq-loc-v0.2.15) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.2.14](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.13...moq-loc-v0.2.14) - 2026-09-26 ### Other diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index 11d260f4be..b30f2a704b 100644 --- a/rs/moq-loc/Cargo.toml +++ b/rs/moq-loc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.14" +version = "0.2.15" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-msf/CHANGELOG.md b/rs/moq-msf/CHANGELOG.md index 03c0126281..714fe9efd4 100644 --- a/rs/moq-msf/CHANGELOG.md +++ b/rs/moq-msf/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.1](https://github.com/moq-dev/moq/compare/moq-msf-v0.5.0...moq-msf-v0.5.1) - 2026-09-26 + +### Added + +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + ## [0.5.0](https://github.com/moq-dev/moq/compare/moq-msf-v0.4.2...moq-msf-v0.5.0) - 2026-09-23 ### Added diff --git a/rs/moq-msf/Cargo.toml b/rs/moq-msf/Cargo.toml index 81ec871fe5..b93878fd68 100644 --- a/rs/moq-msf/Cargo.toml +++ b/rs/moq-msf/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.0" +version = "0.5.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index bf54e97eb3..096436aa1b 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.7](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.6...moq-mux-v0.10.7) - 2026-09-26 + +### Added + +- *(mux)* forward importer discontinuities through publishers ([#4239](https://github.com/moq-dev/moq/pull/4239)) +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + +### Other + +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) + ## [0.10.6](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.5...moq-mux-v0.10.6) - 2026-09-26 ### Other diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index f3a46c917e..46cad13147 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.6" +version = "0.10.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 607907fe5a..3bd5b21584 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.6](https://github.com/moq-dev/moq/compare/moq-net-v0.3.5...moq-net-v0.3.6) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + +### Fixed + +- *(net)* reject undeclared subscription ends ([#4231](https://github.com/moq-dev/moq/pull/4231)) +- *(net)* retain retired counters in host snapshots ([#4238](https://github.com/moq-dev/moq/pull/4238)) +- *(net)* end a track with its session's error when the session dies ([#4120](https://github.com/moq-dev/moq/pull/4120)) + +### Other + +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) +- *(moq-net)* model the dash aggregator's stats load in the session bench ([#4233](https://github.com/moq-dev/moq/pull/4233)) +- *(kio)* keep a parked waiter that quiet lists still hold ([#4240](https://github.com/moq-dev/moq/pull/4240)) +- *(moq-net)* bench lite-06, smoke-run benches nightly, refresh perf quests ([#4229](https://github.com/moq-dev/moq/pull/4229)) + ## [0.3.5](https://github.com/moq-dev/moq/compare/moq-net-v0.3.4...moq-net-v0.3.5) - 2026-09-26 ### Added diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 7af52d3da3..4c29366281 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.5" +version = "0.3.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index f0ad948636..191bfeb6a0 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.7](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.6...moq-relay-v0.15.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + +### Fixed + +- *(auth)* keep accepted grants on fixed expiry deadlines ([#4237](https://github.com/moq-dev/moq/pull/4237)) + +### Other + +- origin narrowing joins auth, drop relay peer set, plan hop-list routing ([#4158](https://github.com/moq-dev/moq/pull/4158)) +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) +- *(relay)* run the outage lease test on the real clock ([#4244](https://github.com/moq-dev/moq/pull/4244)) + ## [0.15.6](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.5...moq-relay-v0.15.6) - 2026-09-26 ### Other diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 1dc1a0abcb..76f663270a 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.15.6" +version = "0.15.7" 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-room/CHANGELOG.md b/rs/moq-room/CHANGELOG.md index b28e108eae..20d2c5c7b9 100644 --- a/rs/moq-room/CHANGELOG.md +++ b/rs/moq-room/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.7](https://github.com/moq-dev/moq/compare/moq-room-v0.2.6...moq-room-v0.2.7) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net, moq-auth, moq-json + ## [0.2.6](https://github.com/moq-dev/moq/compare/moq-room-v0.2.5...moq-room-v0.2.6) - 2026-09-26 ### Other diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index 2b0df1e037..244068734d 100644 --- a/rs/moq-room/Cargo.toml +++ b/rs/moq-room/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.6" +version = "0.2.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index e96565dce7..5a0eb86d6a 100644 --- a/rs/moq-rtc/CHANGELOG.md +++ b/rs/moq-rtc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.7](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.6...moq-rtc-v0.3.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.3.6](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.5...moq-rtc-v0.3.6) - 2026-09-26 ### Other diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index ea23c0717f..57db95df4d 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.6" +version = "0.3.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtmp/CHANGELOG.md b/rs/moq-rtmp/CHANGELOG.md index b5fb3d8b25..872863d765 100644 --- a/rs/moq-rtmp/CHANGELOG.md +++ b/rs/moq-rtmp/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.7](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.6...moq-rtmp-v0.3.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.3.6](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.5...moq-rtmp-v0.3.6) - 2026-09-26 ### Other diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index 1335fb73aa..b7d4d371f0 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/moq-dev/moq" # src/rml/LICENSE and applies to that module regardless of which option you pick. license = "MIT OR Apache-2.0" -version = "0.3.6" +version = "0.3.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-srt/CHANGELOG.md b/rs/moq-srt/CHANGELOG.md index eeb7c8ce93..2daf6a367d 100644 --- a/rs/moq-srt/CHANGELOG.md +++ b/rs/moq-srt/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.7](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.6...moq-srt-v0.3.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.3.6](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.5...moq-srt-v0.3.6) - 2026-09-26 ### Other diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index a6ed2225bc..642cd04f0c 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.6" +version = "0.3.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index 35cb95084d..55e8abedf3 100644 --- a/rs/moq-stats/CHANGELOG.md +++ b/rs/moq-stats/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.7](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.6...moq-stats-v0.2.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.2.6](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.5...moq-stats-v0.2.6) - 2026-09-26 ### Other diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index 3df8fb6278..267ae90289 100644 --- a/rs/moq-stats/Cargo.toml +++ b/rs/moq-stats/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.6" +version = "0.2.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index 9feb940d26..1b43f4157c 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.18](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.17...moq-tokio-v0.19.18) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + +### Fixed + +- *(net)* end a track with its session's error when the session dies ([#4120](https://github.com/moq-dev/moq/pull/4120)) + ## [0.19.17](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.16...moq-tokio-v0.19.17) - 2026-09-26 ### Other diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 14e0a53689..34865d2707 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.19.17" +version = "0.19.18" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index ee90a8c0b6..506e11faf3 100644 --- a/rs/moq-transcode/CHANGELOG.md +++ b/rs/moq-transcode/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.6](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.5...moq-transcode-v0.1.6) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.1.5](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.4...moq-transcode-v0.1.5) - 2026-09-26 ### Other diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index f58c3ec02d..521e559ce7 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-uring/CHANGELOG.md b/rs/moq-uring/CHANGELOG.md index 80c3577805..bc5972bfab 100644 --- a/rs/moq-uring/CHANGELOG.md +++ b/rs/moq-uring/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.8](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.7...moq-uring-v0.0.8) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net + ## [0.0.7](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.6...moq-uring-v0.0.7) - 2026-09-26 ### Other diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 10fce20851..cc48a21633 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.7" +version = "0.0.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 3914d39499..df045937e8 100644 --- a/rs/moq-video/CHANGELOG.md +++ b/rs/moq-video/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.6](https://github.com/moq-dev/moq/compare/moq-video-v0.1.5...moq-video-v0.1.6) - 2026-09-26 + +### Added + +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + +### Other + +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) + ## [0.1.5](https://github.com/moq-dev/moq/compare/moq-video-v0.1.4...moq-video-v0.1.5) - 2026-09-26 ### Fixed diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index a2b4fe45ee..4a63a86d17 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version.workspace = true From 7910cc554eca7649233f2f8a11f3e39ab7c84c00 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 10:24:22 -0700 Subject: [PATCH 03/87] docs(quest): plan release size quests (#4257) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 10 +++++- quest/m1/docker-slim.md | 27 +++++++++++++++ quest/m1/ffi-size-profile.md | 30 +++++++++++++++++ quest/m1/go-mirror-delivery.md | 27 +++++++++++++++ quest/m1/js-bundle-trims.md | 41 +++++++++++++++++++++++ quest/m1/publish-lazy-file.md | 30 +++++++++++++++++ quest/m1/relay-iroh-opt-in.md | 30 +++++++++++++++++ quest/m1/release-profile.md | 51 +++++++++++++++++++++++++++++ quest/m1/release-size.md | 51 ----------------------------- quest/m1/size-report.md | 60 ++++++++++++++++++++++++++++++++++ quest/m1/wasm-opt.md | 26 +++++++++++++++ 11 files changed, 331 insertions(+), 52 deletions(-) create mode 100644 quest/m1/docker-slim.md create mode 100644 quest/m1/ffi-size-profile.md create mode 100644 quest/m1/go-mirror-delivery.md create mode 100644 quest/m1/js-bundle-trims.md create mode 100644 quest/m1/publish-lazy-file.md create mode 100644 quest/m1/relay-iroh-opt-in.md create mode 100644 quest/m1/release-profile.md delete mode 100644 quest/m1/release-size.md create mode 100644 quest/m1/size-report.md create mode 100644 quest/m1/wasm-opt.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 5b22901327..b97087b518 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -129,7 +129,15 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [ID3 catalog section](/quest/m1/id3.md) - timed ID3 as a first-class container-neutral catalog section - [fMP4 emsg](/quest/m1/emsg.md) - event messages survive fMP4 import, and the timed-metadata contract ID3, SCTE-35, and FLV script tags share is settled with them - [FLV script tags](/quest/m1/flv-script.md) - onMetaData and AMF data messages survive RTMP and FLV import -- [Release size](/quest/m1/release-size.md) - the release scripts' LTO exports become the workspace release profile, and a nightly report shows what each moq-ffi build ships +- [Release profile](/quest/m1/release-profile.md) - every release build gets fat LTO, one codegen unit, and stripping from the workspace profile instead of three script exports +- [Size report](/quest/m1/size-report.md) - a nightly job reports every shipped artifact's size, native and JS, and alerts when one grows +- [Publish lazy file source](/quest/m1/publish-lazy-file.md) - a camera or screen `` stops downloading mediabunny's ~99 KB gzip +- [JS bundle trims](/quest/m1/js-bundle-trims.md) - minified worklets, no bowser, split pako, and lazy qmux and captions +- [Slim Docker images](/quest/m1/docker-slim.md) - images carry only the package's nix closure, not ~170 MiB of nixos/nix +- [Bindings size profile](/quest/m1/ffi-size-profile.md) - a benchmark decides whether the moq-ffi builds ship at opt-level "s", which halves the dylib +- [wasm-opt](/quest/m1/wasm-opt.md) - `just wasm` size-optimizes the moq-wasm module with binaryen +- [Go mirror delivery](/quest/m1/go-mirror-delivery.md) - the Go binding's staticlibs stop growing git history by ~210 MiB per release +- [Relay iroh opt-in](/quest/m1/relay-iroh-opt-in.md) - moq-relay drops iroh from its defaults and shipped builds, while moq-cli keeps it for P2P - [Dart on iOS](/quest/m1/dart-ios.md) - prove the shipped iOS native asset actually loads on a device, which no CI can - [libmoq shutdown](/quest/m1/libmoq-shutdown.md) - OBS exits cleanly with the plugin loaded: a C ABI `moq_shutdown` stops the libmoq thread before the module is unloaded - [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - a Kotlin/JVM program exits cleanly whatever the moq-ffi runtime thread is doing, like Python does since #3766 diff --git a/quest/m1/docker-slim.md b/quest/m1/docker-slim.md new file mode 100644 index 0000000000..1b8067ef79 --- /dev/null +++ b/quest/m1/docker-slim.md @@ -0,0 +1,27 @@ +# [S] Slim Docker images + +## Goal + +Each published image contains only its package's nix closure. Today the +final stage of `Dockerfile` is `nixos/nix:latest`, which puts about 170 MiB +of nix runtime into every image. `moqdev/moq-relay` is about 186 MiB, and +`moqdev/moq-token-cli` is 172 MiB for a 1.6 MiB binary. + +## Plan + +Decided in planning: use a `scratch` final stage holding the closure and the +binary, with no shell. Losing `docker exec ... sh` debugging is accepted. + +Guidance: + +- `entry.sh` needs `/bin/sh`, and exec-form `ENTRYPOINT` doesn't expand build + args. In the builder stage, create a fixed-name symlink to the package's + binary and point `ENTRYPOINT` at it. +- Check what the runtime needs outside the closure: CA roots for outbound + TLS (cluster peers, auth fetches), `/tmp`, and a non-root user if the + current image has one. The package's closure should carry them; verify + outbound TLS works rather than assume it. +- Decide what happens to the no-package `sh` default, which only makes + sense with a shell. +- Report before and after image sizes for each published image, and update + any docs that `docker exec` into a shell. diff --git a/quest/m1/ffi-size-profile.md b/quest/m1/ffi-size-profile.md new file mode 100644 index 0000000000..70a00e7363 --- /dev/null +++ b/quest/m1/ffi-size-profile.md @@ -0,0 +1,30 @@ +# [M] Bindings size profile + +## Goal + +Decide, with a benchmark, whether the moq-ffi builds for Swift, Android, +Dart, Go, and Python ship at `opt-level = "s"`. On aarch64-apple-darwin it +halved the stripped dylib from 20.0 MiB to 10.1 MiB (fat LTO, 1 CGU) and +shrank `__text` from 18.2 MB to 8.5 MB. The throughput cost is unmeasured. + +## Plan + +Decided in planning: adopt it only if the benchmark shows the cost is +negligible. The relay and CLI stay at opt-level 3 regardless. + +Guidance: + +- The `cc` crate builds the C dependencies at the profile's opt-level too: + aws-lc, openh264, and libopus. Hold the codec and crypto crates at + opt-level 3 with per-package overrides, and compare against building + everything at "s". +- Measure what the bindings actually do: publish and subscribe throughput + through moq-ffi, plus audio and video encode and decode. Wire the benchmark + into CI, at least nightly. +- If it's adopted, use a named profile (for example `[profile.ffi]`, + inheriting release) that every binding build path selects, so the relay + and self-builders keep opt-level 3. + +## Required + +- [Release profile](/quest/m1/release-profile.md) - the baseline this compares against diff --git a/quest/m1/go-mirror-delivery.md b/quest/m1/go-mirror-delivery.md new file mode 100644 index 0000000000..4575713a8b --- /dev/null +++ b/quest/m1/go-mirror-delivery.md @@ -0,0 +1,27 @@ +# [M] Go mirror delivery + +## Goal + +A recommendation, with a prototype, for delivering the Go binding's +staticlibs without committing them to git. `moq-dev/moq-go-ffi` commits +them straight into git today, at 60.2 MiB for linux, 52.7 for windows, and +39.1 for darwin. That puts the largest file at 60% of GitHub's 100 MB push +limit, and each release adds about 210 MiB of history. + +## Plan + +- Evaluate what keeps `go get` working without a separate download step, and + what doesn't: + - one module per platform + - history that squashes or orphans old releases + - a module proxy we host + - release assets fetched by a build step + Git LFS is out, because the Go module proxy doesn't serve LFS objects. +- The release profile quest shrinks the staticlibs (121 to 41 MiB on macOS), + which buys headroom but doesn't stop the history growth. +- Deliverable: the chosen approach recorded here, and a follow-up quest to + implement it. Split this if the prototype turns out large. + +## Related + +- [Release profile](/quest/m1/release-profile.md) - shrinks what the mirror carries diff --git a/quest/m1/js-bundle-trims.md b/quest/m1/js-bundle-trims.md new file mode 100644 index 0000000000..e0d85fa0db --- /dev/null +++ b/quest/m1/js-bundle-trims.md @@ -0,0 +1,41 @@ +# [M] JS bundle trims + +## Goal + +The watch and publish elements stop shipping bytes a consumer's bundler +cannot remove. Measured in bundled, minified form (2026-09-26): + +- The inlined worklets are built unminified, and code inside a string can't + be minified by the consumer. The watch render worklet is 25.9 KB against + 10.1 KB minified, and the publish capture worklet is 4.7 KB against 2.3 KB. +- `bowser` costs 37 KB for three checks in + `js/net/src/connection/browser.ts`: the WebKit engine, iOS, and Firefox 153 + or later. +- `@moq/flate` imports all of pako (42 KB) where the deflate and inflate + halves have their own entry points. +- `@moq/qmux` (39 KB, the WebSocket fallback) and `media-captions` (15 KB, + watch only) load eagerly even when unused. + +## Plan + +Decided in planning: all four trims are in scope. Mediabunny is its own +quest. + +Guidance: + +- Worklets: minify them in `js/common/vite-plugin-worklet.ts`. +- bowser: write a small user-agent check with unit tests over real UA + strings. Chrome's UA contains `AppleWebKit` too, and iPadOS Safari reports + macOS, so test Chrome and Edge on macOS, iPadOS Safari, iOS Chrome, and + Firefox 152 and 153. +- pako: measure before keeping the change. If a caller needs both halves, + the split buys nothing. +- Lazy qmux: if connect races WebSocket against WebTransport, a lazy import + puts module loading on the fallback path. Measure the connect time it adds, + and drop this trim if the fallback gets noticeably slower. +- Report the before and after first-load sizes in the PR. + +## Related + +- [Publish lazy file source](/quest/m1/publish-lazy-file.md) - the largest JS saving, landed separately +- [Size report](/quest/m1/size-report.md) - tracks these entries nightly diff --git a/quest/m1/publish-lazy-file.md b/quest/m1/publish-lazy-file.md new file mode 100644 index 0000000000..ea1fd906fe --- /dev/null +++ b/quest/m1/publish-lazy-file.md @@ -0,0 +1,30 @@ +# [S] Publish loads mediabunny only for file sources + +## Goal + +A `` element that captures a camera or screen no longer +downloads mediabunny. Today `js/publish/src/element.ts` statically imports +the sources, and `source/file.ts` imports `ALL_FORMATS` from mediabunny, so +every publish element pays about 99 KB gzip (379 KB minified). Of the element's 218 KB +gzip first load, that is the largest avoidable piece. + +## Plan + +Decided in planning: + +- Load the file source with a dynamic `import()` when a file source is + selected. +- Keep `ALL_FORMATS`, so any container mediabunny reads still works. Leave + mediabunny bundled into publish's dist rather than making it external. + +Guidance: + +- The public `@moq/publish` exports can still expose the file source + statically. Only the element and any default-source path need to stop + reaching it eagerly. +- Verify with a bundler metafile that the camera-only element no longer + contains mediabunny, and that picking a file still works in the demo. + +## Related + +- [Size report](/quest/m1/size-report.md) - tracks the publish element's first-load size diff --git a/quest/m1/relay-iroh-opt-in.md b/quest/m1/relay-iroh-opt-in.md new file mode 100644 index 0000000000..51e2fa9b54 --- /dev/null +++ b/quest/m1/relay-iroh-opt-in.md @@ -0,0 +1,30 @@ +# [S] iroh opt-in for moq-relay + +## Goal + +moq-relay no longer builds iroh by default, and its shipped binaries, nix +package, and Docker image leave it out. iroh adds about 63 crates to the +relay: 401 dependencies with it, 338 without. + +## Plan + +Decided in planning: + +- Only the relay changes. moq-cli keeps iroh by default because the P2P + questline dials native peers over it, which mDNS's LAN discovery doesn't + replace. `moq relay` (the planned verb) keeps iroh through moq-cli's + features. +- Target `dev`. Removing a default feature from a published crate, and + flags from a shipped binary, is a published break. + +Guidance: + +- An iroh setting given to a build without the feature must be refused, not + ignored, whether it comes as a flag, an environment variable, or TOML. +- Update `doc/bin/relay/` and any example that relies on the relay's iroh + listener. Report the binary size difference in the PR. + +## Related + +- [`moq relay`](/quest/m1/moq-relay-subcommand.md) - forwards the relay's features from moq-cli's +- [P2P](/quest/m1/p2p/README.md) - why moq-cli keeps iroh diff --git a/quest/m1/release-profile.md b/quest/m1/release-profile.md new file mode 100644 index 0000000000..23c08e00bf --- /dev/null +++ b/quest/m1/release-profile.md @@ -0,0 +1,51 @@ +# [S] Release profile: fat LTO, one codegen unit, stripped + +## Goal + +Every `cargo build --release` produces what we mean to ship: the relay and +CLI binaries, the Python wheel (maturin), Dart's native asset, the nix +packages, and the moq-ffi and libmoq builds all get the same size settings +from `[profile.release]` in the workspace `Cargo.toml`. Today only +`rs/moq-ffi/build.sh`, `rs/libmoq/build.sh`, and `nix/overlay.nix` export +`CARGO_PROFILE_RELEASE_LTO=thin` and one codegen unit, so the PyPI wheel +ships a 31 MiB unstripped `.so` where `build.sh` ships 24 MiB, and the +relay and CLI get no LTO at all. + +Measured on aarch64-apple-darwin, rustc 1.98.1 (2026-09-26): + +| artifact | default release, stripped | fat LTO, 1 CGU, strip | +|---|---|---| +| moq-relay | 26.1 MiB | 22.9 MiB | +| libmoq_ffi.dylib | 21.7 MiB | 20.0 MiB | +| libmoq_ffi.a | 121 MiB (unstripped) | 40.8 MiB | + +Release build time went from 5m to 9m on that machine. + +## Plan + +Decided in planning: + +- `lto = "fat"` and `codegen-units = 1`, not thin: the extra link time is paid + only by release builds, and fat LTO usually helps speed as well as size. +- `strip = "symbols"` everywhere. Released relay binaries already appear + stripped (zig's linker, unconfirmed), and the ffi cdylibs keep their + exported symbols in the dynamic table. Panic backtraces losing function + names is accepted. +- opt-level stays 3 here. A size-optimized profile for the bindings is its own + quest, gated on a benchmark. + +Guidance: + +- Delete the three `CARGO_PROFILE_RELEASE_*` exports. Move the comment about + the Go mirror's 100 MB limit next to the profile. +- `profiling` and `release-with-debug` inherit from release, so they would + inherit the strip too. Override them so they still produce symbolized + captures. `wasm-release` can then drop whatever it now inherits. +- Measure on Linux as well (x86_64 and aarch64), and put sizes and link times + for the release matrix in the PR. Check that the Go mirror's staticlibs + still fit under its limit. + +## Related + +- [Size report](/quest/m1/size-report.md) - the nightly job that shows what this changes over time +- [Bindings size profile](/quest/m1/ffi-size-profile.md) - the opt-level trade this quest leaves out diff --git a/quest/m1/release-size.md b/quest/m1/release-size.md deleted file mode 100644 index 8d0fb4868b..0000000000 --- a/quest/m1/release-size.md +++ /dev/null @@ -1,51 +0,0 @@ -# [S] Release profile: one LTO setting and a nightly size report - -## Goal - -The shipped moq-ffi and libmoq artifacts already build with thin LTO and one -codegen unit, but through `CARGO_PROFILE_RELEASE_*` exports in three places -(`rs/moq-ffi/build.sh`, `rs/libmoq/build.sh`, `nix/overlay.nix`), so the -Python wheel (maturin), `moq`, `moq-relay`, and every `cargo build --release` -by a self-builder get none of it, and a plain release build measures nothing a -user receives. After this quest `[profile.release]` in the workspace -`Cargo.toml` is the one place the setting lives, every release artifact gets -it, and a nightly job reports what each moq-ffi build ships, built the way -the release builds it, so size regressions are visible instead of discovered -in an app store review. - -Measured on aarch64-apple-darwin, moq-ffi, stripped dylib: - -| build | plain release | thin LTO, 1 CGU (as shipped) | -|---|---|---| -| default (audio + video) | 14.78 MB | 14.09 MB | -| `--no-default-features` | 13.40 MB | 12.68 MB | - -The staticlib drops far more (80 MB to 30 MB for the slim build), which is -what the scripts were added for. Of the default dylib's 11.5 MiB of `.text` -before LTO: the network layer (moq_net, moq_native, noq, rustls, aws-lc, -tokio, qmux, reqwest) is about 5.4 MiB, std 1.5 MiB, moq_mux plus mp4_atom -plus hang plus the container parsers 1.2 MiB, the codecs (libopus, openh264, -symphonia, moq_audio, moq_video) 0.9 MiB, and regex 0.6 MiB, pulled by one -AV1 codec-string match in `rs/hang/src/catalog/video/av1.rs`. - -## Plan - -Move `lto` and `codegen-units = 1` into `[profile.release]` and delete the -three exports; the scripts' comments explaining the Go mirror's 100 MB limit -move with the setting. `profiling` and `release-with-debug` inherit it; check -both still produce symbolized captures. Thin LTO is the known-good starting -point. Try `lto = "fat"` and `strip = "symbols"` on top and keep each only if -the table earns it: thin LTO bought 5% on the dylib, so fat is not assumed to -buy much, and `strip` must not break the crash reports C ABI users read -(libmoq ships a symbols file if it does). Link time on the release matrix -goes in the same table as the sizes. - -Then the nightly job: a `size` recipe under `sh/` that builds both moq-ffi -configurations and `libmoq` with the release profile, prints stripped sizes -and `cargo bloat --crates -n 30` per build, and posts the result to the job -summary. `cargo bloat` reads the symbol table, so if `strip` lands the recipe -builds with `CARGO_PROFILE_RELEASE_STRIP=none` for the bloat pass and strips -a copy for the size column. Wire it into `nightly.yml` beside `Features`. The one-line AV1 regex -in hang is worth replacing with a hand parser while the bloat table is open, -if it is what keeps regex in the link (tracing-subscriber's env filter also -uses regex-automata, so check the table rather than assume). diff --git a/quest/m1/size-report.md b/quest/m1/size-report.md new file mode 100644 index 0000000000..d034a049f4 --- /dev/null +++ b/quest/m1/size-report.md @@ -0,0 +1,60 @@ +# [M] Nightly size report + +## Goal + +A nightly job reports the size of what we ship, built the way the release +builds it, and alerts when any of it grows. Size regressions show +up within a day instead of in an app store review or a user's bundle +analyzer. + +## Plan + +Decided in planning: + +- Nightly, not per PR. Size moves only on dependency bumps, feature flips, or + profile changes, so a per-PR comment would say "no change" almost every + time. +- No hard budgets in CI. The job keeps a rolling history and fails when an + artifact grows past a threshold against the previous run (start at 5%) or + against a longer window (for example 10% over 30 days). The window catches + slow drift, which night-to-night checks miss. The existing `alert.yml` path + turns the failure into a Discord alert. Add the workflow to its list if + needed. +- Scope: + - moq-ffi, default and `--no-default-features`, as cdylib and staticlib + - libmoq, moq-relay, and moq-cli + - the moq-wasm module, raw, gzip, and brotli + - the consumer cost of the JS entries: `@moq/net`, `@moq/watch/element` + with and without `/ui`, and `@moq/publish/element`. Measure them the way + a consumer sees them: bundled and minified from the built packages, + first-load chunks only, gzip and brotli. + - the packaged outputs nightly already builds. `nightly.yml` runs the + Python, Kotlin, and Swift release builds, so read their wheel, AAR, and + xcframework sizes instead of rebuilding them. +- Out of scope: Docker images, and per-target release assets nightly doesn't + build. The Docker quest measures its images once. Add others only if one of + them regresses unnoticed. +- The job summary also carries `cargo bloat --crates` for the ffi build and a + metafile breakdown for the publish element, so the cause of a jump is + visible without a local rebuild. `cargo bloat` needs symbols, so build that + pass with `CARGO_PROFILE_RELEASE_STRIP=none` and report stripped sizes + separately. +- Following the tooling questline, the work lives in a `sh/` script behind a + `just` recipe, and `nightly.yml` calls the recipe. + +Considered and declined (do not re-ask): + +- Shrinking npm install size by dropping sourcemaps or `inlineSources`. Maps + are 60-77% of the unpacked packages, but they never reach a browser and + they give consumers real stack traces. +- Splitting the lite and IETF implementations out of `@moq/net`: version + negotiation needs both at connect time. +- Feature gates in moq-tokio for reqwest, tracing-subscriber, usage-rs/toml, + and tokio "full" in the bindings. +- Replacing the AV1 codec-string regex in hang. tracing-subscriber's env + filter keeps regex linked anyway. + +## Related + +- [Release profile](/quest/m1/release-profile.md) - the profile this report measures +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - the same nightly-trend shape for Criterion diff --git a/quest/m1/wasm-opt.md b/quest/m1/wasm-opt.md new file mode 100644 index 0000000000..79b6362671 --- /dev/null +++ b/quest/m1/wasm-opt.md @@ -0,0 +1,26 @@ +# [XS] wasm-opt for moq-wasm + +## Goal + +`just wasm` runs binaryen's `wasm-opt -Oz` after `wasm-bindgen`, so the +module is size-optimized the way browser wasm usually is. The module is +528 KB gzip today, up from the ~471 KB in the `wasm-release` comment in the +workspace `Cargo.toml`, which is now stale. + +## Plan + +Decided in planning: do it now, even though the only consumer is +`test/wasm`. The cost is small and the module is meant to ship. + +Guidance: + +- Add binaryen to the nix dev shell so CI and local builds match. +- wasm-opt must allow the wasm features rustc emits (bulk memory, sign-ext, + and others), or it will reject the module. Pass the matching `--enable-*` + flags. +- Update the comment with measured sizes, and confirm `test/wasm` still + passes. + +## Related + +- [Size report](/quest/m1/size-report.md) - tracks the module nightly From fa499be81b5f237329e29cb35ff506532adbe2d4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 11:30:40 -0700 Subject: [PATCH 04/87] perf(net): join fragmented reads once instead of per chunk (#4267) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 5 ++++ js/net/bench/reader.ts | 56 +++++++++++++++++++++++++++++++++++ js/net/src/stream.test.ts | 34 +++++++++++++++++++-- js/net/src/stream.ts | 46 ++++++++++++++++++++-------- 4 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 js/net/bench/reader.ts diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d15d3ff914..055ba9711f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -77,6 +77,11 @@ jobs: nix develop --command bun install --frozen-lockfile nix develop --command bun js/net/bench/broadcasts.ts + # Fails if reading a fragmented frame costs more per byte as the frame grows. + - name: JS stream reader benchmark + if: ${{ !cancelled() }} + run: nix develop --command bun js/net/bench/reader.ts + # Runs each moq-net Criterion routine once, so a bench that panics (an # unparsable version, a shape that deadlocks) fails here instead of on the # next manual run. Timing is not compared; nextest never runs a diff --git a/js/net/bench/reader.ts b/js/net/bench/reader.ts new file mode 100644 index 0000000000..76eac2e4c9 --- /dev/null +++ b/js/net/bench/reader.ts @@ -0,0 +1,56 @@ +/** Sweep frame size and chunk size for one Reader.read of a fragmented frame. */ +import { Reader } from "../src/stream.ts"; + +const frameSizes = [16 * 1024, 256 * 1024, 1024 * 1024]; +const chunkSizes = [1200, 16 * 1024, 1024 * 1024]; +const bytes = 16 * 1024 * 1024; // Read about this many bytes per case so each row takes similar time. +// Per-byte cost must not grow with frame size, or reassembly has gone quadratic. Each chunk size +// compares against its smallest frame that spans several chunks, since a single chunk skips the +// copy entirely. The margin is loose because that frame pays the fixed per-read overhead over the +// fewest bytes. +const maxSlope = 4; +let checksum = 0; + +console.log("frame_bytes,chunk_bytes,chunks,ns_per_byte"); +const smallest = new Map(); +for (const frameSize of frameSizes) { + for (const chunkSize of chunkSizes) { + const frame = new Uint8Array(frameSize).fill(1); + const chunks: Uint8Array[] = []; + for (let offset = 0; offset < frameSize; offset += chunkSize) { + chunks.push(frame.subarray(offset, Math.min(offset + chunkSize, frameSize))); + } + + const iterations = Math.max(1, Math.floor(bytes / frameSize)); + let elapsed = 0; + for (let index = 0; index < iterations; index++) { + // Queue the whole frame up front so only the Reader is timed, not the source. + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + const reader = new Reader(stream); + + const start = performance.now(); + const read = await reader.read(frameSize); + elapsed += performance.now() - start; + + checksum += read[read.byteLength - 1]; + } + + const ns = (elapsed * 1e6) / (iterations * frameSize); + console.log(`${frameSize},${chunkSize},${chunks.length},${ns.toFixed(3)}`); + + const baseline = smallest.get(chunkSize); + if (baseline === undefined) { + if (chunks.length > 1) smallest.set(chunkSize, ns); + } else if (ns > baseline * maxSlope) { + throw new Error( + `${frameSize} byte frames cost ${ns.toFixed(3)} ns/byte, over ${maxSlope}x ${baseline.toFixed(3)}`, + ); + } + } +} +if (checksum === 0) throw new Error("benchmark did no work"); diff --git a/js/net/src/stream.test.ts b/js/net/src/stream.test.ts index 8a4dc2773f..c02eb411ac 100644 --- a/js/net/src/stream.test.ts +++ b/js/net/src/stream.test.ts @@ -350,7 +350,7 @@ test("Reader stream with partial reads", async () => { expect(await reader.done()).toBe(true); }); -test("Reader owns streamed chunks and preserves returned views across fills", async () => { +test("Reader preserves returned views across fills", async () => { const first = new Uint8Array([99, 1, 2, 99]); const second = new Uint8Array([99, 3, 4, 5, 99]); const stream = new ReadableStream({ @@ -362,10 +362,8 @@ test("Reader owns streamed chunks and preserves returned views across fills", as }); const reader = new Reader(stream); const head = await reader.read(1); - first.fill(0); expect(head).toEqual(new Uint8Array([1])); const joined = await reader.read(3); - second.fill(0); expect(joined).toEqual(new Uint8Array([2, 3, 4])); joined.fill(0); expect(head).toEqual(new Uint8Array([1])); @@ -373,6 +371,36 @@ test("Reader owns streamed chunks and preserves returned views across fills", as expect(await reader.done()).toBe(true); }); +test("Reader returns a view of a chunk that already holds the read", async () => { + const chunk = new Uint8Array([1, 2, 3, 4]); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.close(); + }, + }); + const reader = new Reader(stream); + const read = await reader.read(3); + expect(read.buffer).toBe(chunk.buffer); + expect(read).toEqual(new Uint8Array([1, 2, 3])); + expect(await reader.readAll()).toEqual(new Uint8Array([4])); +}); + +test("Reader joins every chunk a read spans", async () => { + const stream = new ReadableStream({ + start(controller) { + for (let value = 0; value < 100; value++) controller.enqueue(new Uint8Array([value])); + controller.close(); + }, + }); + const reader = new Reader(stream); + expect(await reader.u8()).toBe(0); + expect(await reader.read(98)).toEqual(Uint8Array.from({ length: 98 }, (_, index) => index + 1)); + expect(await reader.done()).toBe(false); + expect(await reader.readAll()).toEqual(new Uint8Array([99])); + expect(await reader.done()).toBe(true); +}); + test("Reader u53 decodes two-byte stream type prefixes", async () => { const { stream, written } = createTestWritableStream(); const writer = new Writer(stream); diff --git a/js/net/src/stream.ts b/js/net/src/stream.ts index eaf3e8ab6d..7cd2b65e07 100644 --- a/js/net/src/stream.ts +++ b/js/net/src/stream.ts @@ -180,7 +180,11 @@ export class Stream { // Reader wraps a stream and provides convience methods for reading pieces from a stream // Unfortunately we can't use a BYOB reader because it's not supported with WebTransport+WebWorkers yet. export class Reader { + // Contiguous unread bytes, followed by chunks not yet joined onto it. Joining only once a + // read needs the bytes keeps a frame arriving in N chunks linear rather than quadratic. #buffer: Uint8Array; + #chunks: Uint8Array[] = []; + #chunked = 0; // bytes across #chunks #stream?: ReadableStream; // if undefined, the buffer is consumed then EOF #reader?: ReadableStreamDefaultReader; #closed?: Promise; @@ -216,16 +220,8 @@ export class Reader { throw new Error("unexpected empty chunk"); } - const buffer = result.value; - - if (this.#buffer.byteLength === 0) { - this.#buffer = new Uint8Array(buffer); - } else { - const temp = new Uint8Array(this.#buffer.byteLength + buffer.byteLength); - temp.set(this.#buffer); - temp.set(buffer, this.#buffer.byteLength); - this.#buffer = temp; - } + this.#chunks.push(result.value); + this.#chunked += result.value.byteLength; return true; } @@ -236,11 +232,36 @@ export class Reader { throw new Error(`read size ${size} exceeds max size ${MAX_READ_SIZE}`); } - while (this.#buffer.byteLength < size) { + if (this.#buffer.byteLength >= size) return; + + while (this.#buffer.byteLength + this.#chunked < size) { if (!(await this.#fill())) { throw new Error("unexpected end of stream"); } } + + this.#join(); + } + + // Move every pending chunk into the buffer, copying only when there's more than one piece. + #join() { + if (this.#chunks.length === 0) return; + + if (this.#buffer.byteLength === 0 && this.#chunks.length === 1) { + this.#buffer = this.#chunks[0]; + } else { + const joined = new Uint8Array(this.#buffer.byteLength + this.#chunked); + joined.set(this.#buffer); + let offset = this.#buffer.byteLength; + for (const chunk of this.#chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + this.#buffer = joined; + } + + this.#chunks = []; + this.#chunked = 0; } // Consumes the first size bytes of the buffer. @@ -266,6 +287,7 @@ export class Reader { while (await this.#fill()) { // keep going } + this.#join(); return this.#slice(this.#buffer.byteLength); } @@ -373,7 +395,7 @@ export class Reader { // Returns false if there is more data to read, blocking if it hasn't been received yet. async done(): Promise { - if (this.#buffer.byteLength > 0) return false; + if (this.#buffer.byteLength > 0 || this.#chunked > 0) return false; return !(await this.#fill()); } From f42f3ad48fa492ae06562b08ab05a9cb32c122f1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 11:40:36 -0700 Subject: [PATCH 05/87] chore(skills): plan-quests hands off to /merge; drop just clean (#4258) Co-authored-by: Claude Opus 5.5 --- .claude/shared | 2 +- .claude/skills/plan-quests/SKILL.md | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.claude/shared b/.claude/shared index 763fb4c986..fb49dae48b 160000 --- a/.claude/shared +++ b/.claude/shared @@ -1 +1 @@ -Subproject commit 763fb4c9865a0625716de8cde80d4af1b5edcef7 +Subproject commit fb49dae48b8c2b756fbe9f0f2f7f5ca01e72febd diff --git a/.claude/skills/plan-quests/SKILL.md b/.claude/skills/plan-quests/SKILL.md index 8ba5c9ff8e..74c233d4d6 100644 --- a/.claude/skills/plan-quests/SKILL.md +++ b/.claude/skills/plan-quests/SKILL.md @@ -38,7 +38,4 @@ Record each settled decision and its reason in the quest's Plan, so later sessio New work joins the milestone matching its priority, at its rank; a questline groups only quests that ship together, and its README holds the work no child owns (the end-to-end test, the docs page). When done, commit and create a draft PR following `CONTRIBUTING.md`. -After local checks pass, mark it ready and monitor CI and the automatic reviews. -Address one review round, then stop and report if the next review still has findings. - -Merge the PR when ready. +After local checks pass, run `/merge` on it. From ed7e6979ce62588aca5a458dce99cbb371ca30fb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 13:28:07 -0700 Subject: [PATCH 06/87] fix(pattern): subtree of a max-depth path is the path itself (#4284) Co-authored-by: Claude Opus 5.5 --- rs/moq-pattern/src/pattern.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/rs/moq-pattern/src/pattern.rs b/rs/moq-pattern/src/pattern.rs index a1ce23a99d..fb9004a7a8 100644 --- a/rs/moq-pattern/src/pattern.rs +++ b/rs/moq-pattern/src/pattern.rs @@ -403,9 +403,14 @@ impl Pattern { /// The pattern matching `path` and every path beneath it: `path/**`. /// /// Normalizes and validates `path` like [`literal`](Self::literal). The empty path - /// yields `**`. + /// yields `**`, and a path of [`MAX_SEGMENTS`](Self::MAX_SEGMENTS) yields the literal, + /// since nothing can sit beneath it. pub fn subtree(path: &str) -> Result { - Self::new(literal_segments(path).chain([Segment::Globstar])) + let mut segments: Vec = literal_segments(path).collect(); + if segments.len() < Self::MAX_SEGMENTS { + segments.push(Segment::Globstar); + } + Self::new(segments) } /// The canonical text: segments joined by `/`, wildcards as `*` and `**`. @@ -993,6 +998,10 @@ mod tests { assert_eq!(Pattern::literal("").unwrap(), Pattern::default()); assert_eq!(Pattern::subtree("foo").unwrap(), pattern("foo/**")); assert_eq!(Pattern::subtree("/").unwrap(), Pattern::all()); + let max = ["a"; Pattern::MAX_SEGMENTS].join("/"); + assert_eq!(Pattern::subtree(&max).unwrap(), pattern(&max)); + let deep = ["a"; Pattern::MAX_SEGMENTS + 1].join("/"); + assert_eq!(Pattern::subtree(&deep), Err(InvalidPattern::TooManySegments)); assert_eq!(Pattern::literal("a/*"), Err(InvalidPattern::InvalidSegment("*".into()))); assert_eq!(Pattern::literal("**"), Err(InvalidPattern::InvalidSegment("**".into()))); } From 42959bd8465a8556bab0810562e916e715d0cb33 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 13:33:51 -0700 Subject: [PATCH 07/87] docs: video resumes on a keyframe after discontinuity() (#4285) Co-authored-by: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 2 +- doc/lib/c/index.md | 2 +- doc/lib/dart/index.md | 2 +- doc/lib/go/index.md | 2 +- doc/lib/kt/index.md | 2 +- doc/lib/py/index.md | 2 +- doc/lib/swift/index.md | 2 +- rs/libmoq/src/api.rs | 3 ++- rs/moq-ffi/src/producer.rs | 3 ++- 9 files changed, 11 insertions(+), 9 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index ef1383523e..12e167a28a 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -12953,7 +12953,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediaproducer_discontinuity() != - 37570) { + 35894) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediaproducer_finish() != 38480) { diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index e3fca369d4..979ce89600 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -63,7 +63,7 @@ if (session < 0) For a locally encoded media track, call `moq_publish_media_flush(media, timestamp_us)` after `moq_publish_media_frame` with the same broadcast-clock PTS. The monotonic handoff time is sampled inside libmoq. Do not call it for file, pipe, or network imports; those remain clock-free. Invalid handles and unrepresentable timestamps return a negative error code. -Call `moq_publish_media_discontinuity(media)` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. +Call `moq_publish_media_discontinuity(media)` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails. ## Connection stats diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index b27b6b786c..36c7d26319 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -110,7 +110,7 @@ catalog and container types are there, so already-encoded frames flow through `MediaProducer.flush(timestampUs: ...)` records the handoff of a locally encoded frame on the broadcast media clock. Call it after `writeFrame` only for live encoder output; file, pipe, and network imports stay clock-free. `MediaProducer` aliases the generated FFI object, so its method is available directly. -Call `media.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. +Call `media.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails. ## Connection stats diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 3c63e18451..7b36cf9a8c 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -71,7 +71,7 @@ broadcast.Close() // keep the producer reachable while publishing, then close For locally encoded media, call `MediaProducer.Flush(timestampUs)` after `WriteFrame` with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit `Flush`; built-in encoders observe their own output. -Call `media.Discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. +Call `media.Discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails. The three advertising operations: `client.CreateBroadcast(path)` (or `origin.CreateBroadcast`) returns an unannounced producer, invisible to everyone; diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 710cd9ebdd..86faab4141 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -53,7 +53,7 @@ Moq.connect("https://relay.example.com").use { moq -> `MediaProducer.flush(timestampUs)` records a locally encoded frame's transport handoff on the broadcast media clock. Call it after `writeFrame` for live encoder output; omit it for file, pipe, and network imports. `MediaProducer` is a typealias, so the generated method is available directly. -Call `media.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. +Call `media.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails. The three advertising operations: `moq.createBroadcast(path)` (or `origin.createBroadcast`) returns an unannounced producer, invisible to everyone; diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 79820f7f68..109d8bab30 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -71,7 +71,7 @@ asyncio.run(main()) For already-encoded live output, call `audio.flush(timestamp_us)` after each `audio.write_frame` with the same broadcast-clock PTS. It samples the transport handoff for catalog jitter. File, pipe, and network imports should omit `flush`; raw-pixel and PCM encoders inside the binding measure their own output. -Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. +Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails. The three advertising operations, as the other bindings spell them: `client.create_broadcast(path)` (or `OriginProducer.create_broadcast`) returns diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 1d5b7c13c2..f9a6f6263b 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -57,7 +57,7 @@ session.shutdown() For already-encoded live output, call `audio.flush(timestampUs:)` after `writeFrame` with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit `flush`; built-in encoders observe their own output. -Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. +Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails. The three advertising operations: `session.publish.createBroadcast(path:)` returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 7d5cfdae51..48cf1e7459 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -2223,7 +2223,8 @@ pub extern "C" fn moq_publish_media_flush(media: u32, timestamp_us: u64) -> i32 /// Mark a timeline break and restart handoff measurement without lowering advertised jitter. /// -/// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock. +/// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock, +/// and video must resume on a keyframe. /// Returns zero on success, or a negative code on failure. #[unsafe(no_mangle)] pub extern "C" fn moq_publish_media_discontinuity(media: u32) -> i32 { diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 6456335841..68261bf203 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -982,7 +982,8 @@ impl MoqMediaProducer { /// Mark a timeline break and restart handoff measurement without lowering advertised jitter. /// - /// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock. + /// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock, + /// and video must resume on a keyframe. pub fn discontinuity(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); From 6493a3e44880f81dfe8aeded71858d2deacfa721 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 13:54:38 -0700 Subject: [PATCH 08/87] quest: libmoq CMake finds the library cargo built (#4289) Co-authored-by: Claude Opus 5.5 --- .claude/shared | 2 +- quest/m1/README.md | 1 + quest/m1/libmoq-cmake-lib.md | 27 +++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 quest/m1/libmoq-cmake-lib.md diff --git a/.claude/shared b/.claude/shared index fb49dae48b..763fb4c986 160000 --- a/.claude/shared +++ b/.claude/shared @@ -1 +1 @@ -Subproject commit fb49dae48b8c2b756fbe9f0f2f7f5ca01e72febd +Subproject commit 763fb4c9865a0625716de8cde80d4af1b5edcef7 diff --git a/quest/m1/README.md b/quest/m1/README.md index b97087b518..a58dc8767e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -143,6 +143,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - a Kotlin/JVM program exits cleanly whatever the moq-ffi runtime thread is doing, like Python does since #3766 - [Dart publish](/quest/m1/dart-publish.md) - the packages are built and dry-run clean but exist nowhere consumers can install from - [Dart codec parity](/quest/m1/dart-codecs.md) - Dart is the one binding that cannot originate media +- [libmoq CMake library](/quest/m1/libmoq-cmake-lib.md) - the in-tree CMake build links the `libmoq.a` cargo reports, not a hardcoded `target/` path - [libmoq fetch](/quest/m1/libmoq-fetch.md) - libmoq gains an additive cached-group fetch entry point - [moq-mux on wasm32](/quest/m1/mux-wasm-target.md) - the crate's two wasm blockers are fixed and the target stays in the clippy lane - [#2850](/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md) - js/net: decode messages synchronously from buffered bytes and delete the publisher read-ahead queue diff --git a/quest/m1/libmoq-cmake-lib.md b/quest/m1/libmoq-cmake-lib.md new file mode 100644 index 0000000000..ba5caa3103 --- /dev/null +++ b/quest/m1/libmoq-cmake-lib.md @@ -0,0 +1,27 @@ +# [XS] libmoq CMake finds the library cargo built + +## Goal + +`rs/libmoq/CMakeLists.txt` links the static library cargo actually produced, +whatever `CARGO_TARGET_DIR`, `--target` triple, or profile the build uses. +Today `BUILD_RUST_LIB` assumes `target//libmoq.a`, so any other +layout links a stale library or fails to find one. + +## Plan + +- `cmake/cargo-build.cmake` already parses cargo's JSON messages to find + `moq.h` in its hashed `OUT_DIR`. Read the libmoq `compiler-artifact` + message's staticlib filename from the same output and copy it beside the + header in the CMake binary dir, `ONLY_IF_DIFFERENT`. `RUST_LIB` then points + there. Chosen over forcing `--target-dir` into the build dir, which loses + the shared workspace cache, and over `cargo metadata`, which still guesses + the triple and profile subdirectories. +- The `BUILD_RUST_LIB=OFF` prebuilt path is unchanged. +- Regression test in CI: the existing `cpp/obs` build against `MOQ_LOCAL` + runs with `CARGO_TARGET_DIR` outside `target/`, so a hardcoded path fails + the build. Update `rs/libmoq/README.md` and `doc/lib/c` if they describe + the path. + +## Related + +- [libmoq shutdown](/quest/m1/libmoq-shutdown.md) - the other libmoq packaging fix OBS needs From aa1f8d599f0a0045a5fd68706d9eb0ca8fade5f9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 14:40:37 -0700 Subject: [PATCH 09/87] test(auth): run the outage grant test on the real clock (#4291) Co-authored-by: Claude Opus 5.5 --- rs/moq-auth/src/client.rs | 41 ++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/rs/moq-auth/src/client.rs b/rs/moq-auth/src/client.rs index 8b8fc0359f..30dc8e991e 100644 --- a/rs/moq-auth/src/client.rs +++ b/rs/moq-auth/src/client.rs @@ -514,30 +514,43 @@ mod tests { assert_eq!(reason, Reason::Expired); } + /// On the real clock: a paused one jumps to the expiry timer whenever the runtime + /// waits on a socket, and macOS delivers loopback asynchronously, so the lease can + /// expire and drop the re-check before the server sees it. Load only delays the + /// close, so asserting it never lands before `expires` holds on a busy machine. #[tokio::test] async fn an_outage_keeps_the_grant_until_expires() { - tokio::time::pause(); + // Whole seconds, as the grant crosses the wire, so the client sees this exact instant. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let expires = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() + 3); + let log = Log::default(); - let client = clock_server( - log.clone(), - grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))), - false, - ) + let server = server(log.clone(), move |request| match request.event { + Event::Connect => { + let mut grant = grant(None, Some(Duration::from_secs(1))); + grant.expires = Some(expires); + ResponseTemplate::new(200).set_body_json(grant) + } + Event::Revalidate => ResponseTemplate::new(503), + Event::End { .. } => ResponseTemplate::new(200), + }) .await; - let consumer = client.connect(request()).await.unwrap(); + let consumer = client(&server).connect(request()).await.unwrap(); - tokio::time::sleep(Duration::from_millis(1500)).await; - assert!(log.revalidates() >= 1, "re-checks happened"); + let reason = tokio::time::timeout(Duration::from_secs(10), consumer.closed()) + .await + .expect("expired"); + assert_eq!(reason, Reason::Expired); + assert!(SystemTime::now() >= expires, "an outage must not close before expires"); + assert!( + log.revalidates() >= 1, + "no re-check reached the server during the outage" + ); assert_eq!( consumer.grant().publish, patterns(&["**"]), "the grant stands through the outage" ); - - let reason = tokio::time::timeout(Duration::from_secs(5), consumer.closed()) - .await - .expect("expired"); - assert_eq!(reason, Reason::Expired); assert!(matches!( log.end().await.event, Event::End { From ae9845011190f70d49ea923592703b6d32b86617 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 16:21:20 -0700 Subject: [PATCH 10/87] quest: plan generated C bindings, park the hand-written libmoq quests (#4300) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 5 +-- quest/m1/c/README.md | 56 ++++++++++++++++++++++++++++ quest/m1/c/backend.md | 25 +++++++++++++ quest/m1/c/consumers.md | 18 +++++++++ quest/m1/c/package.md | 28 ++++++++++++++ quest/m1/c/retire.md | 17 +++++++++ quest/m1/cpp/README.md | 4 +- quest/m1/kt-jvm-exit.md | 2 +- quest/m3/README.md | 4 ++ quest/{m1 => m3}/libmoq-cmake-lib.md | 4 +- quest/{m1 => m3}/libmoq-fetch.md | 2 + quest/{m1 => m3}/libmoq-hidden.md | 2 + quest/{m1 => m3}/libmoq-shutdown.md | 2 + 13 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 quest/m1/c/README.md create mode 100644 quest/m1/c/backend.md create mode 100644 quest/m1/c/consumers.md create mode 100644 quest/m1/c/package.md create mode 100644 quest/m1/c/retire.md rename quest/{m1 => m3}/libmoq-cmake-lib.md (80%) rename quest/{m1 => m3}/libmoq-fetch.md (78%) rename quest/{m1 => m3}/libmoq-hidden.md (70%) rename quest/{m1 => m3}/libmoq-shutdown.md (92%) diff --git a/quest/m1/README.md b/quest/m1/README.md index a58dc8767e..d30396f7e1 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -18,7 +18,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests - [BBR classic ECN](/quest/m1/bbr-classic-ecn.md) - Startup and bandwidth probing respond to CE marks before the bottleneck drops packets -- [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` - [JS group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection @@ -51,6 +50,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed - [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 +- [Generated C bindings](/quest/m1/c/README.md) - C generated from moq-ffi ships as `moq-c` 0.8.0 and replaces the hand-written libmoq - [moq-c](/quest/m1/moq-c.md) - libmoq ships as `moq-c`, beside `moq-cpp`, with its C header and library unchanged - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders - [Opus concealment](/quest/m1/opus-conceal.md) - a lost Opus packet conceals the last packet's length, not 120 ms @@ -139,12 +139,9 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Go mirror delivery](/quest/m1/go-mirror-delivery.md) - the Go binding's staticlibs stop growing git history by ~210 MiB per release - [Relay iroh opt-in](/quest/m1/relay-iroh-opt-in.md) - moq-relay drops iroh from its defaults and shipped builds, while moq-cli keeps it for P2P - [Dart on iOS](/quest/m1/dart-ios.md) - prove the shipped iOS native asset actually loads on a device, which no CI can -- [libmoq shutdown](/quest/m1/libmoq-shutdown.md) - OBS exits cleanly with the plugin loaded: a C ABI `moq_shutdown` stops the libmoq thread before the module is unloaded - [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - a Kotlin/JVM program exits cleanly whatever the moq-ffi runtime thread is doing, like Python does since #3766 - [Dart publish](/quest/m1/dart-publish.md) - the packages are built and dry-run clean but exist nowhere consumers can install from - [Dart codec parity](/quest/m1/dart-codecs.md) - Dart is the one binding that cannot originate media -- [libmoq CMake library](/quest/m1/libmoq-cmake-lib.md) - the in-tree CMake build links the `libmoq.a` cargo reports, not a hardcoded `target/` path -- [libmoq fetch](/quest/m1/libmoq-fetch.md) - libmoq gains an additive cached-group fetch entry point - [moq-mux on wasm32](/quest/m1/mux-wasm-target.md) - the crate's two wasm blockers are fixed and the target stays in the clippy lane - [#2850](/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md) - js/net: decode messages synchronously from buffered bytes and delete the publisher read-ahead queue - [Install moq](/quest/m1/moq-installer.md) - one command installs or upgrades the released CLI on macOS and Linux diff --git a/quest/m1/c/README.md b/quest/m1/c/README.md new file mode 100644 index 0000000000..16dc144787 --- /dev/null +++ b/quest/m1/c/README.md @@ -0,0 +1,56 @@ +# Generated C bindings + +## Goal + +C programs use moq through a C API generated from moq-ffi, shipped as the +`moq-c` package with CMake target `moq::c`, so the hand-written libmoq ABI is +deleted and C tracks every moq-ffi change the way Go, Swift, Kotlin, Python, +Dart, and C++ already do. The C API shape follows moq-ffi and breaks C users +once. The C++ package and the OBS plugin are out of scope; they already sit on +moq-ffi through [C++ through moq-ffi](/quest/m1/cpp/README.md). + +## Plan + +Decided: + +- The generator is a C backend in the `kixelated/uniffi-bindgen-cpp` fork, + beside the C++ one, so both share its parsing, async, and error plumbing and + one pinned tool covers both. No mature upstream C generator exists; uniffi + itself only emits the low-level scaffolding header. +- Async calls take a completion callback and user data and return a task; + freeing the task cancels the call. Callbacks run on moq's dispatcher thread + by default, or on the host's own loop through `moq_set_dispatcher` and + `moq_work_run`, the C form of `moq::set_executor`. Streams are repeated calls. + This removes the trampolines and lock-order rules libmoq's single callback + thread forced on hosts like OBS. +- Errors, records, and names mirror the C++ package: calls return `moq_error *` + (NULL on success) with `moq_error_message()`, results come through + out-params, records are owned structs with `moq__free`, and names are + the uniffi names in snake case. +- The generated package takes over the `moq-c` name and `moq::c` target that + #4288 gives the hand-written crate, so C users migrate once. Its first release + is 0.8.0, a minor bump over the hand-written 0.7.x. +- Docs change inline: the consumer quest rewrites `doc/lib/c`, and retirement + adds an upgrade note. No separate guide. + +The line owns the end-to-end check: every `doc/lib/c` sample and the C interop +client build and run against the released 0.8.0 archive, not only in-tree. + +The hand-written crate's open feature quests are parked in m3 until this line +retires it: [fetch](/quest/m3/libmoq-fetch.md), +[hidden](/quest/m3/libmoq-hidden.md), +[CMake library](/quest/m3/libmoq-cmake-lib.md), and +[shutdown](/quest/m3/libmoq-shutdown.md). + +## Quests + +- [C backend](/quest/m1/c/backend.md) - the fork emits an ergonomic C header and implementation from moq-ffi, with its own tests +- [moq-c package](/quest/m1/c/package.md) - the generated header ships as `moq-c` 0.8.0 with `moq::c`, pkg-config, and a release workflow +- [C consumers](/quest/m1/c/consumers.md) - the C interop client and `doc/lib/c` samples move onto the generated API +- [Retire libmoq](/quest/m1/c/retire.md) - the hand-written crate is deleted after its final release points at the generated package + +## Related + +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the generator fork and package recipe this line extends +- [libmoq becomes moq-c](/quest/m1/moq-c.md) - the rename whose name this line inherits +- [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes moq-ffi, which the generated C then follows for free diff --git a/quest/m1/c/backend.md b/quest/m1/c/backend.md new file mode 100644 index 0000000000..404f5e0f42 --- /dev/null +++ b/quest/m1/c/backend.md @@ -0,0 +1,25 @@ +# [L] C backend in the bindings generator + +## Goal + +`uniffi-bindgen-cpp` (the `kixelated` fork) gains a C backend that turns +moq-ffi's uniffi metadata into an ergonomic C header and implementation, in the +shape the [line](/quest/m1/c/README.md) decided: completion callbacks with a +cancelling task handle, a pluggable dispatcher, `moq_error *` returns with +out-params, owned record structs with free functions, and snake-case uniffi +names. + +## Plan + +- Build on the fork's C++ backend: it already walks the metadata, serializes + records and errors through `RustBuffer`, and drives `rust_future_poll` on a + dispatcher. The C backend emits the same calls behind a C surface, so the + generated implementation may itself be C++ compiled into the package, as long + as the public header is plain C (C99 or C11; say which). +- Cover every construct moq-ffi uses today: objects (handles), records, enums, + the one error type, async methods, and byte buffers. moq-ffi has no callback + interfaces; refuse them loudly rather than emit something half-working. +- The fork's test suite gains C fixtures for each construct, including + cancelling a pending task and running callbacks through a custom dispatcher. +- Offer the backend to the LiveKit or NordSecurity upstream once its shape + settles, or record why not. diff --git a/quest/m1/c/consumers.md b/quest/m1/c/consumers.md new file mode 100644 index 0000000000..79ec900659 --- /dev/null +++ b/quest/m1/c/consumers.md @@ -0,0 +1,18 @@ +# [S] C consumers on the generated API + +## Goal + +The C interop client (`test/interop/clients/c`) and the `doc/lib/c` samples use +the generated `moq-c` API and pass `just test interop --all` and the doc-sample +build. Nothing in the repository calls the hand-written ABI any more. + +## Plan + +- Port the interop client first; it is the smallest real consumer and proves + the callback and dispatcher shape end to end. +- Rewrite `doc/lib/c` around the generated API, including a sample that runs + callbacks on the host's own loop through `moq_set_dispatcher`. + +## Required + +- [moq-c package](/quest/m1/c/package.md) - the package these consumers link diff --git a/quest/m1/c/package.md b/quest/m1/c/package.md new file mode 100644 index 0000000000..686ecc5ea8 --- /dev/null +++ b/quest/m1/c/package.md @@ -0,0 +1,28 @@ +# [M] moq-c package from the generated C + +## Goal + +The generated C ships as the `moq-c` package, version 0.8.0: a release archive +with the header, the moq-ffi staticlib, a CMake config exporting `moq::c`, and +`moq-c.pc`, built the same way `cpp/moq` builds the C++ package. It replaces the +hand-written crate's artifacts under the same names. + +## Plan + +- Mirror `cpp/moq`: CMake runs `cargo build -p moq-ffi`, then the generator's C + backend, and installs the result; a probe test compiles and links from the + installed package through both `find_package(moq-c)` and pkg-config. +- The installed CMake config honors `CMAKE_INSTALL_LIBDIR`, so a `lib64` + distro gets a working `find_package`; the hand-written crate's config + hardcodes `/lib`. +- `doc/index.md` says the C library ships static and shared; say what the + package actually ships. +- A release workflow and nightly/CI jobs follow the C++ package's, pinned to the + fork tag that carries the C backend. +- Every Cross-Package Sync row that names libmoq for moq-ffi changes points at + the generated package instead; update `AGENTS.md` in this quest. + +## Required + +- [libmoq becomes moq-c](/quest/m1/moq-c.md) - the rename that frees the `moq-c` name and `moq::c` target this package takes over +- [C backend](/quest/m1/c/backend.md) - the generator output this packages diff --git a/quest/m1/c/retire.md b/quest/m1/c/retire.md new file mode 100644 index 0000000000..cb3aa49e20 --- /dev/null +++ b/quest/m1/c/retire.md @@ -0,0 +1,17 @@ +# [S] Retire the hand-written libmoq + +## Goal + +The hand-written C crate is gone: its last release points users at the +generated `moq-c` 0.8.0, and the source, workflows, and docs that only served +it are deleted. `doc/setup/upgrade.md` tells C users how to move. + +## Plan + +- Fold in any retirement step #4288 already planned for the renamed crate. +- Decide the parked m3 libmoq quests: delete each whose outcome the generated + API already provides, and say so in the PR. + +## Required + +- [C consumers](/quest/m1/c/consumers.md) - nothing in the repository still calls the hand-written ABI diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index 3faa892c8f..a48bae4194 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -9,8 +9,8 @@ cancellable futures, with no `user_data` plumbing, no handle integers, and no thread of their own to babysit. The OBS plugin is the in-tree consumer that proves the shape; external SDK users are the audience. -Non-goals: libmoq stays as the plain-C ABI, keeps its own release, and keeps -following the Cross-Package Sync table like every other wrapper; no +Non-goals: the plain-C ABI, which moves to C generated from moq-ffi in +[Generated C bindings](/quest/m1/c/README.md); no second hand-written C++ surface (ergonomics are fixed in moq-ffi where every binding benefits); no wire or public Rust API change. diff --git a/quest/m1/kt-jvm-exit.md b/quest/m1/kt-jvm-exit.md index b1ccffb892..62f5681d8f 100644 --- a/quest/m1/kt-jvm-exit.md +++ b/quest/m1/kt-jvm-exit.md @@ -37,4 +37,4 @@ Public API: none; the hook is internal to the binding. Wire: none. ## Related -- [libmoq shutdown](/quest/m1/libmoq-shutdown.md) - the same hazard class for the C ABI and the OBS plugin +- [libmoq shutdown](/quest/m3/libmoq-shutdown.md) - the same hazard class for the C ABI and the OBS plugin diff --git a/quest/m3/README.md b/quest/m3/README.md index d4403e5bef..232e225d3b 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -20,3 +20,7 @@ condition clears, move the quest to the milestone its work belongs in. - [#2893](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - video: validate PipeWire DMA-BUF capture on KDE hardware - [Embedded video](/quest/m3/video-embedded.md) - EGL import in the renderer, so moq-video presents on a Pi - [Vision worker](/quest/m3/processor-vision.md) - a documented customer-run vision worker proves the processor contract +- [libmoq hidden opt-in](/quest/m3/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts +- [libmoq shutdown](/quest/m3/libmoq-shutdown.md) - OBS exits cleanly with the plugin loaded: a C ABI `moq_shutdown` stops the libmoq thread before the module is unloaded +- [libmoq CMake library](/quest/m3/libmoq-cmake-lib.md) - the in-tree CMake build links the `libmoq.a` cargo reports, not a hardcoded `target/` path +- [libmoq fetch](/quest/m3/libmoq-fetch.md) - libmoq gains an additive cached-group fetch entry point diff --git a/quest/m1/libmoq-cmake-lib.md b/quest/m3/libmoq-cmake-lib.md similarity index 80% rename from quest/m1/libmoq-cmake-lib.md rename to quest/m3/libmoq-cmake-lib.md index ba5caa3103..9a9887f2fe 100644 --- a/quest/m1/libmoq-cmake-lib.md +++ b/quest/m3/libmoq-cmake-lib.md @@ -8,6 +8,8 @@ Today `BUILD_RUST_LIB` assumes `target//libmoq.a`, so any other layout links a stale library or fails to find one. ## Plan +Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. + - `cmake/cargo-build.cmake` already parses cargo's JSON messages to find `moq.h` in its hashed `OUT_DIR`. Read the libmoq `compiler-artifact` @@ -24,4 +26,4 @@ layout links a stale library or fails to find one. ## Related -- [libmoq shutdown](/quest/m1/libmoq-shutdown.md) - the other libmoq packaging fix OBS needs +- [libmoq shutdown](/quest/m3/libmoq-shutdown.md) - the other libmoq packaging fix OBS needs diff --git a/quest/m1/libmoq-fetch.md b/quest/m3/libmoq-fetch.md similarity index 78% rename from quest/m1/libmoq-fetch.md rename to quest/m3/libmoq-fetch.md index 1571067215..a6c8430696 100644 --- a/quest/m1/libmoq-fetch.md +++ b/quest/m3/libmoq-fetch.md @@ -6,6 +6,8 @@ A C embedder can fetch one cached group by sequence through the existing frame, handle, and terminal-status conventions. The new entry point is additive. ## Plan +Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. + Mirror `MoqTrackConsumer::fetch_group` from `rs/moq-ffi/src/consumer.rs` in `rs/libmoq`, supporting raw and container-decoded delivery as the FFI does. diff --git a/quest/m1/libmoq-hidden.md b/quest/m3/libmoq-hidden.md similarity index 70% rename from quest/m1/libmoq-hidden.md rename to quest/m3/libmoq-hidden.md index 5c90440ca6..4095d694f6 100644 --- a/quest/m1/libmoq-hidden.md +++ b/quest/m3/libmoq-hidden.md @@ -8,6 +8,8 @@ prefix) the way every other binding can: `moq_origin_announced` takes a caller can only list them by naming the dot segment in `prefix`. ## Plan +Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. + - Adding a parameter breaks the C ABI, so this lands on `dev`. - Update `rs/libmoq/src/{api,origin}.rs`, the libmoq tests, `cpp/obs` if it diff --git a/quest/m1/libmoq-shutdown.md b/quest/m3/libmoq-shutdown.md similarity index 92% rename from quest/m1/libmoq-shutdown.md rename to quest/m3/libmoq-shutdown.md index a9d65aaf76..8b4cc4870e 100644 --- a/quest/m1/libmoq-shutdown.md +++ b/quest/m3/libmoq-shutdown.md @@ -10,6 +10,8 @@ libmoq statically, so the thread's code is unmapped under it. Any host that unloads the library has the same exposure. ## Plan +Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. + Starts on `main`; libmoq's runtime (`rs/libmoq/src/ffi.rs`) is the same on both branches and the change is additive. From 8bc7e9c9770b451bc1491a766915ae17c20ea8ed Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 17:50:11 -0700 Subject: [PATCH 11/87] fix(video): keep the CUDA context alive until the NVDEC decoder is destroyed (#4290) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/nvdec-teardown.md | 21 --------------------- rs/moq-video/src/decode/backend/nvdec.rs | 19 +++++++++++++------ 3 files changed, 13 insertions(+), 28 deletions(-) delete mode 100644 quest/m1/nvdec-teardown.md diff --git a/quest/m1/README.md b/quest/m1/README.md index d30396f7e1..f8463844b1 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -56,7 +56,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Opus concealment](/quest/m1/opus-conceal.md) - a lost Opus packet conceals the last packet's length, not 120 ms - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 - [CMAF Opus](/quest/m1/cmaf-opus-dops.md) - fMP4 import and export keep the Opus pre-skip and gain -- [NVDEC teardown](/quest/m1/nvdec-teardown.md) - dropping an NVDEC decoder no longer segfaults - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match - [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 diff --git a/quest/m1/nvdec-teardown.md b/quest/m1/nvdec-teardown.md deleted file mode 100644 index cb631e47c5..0000000000 --- a/quest/m1/nvdec-teardown.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] Dropping an NVDEC decoder does not crash - -## Goal - -Dropping a `moq-video` NVDEC decoder releases it without a segfault, and the -hardware tests `nvdec_h264_round_trip` and `nvdec_resize_scales_output` pass. - -## Plan - -Both tests die with SIGSEGV on `main` on an RTX 3070 Ti (driver 595.91), while -`nvdec_h265_round_trip` and `nvdec_to_nvenc_zero_copy` pass. The backtrace -ends in `cuEventDestroy_v2`, called by `cuvidDestroyDecoder` from -`::drop` while the test drops the backend. That -`Drop` assumes the caller keeps the CUDA context bound, which nothing -enforces. Suspect the context is not current on the dropping thread, or is -released before the decoder; confirm before fixing. - -Under the Nix dev shell the driver libraries are not on the loader path, so -the NVIDIA tests skip. To run them, expose `libcuda`, `libnvidia-encode`, and -`libnvcuvid` from `/usr/lib/x86_64-linux-gnu` through a directory of symlinks -on `LD_LIBRARY_PATH`. diff --git a/rs/moq-video/src/decode/backend/nvdec.rs b/rs/moq-video/src/decode/backend/nvdec.rs index 0a1883b45b..929776265f 100644 --- a/rs/moq-video/src/decode/backend/nvdec.rs +++ b/rs/moq-video/src/decode/backend/nvdec.rs @@ -74,6 +74,9 @@ struct State { /// An open cuvid decoder plus the output geometry it was created for. struct Decoder { api: &'static cuvid::Api, + /// Held so the context outlives the decoder and can be bound in `Drop`: + /// destroying the decoder after the last context ref is released segfaults. + ctx: Arc, handle: CUvideodecoder, /// The coded size it was created for, to detect reconfigures. coded: (u32, u32), @@ -87,10 +90,13 @@ struct Decoder { impl Drop for Decoder { fn drop(&mut self) { - // SAFETY: the handle is valid and no frame is mapped (every map is - // paired with an unmap before decode returns). The caller keeps the CUDA - // context bound. - unsafe { (self.api.destroy_decoder)(self.handle) }; + // Drop may run on any thread; destroying needs the context current. + if self.ctx.bind_to_thread().is_ok() { + // SAFETY: the handle is valid, its context is alive and current, and + // no frame is mapped (every map is paired with an unmap before decode + // returns). + unsafe { (self.api.destroy_decoder)(self.handle) }; + } } } @@ -227,8 +233,8 @@ impl Backend for Nvdec { impl Drop for Nvdec { fn drop(&mut self) { - // Drop may run on a different thread than decode; the destroy calls - // (parser here, decoder via `state`) need the context current. + // Drop may run on a different thread than decode; destroying the parser + // needs the context current (the decoder binds its own). let _ = self.state.ctx.bind_to_thread(); // SAFETY: the parser is valid and no parse call is in flight (&mut self). unsafe { (self.state.api.destroy_video_parser)(self.parser) }; @@ -350,6 +356,7 @@ impl State { ); self.decoder = Some(Decoder { api: self.api, + ctx: self.ctx.clone(), handle, coded, display_area, From 0c3cd807f90a3dca3f0369e34e79314fbe5d1e43 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 17:50:13 -0700 Subject: [PATCH 12/87] fix(cli): finish the catalog at stdin EOF (#4303) Co-authored-by: Claude Opus 5.5 --- rs/moq-cli/src/publish.rs | 175 +++++++++++++++++++++++++++----------- 1 file changed, 124 insertions(+), 51 deletions(-) diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index 306480c9ae..9ce2ebc93b 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -222,12 +222,32 @@ impl PublishDecoder { } } +/// The catalog a stdin decoder publishes into. TS carries the `mpegts` extension. +enum PublishCatalog { + Media(moq_mux::catalog::Producer), + Ts(moq_mux::catalog::Producer), +} + +impl PublishCatalog { + /// End the catalog tracks cleanly, keeping the renditions they last listed. + fn finish(&mut self) -> anyhow::Result<()> { + match self { + Self::Media(catalog) => catalog.finish()?, + Self::Ts(catalog) => catalog.finish()?, + } + Ok(()) + } +} + // Exactly one Source exists per process, so the size gap between the small // Stream variant and the larger Capture config is irrelevant. #[allow(clippy::large_enum_variant)] enum Source { /// Decode a container read from stdin. - Stream(PublishDecoder), + Stream { + decoder: PublishDecoder, + catalog: PublishCatalog, + }, /// Capture from local devices. The per-medium producers are built on their /// own capture threads (native camera/screen capture, microphone via cpal), publishing /// onto the shared broadcast + catalog; [`Publish::run`] drives them @@ -272,34 +292,43 @@ impl Publish { let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config)?; let ts = ts::Import::new(broadcast.clone(), catalog.reserve()).live(); return Ok(Self { - source: Source::Stream(PublishDecoder::Ts(Box::new(ts))), + source: Source::Stream { + decoder: PublishDecoder::Ts(Box::new(ts)), + catalog: PublishCatalog::Ts(catalog), + }, broadcast, }); } let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config)?; - let source = match format { + let decoder = match format { PublishFormat::Avc3 => { let track = broadcast.unique_track(".avc3", catalog.track_info(hang::catalog::PRIORITY.video))?; let import = moq_mux::codec::h264::Import::new(track, catalog.reserve(), Default::default())?; let split = Box::new(moq_mux::codec::h264::Split::new()); - Source::Stream(PublishDecoder::Avc3 { + PublishDecoder::Avc3 { split, import: Box::new(import), - }) + } } PublishFormat::Fmp4 => { let fmp4 = fmp4::Import::new(broadcast.clone(), catalog.reserve()).live(); - Source::Stream(PublishDecoder::Fmp4(Box::new(fmp4))) + PublishDecoder::Fmp4(Box::new(fmp4)) } PublishFormat::Ts => unreachable!("TS is handled above with the mpegts catalog extension"), PublishFormat::Flv => { let flv = flv::Import::new(broadcast.clone(), catalog.reserve()).live(); - Source::Stream(PublishDecoder::Flv(Box::new(flv))) + PublishDecoder::Flv(Box::new(flv)) } }; - Ok(Self { source, broadcast }) + Ok(Self { + source: Source::Stream { + decoder, + catalog: PublishCatalog::Media(catalog), + }, + broadcast, + }) } /// Build a publisher capturing local devices (camera/screen and microphone). @@ -345,47 +374,7 @@ impl Publish { /// Drive the source until stdin EOF (or the capture devices stop). pub async fn run(self) -> anyhow::Result<()> { match self.source { - Source::Stream(mut decoder) => { - let mut stdin = tokio::io::stdin(); - let mut buffer = bytes::BytesMut::new(); - - // Damage reported so far, so only the change is logged. A live feed is - // diagnosed by the rate at which these climb, and stdin may never end, so - // they have to surface as they accumulate rather than at exit. - let mut reported = decoder.stats(); - - // Run the read/decode loop so an error surfaces here rather than - // dropping the decoder (and its tracks) with a bare Error::Dropped. - let result: anyhow::Result<()> = async { - loop { - buffer.clear(); - let n = tokio::io::AsyncReadExt::read_buf(&mut stdin, &mut buffer).await?; - if n == 0 { - return Ok(()); // EOF - } - decoder.decode_chunk(&buffer)?; - - let latest = decoder.stats(); - if latest != reported { - log_stats(latest.as_ref(), reported.as_ref()); - reported = latest; - } - } - } - .await; - - // Flush on a clean EOF; on any error (read, decode, or the flush - // itself) abort with the real cause so subscribers see it instead of - // a bare Error::Dropped. - let outcome = result.and_then(|()| decoder.finish()); - // The drain at end of input can publish a frame nothing vouched for, so the - // final snapshot is only complete after `finish`. - log_stats(decoder.stats().as_ref(), reported.as_ref()); - if let Err(err) = &outcome { - decoder.abort(moq_net::Error::Transport(err.to_string())); - } - outcome - } + Source::Stream { decoder, catalog } => decode(decoder, catalog, tokio::io::stdin()).await, #[cfg(feature = "capture")] Source::Capture { catalog, video, audio } => { // Each enabled medium publishes its own track onto the shared @@ -435,6 +424,59 @@ impl Publish { } } +/// Decode `input` into the broadcast until EOF. +/// +/// At EOF the media tracks finish, then the catalog does, while it still lists them: the +/// renditions retire from the catalog as the decoder drops, which a subscriber would read +/// as removed tracks, and a catalog dropped unfinished reads as a publisher that vanished. +async fn decode( + mut decoder: PublishDecoder, + mut catalog: PublishCatalog, + mut input: impl tokio::io::AsyncRead + Unpin, +) -> anyhow::Result<()> { + let mut buffer = bytes::BytesMut::new(); + + // Damage reported so far, so only the change is logged. A live feed is + // diagnosed by the rate at which these climb, and stdin may never end, so + // they have to surface as they accumulate rather than at exit. + let mut reported = decoder.stats(); + + // Run the read/decode loop so an error surfaces here rather than + // dropping the decoder (and its tracks) with a bare Error::Dropped. + let result: anyhow::Result<()> = async { + loop { + buffer.clear(); + let n = tokio::io::AsyncReadExt::read_buf(&mut input, &mut buffer).await?; + if n == 0 { + return Ok(()); // EOF + } + decoder.decode_chunk(&buffer)?; + + let latest = decoder.stats(); + if latest != reported { + log_stats(latest.as_ref(), reported.as_ref()); + reported = latest; + } + } + } + .await; + + // Flush on a clean EOF; on any error (read, decode, or the flush + // itself) abort with the real cause so subscribers see it instead of + // a bare Error::Dropped. + let outcome = result.and_then(|()| decoder.finish()); + // The drain at end of input can publish a frame nothing vouched for, so the + // final snapshot is only complete after `finish`. + log_stats(decoder.stats().as_ref(), reported.as_ref()); + match outcome { + Ok(()) => catalog.finish(), + Err(err) => { + decoder.abort(moq_net::Error::Transport(err.to_string())); + Err(err) + } + } +} + #[cfg(feature = "capture")] impl CaptureArgs { /// The video source named by the flags, defaulting to the default camera. @@ -690,7 +732,7 @@ mod tests { settle().await; let mut publish = Publish::new(broadcast, &PublishFormat::Ts, Default::default()).unwrap(); #[allow(irrefutable_let_patterns)] - let Source::Stream(decoder) = &mut publish.source else { + let Source::Stream { decoder, .. } = &mut publish.source else { panic!("expected a stream source"); }; decoder.decode_chunk(&input).unwrap(); @@ -771,7 +813,7 @@ mod tests { let config = moq_mux::catalog::Config::default().with_clock(clock); let mut publish = Publish::new(broadcast, &PublishFormat::Ts, config).unwrap(); #[allow(irrefutable_let_patterns)] - let Source::Stream(decoder) = &mut publish.source else { + let Source::Stream { decoder, .. } = &mut publish.source else { panic!("expected a stream source"); }; let before = clock.now(); @@ -808,6 +850,37 @@ mod tests { ); } + /// At stdin EOF the catalog ends cleanly and still lists the renditions, so a + /// subscriber reads a finished broadcast rather than its tracks being removed. + #[tokio::test(start_paused = true)] + async fn eof_finishes_the_catalog_with_its_renditions() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let publish = Publish::new(broadcast, &PublishFormat::Ts, Default::default()).unwrap(); + let mut catalogs = hang::catalog::Catalog::<()>::subscribe(&consumer).await.unwrap(); + + #[allow(irrefutable_let_patterns)] + let Source::Stream { decoder, catalog } = publish.source else { + panic!("expected a stream source"); + }; + decode(decoder, catalog, BBB).await.unwrap(); + drop(publish.broadcast); + + let mut last = None; + loop { + let next = tokio::time::timeout(Duration::from_secs(1), catalogs.next()) + .await + .expect("the catalog track ends"); + match next.expect("the catalog ends cleanly") { + Some(catalog) => last = Some(catalog), + None => break, + } + } + let last = last.expect("a catalog"); + assert_eq!(last.video.renditions.len(), 1, "the video rendition is still listed"); + assert_eq!(last.audio.renditions.len(), 1, "the audio rendition is still listed"); + } + /// Read the first frame of a verbatim track back as raw bytes. async fn read_frame(consumer: &moq_net::broadcast::Consumer, name: &str) -> Vec { let track = consumer.track(name).unwrap().subscribe(None).await.unwrap(); From 48096c417356d13e72cf004859d84aee648359ff Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 17:50:46 -0700 Subject: [PATCH 13/87] fix(stats): keep an idle path in the frame while its counters live (#4299) Co-authored-by: Claude Opus 5.5 --- demo/web/src/stats.ts | 4 +- doc/concept/stats.md | 10 +-- rs/moq-stats/src/produce.rs | 121 +++++++++++++++++++++++++----------- 3 files changed, 92 insertions(+), 43 deletions(-) diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts index a66d621617..702b9906a1 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -13,8 +13,8 @@ * sessions.json sessions by auth root * * Each frame is `{ "": Snapshot }`. Counters are cumulative; - * "active" = started - ended. The relay only includes currently-live entries, so - * the latest frame is a snapshot of now. We sample the aggregate on an interval + * "active" = started - ended. The relay includes every entry it still holds + * counters for, idle ones too, so the latest frame is a snapshot of now. We sample the aggregate on an interval * to derive per-second throughput rates for the charts. A relay built after the * started/ended rename still writes the legacy names beside the new ones; this * dashboard prefers the canonical spelling and falls back so it also reads an diff --git a/doc/concept/stats.md b/doc/concept/stats.md index 7b6cb321ca..6abee77e94 100644 --- a/doc/concept/stats.md +++ b/doc/concept/stats.md @@ -62,10 +62,12 @@ held open with `{}` until the tier records. Any other name is refused. ## Frames Every frame is a JSON object mapping a key (broadcast path or auth root) to an -entry. An entry appears while it is **live**, meaning some started counter -still exceeds its ended counterpart so traffic could resume at any moment, and -on any tick its counters changed. Once fully closed it appears one last time -with its final counters and is then dropped. A track with no entries holds `{}`. +entry. A traffic entry appears from its first nonzero counter until the relay +drops its counters, even while idle: the last viewer can leave while the +publisher still holds the path, and a returning viewer resumes the same +counters. So a key missing from a frame had no counters, and one that returns +starts from zero. A sessions entry appears while a session is connected and on +the tick its last one disconnects. A track with no entries holds `{}`. The producer drains its counters every interval (one second by default) and writes only when a track's frame changed, so silence means nothing moved, not diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index 7cd482b042..ec423bc308 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -901,16 +901,16 @@ fn requested_track_shape(name: &str) -> Option { }) } -/// Change-detection state for one `(path, tier, side)` slot, owned by the -/// publish task. The task is single-threaded so this needs no atomics. +/// Emission state for one `(path, tier, side)` slot, owned by the publish +/// task. The task is single-threaded so this needs no atomics. #[derive(Default)] struct SlotState { - /// Last [`Traffic`] we emitted for this slot, used to detect changes that - /// warrant re-emission. - prev_emitted: Option, + /// Whether any counter on this side has moved since the registry created + /// the entry. + moved: bool, } -/// Change-detection state for one `(path, tier)`: a [`SlotState`] per side. +/// Emission state for one `(path, tier)`: a [`SlotState`] per side. #[derive(Default)] struct SideSlots { publisher: SlotState, @@ -919,7 +919,7 @@ struct SideSlots { seen: u64, } -/// Change-detection state for one session-track root, mirroring [`SlotState`]. +/// Change-detection state for one session-track root. #[derive(Default)] struct SessionSlotState { prev_emitted: Option, @@ -927,40 +927,28 @@ struct SessionSlotState { seen: u64, } -/// Per-drain work for a single `(side, tier)` slot: update the slot's -/// `prev_emitted` and hand `snap` to `emit` iff the slot is live or changed -/// this drain. +/// Per-drain work for a single `(side, tier)` slot: hand `snap` to `emit` once +/// the side has moved, on every drain until the registry drops the entry. fn process_slot(snap: Traffic, slot_state: &mut SlotState, emit: impl FnOnce(Traffic)) { - // A slot is live while any started counter still exceeds its `*_ended` - // counterpart: a guard is held, so a subscription could begin at any - // moment. Live slots are emitted every drain so a downstream "currently - // active" view always sees the full set. Once every pair is equal no - // traffic can flow and the entry is on its way out (the registry pruned - // it as soon as the last guard released its handle). - let live = !snap.is_idle(); - - // Include the entry whenever it's live OR its snapshot changed this - // drain. Change-driven inclusion catches bumps since the previous drain - // (incl. sub-interval flickers) and emits the final close snapshot on the - // drain a slot transitions to fully closed. + // A side can go idle while its entry lives on: the last viewer leaves but + // the publisher still holds the path, so the egress counters are kept and + // resume where they stopped. Omitting it would make its return look like a + // fresh entry to a reader diffing frames, and the old total would count + // twice. So once moved, a side stays in every frame until `flush` drops its + // state with the entry, and a path missing from a frame really restarted. // - // `None` (slot never emitted) is treated as the default Traffic so a - // first-drain all-zeros snap on an unused tier-side slot doesn't count - // as a "change". Without this, every entry would surface in all four - // tracks with zeros on the drain after creation even if only one slot - // is actually in use. - let prev_snap = slot_state.prev_emitted.unwrap_or_default(); - let changed = snap != prev_snap; - if changed { - slot_state.prev_emitted = Some(snap); - } - if live || changed { + // A side that never moved stays out, so an entry with traffic on one side + // does not surface on the other track as zeros. A live side has a started + // counter above zero, so it has always moved. + slot_state.moved |= snap != Traffic::default(); + if slot_state.moved { emit(snap); } } -/// Per-drain work for one session-track root: same live-or-changed rule as -/// [`process_slot`]. +/// Per-drain work for one session-track root: emit it while a session is +/// connected, and on the drain its counters change. A root's counters are +/// dropped with its last session, so it never idles in the registry. fn process_session_slot(snap: Presence, slot_state: &mut SessionSlotState, emit: impl FnOnce(Presence)) { let live = snap.active() > 0; let prev_snap = slot_state.prev_emitted.unwrap_or_default(); @@ -1271,8 +1259,7 @@ mod tests { // A subscription that opens AND closes within a single drain window // must still surface as a complete broadcasts start/end cycle. The // cumulative counters retain broadcasts_started=1/broadcasts_ended=1, and the - // change-driven inclusion surfaces the entry even though it's net-idle - // by drain time. + // entry surfaces because it moved, even though it's net-idle by drain time. let (producer, origin) = test_producer(Some("sjc")); { // Subscribe, read one 123-byte frame, then drop everything within the @@ -1294,6 +1281,66 @@ mod tests { assert_eq!(snap.frames, 1); } + #[tokio::test(start_paused = true)] + async fn a_path_stays_in_the_frame_while_its_counters_live() { + // The publisher holds `foo/bar` throughout while viewers come and go, so + // the registry keeps its egress counters between them. A reader diffing + // frames must see the path the whole time: if it vanished, its return + // (carrying the first viewer's bytes) would look like a fresh entry. + let (producer, origin) = test_producer(Some("sjc")); + let registry = producer.registry(); + let data = produce_origin(); + let source = data + .clone() + .with_stats(registry.tier(Tier::default()).session("publisher")) + .publish("foo/bar", origin::Route::default()) + .expect("publish"); + let mut video = source.create_track("video", None).expect("create_track"); + let egress = data + .consume() + .with_stats(registry.tier(Tier::default()).session("viewer")); + + async fn view(egress: &origin::Consumer, video: &mut track::Producer, size: usize) { + let broadcast = egress.request_broadcast("foo/bar").await.expect("resolve"); + let mut sub = broadcast + .track("video") + .expect("track") + .subscribe(None) + .await + .expect("subscribe"); + let mut group = video.append_group().expect("group"); + group.write_frame(Timestamp::ZERO, vec![0u8; size]).expect("write"); + group.finish().expect("finish"); + let mut group = sub.recv_group().await.expect("recv").expect("group"); + while group.read_frame().await.expect("read").is_some() {} + } + + view(&egress, &mut video, 1000).await; + let (_, stats) = announced(&origin).await; + for _ in 0..3 { + drive_tick().await; + let frame = read_last_frame(&stats, "publisher.json").await; + let snap = frame.get("foo/bar").expect("kept while its counters live"); + assert_eq!(snap.bytes, 1000); + assert!(snap.is_idle(), "the viewer left"); + } + + view(&egress, &mut video, 500).await; + drive_tick().await; + let frame = read_last_frame(&stats, "publisher.json").await; + assert_eq!(frame["foo/bar"].bytes, 1500, "resumed, not restarted"); + + // Once nothing holds the path the registry drops it, and so does the frame. + drop((video, source)); + drive_tick().await; + drive_tick().await; + let frame = read_last_frame(&stats, "publisher.json").await; + assert!( + !frame.contains_key("foo/bar"), + "dropped with its counters, got {frame:?}" + ); + } + #[tokio::test(start_paused = true)] async fn session_track_surfaces_by_root() { let (producer, origin) = test_producer(Some("sjc")); From d55527158650690d69857977bb32c3c5f6e6ff85 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 17:51:09 -0700 Subject: [PATCH 14/87] fix(egress): single-rendition egress serves the best rendition (#4293) Co-authored-by: Claude Opus 5.5 --- doc/bin/cli.md | 4 +- doc/bin/rtc.md | 3 +- doc/bin/rtmp.md | 4 + quest/m0/bbb-sd.md | 5 +- quest/m1/README.md | 5 +- quest/m1/audio-ranked.md | 14 ++ quest/m1/egress-rendition-pick.md | 24 --- quest/m1/flv-catalog-stream.md | 13 ++ quest/m1/flv-rebind.md | 14 ++ quest/m1/js-ranked.md | 14 ++ quest/m2/whep-abr.md | 9 +- rs/hang/src/catalog/video/mod.rs | 43 ++++++ rs/moq-mux/src/container/flv/export.rs | 46 ++++-- rs/moq-mux/src/container/flv/export_test.rs | 73 +++++++-- rs/moq-rtc/src/egress.rs | 57 ++++++- rs/moq-rtmp/src/server.rs | 159 ++++++++++++++++---- rs/moq-transcode/src/catalog.rs | 30 ++-- rs/moq-transcode/src/lib.rs | 2 +- 18 files changed, 403 insertions(+), 116 deletions(-) create mode 100644 quest/m1/audio-ranked.md delete mode 100644 quest/m1/egress-rendition-pick.md create mode 100644 quest/m1/flv-catalog-stream.md create mode 100644 quest/m1/flv-rebind.md create mode 100644 quest/m1/js-ranked.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 290e9fd853..811b19fb4a 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -159,8 +159,8 @@ watches them. On NVIDIA the whole pipeline stays on the GPU; `--frames cpu` forces decoded frames into CPU memory instead of the default `native`. Requires the `transcode` feature. -The source is the tallest rendition this host can decode with `--decoder`, so a -software-only host transcodes from an H.264 rendition rather than a taller H.265 +The source is the largest rendition this host can decode with `--decoder`, so a +software-only host transcodes from an H.264 rendition rather than a larger H.265 or AV1 one. When no rendition decodes, the command exits naming the decoder's refusal. diff --git a/doc/bin/rtc.md b/doc/bin/rtc.md index 5fb0c25201..989a4d9c3a 100644 --- a/doc/bin/rtc.md +++ b/doc/bin/rtc.md @@ -23,7 +23,8 @@ moq --connect https://relay.example.com/anon --broadcast cam.hang export rtc --l ``` Peers reach the broadcast at `http://host:8080/`. Opus, H.264, -H.265, VP8, VP9, and AV1 are negotiated in both directions. `--cors-origin` +H.265, VP8, VP9, and AV1 are negotiated in both directions. A WHEP peer +receives the largest rendition (then highest bitrate) in its negotiated codec. `--cors-origin` opens the endpoint to browsers on other origins, and `--udp-bind` plus `--public-addr` pin one media port for firewalls. The listener is plain HTTP; put a TLS-terminating proxy in front for WHIP clients that require HTTPS. A fresh WHEP peer joins at the current group, so it diff --git a/doc/bin/rtmp.md b/doc/bin/rtmp.md index dee1c0f39d..6a144135d9 100644 --- a/doc/bin/rtmp.md +++ b/doc/bin/rtmp.md @@ -29,6 +29,10 @@ the [`moq-rtmp`](https://docs.rs/moq-rtmp) library, which hands you each publish or play request to accept, map to a path, or reject. The CLI listener is unauthenticated; firewall it. +A player that advertises enhanced-RTMP multitrack receives every rendition. +Any other player receives one video rendition: the largest picture (then +highest bitrate) in a codec it advertised. A push carries the largest one. + Implemented in pure Rust (no librtmp). The CLI speaks plaintext `rtmp://` only; the library adds RTMPS on the same port when the embedder supplies a TLS config. FLAC and MP3 enhanced-audio payloads are dropped because hang has no diff --git a/quest/m0/bbb-sd.md b/quest/m0/bbb-sd.md index 65d632c84a..2ddfb3df65 100644 --- a/quest/m0/bbb-sd.md +++ b/quest/m0/bbb-sd.md @@ -18,9 +18,8 @@ CPU. moq.pro's always-on demo consumes the same asset. 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. + video PID into its own rendition. WHEP and non-multitrack RTMP serve the + 720p one, the largest, whatever the mapping order. - 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 diff --git a/quest/m1/README.md b/quest/m1/README.md index f8463844b1..7410884299 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -57,7 +57,10 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 - [CMAF Opus](/quest/m1/cmaf-opus-dops.md) - fMP4 import and export keep the Opus pre-skip and gain - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match -- [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name +- [JS rendition ranking](/quest/m1/js-ranked.md) - `@moq/hang` ranks video renditions like Rust, and `@moq/watch`'s fallback uses it +- [Audio rendition pick](/quest/m1/audio-ranked.md) - single-track FLV/RTMP and WHEP serve the best audio rendition, not the first by name +- [FLV rebind before header](/quest/m1/flv-rebind.md) - single-track FLV switches to a better rendition announced before the header +- [FLV catalog stream](/quest/m1/flv-catalog-stream.md) - on dev, `flv::Export` takes a catalog stream like fmp4, replacing `with_select` - [Keyframe trigger](/quest/m1/keyframe-trigger.md) - an application can ask the built-in capture encoder for a keyframe - [Video keyframe flag](/quest/m1/video-keyframe-flag.md) - encoded video marks its keyframes, so a requested cut never forces an extra one after a cadence keyframe - [QoS](/quest/m1/qos/README.md) - broadcast health: relay starvation and timeliness histograms, and client stats broadcasts from publishers and viewers diff --git a/quest/m1/audio-ranked.md b/quest/m1/audio-ranked.md new file mode 100644 index 0000000000..8f4dbdc246 --- /dev/null +++ b/quest/m1/audio-ranked.md @@ -0,0 +1,14 @@ +# [S] Audio rendition pick + +## Goal + +An egress that carries one audio rendition (single-track FLV export and RTMP +play, WHEP) serves the best one it supports, not the first by track name. + +## Plan + +Video already shares `hang::catalog::Video::ranked`. Decide what "best" means +for audio (bitrate, then sample rate and channels are candidates) and add the +matching `Audio` ranking. RTMP's play check still checks the first audio +rendition by name; narrow it to what the client advertised, as video does. Test +with a catalog whose weaker rendition sorts first. diff --git a/quest/m1/egress-rendition-pick.md b/quest/m1/egress-rendition-pick.md deleted file mode 100644 index 828f1d66ee..0000000000 --- a/quest/m1/egress-rendition-pick.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] Single-rendition egress picks the best rendition - -## Goal - -An egress that can carry one video rendition serves the highest-quality one -the client can decode, not the first by track name: WHEP (`moq-rtc`), -non-multitrack RTMP play and FLV export. A multi-rendition broadcast serves -the same picture regardless of how its tracks are named. - -## Plan - -`moq-rtc`'s `pick_track` (`rs/moq-rtc/src/egress.rs`), FLV's `bind_video` -(`rs/moq-mux/src/container/flv/export.rs`), and RTMP's -`check_play_capabilities` (`rs/moq-rtmp/src/server.rs`) each take the first -catalog entry, which is BTreeMap name order. Share one ranking with the -player's fallback and `catalog::choose_source`: largest resolution, then -highest bitrate, among renditions the egress supports. Multitrack FLV/RTMP -and TS/SRT keep carrying every rendition. Test with a catalog whose -lower-quality rendition sorts first. - -## Related - -- [WHEP ABR](/quest/m2/whep-abr.md) - switch the served rendition per peer instead of fixing one -- [Transcode](/doc/bin/cli.md) - the source is the tallest rendition this host can decode diff --git a/quest/m1/flv-catalog-stream.md b/quest/m1/flv-catalog-stream.md new file mode 100644 index 0000000000..72d52b79d0 --- /dev/null +++ b/quest/m1/flv-catalog-stream.md @@ -0,0 +1,13 @@ +# [S] FLV export takes a catalog stream + +## Goal + +On `dev`, `flv::Export` is built from a catalog stream like `fmp4::Export`, so +callers narrow renditions with `catalog::Stream::select` and the FLV-only +`with_select` builder is gone. + +## Plan + +This is a published API break, so it targets `dev`. Consider whether the TS +and Matroska exports should take the same shape in the same pass. RTMP play +passes its client-capability selection through the stream instead. diff --git a/quest/m1/flv-rebind.md b/quest/m1/flv-rebind.md new file mode 100644 index 0000000000..4aacafda74 --- /dev/null +++ b/quest/m1/flv-rebind.md @@ -0,0 +1,14 @@ +# [XS] FLV rebind before header + +## Goal + +A single-track FLV export whose first catalog snapshot lacks the best +rendition switches to it if it appears before the stream header goes out, so +the pick doesn't depend on catalog timing. + +## Plan + +`flv::Export` binds from the first snapshot and ignores later ones in +single-track mode. Before the header, a better-ranked rendition could replace +the bound one; after it, FLV can't introduce a new config, so the current track +stays. Mock time in the test. diff --git a/quest/m1/js-ranked.md b/quest/m1/js-ranked.md new file mode 100644 index 0000000000..c7f2131e89 --- /dev/null +++ b/quest/m1/js-ranked.md @@ -0,0 +1,14 @@ +# [S] JS rendition ranking + +## Goal + +`@moq/hang` ranks video renditions the same way as Rust's +`hang::catalog::Video::ranked` (largest picture, then highest bitrate, ties in +name order), and `@moq/watch`'s fallback pick uses it, so the browser and the +native egresses can't drift apart. + +## Plan + +Mirror the Rust name and semantics. `bestRendition` in `js/watch` already +matches today; replace it rather than keep two copies. Check whether the +`byDimensions`/`byBitrate` filters can reuse the same ordering. diff --git a/quest/m2/whep-abr.md b/quest/m2/whep-abr.md index ed9896b80a..af2367d95a 100644 --- a/quest/m2/whep-abr.md +++ b/quest/m2/whep-abr.md @@ -12,10 +12,5 @@ Open questions: whether to drive switching from str0m's bandwidth estimate (TWCC) or from loss and REMB, how to switch without a keyframe gap (subscribe the new rendition and splice at its next group, as the JS decoder does), and whether offering the renditions as WebRTC simulcast layers (RIDs) buys -anything for a receive-only browser. Keep the -[best-rendition pick](/quest/m1/egress-rendition-pick.md) as the starting -rendition. - -## Required - -- [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - the shared ranking this starts from +anything for a receive-only browser. Keep the best rendition +(`hang::catalog::Video::ranked`) as the starting rendition. diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index db43d6c3eb..f4bba68565 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -106,6 +106,20 @@ impl Video { self.renditions.remove(name) } + /// Iterate the renditions best first: largest picture, then highest bitrate. + /// + /// A consumer that carries one rendition takes the first it supports, so the + /// picture doesn't depend on how the tracks are named. Unknown dimensions or + /// bitrate rank below known ones, and exact ties keep name order. + pub fn ranked(&self) -> impl Iterator { + let mut ranked: Vec<_> = self.renditions.iter().collect(); + ranked.sort_by_key(|(_, config)| { + let area = u64::from(config.coded_width.unwrap_or(0)) * u64::from(config.coded_height.unwrap_or(0)); + std::cmp::Reverse((area, config.bitrate)) + }); + ranked.into_iter() + } + /// Normalize and replace the properties shared by every video rendition. pub fn set_properties(&mut self, properties: VideoProperties) -> crate::Result<()> { let properties = properties.normalized()?; @@ -282,6 +296,35 @@ mod test { use super::*; + #[test] + fn ranked_orders_by_picture_then_bitrate() { + fn rendition(size: Option<(u32, u32)>, bitrate: Option) -> VideoConfig { + let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = size.map(|(w, _)| w); + config.coded_height = size.map(|(_, h)| h); + config.bitrate = bitrate; + config + } + + let mut video = Video::default(); + // Names sort worst first, so name order alone would pick the wrong one. + video.insert("a", rendition(None, Some(9_000_000))).unwrap(); + video.insert("b", rendition(Some((640, 360)), Some(1_000_000))).unwrap(); + video.insert("c", rendition(Some((1280, 720)), None)).unwrap(); + video + .insert("d", rendition(Some((1280, 720)), Some(3_000_000))) + .unwrap(); + video + .insert("e", rendition(Some((1280, 720)), Some(3_000_000))) + .unwrap(); + video + .insert("f", rendition(Some((1920, 1080)), Some(6_000_000))) + .unwrap(); + + let names: Vec<_> = video.ranked().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names, ["f", "d", "e", "c", "b", "a"]); + } + #[test] fn label_round_trips() { let mut config = VideoConfig::new(VideoCodec::VP8); diff --git a/rs/moq-mux/src/container/flv/export.rs b/rs/moq-mux/src/container/flv/export.rs index 72c7bcb4a5..73491f6ae6 100644 --- a/rs/moq-mux/src/container/flv/export.rs +++ b/rs/moq-mux/src/container/flv/export.rs @@ -10,7 +10,8 @@ //! other codecs through unchanged. //! //! By default FLV carries a single video and a single audio stream, so only the -//! first rendition of each kind is muxed and the rest are ignored. With +//! best video rendition (see [`Video::ranked`](hang::catalog::Video::ranked)) and +//! the first audio rendition are muxed and the rest are ignored. With //! [`with_multitrack`](Export::with_multitrack) every rendition is muxed instead, //! each as an enhanced-RTMP multitrack track addressed by its own track id (use //! this only for a player that advertised the `Multitrack` capability). @@ -105,8 +106,10 @@ pub struct Export { catalog: Option, max_age: std::time::Duration, /// Emit every rendition as an enhanced-RTMP multitrack track, rather than only - /// the first video + first audio rendition. + /// the best video + first audio rendition. multitrack: bool, + /// Only mux the renditions this selects, or every rendition when unset. + select: Option, video: Vec, audio: Vec, @@ -181,6 +184,7 @@ impl Export { catalog: Some(catalog), max_age: std::time::Duration::ZERO, multitrack: false, + select: None, video: Vec::new(), audio: Vec::new(), header_emitted: false, @@ -198,7 +202,7 @@ impl Export { } /// Mux every rendition as an enhanced-RTMP multitrack track (one FLV stream - /// carrying several video and/or audio tracks), rather than only the first + /// carrying several video and/or audio tracks), rather than only the best /// video + first audio rendition. /// /// Only enable this for a player that advertised the enhanced-RTMP @@ -209,6 +213,15 @@ impl Export { self } + /// Only mux the renditions `select` keeps, such as the codecs a player can decode. + /// + /// A single-track stream then carries the best video rendition among them. + /// Defaults to every rendition. + pub fn with_select(mut self, select: crate::select::Broadcast) -> Self { + self.select = Some(select); + self + } + /// Get the next byte chunk. pub async fn next(&mut self) -> anyhow::Result> { kio::wait(|waiter| self.poll_next(waiter)).await @@ -304,10 +317,12 @@ impl Export { fn update_catalog(&mut self, mut catalog: Catalog) -> anyhow::Result<()> { self.source.retain_valid_media(&mut catalog); + if let Some(select) = &self.select { + select.retain(&mut catalog); + } - // A single-track FLV stream binds only the first rendition of each kind; - // multitrack binds them all. Bind newly-seen renditions in name order (the - // catalog is a BTreeMap) so each keeps a stable track id. + // A single-track FLV stream binds one rendition of each kind; multitrack + // binds them all. // // Only bind before the header is emitted: the sequence-header (config) tags // go out with the header, and there's no in-band way to introduce a new @@ -317,7 +332,8 @@ impl Export { if !self.header_emitted { self.bind_video(&catalog)?; self.bind_audio(&catalog)?; - } else if catalog.video.renditions.len() > self.video.len() || catalog.audio.renditions.len() > self.audio.len() + } else if self.multitrack + && (catalog.video.renditions.len() > self.video.len() || catalog.audio.renditions.len() > self.audio.len()) { tracing::warn!("ignoring FLV rendition that appeared after the stream header"); } @@ -336,9 +352,21 @@ impl Export { } fn bind_video(&mut self, catalog: &Catalog) -> anyhow::Result<()> { - for (name, config) in &catalog.video.renditions { + let renditions: Vec<_> = if self.multitrack { + // Name order (the catalog is a BTreeMap), so each keeps a stable track id. + catalog.video.renditions.iter().collect() + } else { + // The best rendition FLV can carry, whatever the names. The rest follow in + // rank order so a catalog with nothing FLV carries fails on its best one. + let mut ranked: Vec<_> = catalog.video.ranked().collect(); + ranked.sort_by_key(|(_, config)| { + video_flavor(config).is_err() || !matches!(config.container, Container::Legacy | Container::Loc) + }); + ranked + }; + + for (name, config) in renditions { if !self.multitrack && !self.video.is_empty() { - tracing::warn!("FLV export only supports one video track; ignoring the rest (enable multitrack)"); break; } if self.video.iter().any(|t| &t.name == name) { diff --git a/rs/moq-mux/src/container/flv/export_test.rs b/rs/moq-mux/src/container/flv/export_test.rs index 3c1350f3a5..147bdc165e 100644 --- a/rs/moq-mux/src/container/flv/export_test.rs +++ b/rs/moq-mux/src/container/flv/export_test.rs @@ -509,6 +509,9 @@ type Keepalive = ( /// Build a broadcast with two H.264 video renditions plus one AAC audio rendition, /// each with a single keyframe, so multitrack export has several tracks to mux. +/// +/// The first description's rendition is the smaller and sorts first by name, so +/// name order alone would pick the wrong one. fn build_multitrack_broadcast() -> (moq_net::broadcast::Consumer, Vec>, Keepalive) { use hang::catalog::{AAC, AudioConfig, Container, H264, VideoConfig}; use moq_net::Timestamp; @@ -523,7 +526,7 @@ fn build_multitrack_broadcast() -> (moq_net::broadcast::Consumer, Vec>, let mut tracks = Vec::new(); let descriptions: Vec> = vec![avcc_level(0x1f), avcc_level(0x1e)]; - for description in &descriptions { + for (description, (width, height)) in descriptions.iter().zip([(640, 360), (1920, 1080)]) { let track = producer.create_track(producer.unique_name(".avc1"), None).unwrap(); let mut config = VideoConfig::new(H264 { profile: 0x42, @@ -533,6 +536,8 @@ fn build_multitrack_broadcast() -> (moq_net::broadcast::Consumer, Vec>, }); config.container = Container::Legacy; config.description = Some(Bytes::from(description.clone())); + config.coded_width = Some(width); + config.coded_height = Some(height); catalog .modify() .unwrap() @@ -605,8 +610,7 @@ async fn drain_to_end(mut exporter: Export, keepalive: Keepalive) -> Vec { } /// With multitrack enabled, every rendition is muxed as an enhanced-RTMP -/// multitrack track and survives an export -> import round trip. Without it, only -/// the first video rendition is muxed. +/// multitrack track and survives an export -> import round trip. #[tokio::test(start_paused = true)] async fn export_multitrack_roundtrips_all_renditions() { let (consumer, descriptions, keepalive) = build_multitrack_broadcast(); @@ -665,11 +669,11 @@ async fn export_multitrack_roundtrips_all_renditions() { assert_eq!(got, want, "each rendition should keep its own avcC"); } -/// Without multitrack, a multi-rendition broadcast exports only the first video -/// rendition (the single-track fallback for a legacy player). +/// Without multitrack, a multi-rendition broadcast exports only the best video +/// rendition (the single-track fallback for a legacy player), not the first by name. #[tokio::test(start_paused = true)] -async fn export_without_multitrack_keeps_first_rendition() { - let (consumer, _, keepalive) = build_multitrack_broadcast(); +async fn export_without_multitrack_keeps_best_rendition() { + let (consumer, descriptions, keepalive) = build_multitrack_broadcast(); let exporter = Export::new(crate::source::announced(&consumer)).await.unwrap(); let exported = drain_to_end(exporter, keepalive).await; @@ -688,13 +692,62 @@ async fn export_without_multitrack_keeps_first_rendition() { imp2.decode(&bytes::BytesMut::from(exported.as_slice())).unwrap(); imp2.finish().unwrap(); + let snap = cat2.snapshot(); + let renditions: Vec<_> = snap.video.renditions.values().collect(); + assert_eq!(renditions.len(), 1, "only one rendition without multitrack"); assert_eq!( - cat2.snapshot().video.renditions.len(), - 1, - "only one rendition without multitrack" + renditions[0].description.as_deref(), + Some(descriptions[1].as_slice()), + "the larger rendition wins" ); } +/// A selection narrows what the export may carry, and a single-track export +/// picks the best rendition among what remains. +#[tokio::test(start_paused = true)] +async fn export_picks_the_best_selected_rendition() { + use crate::catalog::Stream; + + let (consumer, descriptions, keepalive) = build_multitrack_broadcast(); + + // Select only the smaller rendition, so the larger one is off the table. + let snapshot = crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap(); + let small = snapshot + .video + .renditions + .iter() + .find(|(_, config)| config.coded_height == Some(360)) + .map(|(name, _)| name.clone()) + .unwrap(); + + let select = crate::select::Broadcast::default() + .video(crate::select::Video::default().name(small)) + .audio(crate::select::Audio::default()); + let exporter = Export::new(crate::source::announced(&consumer)) + .await + .unwrap() + .with_select(select); + let exported = drain_to_end(exporter, keepalive).await; + + let mut bcast2 = moq_net::broadcast::Info::new().produce(); + let cat2 = crate::catalog::Producer::new(&mut bcast2, crate::catalog::Config::default()).unwrap(); + let mut imp2 = Import::new(bcast2, cat2.reserve()); + imp2.decode(&bytes::BytesMut::from(exported.as_slice())).unwrap(); + imp2.finish().unwrap(); + + let snap = cat2.snapshot(); + let renditions: Vec<_> = snap.video.renditions.values().collect(); + assert_eq!(renditions.len(), 1); + assert_eq!(renditions[0].description.as_deref(), Some(descriptions[0].as_slice())); + assert_eq!(snap.audio.renditions.len(), 1, "audio is selected too"); +} + struct ParsedTag { tag_type: u8, timestamp: u32, diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index ab9f59cf87..eae8a5aa82 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -192,8 +192,8 @@ fn valid_reference(source: &moq_mux::Source, broadcast: Option<&moq_net::path::R source.resolve_reference(broadcast).is_some() } -/// Find the first catalog rendition for the given codec and build a -/// [`codec::Track`] subscribed to it, honoring an optional cross-broadcast +/// Find a catalog rendition for the given codec (the best one, for video) and +/// build a [`codec::Track`] subscribed to it, honoring an optional cross-broadcast /// reference (the rendition's catalog `broadcast` field). Returns `None` if no /// rendition matches. async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) -> Result> { @@ -218,12 +218,7 @@ async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) - Codec::Av1 => VideoCodecKind::AV1, _ => unreachable!(), }; - let Some((name, config)) = catalog - .video - .renditions - .iter() - .find(|(_, c)| c.codec.kind() == target && valid_reference(source, c.broadcast.as_ref())) - else { + let Some((name, config)) = pick_video(source, catalog, target) else { return Ok(None); }; let track = source.subscribe_track(config.broadcast.as_ref(), name).await?; @@ -233,6 +228,19 @@ async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) - } } +/// The best [ranked](hang::catalog::Video::ranked) video rendition in `target`'s codec +/// that `source` can reach. +fn pick_video<'a>( + source: &moq_mux::Source, + catalog: &'a Catalog, + target: VideoCodecKind, +) -> Option<(&'a String, &'a hang::catalog::VideoConfig)> { + catalog + .video + .ranked() + .find(|(_, c)| c.codec.kind() == target && valid_reference(source, c.broadcast.as_ref())) +} + /// Per-rendition pump task. Reads frames, converts the timestamp into the /// codec's clock domain, and forwards as a [`WriteRequest`]. async fn pump(mid: Mid, pt: Pt, clock_rate: Frequency, mut track: codec::Track, tx: mpsc::Sender) { @@ -345,6 +353,39 @@ mod tests { assert_eq!(egress.catalog_codecs(), vec![Codec::Vp8]); } + #[test] + fn picks_the_best_video_rendition_whatever_its_name() { + let origin = produce_origin(); + let source = moq_mux::Source::new(origin.consume(), "a/pub"); + let mut catalog = Catalog::default(); + + let rendition = |height: u32| { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + config.coded_width = Some(height * 16 / 9); + config.coded_height = Some(height); + config + }; + // Name order would pick the lowest rendition. + catalog.video.renditions.insert("a".to_string(), rendition(360)); + catalog.video.renditions.insert("b".to_string(), rendition(1080)); + catalog.video.renditions.insert("c".to_string(), rendition(720)); + catalog + .video + .renditions + .insert("d".to_string(), VideoConfig::new(VideoCodec::VP8)); + + let (name, _) = pick_video(&source, &catalog, VideoCodecKind::H264).unwrap(); + assert_eq!(name, "b"); + let (name, _) = pick_video(&source, &catalog, VideoCodecKind::VP8).unwrap(); + assert_eq!(name, "d"); + assert!(pick_video(&source, &catalog, VideoCodecKind::AV1).is_none()); + } + #[test] fn egress_clock_ignores_cross_track_dequeue_jitter() { let mut clock = EgressClock::default(); diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index a4ed3c0094..19f661f371 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -41,9 +41,10 @@ use crate::rml::time::RtmpTimestamp; use futures::StreamExt; use futures::future::BoxFuture; use futures::stream::FuturesUnordered; -use hang::catalog::{AudioCodec, VideoCodec}; +use hang::catalog::{AudioCodec, VideoCodecKind, VideoConfig}; use moq_mux::catalog::{CatalogFormat, Stream as CatalogStream}; use moq_mux::container::flv::{Export as FlvExport, Import as FlvImport}; +use moq_mux::select; use moq_net::origin; use socket2::{SockRef, TcpKeepalive}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; @@ -776,10 +777,13 @@ impl Play { } } }; - if let Err(reason) = check_play_capabilities(&catalog, &self.capabilities) { - tracing::debug!(peer = %self.peer, %path, %reason, "rejecting RTMP play: unsupported client capabilities"); - return self.reject(&reason).await; - } + let select = match play_selection(&catalog, &self.capabilities) { + Ok(select) => select, + Err(reason) => { + tracing::debug!(peer = %self.peer, %path, %reason, "rejecting RTMP play: unsupported client capabilities"); + return self.reject(&reason).await; + } + }; // The export re-resolves the broadcast (and any sibling broadcast a rendition's // catalog `broadcast` field references) through the origin. @@ -787,7 +791,8 @@ impl Play { .await .map_err(|e| anyhow::anyhow!("init FLV export: {e}"))? .with_max_age(self.latency) - .with_multitrack(self.capabilities.multitrack); + .with_multitrack(self.capabilities.multitrack) + .with_select(select); // Resolve the catalog and codec headers before Play.Start, too. Otherwise a // broadcast that never produces a playable FLV header looks successful to the @@ -868,24 +873,34 @@ impl Play { } } -fn check_play_capabilities( +/// Narrow a play to the video the client can decode, or refuse it. +/// +/// A multitrack client receives every rendition, so it must play them all. A +/// single-track client receives the best video rendition it can play. +fn play_selection( catalog: &moq_mux::catalog::hang::Catalog, capabilities: &ClientCapabilities, -) -> std::result::Result<(), String> { - let limit = if capabilities.multitrack { usize::MAX } else { 1 }; - - for config in catalog.video.renditions.values().take(limit) { - let Some(fourcc) = video_fourcc(&config.codec, capabilities.multitrack) else { - continue; - }; - if !capabilities.supports_video(&fourcc) { - return Err(format!( +) -> std::result::Result { + let playable = |config: &VideoConfig| plays_video(capabilities, config.codec.kind()); + let refused = if capabilities.multitrack { + catalog.video.renditions.values().find(|config| !playable(config)) + } else if catalog.video.renditions.values().any(playable) { + None + } else { + catalog.video.ranked().next().map(|(_, config)| config) + }; + if let Some(config) = refused { + return Err(match video_fourcc(config.codec.kind(), capabilities.multitrack) { + Some(fourcc) => format!( "client did not advertise required RTMP FourCC {}", fourcc_label(&fourcc) - )); - } + ), + None => format!("RTMP can't carry video codec {}", config.codec), + }); } + // Audio is still the first rendition by name for a single-track client. + let limit = if capabilities.multitrack { usize::MAX } else { 1 }; for config in catalog.audio.renditions.values().take(limit) { let Some(fourcc) = audio_fourcc(&config.codec, capabilities.multitrack) else { continue; @@ -898,16 +913,40 @@ fn check_play_capabilities( } } - Ok(()) + let mut video = select::Video::default(); + let mut any = false; + for kind in [ + VideoCodecKind::H264, + VideoCodecKind::H265, + VideoCodecKind::AV1, + VideoCodecKind::VP9, + ] { + if plays_video(capabilities, kind) { + video = video.codec(kind); + any = true; + } + } + // An empty codec list would select every codec, so a client that plays none gets no video. + let select = select::Broadcast::default().audio(select::Audio::default()); + Ok(if any { select.video(video) } else { select }) } -fn video_fourcc(codec: &VideoCodec, multitrack: bool) -> Option<[u8; 4]> { - match codec { - VideoCodec::H264(_) if multitrack => Some(*b"avc1"), - VideoCodec::H265(_) => Some(*b"hvc1"), - VideoCodec::AV1(_) => Some(*b"av01"), - VideoCodec::VP9(_) => Some(*b"vp09"), - VideoCodec::H264(_) | VideoCodec::VP8 | VideoCodec::Unknown(_) => None, +/// Whether a client with `capabilities` can play `kind` over FLV. +fn plays_video(capabilities: &ClientCapabilities, kind: VideoCodecKind) -> bool { + match video_fourcc(kind, capabilities.multitrack) { + Some(fourcc) => capabilities.supports_video(&fourcc), + // Every client plays H.264 by its legacy CodecID; nothing else goes without a FourCC. + None => kind == VideoCodecKind::H264, + } +} + +/// The enhanced-RTMP FourCC a client must advertise to play `kind`, if any. +fn video_fourcc(kind: VideoCodecKind, multitrack: bool) -> Option<[u8; 4]> { + match kind { + VideoCodecKind::H264 if multitrack => Some(*b"avc1"), + VideoCodecKind::H265 => Some(*b"hvc1"), + VideoCodecKind::AV1 => Some(*b"av01"), + VideoCodecKind::VP9 => Some(*b"vp09"), _ => None, } } @@ -1769,6 +1808,68 @@ mod tests { server_task.await.unwrap(); } + /// A single-track client gets the best rendition it can decode, whatever the + /// names; a multitrack client must decode every rendition. + #[test] + fn play_selection_picks_the_best_playable_rendition() { + fn rendition(codec: impl Into, height: u32) -> VideoConfig { + let mut config = VideoConfig::new(codec); + config.coded_width = Some(height * 16 / 9); + config.coded_height = Some(height); + config + } + let h264 = hang::catalog::H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }; + + let mut catalog = moq_mux::catalog::hang::Catalog::default(); + // Name order would pick the lowest rendition. + catalog + .video + .renditions + .insert("a".to_string(), rendition(h264.clone(), 360)); + catalog.video.renditions.insert( + "b".to_string(), + rendition( + hang::catalog::H265 { + in_band: false, + profile_space: 0, + profile_idc: 1, + profile_compatibility_flags: [0x60, 0, 0, 0], + tier_flag: false, + level_idc: 120, + constraint_flags: [0x90, 0, 0, 0, 0, 0], + }, + 1080, + ), + ); + catalog.video.renditions.insert("c".to_string(), rendition(h264, 720)); + + let best = |capabilities: &ClientCapabilities| { + let select = play_selection(&catalog, capabilities).unwrap(); + let mut catalog = catalog.clone(); + select.retain(&mut catalog); + catalog.video.ranked().next().map(|(name, _)| name.clone()) + }; + + let legacy = ClientCapabilities::default(); + assert_eq!(best(&legacy).as_deref(), Some("c")); + + let hevc = FourCcSupport { + any: false, + fourccs: vec![*b"hvc1"], + }; + let enhanced = ClientCapabilities::new(0, hevc.clone(), FourCcSupport::default()); + assert_eq!(best(&enhanced).as_deref(), Some("b")); + + // Multitrack carries every rendition, so one the client can't decode refuses the play. + let multitrack = ClientCapabilities::new(CAPS_EX_MULTITRACK, hevc, FourCcSupport::default()); + assert!(play_selection(&catalog, &multitrack).is_err()); + } + #[test] fn play_capability_check_uses_per_kind_decode_support() { let mut catalog = moq_mux::catalog::hang::Catalog::default(); @@ -1786,7 +1887,7 @@ mod tests { fourccs: vec![*b"vp09"], }; assert!( - check_play_capabilities( + play_selection( &catalog, &ClientCapabilities::new(0, video_support, FourCcSupport::default()) ) @@ -1798,7 +1899,7 @@ mod tests { fourccs: vec![*b"vp09"], }; assert!( - check_play_capabilities( + play_selection( &catalog, &ClientCapabilities::new(0, FourCcSupport::default(), audio_support) ) @@ -1810,7 +1911,7 @@ mod tests { fourccs: Vec::new(), }; assert!( - check_play_capabilities( + play_selection( &catalog, &ClientCapabilities::new(0, wildcard, FourCcSupport::default()) ) diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index 5568b15626..8f741798cf 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -158,32 +158,20 @@ impl Decoders { } } -/// Pick the rendition to transcode from: the highest-resolution rendition local +/// Pick the rendition to transcode from: the best [ranked](Video::ranked) rendition local /// to the source broadcast that this host can decode. /// /// [`Error::NoSource`] means wait for a later snapshot. Any other error means -/// nothing on offer can be decoded here, and is why the tallest one refused. +/// nothing on offer can be decoded here, and is why the best one refused. pub(crate) async fn choose_source(video: &Video, decoders: &mut Decoders) -> Result<(String, VideoConfig), Error> { - let mut candidates: Vec<_> = video - .renditions - .iter() + // Best first. A rendition without dimensions ranks after every one with them: + // it can't be chosen yet, but it can still keep the transcoder waiting. + let candidates = video + .ranked() // A rendition that itself lives in another broadcast can't be subscribed // through this one; composing relative references is a follow-up. .filter(|(_, config)| config.broadcast.is_none()) - .filter_map(|(name, config)| Some((name, config, codec(config)?))) - .collect(); - // Largest first, ties going to the last name as they always have. A rendition - // without dimensions sorts after every one with them: it can't be chosen yet, - // but it can still keep the transcoder waiting. - candidates.reverse(); - candidates.sort_by_key(|(_, config, _)| { - std::cmp::Reverse(( - dimensions(config).is_some(), - config.coded_height, - config.coded_width, - config.bitrate, - )) - }); + .filter_map(|(name, config)| Some((name, config, codec(config)?))); let mut refused = None; for (name, config, codec) in candidates { @@ -749,14 +737,14 @@ mod tests { let (name, _) = choose_source(&video, &mut decoders).await.unwrap(); assert_eq!(name, "avc"); - // Hardware that decodes H.265 keeps the usual pick of the tallest it can. + // Hardware that decodes H.265 keeps the usual pick of the largest it can. let (name, _) = choose_source(&video, &mut Decoders::assume(&[Codec::H264, Codec::H265])) .await .unwrap(); assert_eq!(name, "hevc"); } - /// Nothing on offer decodes here: a refusal carrying why the tallest + /// Nothing on offer decodes here: a refusal carrying why the largest /// rendition's decoder refused, rather than waiting on a complete catalog. #[tokio::test] async fn refuses_when_no_rendition_decodes() { diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index 99e1f1fa84..2fbbd371cb 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -1452,7 +1452,7 @@ mod tests { .expect("run kept waiting for a source it can never decode"); match result { - // Why the tallest rendition's decoder refused. + // Why the largest rendition's decoder refused. Err(Error::Video(moq_video::Error::UnknownDecoder { name, codec, .. })) => { assert_eq!(name, "missing"); assert_eq!(codec, moq_video::decode::Codec::H265); From c2c4efd24ff088805f1ab4cbc2f7246fc8e324d4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:05:43 -0700 Subject: [PATCH 15/87] docs(quest): plan signed priority (#4275) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 + quest/m1/signed-priority.md | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 quest/m1/signed-priority.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 7410884299..a0bf41576d 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -72,6 +72,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [BBR idle burst](/quest/m1/bbr-idle-burst.md) - a BBRv3 burst after a long idle paces near the learned bandwidth, proven by a fork regression - [P2P](/quest/m1/p2p/README.md) - opted-in clients serve each other over data channels and iroh while the relay stays the rendezvous and the fallback, under application policy - [One port](/quest/m1/one-port/README.md) - a relay speaks QUIC, STUN, WebRTC media, and SRT on one UDP port and HTTP, RTMP, and RTMPS on one TCP port +- [Signed priority](/quest/m1/signed-priority.md) - on dev, every API priority is an `i8` with 0 as the unset midpoint, and hang's built-ins sit above it - [Scope track priority](/quest/m1/track-priority-scope.md) - priority orders one owner's streams, and a shared cluster session is fair across tenants - [Stream sessions](/quest/m1/uring-tcp/README.md) - serve WebSocket and HTTP from the io_uring workers, where io_uring pays off most - [IETF on the ring](/quest/m1/uring-ietf.md) - the io_uring workers serve moq-transport sessions too, so a uring relay drops no client protocol diff --git a/quest/m1/signed-priority.md b/quest/m1/signed-priority.md new file mode 100644 index 0000000000..12ea0eb5ee --- /dev/null +++ b/quest/m1/signed-priority.md @@ -0,0 +1,44 @@ +# [L] Signed priority + +## Goal + +Every priority in the API is an `i8`, higher first, with 0 as the unset +midpoint: `track::Info`, `Subscription`, and `group::Fetch` in Rust, their +JS counterparts, moq-ffi, libmoq, and every wrapper. Nobody has to know that +127 is the middle of a `u8`, the default the moxygen line ships. The wire +stays a byte. + +## Plan + +Decided with the maintainer: + +- moq-lite carries `p + 128` (flip the top bit). The mapping is one-to-one + and keeps order, so a given byte means what it means today; only the API + number changes. The default becomes byte 128. +- IETF carries `128 - p`, saturating. An unset priority goes out as the + draft's usual 128, and an absent IETF priority decodes to 0. The cost is + that i8 -128 and -127 share byte 255, and IETF bytes 0 and 1 both decode to + 127. Pin both ends in tests. +- hang's built-in priorities move above 0, so hang media outranks a track that + never set one. Something like catalog 40, text 30, audio 20, video 10; the + spacing is the implementer's call. Rust and JS keep matching values. +- A zeroed libmoq `moq_track_info` then means the default, which retires the + need for a `priority_present` flag. + +Changing published `u8` fields to `i8` is an API break in every language, so +this lands on `dev`. Look for anything that does arithmetic on priority +(the lite send queue, JS send-order packing, the bandwidth allocator, the +relay's max-of-subscribers) and keep its ordering, not just its type. + +moq-archive's `Info::priority` follows. Its version-1 `.info` stores the +`u8`, so existing recordings must keep their meaning: store the moq-lite byte +(`p + 128`) or bump the format version, never reinterpret silently. +`doc/concept/moq-lite.md` (the 0..255 knob) and `doc/concept/standard.md` +(IETF 128 maps to 127) move to the new range and mapping. + +Report the wire impact in the PR: none in format, but the default byte on +moq-lite moves again. + +## Related + +- [Scope track priority](/quest/m1/track-priority-scope.md) - which streams a priority competes with, not its type From c9c48edd26927306717757358f5cd13cdcec3565 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:08:35 -0700 Subject: [PATCH 16/87] fix(fmp4): carry Opus pre-skip and gain through dOps (#4294) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 5 +- quest/m1/cmaf-opus-dops.md | 20 ---- quest/m1/cmaf-opus-surround.md | 22 ++++ quest/m1/js-dops-pre-skip.md | 12 +++ quest/m1/mp4-atom-dops-mapping.md | 15 +++ quest/m1/opus-catalog-rate.md | 15 +++ rs/moq-mux/src/container/fmp4/export_test.rs | 19 ++++ rs/moq-mux/src/container/fmp4/import.rs | 18 +++- rs/moq-mux/src/container/fmp4/import_test.rs | 103 +++++++++++++++++-- rs/moq-mux/src/container/fmp4/mod.rs | 18 ++-- 10 files changed, 203 insertions(+), 44 deletions(-) delete mode 100644 quest/m1/cmaf-opus-dops.md create mode 100644 quest/m1/cmaf-opus-surround.md create mode 100644 quest/m1/js-dops-pre-skip.md create mode 100644 quest/m1/mp4-atom-dops-mapping.md create mode 100644 quest/m1/opus-catalog-rate.md diff --git a/quest/m1/README.md b/quest/m1/README.md index a0bf41576d..09b3bdd90c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -55,7 +55,10 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders - [Opus concealment](/quest/m1/opus-conceal.md) - a lost Opus packet conceals the last packet's length, not 120 ms - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 -- [CMAF Opus](/quest/m1/cmaf-opus-dops.md) - fMP4 import and export keep the Opus pre-skip and gain +- [Opus catalog rate](/quest/m1/opus-catalog-rate.md) - MKV Opus import publishes the 48 kHz codec rate in the catalog, not the OpusHead input rate +- [mp4-atom dOps mapping](/quest/m1/mp4-atom-dops-mapping.md) - a released mp4-atom reads and writes any `dOps` channel mapping family and table +- [CMAF surround Opus](/quest/m1/cmaf-opus-surround.md) - fMP4 import and export carry an Opus channel mapping table +- [js/hang dOps pre-skip](/quest/m1/js-dops-pre-skip.md) - CMAF encoding in js/hang stops hard-coding a 312-sample pre-skip - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match - [JS rendition ranking](/quest/m1/js-ranked.md) - `@moq/hang` ranks video renditions like Rust, and `@moq/watch`'s fallback uses it - [Audio rendition pick](/quest/m1/audio-ranked.md) - single-track FLV/RTMP and WHEP serve the best audio rendition, not the first by name diff --git a/quest/m1/cmaf-opus-dops.md b/quest/m1/cmaf-opus-dops.md deleted file mode 100644 index 0456fb6f25..0000000000 --- a/quest/m1/cmaf-opus-dops.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Carry Opus pre-skip and gain through CMAF - -## Goal - -An Opus track imported from or exported to fMP4 keeps the pre-skip and output -gain its `dOps` box declares, so it decodes the same as the OpusHead it came -from. - -## Plan - -The fMP4 importer builds an Opus catalog entry from the sample entry alone and -publishes no description, so a CMAF Opus track decodes with no pre-skip or -gain. Build the OpusHead from `dOps` (input rate, channels, pre-skip, gain) -with `moq_mux::codec::opus::Config` and publish it as the description. The -exporter writes `dOps.output_gain` as 0; take it from the parsed head like the -pre-skip. Refuse a `dOps` channel mapping family other than 0 if `mp4-atom` -exposes one, rather than dropping its table. - -Regression: an fMP4 Opus fixture with nonzero pre-skip and gain round-trips -through import and export with both preserved. diff --git a/quest/m1/cmaf-opus-surround.md b/quest/m1/cmaf-opus-surround.md new file mode 100644 index 0000000000..da3ae24b66 --- /dev/null +++ b/quest/m1/cmaf-opus-surround.md @@ -0,0 +1,22 @@ +# [S] Carry surround Opus through CMAF + +## Goal + +An fMP4 Opus track whose `dOps` declares channel mapping family 1 imports with +its mapping table in the OpusHead description, and a family 1 description +exports to a `dOps` that keeps the table. Today both directions refuse it. + +## Plan + +Build the head from the `dOps` family and table with `opus::Mapping` on import, +and write the table from the parsed head on export in place of the +`UnsupportedMappingFamily` refusal in `synthesize_audio_trak`. Keep refusing +families the head cannot describe. + +Regression: a family 1 5.1 `dOps` round-trips through import and export with +its table intact. + +## Required + +- [mp4-atom dOps mapping](/quest/m1/mp4-atom-dops-mapping.md) - `Dops` carries the mapping family and table +- [Audio codecs](/quest/m1/audio-codecs/README.md) - `opus::Mapping` and a `Config::encode` that writes any family's table diff --git a/quest/m1/js-dops-pre-skip.md b/quest/m1/js-dops-pre-skip.md new file mode 100644 index 0000000000..273669a0f9 --- /dev/null +++ b/quest/m1/js-dops-pre-skip.md @@ -0,0 +1,12 @@ +# [XS] js/hang dOps without an invented pre-skip + +## Goal + +`js/hang` CMAF encoding stops writing a hard-coded 312-sample pre-skip into +`dOps` when the track has no description, so a remuxed track never trims audio +its encoder did not ask to trim. + +## Plan + +Without an OpusHead there is no known pre-skip; write 0 or refuse, whichever +matches how the Rust exporter (`synthesize_audio_trak`) handles the same case. diff --git a/quest/m1/mp4-atom-dops-mapping.md b/quest/m1/mp4-atom-dops-mapping.md new file mode 100644 index 0000000000..3d1f4d818d --- /dev/null +++ b/quest/m1/mp4-atom-dops-mapping.md @@ -0,0 +1,15 @@ +# [S] mp4-atom dOps channel mapping + +## Goal + +A released `mp4-atom` decodes and encodes a `dOps` box with any channel mapping +family, exposing the family and its table (stream count, coupled count, and the +per-channel mapping) instead of refusing a nonzero family. This repository +bumps to that release. + +## Plan + +The work lands in kixelated/mp4-atom. Validate the table against the output +channel count on decode rather than trusting it, and keep family 0 encoding +byte-identical. The bump here is a separate, small step once the release +ships. diff --git a/quest/m1/opus-catalog-rate.md b/quest/m1/opus-catalog-rate.md new file mode 100644 index 0000000000..d6ba6bdd84 --- /dev/null +++ b/quest/m1/opus-catalog-rate.md @@ -0,0 +1,15 @@ +# [XS] Opus catalog rate matches the decoder + +## Goal + +MKV Opus import publishes the codec rate (48 kHz) as the catalog sample rate, +not the OpusHead input rate, which is informational. The catalog describes +what the decoder outputs. + +## Plan + +fMP4 import already does this. Check the other importers that build the +catalog from an OpusHead (FLV and the raw Opus import look similar) and align +them in the same PR. The OpusHead description keeps the input rate. + +Regression: a 44.1 kHz-input OpusHead imports with a 48 kHz catalog rate. diff --git a/rs/moq-mux/src/container/fmp4/export_test.rs b/rs/moq-mux/src/container/fmp4/export_test.rs index c5d20ebac6..1f454c3fea 100644 --- a/rs/moq-mux/src/container/fmp4/export_test.rs +++ b/rs/moq-mux/src/container/fmp4/export_test.rs @@ -873,6 +873,25 @@ fn synthesize_opus_trak_preserves_pre_skip() { assert_eq!(opus.dops.pre_skip, 312); } +/// dOps has nowhere to put a channel mapping table, so a family 1 head is refused rather +/// than written as family 0 without it. +#[test] +fn synthesize_opus_trak_refuses_mapping_family() { + use hang::catalog::{AudioCodec, AudioConfig}; + + let mut head = crate::codec::opus::Config::new(48_000, 2).encode().unwrap().to_vec(); + head[18] = 1; // channel mapping family + head.extend_from_slice(&[1, 1, 0, 1]); // one coupled stream feeding both channels + let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); + config.description = Some(head.into()); + + let err = super::synthesize_audio_trak(1, 48_000, &config).unwrap_err(); + assert!(matches!( + err, + super::Error::Opus(crate::codec::opus::Error::UnsupportedMappingFamily(1)) + )); +} + /// A legacy FLAC rendition (no init segment) synthesizes a `fLaC` sample entry /// whose `dfLa` STREAMINFO is rebuilt from the catalog description. #[test] diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 15d2cf26ea..d30facd4e4 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -561,11 +561,19 @@ impl Import { config } mp4_atom::Codec::Opus(opus) => { - let mut config = AudioConfig::new( - AudioCodec::Opus, - opus.audio.sample_rate.integer() as _, - opus.audio.channel_count as _, - ); + // dOps carries the OpusHead fields in ISOBMFF byte order; republish them as the + // OpusHead description so decoders apply its pre-skip and gain. mp4-atom already + // refuses a nonzero channel mapping family, so the table is never dropped here. + let dops = &opus.dops; + let mut head = + crate::codec::opus::Config::new(dops.input_sample_rate, dops.output_channel_count as u32) + .with_pre_skip(dops.pre_skip); + head.output_gain = dops.output_gain; + + // The catalog describes the decoder's output: Opus always decodes at 48 kHz, whatever + // the sample entry or the informational input rate claims. + let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, head.channel_count); + config.description = Some(head.encode()?); config.container = container; config } diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index 9d54992e80..40c10a626d 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -554,7 +554,24 @@ fn test_flac_catalog() { }, }; - let trak = super::build_audio_trak(1, 96_000, mp4_atom::Codec::from(flac)); + let data = audio_init(super::build_audio_trak(1, 96_000, mp4_atom::Codec::from(flac))); + + let catalog = run_fmp4(&data); + assert_eq!(catalog.audio.renditions.len(), 1); + + let a = catalog.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, hang::catalog::AudioCodec::Flac)); + assert_eq!(a.sample_rate, 96_000); + assert_eq!(a.channel_count, 2); + // fmp4 import is CMAF passthrough. + assert!(matches!(a.container, Container::Cmaf { .. })); + // The WebCodecs FLAC description: `fLaC` marker + STREAMINFO. + let desc = a.description.as_ref().expect("flac description"); + assert_eq!(&desc[..4], b"fLaC"); +} + +/// An init segment (ftyp + moov) holding a single audio trak with track ID 1. +fn audio_init(trak: mp4_atom::Trak) -> Vec { let moov = mp4_atom::Moov { mvhd: mp4_atom::Mvhd { timescale: 1000, @@ -580,19 +597,83 @@ fn test_flac_catalog() { let mut data = Vec::new(); ftyp.encode(&mut data).unwrap(); moov.encode(&mut data).unwrap(); + data +} - let catalog = run_fmp4(&data); - assert_eq!(catalog.audio.renditions.len(), 1); +/// An Opus init segment whose dOps declares a 44.1 kHz input, 312 samples of pre-skip, +/// and -6 dB of gain, behind a sample entry claiming `entry_rate`. +fn opus_init(entry_rate: u16) -> (mp4_atom::Dops, Vec) { + let dops = mp4_atom::Dops { + output_channel_count: 2, + pre_skip: 312, + input_sample_rate: 44_100, + output_gain: -1536, + }; + let opus = mp4_atom::Opus { + audio: mp4_atom::Audio { + data_reference_index: 1, + channel_count: 2, + sample_size: 16, + sample_rate: mp4_atom::FixedPoint::from(entry_rate), + }, + dops: dops.clone(), + btrt: None, + }; + let data = audio_init(super::build_audio_trak(1, 48_000, mp4_atom::Codec::from(opus))); + (dops, data) +} - let a = catalog.audio.renditions.values().next().unwrap(); - assert!(matches!(a.codec, hang::catalog::AudioCodec::Flac)); - assert_eq!(a.sample_rate, 96_000); +/// dOps becomes the OpusHead description, and a track re-exported from that description +/// writes the same dOps back, so pre-skip and gain survive the round trip. +#[test] +fn opus_dops_round_trips() { + let (dops, data) = opus_init(48_000); + + let catalog = run_fmp4(&data); + let a = catalog.audio.renditions.values().next().expect("opus rendition"); + assert!(matches!(a.codec, hang::catalog::AudioCodec::Opus)); + assert_eq!(a.sample_rate, 48_000); assert_eq!(a.channel_count, 2); - // fmp4 import is CMAF passthrough. - assert!(matches!(a.container, Container::Cmaf { .. })); - // The WebCodecs FLAC description: `fLaC` marker + STREAMINFO. - let desc = a.description.as_ref().expect("flac description"); - assert_eq!(&desc[..4], b"fLaC"); + + let desc = a.description.as_ref().expect("opus description"); + let head = crate::codec::opus::Config::parse(&mut desc.as_ref()).unwrap(); + assert_eq!(head.sample_rate, 44_100); + assert_eq!(head.channel_count, 2); + assert_eq!(head.pre_skip, 312); + assert_eq!(head.output_gain, -1536); + + let trak = super::synthesize_audio_trak(1, 48_000, a).expect("synthesize Opus trak"); + match &trak.mdia.minf.stbl.stsd.codecs[0] { + mp4_atom::Codec::Opus(opus) => assert_eq!(opus.dops, dops), + other => panic!("expected Opus sample entry, got {other:?}"), + } +} + +/// The catalog describes the decoder's 48 kHz output even when the sample entry claims +/// another rate. +#[test] +fn opus_catalog_uses_the_decode_rate() { + let (_, data) = opus_init(44_100); + let catalog = run_fmp4(&data); + let a = catalog.audio.renditions.values().next().expect("opus rendition"); + assert_eq!(a.sample_rate, 48_000); +} + +/// A dOps with a channel mapping table is refused rather than imported without it. +#[test] +fn opus_dops_mapping_family_is_refused() { + let (_, mut data) = opus_init(48_000); + + // The mapping family byte follows version, channels, pre-skip, rate, and gain. + let at = data.windows(4).position(|w| w == b"dOps").unwrap() + 4 + 10; + assert_eq!(data[at], 0); + data[at] = 1; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); + let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.reserve()); + assert!(fmp4.decode(&bytes::BytesMut::from(data.as_slice())).is_err()); + assert!(catalog.snapshot().audio.renditions.is_empty()); } // ---- Segment-driven grouping ---- diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 30af4a65c1..f44a39e698 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -715,20 +715,24 @@ pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &Audi let sample_entry = match &config.codec { AudioCodec::Opus => { - let pre_skip = match &config.description { + let head = match &config.description { Some(description) => { - let mut description = description.as_ref(); - crate::codec::opus::Config::parse(&mut description)?.pre_skip + let head = crate::codec::opus::Config::parse(&mut description.as_ref())?; + // dOps shares OpusHead's family 0 layout; a mapping table would need writing too. + if head.mapping_family != 0 { + return Err(crate::codec::opus::Error::UnsupportedMappingFamily(head.mapping_family).into()); + } + head } - None => 0, + None => crate::codec::opus::Config::new(config.sample_rate, config.channel_count), }; mp4_atom::Codec::from(mp4_atom::Opus { audio, dops: mp4_atom::Dops { output_channel_count: config.channel_count as u8, - pre_skip, - input_sample_rate: config.sample_rate, - output_gain: 0, + pre_skip: head.pre_skip, + input_sample_rate: head.sample_rate, + output_gain: head.output_gain, }, btrt: None, }) From 34ba251b1d8b41cd933da8a041e89c910e7bd2fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:16:57 -0700 Subject: [PATCH 17/87] chore: pin shared skills; document package versions in CONTRIBUTING (#4272) Co-authored-by: Claude Opus 5.5 --- .claude/shared | 2 +- CONTRIBUTING.md | 11 +++++++++++ go/wrapper/README.md | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.claude/shared b/.claude/shared index 763fb4c986..e12b7a9dee 160000 --- a/.claude/shared +++ b/.claude/shared @@ -1 +1 @@ -Subproject commit 763fb4c9865a0625716de8cde80d4af1b5edcef7 +Subproject commit e12b7a9dee41b1faf8b3d4344e4233d3f5311818 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10c8135fdb..eed93b1532 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,3 +57,14 @@ For non-trivial tasks, file an issue or offer to run `/plan-quests`. Its `moq-sync` workflow merges n0-computer/noq weekly as a PR; review it like any other, and `PARENT` names the upstream commit each release includes. A carried change lists its upstream PR, or the reason it has none, in the fork PR. For an advisory against noq or Quinn, compare the pinned release's `PARENT` with the fixing upstream commit, then sync, release the fork, and bump the pin here. + +# Versions + +Releases are cut separately; bump only when asked. Each package's version lives in one place: + +- **Rust**: release-plz owns crate versions and Rust dependency requirements. +- **JavaScript**: `js/*/package.json` packages with a `scripts.release` entry (skip private ones like `@moq/clock`, `@moq/wasm`), plus the matching workspace version in `bun.lock`. +- **Python**: `py/moq-rs/pyproject.toml` only; `py/moq-ffi` follows the `moq-ffi-v*` tag and Rust crate. +- **Swift**: `swift/VERSION`. **Kotlin**: `moq.version` in `kt/gradle.properties`. **Dart**: `version` in `dart/moq/pubspec.yaml`. Their FFI counterparts track the Rust crate. +- **Go**: `go/wrapper/VERSION` holds a human-owned `MAJOR.MINOR` line; CI derives the patch, so only edit it for a breaking API. Leave the placeholder FFI version in `go.mod` alone. +- **OBS**: the plugin takes the `libmoq` crate's version (`cpp/obs/CMakeLists.txt`), so release-plz owns it. diff --git a/go/wrapper/README.md b/go/wrapper/README.md index cf241671b5..dbc9bd15b0 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -115,7 +115,7 @@ deliver them, and there is no stream fallback. ## Versioning -`VERSION` holds the human-owned `MAJOR.MINOR` line (the wrapper API version). Bump it in a PR when the wrapper's own API changes. The patch number is derived by CI from the existing mirror tags, so every release (whether triggered by a wrapper change or by a new `moq.dev/moq-ffi`) just takes the next patch on that line. +`VERSION` holds the human-owned `MAJOR.MINOR` line (the wrapper API version). Bump it in a PR only for a breaking change to the wrapper's own API. The patch number is derived by CI from the existing mirror tags, so every release (whether triggered by a wrapper change or by a new `moq.dev/moq-ffi`) just takes the next patch on that line. The committed `go.mod` carries a `require moq.dev/moq-ffi v0.0.0` **placeholder**. Do not "fix" it or add a `replace`: `just go check` injects a local `replace` to the freshly-generated bindings, and CI rewrites the `require` to the latest published `moq.dev/moq-ffi` at release time. Because Go resolves to the maximum version across the build graph, that `require` is a floor. Consumers always get an ffi at least as new as the wrapper was built against. From 11ede8d3ee06b72b4e9e4697d348c683f437851f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:24:40 -0700 Subject: [PATCH 18/87] docs: fix stale agent rules, the moq-net hop range, and the ffi unannounce doc (#4305) Co-authored-by: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 2 +- rs/AGENTS.md | 4 ++-- rs/moq-ffi/src/producer.rs | 4 ++-- rs/moq-net/AGENTS.md | 2 +- rs/moq-net/CHANGELOG.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 12e167a28a..a79ae74b9a 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -12896,7 +12896,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce() != - 63513) { + 49647) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqcontainerproducer_cut() != 17534) { diff --git a/rs/AGENTS.md b/rs/AGENTS.md index d2ba893ae8..4d1356c455 100644 --- a/rs/AGENTS.md +++ b/rs/AGENTS.md @@ -38,7 +38,7 @@ Prefer poll. New logic is a `poll_*` with an `async` helper, not the other way a - `if let` / `let else` over a `match` whose only job is to bind. Keep `match` when both arms do work. - Derive `Serialize`/`Deserialize`; a hand-written impl is a second copy of the wire shape. Reach for `serde_with` for what the derive can't express. - Public modules with short names: `broadcast::Consumer`, not `BroadcastConsumer`. Keep `mod encoder` private and re-export flat as `encode::Encoder`. -- Workspace members and shared dependency versions live in the root `Cargo.toml`; crates reference deps via `{ workspace = true }`. +- Workspace members and shared dependency versions live in the root `Cargo.toml`; crates reference deps via `{ workspace = true }`, except internal dev-dependencies, which are path-only so releases publish (`_publish-test` enforces it). - Use newtypes and enums instead of untyped strings. - Have the language make misuse impossible: terminal operations consume `self`, cleanup in `Drop`, etc. @@ -50,7 +50,7 @@ Prefer poll. New logic is a `poll_*` with an `async` helper, not the other way a # Testing -- Tests are inline `#[cfg(test)] mod tests`. Time-dependent async tests call `tokio::time::pause()` first. +- Tests are inline `#[cfg(test)] mod tests`. Time-dependent async tests call `tokio::time::pause()` first, unless they cross real networking that can't be mocked (sockets, smoke tests); those run on the wall clock and assert lower bounds. - Run tests through `just` (nextest), not `cargo test`: nextest kills a wedged test as TIMEOUT, cargo hangs forever. A test flagged SLOW is a bug to fix, not a threshold to raise. - `just check` compiles default features only, like CI. `just rs features` (nightly) covers `--all-features` / `--no-default-features`. Keep a feature gate around the dependency, not the logic, so the logic's tests stay in the merge gate. - Local checks compile only the host platform; `just rs windows` / `macos` must run on that OS and `just rs wasm` covers `moq-wasm`. Say plainly in the PR when platform code is uncompiled. diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 68261bf203..377ec3c7a4 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -253,8 +253,8 @@ impl MoqBroadcastProducer { /// Retract this broadcast's exact-path advertisement, if any. /// /// Local consumers and peers alike stop discovering and requesting it; - /// tracks already in flight carry on. Announcing again brings it back. Errors - /// with `Closed` on a standalone broadcast (no origin to announce on). + /// tracks already in flight carry on. Announcing again brings it back. A no-op + /// on a standalone broadcast. pub fn unannounce(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); self.with_state(|state| { diff --git a/rs/moq-net/AGENTS.md b/rs/moq-net/AGENTS.md index ed248fc94c..1fbbd48ffa 100644 --- a/rs/moq-net/AGENTS.md +++ b/rs/moq-net/AGENTS.md @@ -11,6 +11,6 @@ The wire layer. Generic over the transport and media-agnostic: the relay never i # Testing -- `just rs fuzz ` (`lite`, `ietf`, `varint`, `path`) needs nightly. The target bodies live in `src/fuzz.rs`; `fuzz/regressions//` replays under `just check`, so commit every crash input there. +- `just rs fuzz ` (one per file in `fuzz/fuzz_targets/`) needs nightly. The target bodies live in `src/fuzz.rs`; `fuzz/regressions//` replays under `just check`, so commit every crash input there. - `just rs loom` model-checks the concurrent handoffs. A hang is a lost wakeup, not a flake. - `just test interop --all` runs the cross-language interop matrix after a wire change. diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 3bd5b21584..89eaa3be34 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -131,7 +131,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [**breaking**] `create_track`, `reserve_track`, `unique_track`, `finish`, `create_group`, and `append_group` take `&self`. `track::Consumer::info()` is `query()`. `track::Demand` gains `is_used` / `poll_used` / `poll_unused`. `track::Producer::poll_unused` returns `Poll>`. `bandwidth::Producer::closed()` returns the cause. - [**breaking**] `stats::Presence` and `stats::Traffic` name both edges of each cumulative pair `*_started` / `*_ended` (`sessions_started` / `sessions_ended`, `announces_started` / `announces_ended`, `broadcasts_*`, `subscriptions_*`). Serialize still writes the previous `announced` / `*_closed` names beside the new ones; deserialize accepts either spelling, with the canonical name winning. - [**breaking**] `origin::Info` is `origin::Config` with public fields and no `with_*` builders. `Producer::info()` is `config()`. -- [**breaking**] `origin::Config::default()` mints a random hop, `Config::id` is `hop`, and origin handles expose `hop()` instead of dereferencing to `Hop`. Random hops now use the full 62-bit wire range; current `@moq/net` clients decode them as `bigint`, while legacy `@moq/lite` clients limited to `Number.MAX_SAFE_INTEGER` can reject larger values and must upgrade. +- [**breaking**] `origin::Config::default()` mints a random hop, `Config::id` is `hop`, and origin handles expose `hop()` instead of dereferencing to `Hop`. Random hops stay below 2^53, so legacy `@moq/lite` clients limited to `Number.MAX_SAFE_INTEGER` still decode them; current `@moq/net` clients decode the full 62-bit wire range as `bigint`. - [**breaking**] `origin::Producer::scope(root, patterns)` and `origin::Consumer::scope(root, patterns)` replace the separate `with_root` / `scope` calls and return `Result` with `Unauthorized` for an empty grant. - [**breaking**] `origin::Pending` is `origin::Requesting`, the consumer-side wait for a request to resolve. - `origin::Producer::publish(path, route)` creates and advertises a broadcast together. From 57a5c83e9113971b1c24e5588b816896137d04ee Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:30:17 -0700 Subject: [PATCH 19/87] quest: plan follow-ups from the current PR wave (#4304) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 9 +++++ quest/m1/announce-live-apps.md | 42 +++++++++++++++++++++++ quest/m1/cache-wall-eviction.md | 39 +++++++++++++++++++++ quest/m1/data-capture-bindings.md | 49 ++++++++++++++++++++++++++ quest/m1/error-display.md | 34 ++++++++++++++++++ quest/m1/go-origin-gc.md | 32 +++++++++++++++++ quest/m1/gpu-ci.md | 57 +++++++++++++++++++++++++++++++ quest/m1/js-ietf-datagram.md | 29 ++++++++++++++++ quest/m1/qos/README.md | 4 +++ quest/m1/qos/final-lag-sample.md | 31 +++++++++++++++++ quest/m1/qos/lag-dashboard.md | 34 ++++++++++++++++++ quest/m1/raw-stream-codes.md | 38 +++++++++++++++++++++ quest/m1/worker-socket-count.md | 25 ++++++++++++++ 13 files changed, 423 insertions(+) create mode 100644 quest/m1/announce-live-apps.md create mode 100644 quest/m1/cache-wall-eviction.md create mode 100644 quest/m1/data-capture-bindings.md create mode 100644 quest/m1/error-display.md create mode 100644 quest/m1/go-origin-gc.md create mode 100644 quest/m1/gpu-ci.md create mode 100644 quest/m1/js-ietf-datagram.md create mode 100644 quest/m1/qos/final-lag-sample.md create mode 100644 quest/m1/qos/lag-dashboard.md create mode 100644 quest/m1/raw-stream-codes.md create mode 100644 quest/m1/worker-socket-count.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 09b3bdd90c..d11e511ab7 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -23,23 +23,30 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine +- [Go cancel test](/quest/m1/go-origin-gc.md) - the Go request-cancel test keeps its origin alive, so the collector can't close it mid-test +- [Worker socket count](/quest/m1/worker-socket-count.md) - the moq-tokio worker test counts only its own listener's sockets - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone - [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name +- [Error messages](/quest/m1/error-display.md) - Python, Go, and Dart print `MoqError` with Rust's message, as Kotlin and Swift do - [Remove finish](/quest/m1/broadcast-remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause - [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 - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport +- [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust +- [Live in apps](/quest/m1/announce-live-apps.md) - the demo and `@moq/room` show "no broadcasts" from the `live` marker, which waits for the first session on page load - [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 - [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` +- [Data capture in bindings](/quest/m1/data-capture-bindings.md) - moq-ffi and every wrapper pass a data frame's capture time, and the JSON window producer takes one - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass +- [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships - [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability @@ -59,6 +66,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [mp4-atom dOps mapping](/quest/m1/mp4-atom-dops-mapping.md) - a released mp4-atom reads and writes any `dOps` channel mapping family and table - [CMAF surround Opus](/quest/m1/cmaf-opus-surround.md) - fMP4 import and export carry an Opus channel mapping table - [js/hang dOps pre-skip](/quest/m1/js-dops-pre-skip.md) - CMAF encoding in js/hang stops hard-coding a 312-sample pre-skip +- [GPU CI](/quest/m1/gpu-ci.md) - NVIDIA tests run nightly on a self-hosted GPU runner, and `just rs nvidia` runs them locally instead of skipping - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match - [JS rendition ranking](/quest/m1/js-ranked.md) - `@moq/hang` ranks video renditions like Rust, and `@moq/watch`'s fallback uses it - [Audio rendition pick](/quest/m1/audio-ranked.md) - single-track FLV/RTMP and WHEP serve the best audio rendition, not the first by name @@ -97,6 +105,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw - [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies - [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on every version +- [Plan: cache age-out](/quest/m1/cache-wall-eviction.md) - a swept benchmark decides whether the track cache ages groups out on wall time without a write - [Relay memory](/quest/m1/relay-memory.md) - remeasure what an announcement costs after prefix routes - [PoP skipping](/quest/m1/pop-skipping/README.md) - short cold paths for unpopular broadcasts without losing warm backhaul dedup - [Route cost in the JS origin](/quest/m1/route-cost.md) - the browser origin ranks routes by cost and hops like Rust instead of newest-first diff --git a/quest/m1/announce-live-apps.md b/quest/m1/announce-live-apps.md new file mode 100644 index 0000000000..b9e7a4a995 --- /dev/null +++ b/quest/m1/announce-live-apps.md @@ -0,0 +1,42 @@ +# [M] Apps show "no broadcasts" from the live marker + +## Goal + +A browser page listing broadcasts shows an empty state once the relay has +said there are none, never a spinner that never resolves and never a false +empty state before the first session answered. The demo watch page and +`@moq/room` use `@moq/net`'s `live` marker, and `@moq/net` settles when an +origin stream opened before the first connection goes live. + +## Plan + +- #4261 (on `dev`) adds the `live` event; the consumers it touches + (`demo/web/src/index.ts`, `js/room/src/room.ts`, `js/watch/src/broadcast.ts`, + `js/moq-boy`) skip it today. +- Page load: an origin stream opened before any session connects has no + session to wait on, so today it goes `live` at once and broadcasts arrive + after it. Settled: the reconnect loop (`js/net/src/connection/reload.ts`), + which already answers requests through `expect()`, holds the marker until + its first session lands `live` or its first dial gives up. An empty list + then means the relay said so or is unreachable, which a UI can tell apart. + Once a session is up, its own `live` ends the hold: every wire guarantees + one (ANNOUNCE_OK, ANNOUNCE_INIT, or the quiet-stream fallback). A peer that + accepts the announce stream and never answers is a peer bug, so no extra + timeout. + Check whether Rust's reconnecting client has the same gap. +- Apps: loading before `live`, an explicit empty state after it with nothing + announced, and an error state when the connection gives up. Libraries + expose the state as a signal; wording stays in the demo. +- Tests: an origin stream opened before connect is not `live` until the first + session is; a connection that cannot connect ends the wait. + +Public API: when `@moq/net` emits `live` changes; any state signal on +`@moq/room` or `@moq/watch` is additive. Lands on `dev` with #4261. Wire: none. + +## Required + +- [JS caught up](/quest/m1/js-announce-caught-up.md) - the `live` marker (#4261) + +## Related + +- [Bindings caught up](/quest/m1/announce-live-bindings.md) - the same marker in the bindings diff --git a/quest/m1/cache-wall-eviction.md b/quest/m1/cache-wall-eviction.md new file mode 100644 index 0000000000..6451b320b8 --- /dev/null +++ b/quest/m1/cache-wall-eviction.md @@ -0,0 +1,39 @@ +# [S] Plan: wall-clock age-out in the track cache + +## Goal + +A benchmark decides whether a track's cache ages groups out without waiting +for a write, on `max(wall, pts)` like other time decisions in moq-net, or +stays the one write-driven exception. The result is an implementation quest +or a recorded reason to keep the exception. + +## Plan + +Settled scope: a track's `max_age` retention aging groups out on a timer +instead of only on a write. The pool's idle expiry is out of scope. + +- Today a track's `max_age` is media time and is applied only when the track + writes: a group ages out when a later one starts (`is_stale` and the expiry + scans in `rs/moq-net/src/model/track.rs`). A track that stops writing keeps + groups past `max_age` until the pool's idle expiry (`Pool::gc`, driven by + the origin driver without a write) or byte pressure reclaims them. + `max_age_does_not_drive_wall_eviction` pins that behaviour. +- Prototype the alternative behind a bench-only switch: each track keeps a + deadline for its oldest group on `max(wall elapsed, pts)`, armed on the + timers the origin driver already runs, and evicts on expiry. +- Bench in `rs/moq-net/benches/track.rs`, swept over tracks (1 to 10k) and + cached groups per track (1 to 1k): write-path cost, timer cost per driver + pass, and retained memory for a population of idle tracks. A cost that + grows with the table should show as a slope. +- Weigh the semantics too: `max_age` is media time on purpose, so a congestion + stall cannot age content out (`track::Info::max_age`). A wall term changes + that for a stalled but live publisher. +- Record the numbers and the decision in the PR, then rewrite this quest into + the implementation or delete it. + +Public API: none from the plan. Wire: none. + +## Related + +- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - relay memory past the expiry window, in the same cache +- [Cache shard](/quest/m1/perf/cache-shard.md) - the pool's shared counters under many workers diff --git a/quest/m1/data-capture-bindings.md b/quest/m1/data-capture-bindings.md new file mode 100644 index 0000000000..ba9de9d5e5 --- /dev/null +++ b/quest/m1/data-capture-bindings.md @@ -0,0 +1,49 @@ +# [M] Bindings stamp data frames with a capture time + +## Goal + +A moq-ffi publisher, and every wrapper over it (Python, Swift, Kotlin, Go, +Dart), can pass a capture time with a JSON or binary snapshot `update` or +stream `append`, so its data tracks advertise `delay` and `jitter` like a Rust +publisher's. Leaving it out keeps today's behaviour. `moq-json`'s `window` +producer takes a capture time too. Settled scope: moq-ffi and its wrappers, +not libmoq. + +## Plan + +- #4270 gives the Rust producers `moq_net::Timed`, built with + `Timed::from(value).at(t)`. The `moq-mux` data producers take + `Timed<_, Instant>`, map it onto the broadcast clock, and refuse one ahead of + now (`Error::InvalidCapture`). The moq-ffi producers in + `rs/moq-ffi/src/{json,binary}.rs` pass bare values. +- Settled: the capture time is a media timestamp on the broadcast's + timeline. moq-ffi exposes the broadcast clock's `now()` as a timestamp; + callers stamp payloads with values taken from it, and moq refuses one ahead + of now. That keeps a device or process clock out, as the Rust `Instant` + mapping does. The `moq-mux` producers take an `Instant` today, so either + map the timestamp back through the clock inside moq-ffi or give the clock a + typed timestamp moq-mux accepts; keep a raw `Timestamp` from compiling + there. Make it optional on the existing methods, never a `_with_capture` + twin. +- `moq-json` window: `window::Producer::push` stamps `Timestamp::now()` + (`rs/moq-json/src/window/producer.rs`). Accept `Timed` as the snapshot and + stream producers do. Nothing in `moq-mux` publishes window mode, so there is + no estimator to feed. +- Wrappers follow per the cross-package sync table, each with a test that a + past capture time is accepted and a future one refused. Update + `doc/lib/{py,swift,kt,go,dart}`. + +Public API: breaking, so it lands on `dev`. A new parameter on the generated +`update` and `append` breaks every published binding caller (Go, for one, has +no optional arguments), and a `_with_x` twin is ruled out. The broadcast clock +`now()` is additive; `window::Producer::push` accepts `Timed`, source-compatible. +Wire: none. + +## Required + +- [Data jitter](/quest/m1/data-jitter.md) - `Timed` and the mux capture path (#4270) + +## Related + +- [FFI shape](/quest/m1/ffi-shape/README.md) - moves the data producers into a json namespace +- [Generated C bindings](/quest/m1/c/README.md) - replaces libmoq, so C inherits this from moq-ffi diff --git a/quest/m1/error-display.md b/quest/m1/error-display.md new file mode 100644 index 0000000000..894f3581d0 --- /dev/null +++ b/quest/m1/error-display.md @@ -0,0 +1,34 @@ +# [M] Every binding prints MoqError's message + +## Goal + +Python's `str(err)`, Go's `err.Error()`, and Dart's `toString()` on a +`MoqError` return the message from moq-ffi's exported `Display`, as Kotlin's +`toString()` and Swift's `description` already do. No hand-written +per-variant strings and no extra message function. + +## Plan + +- #4292 adds `#[uniffi::export(Display)]` on `MoqError` in + `rs/moq-ffi/src/error.rs`, on the C++ line. If it has not reached `main` + when this starts, add the same attribute here; it is additive. +- Go and Dart are fixed in their generators; Python in its wrapper: + - Go: the `kixelated/uniffi-bindgen-go` fork. The generated `Error()` prints + `MoqError: `. Render the exported `Display` for errors, tag, and + bump every pin site the `flake.nix` comment lists. + - Dart: the `kixelated/uniffi-dart` fork. The regenerated bindings already + carry the unused extern; wire it to `toString()`, tag, bump `flake.nix`, + and regenerate `dart/moq_ffi`. + - Python: upstream `mozilla/uniffi-rs` (0.32.2 here) renders no uniffi + traits on errors (`ErrorTemplate.py`). Settled: no upstream PR. The + hand-written `py/moq-rs` package defines `__str__` on `MoqError` by + calling the exported `Display`, so the message still comes from Rust. +- A test per binding that a known error prints Rust's text (`Closed` prints + `closed`), and `doc/lib/{py,go,dart}` updated where they show error output. + +Public API: the string form of `MoqError` changes in Python, Go, and Dart. +Wire: none. + +## Related + +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - where #4292 adds the export and C++ `to_string()` diff --git a/quest/m1/go-origin-gc.md b/quest/m1/go-origin-gc.md new file mode 100644 index 0000000000..25bc6aae9e --- /dev/null +++ b/quest/m1/go-origin-gc.md @@ -0,0 +1,32 @@ +# [XS] Go cancel test keeps its origin + +## Goal + +`TestRequestBroadcastCancelKeepsTheOrigin` in `go/wrapper/moq_test.go` passes +under load, not only alone (30/30 in isolation, `MoqError: Closed` under a +loaded run). Fixed at the cause, with no retry or longer timeout. + +## Plan + +Likely cause, found by reading and not yet reproduced: the test never touches +`origin` after `origin.Dynamic(...)` and `origin.Consume()`, so Go may collect +it mid-test. The generated finalizer then drops the only `MoqOriginProducer`, +the origin's driver finishes once every producer handle is gone +(`origin::Driver::poll` in `rs/moq-net/src/model/origin.rs`), and the next call +on the consumer or the dynamic handle gets `Error::Closed`. The collector runs +more often under load, which fits. + +- Reproduce first. `runtime.GC()` after `cancel()` or `GOGC=1` only makes the + collection likely: finalizers run later on their own goroutine. For a + deterministic failure, do what the finalizer does: an internal test calls + the inner handle's `Destroy` before the next call. +- Keep the producer reachable to the end of the test (`runtime.KeepAlive`, as + the file already does for `pending`), and audit the other `go/wrapper` tests + that hold an `OriginProducer` only for setup. +- Users hit the same trap: a Go `OriginProducer` has no `Close`, so its origin + ends whenever the collector reaches it. Say so in its doc comment and + `doc/lib/go`. Whether consumers should keep their producer alive, or the + wrapper should gain an explicit `Close`, is an API call: propose it to the + maintainer rather than ship it here. + +Public API: none unless the maintainer picks an API change. Wire: none. diff --git a/quest/m1/gpu-ci.md b/quest/m1/gpu-ci.md new file mode 100644 index 0000000000..0c0a8a054e --- /dev/null +++ b/quest/m1/gpu-ci.md @@ -0,0 +1,57 @@ +# [S] NVIDIA tests run on real hardware + +## Goal + +The NVDEC, NVENC, and CUDA tests run nightly on the maintainer's Linux host +(RTX 3070 Ti) instead of passing without a GPU on hosted runners, and a local +`just rs nvidia` runs them against the host driver instead of silently +skipping inside the Nix shell. + +## Plan + +- Why they skip: the driver libraries are loaded at runtime (`libcuda` by + cudarc, `libnvidia-encode` in `rs/moq-nvenc/src/safe/api.rs`, `libnvcuvid` + in `rs/moq-nvenc/src/cuvid.rs`), and the tests return early when they are + missing (`hw_available` in `rs/moq-video/src/decode/backend/nvdec.rs`). The + Nix shell's loader path lacks Ubuntu's `/usr/lib/x86_64-linux-gnu`. +- Select every test that needs the GPU, not only names containing `nvdec`, + `nvenc`, or `cuda`: `safe::session::tests::failed_submission_releases_the_session` + in `rs/moq-nvenc` needs hardware and matches none of them. Find them by + their driver probes (`hw_available`, `driver_libs_present`, `Api::get` and + friends in `moq-nvenc` and `moq-video`). Recommendation: follow the + existing `#[ignore = "requires ..."]` convention (as `frame/vulkan_test.rs` + does) and put them in `nvidia` test modules, so hosted CI reports them + ignored instead of passed and one filter, `--run-ignored only -E + 'test(/::nvidia::/)'`, selects them all without the other ignored hardware + tests (Android, D3D11, PipeWire). Inside that selection a missing GPU fails + the test instead of returning early. Keep the no-driver tests + (`missing_driver_errors_instead_of_panicking`) outside it. +- `just rs nvidia`: symlink only those three libraries (by soname) from + `/usr/lib/x86_64-linux-gnu` into a private directory, put that on + `LD_LIBRARY_PATH`, and run that selection. Fail when a library is missing + instead of skipping. `just rs vulkan-cuda` puts the whole host directory on + the path, which lets host libraries shadow the Nix ones; fold it into this + recipe, since its `vulkan_cuda_` tests are the same kind. Those also need + the Vulkan loader to find the host NVIDIA ICD: point it at the ICD manifest + and expose the driver libraries it names, or keep them in their own recipe. +- Nightly: a job in `.github/workflows/nightly.yml` runs `just rs nvidia` on + the self-hosted runner. A self-hosted runner on a public repository must + never run untrusted code: only `schedule` and `workflow_dispatch`, with the + job gated to `refs/heads/main`, never `pull_request`; a dedicated label only + this job selects; read-only `permissions`. Read GitHub's self-hosted runner + hardening guidance before wiring it. +- Share the runner with the io_uring one that #4132 plans + (`quest/m1/uring-runner.md` on the drain line, which wants a 6.12+ kernel on + the same host): one registration and one security posture, a label per + capability. Whichever quest lands second reuses the first's job shape. + +Public API: none. Wire: none. + +## Required + +- A self-hosted runner is registered for moq-dev/moq on the maintainer's host, with the NVIDIA driver + +## Related + +- [Video hardware validation](/quest/m3/video-hardware.md) - hardware paths nothing runs yet +- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - on-demand jobs on hardware hosts, a broader contract than a nightly diff --git a/quest/m1/js-ietf-datagram.md b/quest/m1/js-ietf-datagram.md new file mode 100644 index 0000000000..417609fe0a --- /dev/null +++ b/quest/m1/js-ietf-datagram.md @@ -0,0 +1,29 @@ +# [M] @moq/net datagrams over moq-transport + +## Goal + +A `@moq/net` session on moq-transport sends and receives datagram groups as +`OBJECT_DATAGRAM`, one object per group with its sequence kept, as Rust does. +A datagram track crosses IETF between Rust and JS in both directions in +`just test interop`. + +## Plan + +- JS routes datagrams on moq-lite only (`js/net/src/lite/datagram.ts`, + `runDatagrams` from lite-05); `js/net/src/ietf/` reads and writes none. + Mirror the lite path on the IETF session and the Rust mapping from #4274: + receive decodes `OBJECT_DATAGRAM` and inserts on the aliased subscription's + track; send writes object 0 with END_OF_GROUP, the explicit publisher + priority, and the timestamp property when the track has a timescale. +- Keep Rust's edges: an Object ID other than 0, a non-Normal status, or an + unbound alias is dropped; a malformed Type closes the session. Rust covers + drafts 14 and later, whose Type flags differ between 14 and 15+; decide + what draft 07, which JS also speaks, does. +- Add IETF datagram cases beside the lite ones in the interop harness. + +Public API: none expected. Wire: `@moq/net` moq-transport sessions send and +accept `OBJECT_DATAGRAM` as the drafts define; no project draft changes. + +## Required + +- [Datagram groups](/quest/m1/moxygen/datagram.md) - the Rust side this mirrors and interops with diff --git a/quest/m1/qos/README.md b/quest/m1/qos/README.md index b6c84121b6..a5fe3fa32b 100644 --- a/quest/m1/qos/README.md +++ b/quest/m1/qos/README.md @@ -36,6 +36,10 @@ preflight, consumes them downstream. - [Starvation](/quest/m1/qos/starvation.md) - per broadcast, how far behind the acknowledged frontier of its subscriptions is, in media time, plus the media dropped before it was acknowledged +- [Final lag sample](/quest/m1/qos/final-lag-sample.md) - a closing + subscription records its last partial interval instead of losing it +- [Lag dashboard](/quest/m1/qos/lag-dashboard.md) - the demo stats + dashboard shows viewer lag percentiles and dropped media - [Starvation at frame granularity](/quest/m1/qos/starvation-frames.md) - the acknowledged frontier moves at every frame boundary through `poll_acked`, with a delivery-delay histogram for jitter diff --git a/quest/m1/qos/final-lag-sample.md b/quest/m1/qos/final-lag-sample.md new file mode 100644 index 0000000000..6ee05196af --- /dev/null +++ b/quest/m1/qos/final-lag-sample.md @@ -0,0 +1,31 @@ +# [XS] A closing subscription takes its last lag sample + +## Goal + +When an egress subscription ends, the bytes its track produced since the last +stats tick still land in the `lag` histogram at its final lag, instead of +being lost. A subscription that opens and closes between two ticks is sampled +at least once. + +## Plan + +Lag is sampled only on `Registry::report` ticks: `Counters::sample` in +`rs/moq-net/src/stats.rs` walks weak `FrontierInner` references and prunes +the dead ones without sampling them, so a subscription's last partial +interval disappears with it. + +- The subscription guard and each in-flight group `Delivery` hold a strong + reference, so the frontier dies only once the last of them does. Its drop is + the natural place for one final `sample(now)` into the same counters. +- No double counting: `sample` advances each source's `sampled` bytes under the + frontier lock, so a tick racing the drop leaves nothing for it to count again. +- Test: a subscription that opens and closes between ticks while its track + produces lands those bytes in the histogram; one that closes mid-interval + adds exactly the bytes since its last sample. Note the behaviour in + `doc/concept/stats.md`. + +Public API: none. Wire: none, only the histogram's values change. + +## Required + +- [Starvation](/quest/m1/qos/starvation.md) - the sampler this extends (#4298) diff --git a/quest/m1/qos/lag-dashboard.md b/quest/m1/qos/lag-dashboard.md new file mode 100644 index 0000000000..c89e45f38f --- /dev/null +++ b/quest/m1/qos/lag-dashboard.md @@ -0,0 +1,34 @@ +# [S] Demo dashboard shows viewer lag + +## Goal + +The demo stats dashboard (`demo/web/src/stats.ts`) shows the relay's viewer +lag and dropped media: percentiles of the egress `lag` histogram over the +chart window, and `dropped` duration, bytes, and groups as rates, cluster-wide +and per node like the existing counters. + +## Plan + +- The relay writes both on `publisher.json` rows only. `lag` is a cumulative + byte count per bucket keyed by its upper edge (`"50ms"` to `"5s"`, then + `"inf"`, empty buckets omitted); `dropped` is `{ duration, bytes, groups }` + with the duration in fractional milliseconds. `doc/concept/stats.md` on the + QoS line documents them. Diff two samples for an interval's distribution, + as the dashboard already does to turn cumulative bytes into rates, and sum + nodes bucket by bucket. +- A percentile read from buckets is a bucket edge, not a point value, and + `inf` has no upper edge. Show it as "under X" or interpolate inside the + bucket, and say which. +- Skip `.`-prefixed system broadcasts, as the existing aggregate does. Lag is + per broadcast, so a per-broadcast view is worth adding if it stays cheap. +- The [browser stats quest](/quest/m1/qos/stats/js.md) moves the dashboard + onto `@moq/stats` on the same line. If it has landed, read `lag` and + `dropped` through its schemas; otherwise extend the existing interfaces and + let whichever lands second reconcile. + +Public API: none. Wire: none. + +## Required + +- [Starvation](/quest/m1/qos/starvation.md) - the `lag` histogram and + `dropped` counters (#4298) diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md new file mode 100644 index 0000000000..a4c1b517da --- /dev/null +++ b/quest/m1/raw-stream-codes.md @@ -0,0 +1,38 @@ +# [M] Raw QUIC stream codes stay raw + +## Goal + +On a raw QUIC session (`moqt://`, `moql://`, raw iroh), RESET_STREAM and +STOP_SENDING carry the application's code as-is, not mapped through the +HTTP/3 WebTransport code space, so moq agrees with other raw QUIC MoQ stacks +on stream errors. WebTransport sessions keep the mapping they need. + +## Plan + +- `web-transport-moq` (moq-dev/noq) runs every stream code through + `web_transport_proto::error_to_http3` and `error_from_http3` in `send.rs` + and `recv.rs`, whether or not the session came from `Session::raw`. + `web-transport-iroh` and `web-transport-quinn` (moq-dev/web-transport) do + the same. A raw peer's code 5 reads as `None` or another value, and ours + reaches it as a large HTTP/3 code. +- [Close codes](/quest/m1/close-codes.md) fixed the same mix-up for + `ApplicationClosed` (noq#11, #4262). Let each stream know whether its + session is raw and skip the mapping there, in all three adapters, with a + round-trip test per adapter against a plain QUIC peer. +- Release the fixed crates and bump the pins here in the same quest; published + crates depend on crates.io releases, never a patch. A moq-tokio test over + `moqt://` asserts a reset code arrives verbatim, beside `close_code.rs`. +- Mixed versions: two moq peers on raw QUIC agree today because both map, and + wire changes must stay compatible with published versions. A fixed peer can + read both forms, since a mapped code lands in the HTTP/3 WebTransport range + that no application code reaches. An older peer misreads a fixed peer's raw + codes. Check which codes moq-net acts on (group stream resets, subscribe + STOP_SENDING): if any drives behaviour beyond reporting, this is a wire + break and retargets to `dev`. + +Public API: none expected. Wire: raw QUIC stream error codes become the +application's own values; compatible only if older peers merely report them. + +## Required + +- [Close codes](/quest/m1/close-codes.md) - the adapter releases and trait 0.5 this builds on diff --git a/quest/m1/worker-socket-count.md b/quest/m1/worker-socket-count.md new file mode 100644 index 0000000000..dd6b549c2f --- /dev/null +++ b/quest/m1/worker-socket-count.md @@ -0,0 +1,25 @@ +# [XS] Worker socket count sees only its own listener + +## Goal + +`dropping_a_server_keeps_its_socket` in `rs/moq-tokio/tests/worker.rs` counts +only the sockets its own listener holds, so an unrelated UDP socket elsewhere +on the machine can't fail it. + +## Plan + +`udp_sockets_on(port)` counts every `/proc/net/udp` entry whose local address +ends in the port, so another process's socket on the same port number but a +different address (an ephemeral port on another interface, say) breaks both +the `>= 2` assertion and the final `== 0`. + +- The listener binds `127.0.0.1:0` (`listen_config`), so match the full local + address rather than the port suffix. +- The final `== 0` runs after the port is released, when a parallel test may + bind the same address. If that window matters, count only this process's + sockets by joining the table's inode column with `/proc/self/fd`. Prefer + whichever holds with a stranger on the port at every assertion. +- Reproduce by holding a UDP socket on the same port on another local address + while the test runs. + +Public API: none. Wire: none. From a19ebc5e0dbf31128fa2a705d006f1178a778131 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:36:08 -0700 Subject: [PATCH 20/87] test: fix three load-only test failures at the cause (#4286) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 1 + js/common/declarations.test.ts | 50 ++++++++++ js/common/declarations.ts | 128 ++++++++++++++++++++++++ js/common/package.ts | 13 +++ js/json/src/snapshot/encoder.ts | 12 ++- js/json/src/snapshot/snapshot.test.ts | 47 ++++----- js/justfile | 2 +- js/net/src/declarations.test.ts | 136 -------------------------- quest/m1/README.md | 4 +- quest/m1/json-compressed-gate-rs.md | 17 ++++ quest/m1/json-rolls-snapshot-test.md | 14 +++ quest/m1/shaper-virtual-time.md | 18 ++++ quest/m1/test-flakes.md | 22 ----- rs/moq-tokio/tests/backend.rs | 36 +++---- 14 files changed, 295 insertions(+), 205 deletions(-) create mode 100644 js/common/declarations.test.ts create mode 100644 js/common/declarations.ts delete mode 100644 js/net/src/declarations.test.ts create mode 100644 quest/m1/json-compressed-gate-rs.md create mode 100644 quest/m1/json-rolls-snapshot-test.md create mode 100644 quest/m1/shaper-virtual-time.md delete mode 100644 quest/m1/test-flakes.md diff --git a/AGENTS.md b/AGENTS.md index 494dbdaa8f..109cfb070f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ This file is split into nested `AGENTS.md` files based on the language/situation - Dig into the root cause and fix it at the source. Never work around a fixable bug with a retry, sleep, or timeout. - Fail loud and early. Error on unsupported or malformed input rather than warn and continue: supported or refused. - Reproduce bugs before fixing them. Land each fix with a regression test that fails without it, when one is easy. +- Unit tests mock time instead of depending on wall-clock timing or sleeps. - Keep the PR focused. No unrelated refactors, formatting churn, or drive-by changes; split when in doubt. - Refactor aggressively for long-term maintainability, but re-evaluate the direction as you learn. - Propose a course change, even suggest abandoning a PR, rather than finish a half-solution. diff --git a/js/common/declarations.test.ts b/js/common/declarations.test.ts new file mode 100644 index 0000000000..79e3b8b328 --- /dev/null +++ b/js/common/declarations.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { problems } from "./declarations"; + +const root = "/dist"; + +test("an import of a stripped export is reported", () => { + const files = new Map([ + ["/dist/reload.d.ts", "export declare class Reload {}\n"], + ["/dist/index.d.ts", 'import { ReloadDelay } from "./reload.js";\nexport declare const delay: ReloadDelay;\n'], + ]); + expect(problems(root, files)).toEqual([ + "index.d.ts: imports ReloadDelay from ./reload.js, which does not export it", + ]); +}); + +test("an import of a file with no declarations is reported", () => { + const files = new Map([["/dist/index.d.ts", 'import type { Mock } from "./mock.ts";\n']]); + expect(problems(root, files)).toEqual(["index.d.ts: imports ./mock.ts, which emitted no declarations"]); +}); + +test("names re-exported through a star, an alias, a default, or a directory index resolve", () => { + const files = new Map([ + ["/dist/inner.d.ts", "export interface Delay {}\ndeclare const status = 1;\nexport { status as Status };\n"], + ["/dist/outer/index.d.ts", 'export * from "../inner.js";\n'], + ["/dist/element.d.ts", "export default class Element {}\n"], + ["/dist/page.d.ts", 'import { Delay } from "./outer";\nimport { Status } from ".";\n'], + [ + "/dist/index.d.ts", + 'export * from "./outer";\nexport type { default as Element } from "./element.tsx";\nimport { type Delay, Status as S } from "./outer/index.ts";\nimport { Other } from "@moq/other";\nimport icon from "./icon.svg?raw";\n', + ], + ]); + expect(problems(root, files)).toEqual([]); +}); + +test("a default import of a module without a default export is reported", () => { + const files = new Map([ + ["/dist/element.d.ts", "export declare class Element {}\n"], + ["/dist/index.d.ts", 'import type Element from "./element.js";\nexport { Element };\n'], + ]); + expect(problems(root, files)).toEqual(["index.d.ts: imports default from ./element.js, which does not export it"]); +}); + +test("type-only star re-exports, const enums, and let declarations resolve", () => { + const files = new Map([ + ["/dist/types.d.ts", "export declare const enum State { Open }\nexport declare let value: number;\n"], + ["/dist/outer.d.ts", 'export type * from "./types.js";\n'], + ["/dist/index.d.ts", 'import type { State, value } from "./outer.js";\n'], + ]); + expect(problems(root, files)).toEqual([]); +}); diff --git a/js/common/declarations.ts b/js/common/declarations.ts new file mode 100644 index 0000000000..f7e9d21ba5 --- /dev/null +++ b/js/common/declarations.ts @@ -0,0 +1,128 @@ +// Checks a package's emitted `.d.ts` files for imports the target file no longer exports. +// +// `stripInternal` drops an `@internal` export from its own `.d.ts` but leaves the import in any +// file that names the type, so a published consumer sees a module that does not export it. Only +// declaration emit shows this, which is why it runs over `dist/` after the build rather than as a +// test that would have to run the compiler again. + +import { dirname, extname, join, relative, resolve } from "node:path"; +import { parse } from "@babel/parser"; + +type Statement = ReturnType["program"]["body"][number]; +type Declaration = Extract["declaration"]; + +type FileExports = { + names: Set; + stars: string[]; + imports: Array<{ specifier: string; names: string[] }>; +}; + +/** Every import in `files` (path to `.d.ts` source) that its relative target does not export. */ +export function problems(root: string, files: Map): string[] { + const parsed = new Map(); + for (const [file, source] of files) { + parsed.set(file, fileExports(source)); + } + + const found: string[] = []; + for (const [file, info] of parsed) { + for (const { specifier, names } of info.imports) { + const target = dtsPath(parsed, file, specifier); + if (!target) continue; + + if (!parsed.has(target)) { + found.push(`${relative(root, file)}: imports ${specifier}, which emitted no declarations`); + continue; + } + + for (const name of names) { + if (!exported(parsed, target, name)) { + found.push(`${relative(root, file)}: imports ${name} from ${specifier}, which does not export it`); + } + } + } + } + return found; +} + +// The declaration file a relative specifier resolves to, as a file or a directory index. A package +// specifier resolves outside the package, and an asset (`./icon.svg?raw`) is typed by the bundler, +// so neither is checked. +function dtsPath(files: Map, from: string, specifier: string): string | undefined { + if (!specifier.startsWith(".")) return undefined; + const ext = extname(specifier); + if (ext && !/^\.(js|jsx|ts|tsx)$/.test(ext)) return undefined; + const base = resolve(dirname(from), specifier).replace(/\.(js|jsx|ts|tsx)$/, ""); + const index = join(base, "index.d.ts"); + return files.has(index) ? index : `${base}.d.ts`; +} + +function exported(files: Map, file: string, name: string, seen = new Set()): boolean { + if (seen.has(file)) return false; + seen.add(file); + + const info = files.get(file); + if (!info) return false; + if (info.names.has(name)) return true; + + for (const specifier of info.stars) { + const target = dtsPath(files, file, specifier); + if (target && exported(files, target, name, seen)) return true; + } + return false; +} + +function fileExports(source: string): FileExports { + const names = new Set(); + const stars: string[] = []; + const imports: Array<{ specifier: string; names: string[] }> = []; + + const ast = parse(source, { sourceType: "module", plugins: [["typescript", { dts: true }]] }); + for (const statement of ast.program.body) { + switch (statement.type) { + case "ImportDeclaration": + if (statement.specifiers.length === 0) break; + imports.push({ + specifier: statement.source.value, + names: statement.specifiers.flatMap((s) => { + if (s.type === "ImportDefaultSpecifier") return ["default"]; + if (s.type === "ImportSpecifier") return [moduleName(s.imported)]; + return []; + }), + }); + break; + case "ExportNamedDeclaration": { + const imported: string[] = []; + for (const s of statement.specifiers) { + names.add(moduleName(s.exported)); + if (s.type === "ExportSpecifier") imported.push(moduleName(s.local)); + } + if (statement.source) imports.push({ specifier: statement.source.value, names: imported }); + for (const name of declared(statement.declaration)) names.add(name); + break; + } + case "ExportAllDeclaration": + stars.push(statement.source.value); + break; + case "ExportDefaultDeclaration": + names.add("default"); + break; + } + } + + return { names, stars, imports }; +} + +function moduleName(node: { type: "Identifier"; name: string } | { type: "StringLiteral"; value: string }): string { + return node.type === "Identifier" ? node.name : node.value; +} + +// The names an `export declare ...` statement introduces. +function declared(declaration: Declaration | null | undefined): string[] { + if (!declaration) return []; + if (declaration.type === "VariableDeclaration") { + return declaration.declarations.flatMap((d) => (d.id.type === "Identifier" ? [d.id.name] : [])); + } + if (!("id" in declaration) || !declaration.id) return []; + return declaration.id.type === "Identifier" ? [declaration.id.name] : []; +} diff --git a/js/common/package.ts b/js/common/package.ts index 7ad9309b5b..3ada558788 100644 --- a/js/common/package.ts +++ b/js/common/package.ts @@ -6,6 +6,7 @@ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { publint } from "publint"; import { formatMessage } from "publint/utils"; +import { problems } from "./declarations.ts"; console.log("✍️ Rewriting package.json..."); const pkg = JSON.parse(readFileSync("package.json", "utf8")); @@ -120,6 +121,18 @@ if (messages.length > 0) { process.exit(1); } +console.log("🔍 Checking declaration imports..."); +const declarations = new Map(); +for (const rel of new Bun.Glob("**/*.d.ts").scanSync("dist")) { + const file = resolve("dist", rel); + declarations.set(file, readFileSync(file, "utf8")); +} +const unresolved = problems(resolve("dist"), declarations); +if (unresolved.length > 0) { + for (const problem of unresolved) console.error(problem); + process.exit(1); +} + console.log("📦 Package built successfully in dist/"); // Optionally emit a jsr.json so the package can also publish to JSR (jsr.io). diff --git a/js/json/src/snapshot/encoder.ts b/js/json/src/snapshot/encoder.ts index 67ce5047b1..82800aa939 100644 --- a/js/json/src/snapshot/encoder.ts +++ b/js/json/src/snapshot/encoder.ts @@ -42,6 +42,14 @@ export interface Config { // `"none"`/unset (the default) writes plaintext JSON frames. A {@link Decoder} reading them // must set the same {@link compression}. compression?: Compression; + + /** + * Bytes a group may hold before it rolls, defaulting to moq-net's per-group cache limit. Lets a + * test reach the limit without megabytes of JSON. + * + * @internal + */ + maxGroupBytes?: number; } /** One encoded frame, and the group boundary it implies. */ @@ -106,6 +114,7 @@ export interface Pending extends Encoded { export class Encoder { #config: Config; #compress: boolean; + #maxGroupBytes: number; // The last encoded value, normalized through JSON so it matches what landed on the wire. The // baseline every delta is diffed against, and `undefined` until the first snapshot. @@ -140,6 +149,7 @@ export class Encoder { constructor(config: Config = {}) { this.#config = config; this.#compress = isDeflate(config.compression); + this.#maxGroupBytes = config.maxGroupBytes ?? Group.MAX_GROUP_CACHE_BYTES; } /** @@ -218,7 +228,7 @@ export class Encoder { // can come out slightly larger than its input, so the plaintext is not an upper bound. // Compressing first advances the window, but `#snapshot` opens a fresh one, so an // over-budget delta costs only the wasted compression. - if (this.#snapshotLen + this.#deltaBytes + payload.length <= Group.MAX_GROUP_CACHE_BYTES) { + if (this.#snapshotLen + this.#deltaBytes + payload.length <= this.#maxGroupBytes) { this.#last = json; this.#deltaBytes += payload.length; this.#groupFrames += 1; diff --git a/js/json/src/snapshot/snapshot.test.ts b/js/json/src/snapshot/snapshot.test.ts index 4200fd9654..4d669e7999 100644 --- a/js/json/src/snapshot/snapshot.test.ts +++ b/js/json/src/snapshot/snapshot.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { Group, Error as NetError, StreamCode, Time, Track } from "@moq/net"; import { Consumer } from "./consumer.ts"; +import { Encoder } from "./encoder.ts"; import { Producer } from "./producer.ts"; type Value = Record; @@ -358,38 +359,38 @@ test("a delta that would overflow the snapshot rolls a new one instead", async ( test("a compressed delta is gated on its encoded size, not its plaintext", async () => { // A sync-flushed DEFLATE frame can come out larger than its input, so the plaintext is not an - // upper bound on what lands in the group. A snapshot that fills the cache to within a few bytes - // plus a tiny patch that compresses to more than it measures would otherwise slip through the - // gate and evict frame 0. + // upper bound on what lands in the group. A patch that fits the budget by its plaintext but not + // by its encoded size would otherwise slip through the gate and overflow the group. + const value = { v: "x".repeat(1000) }; + const patched = { ...value, q: "a" }; + const plaintext = JSON.stringify({ q: "a" }).length; + + // Measure the frames with the default budget, which admits the delta. + const probe = new Encoder({ compression: "deflate" }); + const snapshot = probe.update(value); + snapshot?.commit(); + const delta = probe.update(patched); + expect(delta?.keyframe).toBe(false); + expect(delta?.payload.length).toBeGreaterThan(plaintext); + + // A budget with room for the plaintext patch but not the encoded one. + const maxGroupBytes = (snapshot?.payload.length ?? 0) + plaintext; const track = new Track.Producer("test"); - const producer = new Producer({ track, compression: "deflate" }); - - // Highly repetitive, so the compressed snapshot lands just under the cap. - producer.update({ v: "x".repeat(Group.MAX_GROUP_CACHE_BYTES) }); - producer.update({ v: "x".repeat(Group.MAX_GROUP_CACHE_BYTES), q: "a" }); + const producer = new Producer({ track, compression: "deflate", maxGroupBytes }); + producer.update(value); + producer.update(patched); producer.finish(); - // Whatever the split, no group may exceed the cache, and the newest value must be readable. - const subscriber = track.subscribe({ maxAge: REPLAY_LATENCY }).ordered(); - for (;;) { - const group = await subscriber.nextGroup(); - if (!group) break; - let bytes = 0; - for (;;) { - const frame = await group.readFrame(); - if (!frame) break; - bytes += frame.payload.byteLength; - } - expect(bytes).toBeLessThanOrEqual(Group.MAX_GROUP_CACHE_BYTES); - } + // The patch rolled into a fresh snapshot rather than joining the first group. + expect(await structure(track.subscribe({ maxAge: REPLAY_LATENCY }).ordered())).toEqual([1, 1]); const consumer = new Consumer({ track: track.subscribe({ maxAge: REPLAY_LATENCY }), compression: "deflate", }); const values: Value[] = []; - for await (const value of consumer) values.push(value); - expect(values[values.length - 1]).toEqual({ v: "x".repeat(Group.MAX_GROUP_CACHE_BYTES), q: "a" }); + for await (const out of consumer) values.push(out); + expect(values[values.length - 1]).toEqual(patched); }); // A malformed or failed group must reach the caller; only an explicit retention gap is resumable. diff --git a/js/justfile b/js/justfile index bcad24af59..4be426446e 100644 --- a/js/justfile +++ b/js/justfile @@ -84,7 +84,7 @@ test $FILES="": exit 0 fi bun install --frozen-lockfile - bun test common/deps.test.ts common/workers.test.ts + bun test common/declarations.test.ts common/deps.test.ts common/workers.test.ts if tty -s; then bun run --filter='*' --elide-lines=0 test else diff --git a/js/net/src/declarations.test.ts b/js/net/src/declarations.test.ts deleted file mode 100644 index 65636b4610..0000000000 --- a/js/net/src/declarations.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; - -const pkg = resolve(import.meta.dir, ".."); -const tsc = join(dirname(Bun.resolveSync("typescript/package.json", pkg)), "lib/tsc.js"); - -// `stripInternal` drops the export from the target `.d.ts` but leaves the import in any file -// that names the type, so a published consumer sees a module that does not export it. -test("emitted declarations import only names the target file exports", () => { - const outDir = mkdtempSync(join(tmpdir(), "moq-net-dts-")); - try { - const result = Bun.spawnSync( - ["bun", tsc, "-p", "tsconfig.build.json", "--emitDeclarationOnly", "--outDir", outDir], - { cwd: pkg, stdout: "pipe", stderr: "pipe" }, - ); - expect(result.exitCode, result.stderr.toString() || result.stdout.toString()).toBe(0); - - const files = dtsFiles(outDir); - expect(files.length).toBeGreaterThan(0); - - const parsed = new Map(); - for (const file of files) { - parsed.set(file, fileExports(readFileSync(file, "utf8"))); - } - - const problems: string[] = []; - for (const [file, info] of parsed) { - for (const { specifier, names } of info.imports) { - const target = dtsPath(file, specifier); - if (!target) continue; - - if (!parsed.has(target)) { - problems.push(`${relative(outDir, file)}: imports ${specifier}, which emitted no declarations`); - continue; - } - - for (const name of names) { - if (!exported(parsed, target, name)) { - problems.push( - `${relative(outDir, file)}: imports ${name} from ${specifier}, which does not export it`, - ); - } - } - } - } - - expect(problems).toEqual([]); - } finally { - rmSync(outDir, { recursive: true, force: true }); - } -}); - -type FileExports = { - names: Set; - stars: string[]; - imports: Array<{ specifier: string; names: string[] }>; -}; - -function dtsFiles(dir: string): string[] { - const found: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) found.push(...dtsFiles(path)); - else if (entry.name.endsWith(".d.ts")) found.push(path); - } - return found; -} - -function dtsPath(from: string, specifier: string): string | undefined { - if (!specifier.startsWith(".")) return undefined; - return `${resolve(dirname(from), specifier).replace(/\.(js|ts)$/, "")}.d.ts`; -} - -function exported(files: Map, file: string, name: string, seen = new Set()): boolean { - if (seen.has(file)) return false; - seen.add(file); - - const info = files.get(file); - if (!info) return false; - if (info.names.has(name)) return true; - - for (const specifier of info.stars) { - const target = dtsPath(file, specifier); - if (target && exported(files, target, name, seen)) return true; - } - return false; -} - -function fileExports(source: string): FileExports { - const names = new Set(); - const stars: string[] = []; - const imports: Array<{ specifier: string; names: string[] }> = []; - - const named = /^(import|export)(?:\s+type)?\s*\{([\s\S]*?)\}\s*(?:from\s+["']([^"']+)["'])?/gm; - const star = /^export\s+\*\s+from\s+["']([^"']+)["']/gm; - const asStar = /^export\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["']/gm; - const decl = - /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:type|interface|class|function|const|enum|namespace)\s+([A-Za-z_$][\w$]*)/gm; - - for (const match of source.matchAll(named)) { - const [, kind, list, specifier] = match; - if (specifier) imports.push({ specifier, names: ident(list ?? "", "imported") }); - if (kind === "export") { - for (const name of ident(list ?? "", "exported")) names.add(name); - } - } - - for (const match of source.matchAll(star)) { - if (match[1]) stars.push(match[1]); - } - - for (const match of source.matchAll(asStar)) { - if (match[1]) names.add(match[1]); - } - - for (const match of source.matchAll(decl)) { - if (match[1]) names.add(match[1]); - } - - return { names, stars, imports }; -} - -function ident(list: string, side: "imported" | "exported"): string[] { - return list - .split(",") - .map((part) => part.trim()) - .filter(Boolean) - .map((part) => { - const [left, right] = part.replace(/^type\s+/, "").split(/\s+as\s+/); - const name = side === "imported" ? left : (right ?? left); - return name?.trim() ?? ""; - }) - .filter(Boolean); -} diff --git a/quest/m1/README.md b/quest/m1/README.md index d11e511ab7..01b37243b9 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -54,7 +54,9 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest - [Setup token](/quest/m1/setup-token.md) - a moq-transport SETUP `AUTHORIZATION TOKEN` reaches the accepted handshake and the relay's auth request, so a verifier can run on it - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish -- [Tests under load](/quest/m1/test-flakes.md) - three tests that time out or run out of file descriptors under `just check` are fixed at the cause +- [Shaper virtual time](/quest/m1/shaper-virtual-time.md) - `moq-shaper` tests judge seeded decisions on paused time, not on wall-clock delivery under load +- [Rust compressed gate test](/quest/m1/json-compressed-gate-rs.md) - `rs/moq-json` proves a snapshot delta is gated on its encoded size +- [Shrink the JS overflow test](/quest/m1/json-rolls-snapshot-test.md) - the `js/json` roll-on-overflow test uses a small `maxGroupBytes` budget instead of megabytes of JSON - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed - [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 - [Generated C bindings](/quest/m1/c/README.md) - C generated from moq-ffi ships as `moq-c` 0.8.0 and replaces the hand-written libmoq diff --git a/quest/m1/json-compressed-gate-rs.md b/quest/m1/json-compressed-gate-rs.md new file mode 100644 index 0000000000..0e84456b81 --- /dev/null +++ b/quest/m1/json-compressed-gate-rs.md @@ -0,0 +1,17 @@ +# [XS] Rust test for the compressed snapshot gate + +## Goal + +`rs/moq-json`'s snapshot encoder has a test proving that a delta is admitted +by its encoded size, not its plaintext: a sync-flushed DEFLATE frame can come +out larger than its input, so a plaintext gate lets a patch overflow the +group and evict the snapshot a late joiner needs. The JS encoder gained this +test in the PR that retired `quest/m1/test-flakes.md`. + +## Plan + +- The gate compares against `moq_net::group::MAX_CACHE_BYTES`, which is too + large to reach cheaply. Mirror the JS approach: a crate-private budget the + test can shrink, so it measures real frames and sets a budget between the + patch's plaintext and encoded sizes. +- Confirm the test fails when the gate measures the plaintext. diff --git a/quest/m1/json-rolls-snapshot-test.md b/quest/m1/json-rolls-snapshot-test.md new file mode 100644 index 0000000000..653d9eba4c --- /dev/null +++ b/quest/m1/json-rolls-snapshot-test.md @@ -0,0 +1,14 @@ +# [XS] Shrink the JS snapshot overflow test + +## Goal + +The `js/json` test "a delta that would overflow the snapshot rolls a new one +instead" stops building two ~19 MiB values (about 1 s idle, several under +load) and exercises the same roll with a few bytes, through the encoder's +internal `maxGroupBytes` budget. + +## Plan + +- Keep one assertion that the default budget is moq-net's group cache limit, + so the shrunk test doesn't hide a drift between the two. +- Confirm the test fails when the gate is removed. diff --git a/quest/m1/shaper-virtual-time.md b/quest/m1/shaper-virtual-time.md new file mode 100644 index 0000000000..3fea8a4fb4 --- /dev/null +++ b/quest/m1/shaper-virtual-time.md @@ -0,0 +1,18 @@ +# [S] moq-shaper tests on virtual time + +## Goal + +The `moq-shaper` unit tests pass under any host load: their verdicts come +from the seeded decisions, not from how fast the kernel delivers datagrams. +`reorder_and_jitter_overtake` and `the_seed_reproduces_the_losses` saw every +datagram lost during a slowed full-workspace run, because `round_trip` stops +at the first 200 ms of silence on a real socket. + +## Plan + +- Drive the tests on paused tokio time (or an injected clock) so delay, + jitter, and the read deadline advance deterministically. +- Watch out: paused time auto-advances while the runtime idles, which can + fire a timer before a real UDP datagram lands. If the real sockets fight + the paused clock, separate the scheduling logic from the socket I/O and test + it directly, keeping one socket smoke test with a generous deadline. diff --git a/quest/m1/test-flakes.md b/quest/m1/test-flakes.md deleted file mode 100644 index 909d6cbc28..0000000000 --- a/quest/m1/test-flakes.md +++ /dev/null @@ -1,22 +0,0 @@ -# [M] Tests hold up under load - -## Goal - -Three tests that pass alone but fail under a full `just check` pass reliably, -fixed at the cause rather than by raising a timeout or adding a retry: - -- `js/json/src/snapshot/snapshot.test.ts:359`, "a compressed delta is gated - on its encoded size", which takes about 4.3 s against a 5 s limit. -- The `js/net/src/declarations.test.ts` test that times out at 5 s. -- `rs/moq-tokio/tests/backend.rs:739` `noq_cert_reload`, which fails with - "Too many open files". - -## Plan - -- Find why each JS test is slow. It should shrink its input or reveal a real - slowdown in the code under test; fix whichever it is. -- For `noq_cert_reload`, find what holds the descriptors: a leak in the test - or code under test, or nextest parallelism against the file limit. Fix a leak - at its source, and otherwise cap the test's concurrency in - `.config/nextest.toml`. -- Prove it by running `just check --all` several times on a loaded machine. diff --git a/rs/moq-tokio/tests/backend.rs b/rs/moq-tokio/tests/backend.rs index 15d850c24b..968f37f730 100644 --- a/rs/moq-tokio/tests/backend.rs +++ b/rs/moq-tokio/tests/backend.rs @@ -338,40 +338,30 @@ async fn reload_test() { server_config.tls.cert = vec![cert.clone()]; server_config.tls.key = vec![key.clone()]; + let server = server_config + .init(moq_tokio::quic::Config::default()) + .expect("failed to init server"); + let server = server.listen().await.expect("failed to listen"); + + // Every process the user runs shares one inotify instance limit, so a loaded host can refuse + // the listener its watcher. Judge by the listener's own watcher: a separate probe would race + // the rest of the host for the same limit. #[cfg(feature = "watch")] - if moq_tokio::watch::Files::new(std::slice::from_ref(&cert)).is_err() { + if tracing_test::internal::logs_with_scope_contain("moq_tokio", "hot reload disabled") { eprintln!("skipping reload_test: host cannot start an inotify watcher"); return; } - let server = server_config - .init(moq_tokio::quic::Config::default()) - .expect("failed to init server"); - let server = server.listen().await.expect("failed to listen"); let certificates = server.certificates(); let before = certificates.fingerprints(); assert_eq!(before.len(), 1); - // The reload task is spawned while the listener is built and registers its - // watcher the first time the runtime polls it, which may be after this point. - // Rotating before then would replace the files with nothing watching them. - tokio::time::sleep(Duration::from_millis(200)).await; - - // Rotate in place, the way cert-manager or a secret mount would. + // Rotate in place, the way cert-manager or a secret mount would. The listener registered its + // watch before returning, so the rotation cannot land before it. let (new_cert, new_key) = write_self_signed(dir.path(), "rotated", "localhost"); std::fs::rename(&new_cert, &cert).expect("rotate cert"); std::fs::rename(&new_key, &key).expect("rotate key"); - tokio::time::sleep(Duration::from_secs(2)).await; - assert!( - tracing_test::internal::logs_with_scope_contain("moq_tokio", "reloading server certificates"), - "no reload log" - ); - assert!( - !tracing_test::internal::logs_with_scope_contain("moq_tokio", "hot reload disabled"), - "watcher failed" - ); - let reloaded = tokio::time::timeout(TIMEOUT, async { loop { let now = certificates.fingerprints(); @@ -384,6 +374,10 @@ async fn reload_test() { .await .expect("certificate reload timed out"); + assert!( + tracing_test::internal::logs_with_scope_contain("moq_tokio", "reloading server certificates"), + "no reload log" + ); assert_eq!(reloaded.len(), 1); drop(server); } From 2eab1c3cad34ef1757e3cd40814e7957e89865de Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:36:27 -0700 Subject: [PATCH 21/87] feat(net): the SETUP AUTHORIZATION TOKEN option reaches the verifier (#4278) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/auth.md | 20 ++- doc/concept/standard.md | 9 ++ doc/lib/rs/moq-auth.md | 4 +- js/auth/src/contract.test.ts | 31 +++- js/auth/src/contract.ts | 17 +- js/net/src/ietf/index.ts | 1 + js/net/src/ietf/token.test.ts | 155 ++++++++++++++++++ js/net/src/ietf/token.ts | 119 ++++++++++++++ quest/m1/README.md | 1 - quest/m1/auth/request-token.md | 5 +- quest/m1/auth/token-in-band.md | 4 +- quest/m1/setup-token.md | 67 -------- quest/m2/cat/README.md | 11 +- quest/m2/cat/present.md | 2 - quest/m2/cat/verify.md | 5 - rs/moq-auth/src/request.rs | 56 ++++++- rs/moq-auth/src/serve.rs | 106 +++++++++++-- rs/moq-net/src/ietf/mod.rs | 1 + rs/moq-net/src/ietf/session.rs | 5 + rs/moq-net/src/ietf/token.rs | 233 ++++++++++++++++++++++++++++ rs/moq-net/src/lib.rs | 2 +- rs/moq-net/src/server.rs | 186 ++++++++++++++++++++-- rs/moq-net/src/setup.rs | 28 +++- rs/moq-relay/Cargo.toml | 1 + rs/moq-relay/src/auth.rs | 4 + rs/moq-relay/src/session.rs | 17 +- rs/moq-relay/tests/auth_lifetime.rs | 45 ++++++ rs/moq-tokio/src/server.rs | 8 + 28 files changed, 1005 insertions(+), 138 deletions(-) create mode 100644 js/net/src/ietf/token.test.ts create mode 100644 js/net/src/ietf/token.ts delete mode 100644 quest/m1/setup-token.md create mode 100644 rs/moq-net/src/ietf/token.rs diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index f9289e3f61..596f32e4a6 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -30,12 +30,14 @@ grant back. The schemas are `moq_auth::Request` and `moq_auth::Grant` `http` for a one-shot `/fetch` or `/announced` request), `remote` and `local` socket addresses, `server_name` (the SNI or the host the client addressed), `alpn` (the negotiated moq protocol), `path` exactly as dialed, `query` raw, -`role` the client declared at SETUP (`publisher`, `subscriber`, absent for -both), and `tls` with the verified client certificate when one was presented: +`token` with the credential a moq-transport client put in its SETUP's +`AUTHORIZATION TOKEN` option (`kind`, the draft's Token Type, and `value`, the +bytes as unpadded base64url), `role` the client declared at SETUP +(`publisher`, `subscriber`, absent for both), and `tls` with the verified client certificate when one was presented: `name` (first SAN DNS name, else CN, else the fingerprint; a server cannot tell which), `fingerprint` (SHA-256 of the leaf), `expires`, `issuer`. Nothing is parsed on the server's behalf: the `jwt` query parameter is a convention of `moq auth serve`, not of -the relay. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and +the relay, and the relay forwards a SETUP token without reading it. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and received. **Grant.** `publish` and `subscribe` as pattern unions (`foo/**` is a subtree, @@ -98,7 +100,7 @@ moq auth sessions --internal-url http://127.0.0.1:9101 --path 'demo/**' ``` `GET /sessions` is the dry run: the same matches, each request plus start -time, with `query` omitted so a jwt on the plane cannot be replayed. A +time, with `query` and `token` omitted so a credential on the plane cannot be replayed. A matching POST returns 202 and the ids; no match is 200 with an empty list. An unknown field, including `query`, is 400. One node, no cluster fan-out: the server already knows each session's `node` from `connect` and calls that @@ -142,7 +144,9 @@ moq auth verify --key public.jwk --in alice.jwt moq auth serve --key public.jwk # or --key-dir /etc/moq/keys/ for {kid}.jwk rotation ``` -The client dials `https://relay.example.com/rooms/123?jwt=`. HMAC +The client dials `https://relay.example.com/rooms/123?jwt=`. A +moq-transport client can instead put the JWT in its SETUP's `AUTHORIZATION +TOKEN` option with Token Type 0; `moq auth serve` verifies it the same way. HMAC (HS256/384/512), RSA (RS/PS), ECDSA (ES256/384), and EdDSA keys all work. A key can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. @@ -237,14 +241,16 @@ moq auth serve --listen 127.0.0.1:4440 \ Policy runs in this order and stops at the first that applies: -1. A `jwt` in the query is verified against `--key FILE` or `--key-dir DIR` +1. A JWT, from the `jwt` query or a SETUP `token` of `kind` 0, is verified + against `--key FILE` or `--key-dir DIR` (by `kid`, read per request so rotation needs no restart). Its claims are authorized at the dialed path (`Claims::authorize`): the path may equal the root, extend it (which narrows the grant), or be a parent of it (the grant stays anchored at the root). Residuals become the grant. An unrelated path, or one the token grants nothing at, is refused. A malformed, expired, or unknown-key token is refused; it never falls - through to the anonymous rules. + through to the anonymous rules. So is a SETUP token of any other `kind`, + and a session presenting both a SETUP token and a `jwt` query. 2. A verified client certificate gets `--mtls-publish` and `--mtls-subscribe`, and nothing when they are empty. Cluster peers are admitted this way; a mesh needs `'**'` for both. diff --git a/doc/concept/standard.md b/doc/concept/standard.md index f2c7e9929e..282dcaf880 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -41,6 +41,15 @@ because they do not issue joining fetches. Other publishers may replay a cached backlog for that filter; selecting the next group instead would leave static tracks waiting for a group that never arrives. +A client may present one credential in its `SETUP` with the `AUTHORIZATION +TOKEN` option. The server reads a value (`USE_VALUE`, or `REGISTER`, which it +treats as a value since it advertises no token cache) and hands its Token Type +and bytes to the application unverified; a relay forwards them to its +[auth server](/bin/relay/auth#the-contract). An alias reference (`DELETE`, +`USE_ALIAS`) closes the session with `PROTOCOL_VIOLATION`, a structure that +does not decode with `KEY_VALUE_FORMATTING_ERROR`, and a second token is +refused. + Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [solicit](/draft/moq-solicit) to make announcements opt-in, diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index ddab0dc5a9..b406e0adf7 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -13,10 +13,10 @@ Everything a party needs to ask for or answer an authorization on answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. -- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. +- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. - **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline: future expiries are unchanged, and one already less than five seconds late gets the remainder of that skew window. - **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`, 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 or a `{kid}.jwk` directory, an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. +- **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a JWT from the `jwt` query or a type-0 SETUP token, verified against a key file or a `{kid}.jwk` directory (a token of another type, or both at once, is refused), an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. - **Keys**: generate HS256/384/512, RS256/384/512, PS256/384/512, ES256/384, or EdDSA keys as JWKs, with a `kid` for rotation and an optional immutable scope that caps every token the key signs. - **Claims**: `root`, `publish`, `subscribe`, `exp`, `iat`. Grants are [`Pattern`](https://docs.rs/moq-pattern) unions: `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. `Key::sign` and `Key::verify` handle the signature and expiry. Legacy `put`/`get` prefix claims and scopes read as subtrees (`p` is `p/**`), and grants that are all subtrees are written that way so older verifiers accept them. - **Authorization**: `Claims::authorize(path)` scopes verified claims to the path a client dialed and returns the publish and subscribe patterns relative to it, exactly as `moq auth serve` does. The relay forwards the raw path and enforces the grant it gets. diff --git a/js/auth/src/contract.test.ts b/js/auth/src/contract.test.ts index 3aed509742..aad28a2115 100644 --- a/js/auth/src/contract.test.ts +++ b/js/auth/src/contract.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { GrantSchema, RequestSchema } from "./contract.ts"; +import { GrantSchema, RequestSchema, TokenSchema } from "./contract.ts"; // The fixtures below are what the Rust `moq_auth::Request` and `Grant` serialize to, // so a server written against these schemas reads what a relay sends. @@ -52,6 +52,35 @@ test("an end request carries its facts beside the rest", () => { expect(invalid.reason).toBe("invalid"); }); +test("a SETUP token parses as the Rust vector", () => { + // What `moq_auth::Request` serializes a CAT of bytes 00 fb ff to. + const request = RequestSchema.parse( + JSON.parse( + '{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}', + ), + ); + expect(request.token).toEqual({ kind: 1, value: "APv_" }); + + expect(() => + RequestSchema.parse({ + id: "1", + event: "connect", + node: "n", + transport: "quic", + path: "/", + token: { kind: 0, value: "AP+/" }, + }), + ).toThrow(); + + // Not a length or final character any byte string encodes to, which Rust refuses too. + for (const value of ["A", "AB", "APv_A"]) { + expect(() => TokenSchema.parse({ kind: 0, value })).toThrow(); + } + for (const value of ["", "AA", "AAA", "AAAA", "AQ", "AAE"]) { + expect(TokenSchema.parse({ kind: 0, value }).value).toBe(value); + } +}); + test("a connect must not carry end facts, and an unknown transport is refused", () => { expect(() => RequestSchema.parse({ id: "1", event: "end", node: "n", transport: "quic", path: "/" })).toThrow(); expect(() => diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index 3dfd527b73..c3371ba6e7 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -36,6 +36,17 @@ export const PeerSchema = z.object({ }); export type Peer = z.infer; +/** A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed. */ +export const TokenSchema = z.object({ + /** The moq-transport Token Type: 0 is negotiated out of band (a JWT to `moq auth serve`), 1 is a Common Access Token. */ + kind: z.int().check(z.nonnegative()), + /** The token bytes, base64url without padding. The final character must leave no stray bits, as Rust decodes it. */ + value: z + .string() + .check(z.regex(/^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$/)), +}); +export type Token = z.infer; + /** Byte totals for a session, both directions from the relay's point of view. */ export const BytesSchema = z.object({ /** Bytes the relay sent to the peer. */ @@ -64,6 +75,8 @@ const BaseRequestSchema = z.object({ path: z.string(), /** The raw query string, without the leading `?`. */ query: z.optional(z.string()), + /** The credential a moq-transport client presented in its SETUP. */ + token: z.optional(TokenSchema), /** The direction the client declared at SETUP; absent means both. */ role: z.optional(RoleSchema), /** The verified client certificate, when one was presented. */ @@ -73,8 +86,8 @@ const BaseRequestSchema = z.object({ /** * Everything a relay knows about a session, sent to the auth server on every event. * - * Nothing is parsed on the relay's behalf: the server keys policy on the raw `path` - * and `query`, so no query parameter is special. The same shape carries every event; + * Nothing is parsed on the relay's behalf: the server keys policy on the raw `path`, + * `query`, and `token`, so no query parameter is special. The same shape carries every event; * an `end` adds what the session did. */ export const RequestSchema = z.discriminatedUnion("event", [ diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index e1ebb8199d..9c895e9e29 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -16,5 +16,6 @@ export * from "./solicit.ts"; export * from "./subscribe.ts"; export * from "./subscribe_namespace.ts"; export * from "./subscriber.ts"; +export * from "./token.ts"; export * from "./track.ts"; export * from "./version.ts"; diff --git a/js/net/src/ietf/token.test.ts b/js/net/src/ietf/token.test.ts new file mode 100644 index 0000000000..5d4b4137d9 --- /dev/null +++ b/js/net/src/ietf/token.test.ts @@ -0,0 +1,155 @@ +import { expect, test } from "bun:test"; +import { SessionCode, SessionError } from "../error.ts"; +import { Reader, Writer } from "../stream.ts"; +import * as Varint from "../varint.ts"; +import { SetupOption, SetupOptions } from "./parameters.ts"; +import { TOKEN_OUT_OF_BAND, type Token, tokenFromSetup, tokenIntoSetup } from "./token.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +const VERSIONS: IetfVersion[] = [ + Version.DRAFT_14, + Version.DRAFT_15, + Version.DRAFT_16, + Version.DRAFT_17, + Version.DRAFT_18, + Version.DRAFT_19, + Version.DRAFT_20, + Version.DRAFT_21, + Version.DRAFT_22, +]; + +// A kind past one varint byte and a value that is not text, matching the Rust tests. +const TOKEN: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff, 0x03, 0x80, 0x6a]) }; + +function leadingOnes(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; +} + +function varint(v: bigint, version: IetfVersion): Uint8Array { + return leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v); +} + +/** The option as it arrives, after a trip through the SETUP parameter block. */ +async function received(params: SetupOptions, version: IetfVersion): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ + write(chunk) { + chunks.push(new Uint8Array(chunk)); + }, + }), + version, + ); + await params.encode(writer, version); + writer.close(); + await writer.closed; + + const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return SetupOptions.decode(new Reader(undefined, bytes, version), version); +} + +/** A raw Token structure: varint fields then a value. */ +function structure(version: IetfVersion, fields: bigint[], value: Uint8Array = new Uint8Array()): SetupOptions { + const parts = [...fields.map((field) => varint(field, version)), value]; + const raw = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + raw.set(part, offset); + offset += part.length; + } + const params = new SetupOptions(); + params.setBytes(SetupOption.AuthorizationToken, raw); + return params; +} + +function sessionCode(fn: () => unknown): SessionCode | undefined { + try { + fn(); + } catch (err) { + if (err instanceof SessionError) return err.code; + throw err; + } + return undefined; +} + +test("USE_VALUE round trips on every draft", async () => { + for (const version of VERSIONS) { + const params = new SetupOptions(); + tokenIntoSetup(params, TOKEN, version); + expect(tokenFromSetup(await received(params, version), version)).toEqual(TOKEN); + } +}); + +/** The same bytes `rs/moq-net/src/ietf/token.rs` asserts, so the two agree on the wire. */ +test("the encoding matches the cross-language vector", () => { + const token: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff]) }; + for (const [version, expected] of [ + [Version.DRAFT_14, [0x03, 0x41, 0x2c, 0x00, 0xff]], + [Version.DRAFT_17, [0x03, 0x81, 0x2c, 0x00, 0xff]], + ] as const) { + const params = new SetupOptions(); + tokenIntoSetup(params, token, version); + expect(params.getBytes(SetupOption.AuthorizationToken)).toEqual(new Uint8Array(expected)); + } +}); + +test("an absent option is no token", () => { + for (const version of VERSIONS) { + expect(tokenFromSetup(new SetupOptions(), version)).toBeUndefined(); + } +}); + +test("an empty value is a token", () => { + for (const version of VERSIONS) { + const params = structure(version, [0x3n, TOKEN_OUT_OF_BAND]); + expect(tokenFromSetup(params, version)).toEqual({ kind: TOKEN_OUT_OF_BAND, value: new Uint8Array() }); + } +}); + +/** We advertise no cache, so a registration is the draft's own USE_VALUE. */ +test("REGISTER is a value", () => { + for (const version of VERSIONS) { + const params = structure(version, [0x1n, 7n, TOKEN.kind], TOKEN.value); + expect(tokenFromSetup(params, version)).toEqual(TOKEN); + } +}); + +test("an alias reference is a protocol violation", () => { + for (const version of VERSIONS) { + for (const aliasType of [0x0n, 0x2n]) { + const params = structure(version, [aliasType, 7n]); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.ProtocolViolation); + } + } +}); + +test("an undecodable structure is a formatting error", () => { + for (const version of VERSIONS) { + for (const fields of [[], [0x3n], [0x1n], [0x1n, 7n], [0x4n, 0n]]) { + const params = structure(version, fields); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.KeyValueFormatting); + } + } +}); + +/** One credential per connection: a second token is refused, not unioned or dropped. */ +test("two tokens are refused", async () => { + for (const version of VERSIONS) { + const value = [...varint(0x3n, version), ...varint(TOKEN_OUT_OF_BAND, version)]; + const key = SetupOption.AuthorizationToken; + // Delta-encoded from draft-16, so the repeat is a delta of zero. + const repeat = version === Version.DRAFT_14 || version === Version.DRAFT_15 ? key : 0n; + const count = leadingOnes(version) ? [] : [...varint(2n, version)]; + const entry = (k: bigint) => [...varint(k, version), ...varint(BigInt(value.length), version), ...value]; + const bytes = new Uint8Array([...count, ...entry(key), ...entry(repeat)]); + + await expect(SetupOptions.decode(new Reader(undefined, bytes, version), version)).rejects.toThrow( + /duplicate parameter/, + ); + } +}); diff --git a/js/net/src/ietf/token.ts b/js/net/src/ietf/token.ts new file mode 100644 index 0000000000..818466696f --- /dev/null +++ b/js/net/src/ietf/token.ts @@ -0,0 +1,119 @@ +import { SessionCode, SessionError } from "../error.ts"; +import * as Varint from "../varint.ts"; +import { SetupOption, type SetupOptions } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +/** + * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). + * + * The value is the Token structure of section 8.9: an Alias Type, then fields that type + * selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means no alias + * is ever registered and every token arrives by value. + * + * Mirrors `rs/moq-net/src/ietf/token.rs`. + * + * @module + * @internal + */ + +/** Retire a registered alias. */ +const DELETE = 0x0n; +/** Register an alias for this type and value, then use them. */ +const REGISTER = 0x1n; +/** Use the type and value a registered alias names. */ +const USE_ALIAS = 0x2n; +/** Use the type and value carried inline. */ +const USE_VALUE = 0x3n; + +/** + * A credential presented in a SETUP's `AUTHORIZATION TOKEN` option. + * + * @internal + */ +export interface Token { + /** The wire Token Type, naming how `value` is encoded. */ + kind: bigint; + /** The token itself. */ + value: Uint8Array; +} + +/** + * Token Type 0: a format the endpoints agreed on out of band, such as a JWT. + * + * @internal + */ +export const TOKEN_OUT_OF_BAND = 0x0n; + +/** + * Token Type 1: a Common Access Token (draft-ietf-moq-c4m). + * + * @internal + */ +export const TOKEN_CAT = 0x1n; + +/** Draft-17 replaced QUIC's two-bit-length varint with a leading-ones one. */ +function leadingOnes(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; +} + +/** + * The token the peer's SETUP presented, if any. + * + * A second token is already refused as a duplicate option by {@link SetupOptions}: one + * credential per connection. + * + * @throws {SessionError} `ProtocolViolation` for an alias reference, which nothing before + * SETUP could have registered, or `KeyValueFormatting` for a structure that cannot be decoded. + * @internal + */ +export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Token | undefined { + const raw = params.getBytes(SetupOption.AuthorizationToken); + if (raw === undefined) return undefined; + + // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. + const malformed = (cause?: unknown) => + new SessionError(SessionCode.KeyValueFormatting, { cause, reason: "malformed AUTHORIZATION TOKEN" }); + const unvarint = (buf: Uint8Array): [bigint, Uint8Array] => { + try { + return leadingOnes(version) ? Varint.decodeLeadingOnes(buf) : Varint.decodeBigInt(buf); + } catch (err) { + throw malformed(err); + } + }; + + let [aliasType, rest] = unvarint(raw); + switch (aliasType) { + case USE_VALUE: + break; + // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. + case REGISTER: + [, rest] = unvarint(rest); + break; + // Section 9.1.4: nothing can have been registered before SETUP. + case DELETE: + case USE_ALIAS: + throw new SessionError(SessionCode.ProtocolViolation, { reason: "AUTHORIZATION TOKEN alias in SETUP" }); + default: + throw malformed(); + } + + const [kind, value] = unvarint(rest); + return { kind, value: value.slice() }; +} + +/** + * Present `token` in our SETUP, by value. + * + * @internal + */ +export function tokenIntoSetup(params: SetupOptions, token: Token, version: IetfVersion) { + const varint = (v: bigint) => (leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v)); + const aliasType = varint(USE_VALUE); + const kind = varint(token.kind); + + const out = new Uint8Array(aliasType.length + kind.length + token.value.length); + out.set(aliasType, 0); + out.set(kind, aliasType.length); + out.set(token.value, aliasType.length + kind.length); + params.setBytes(SetupOption.AuthorizationToken, out); +} diff --git a/quest/m1/README.md b/quest/m1/README.md index 01b37243b9..8b424e9bd7 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -52,7 +52,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest -- [Setup token](/quest/m1/setup-token.md) - a moq-transport SETUP `AUTHORIZATION TOKEN` reaches the accepted handshake and the relay's auth request, so a verifier can run on it - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish - [Shaper virtual time](/quest/m1/shaper-virtual-time.md) - `moq-shaper` tests judge seeded decisions on paused time, not on wall-clock delivery under load - [Rust compressed gate test](/quest/m1/json-compressed-gate-rs.md) - `rs/moq-json` proves a snapshot delta is gated on its encoded size diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index c6125f8295..34f77640e1 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -16,8 +16,8 @@ on the unknown key, and the legacy drafts silently ignore it. ## Plan -- Decode with the [Setup token](/quest/m1/setup-token.md) structure and - rules: `USE_VALUE` yields the token, `REGISTER` is a value since we +- Decode with the SETUP option's structure and rules + (`rs/moq-net/src/ietf/token.rs`, `js/net/src/ietf/token.ts`): `USE_VALUE` yields the token, `REGISTER` is a value since we advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, and `DELETE` or `USE_ALIAS` closes with `PROTOCOL_VIOLATION`. Both decoder families change: the strict `decode_params!` path, which rejects the key today, and the generic KVP @@ -64,6 +64,5 @@ to). Wire: none new; the parameter already exists in every supported draft. ## Required -- [Setup token](/quest/m1/setup-token.md) - supplies the token decoder - [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the per-token lease and `Client::attach` path each request uses diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 2c38ad0345..ee68cf6fb7 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -43,7 +43,7 @@ AUTH can carry the full grant once the pattern-interest prerequisite lands. stream; every other configured token gets its own AUTH stream on an AUTH-capable session, so no token is ever granted twice. On moq-transport the first token also rides the AUTHORIZATION TOKEN setup option - (`ParameterBytes::AuthorizationToken`, `USE_VALUE`, token type 0), which + (`ietf::token::into_setup`, `USE_VALUE`, token type 0), which scopes at accept the way the URL does. - The relay admits on the URL, then widens. An anonymous connection today is admitted with the public grant when one is configured and refused @@ -77,5 +77,3 @@ Additive. token setters sit beside - [moq-transport](/quest/m1/auth/moq-transport.md) - supplies the IETF AUTH exchange the setup-option token pairs with -- [Setup token](/quest/m1/setup-token.md) - supplies `setup::Token` and the - setup-option encoder diff --git a/quest/m1/setup-token.md b/quest/m1/setup-token.md deleted file mode 100644 index f90d4f5d97..0000000000 --- a/quest/m1/setup-token.md +++ /dev/null @@ -1,67 +0,0 @@ -# [M] The SETUP AUTHORIZATION TOKEN option reaches the verifier - -## Goal - -A moq-transport peer's SETUP `AUTHORIZATION TOKEN` option (key `0x03`, -draft-14 through the newest supported draft) is decoded by `rs/moq-net` into -a token type and value, exposed on the accepted handshake so an app can run -its own verifier, and forwarded by the relay in `moq_auth::Request` as -`token`, so an auth server can verify it. Today the option is stored as -opaque bytes and ignored. - -Boundaries: SETUP only. The same parameter on SUBSCRIBE, REQUEST_UPDATE, and -every other request is [Request tokens](/quest/m1/auth/request-token.md): a -fallback that authorizes only that request. No client configuration: [Token in -band](/quest/m1/auth/token-in-band.md) owns presenting one. - -## Plan - -- `moq_net::setup::Token { kind: u64, value: Vec }`, `kind` being the - wire Token Type: `Token::OUT_OF_BAND` is `0x0` and `Token::CAT` is the - `0x01` c4m-01 registers. `setup` becomes a public module exporting only - `Token`; the SETUP wire types stay crate-private. Decode the draft-21 - section 8.9 structure: Alias Type `USE_VALUE` yields the token; `REGISTER` - is treated as `USE_VALUE` because we advertise no - `MAX_AUTH_TOKEN_CACHE_SIZE` (the default of 0 makes that the draft's own - rule), so no alias state exists; `DELETE` or `USE_ALIAS` in SETUP closes - with `PROTOCOL_VIOLATION`; an undecodable structure closes with - `KEY_VALUE_FORMATTING_ERROR`. More than one token in SETUP is refused: one - credential per connection is the shape the contract has. -- Encode side: `Parameters` learns to write a `USE_VALUE` token, so [Token - in band](/quest/m1/auth/token-in-band.md) and - [Present](/quest/m2/cat/present.md) have nothing to add on the wire. -- `moq_net::server::Handshake::token() -> Option<&setup::Token>` beside - `path()` and `role()`, carried through the `Legacy` (draft-14 to 16) and - `PeerSetup` (draft-17+) paths; `moq_tokio::server::Request::token()` - forwards it; lite sessions return `None`. -- `moq_auth::Request.token: Option`, - serialized with `serde_with` base64 like the rest of the request. The relay - fills it in `request_for` in `rs/moq-relay/src/auth.rs`, beside the URL - query it already forwards. `moq auth serve` treats kind `0x0` as the JWT - the deployment negotiated out of band, verified exactly like `?jwt=`, and - refuses any other kind naming the code until - [Verify](/quest/m2/cat/verify.md) teaches it `0x01`. A request carrying - both a SETUP token and a `jwt` query is refused naming both. -- `js/net/src/ietf/parameters.ts` mirrors the decode and encode rules. No JS - accept-side API: nothing in `js/net` authorizes an IETF session. -- Docs: `doc/concept` on the IETF binding gains the option; - `doc/lib/rs/moq-auth.md` gains the request field; `doc/bin/relay/auth.md` - says the relay forwards the option and `moq auth serve` verifies a type-0 - token like `?jwt=`. -- Tests: decode and encode round trips for every alias type on every draft - in Rust and JS; `DELETE`/`USE_ALIAS` in SETUP close the session; `REGISTER` - is accepted as a value; two tokens in SETUP are refused; the handshake - exposes the bytes on one legacy and one draft-17+ session and a lite - session reports none; the relay forwards the bytes to a wiremock auth - server byte for byte; `moq auth serve` admits a type-0 JWT, refuses an - unknown kind, and refuses a token plus `?jwt=`. - -Public API: additive on `moq-net`, `moq-tokio`, `moq-auth`, and `js/net`. -Wire: none new; the option already exists in every supported draft. - -## Related - -- [Token in band](/quest/m1/auth/token-in-band.md) - writes the same option - from the client side with the shared `setup::Token` -- [Common Access Tokens](/quest/m2/cat/README.md) - verifies and presents a - CAT through this option diff --git a/quest/m2/cat/README.md b/quest/m2/cat/README.md index 8460cd1520..4c29c9c4c5 100644 --- a/quest/m2/cat/README.md +++ b/quest/m2/cat/README.md @@ -41,9 +41,9 @@ Boundaries decided while planning: ## Plan -Order: the wire first so a token reaches the auth server, which is the -standalone [Setup token](/quest/m1/setup-token.md) quest; verification; -then our clients present one. Everything rides `moq_auth::Request` and +Order: the SETUP option already reaches the auth server as +`moq_auth::Request.token`; verification comes first, then our clients present +one. Everything rides `moq_auth::Request` and `moq auth serve`, which shipped on dev. The JWT types sit at the crate root; the verify quest moves them under `moq_auth::jwt` so `cat` is a sibling module rather than a set of prefixed names. @@ -56,11 +56,6 @@ module rather than a set of prefixed names. - [Present](/quest/m2/cat/present.md) - a CAT is one kind of configured token, riding the SETUP option the in-band token quest already writes -## Required - -- [Setup token](/quest/m1/setup-token.md) - the SETUP option reaches - `moq_auth::Request` as `token` - ## Related - [In-band auth](/quest/m1/auth/README.md) - credentials presented after diff --git a/quest/m2/cat/present.md b/quest/m2/cat/present.md index cb318d20e1..7a2cf3d821 100644 --- a/quest/m2/cat/present.md +++ b/quest/m2/cat/present.md @@ -34,8 +34,6 @@ Public API: additive on `moq-tokio` and `js/net`. Wire: none. ## Required -- [Setup token](/quest/m1/setup-token.md) - the server-side exposure - and the shared `setup::Token` - [Verify](/quest/m2/cat/verify.md) - the server that admits the token the end-to-end test presents - [Token in band](/quest/m1/auth/token-in-band.md) - the token diff --git a/quest/m2/cat/verify.md b/quest/m2/cat/verify.md index 29a03c3126..c77c2c532e 100644 --- a/quest/m2/cat/verify.md +++ b/quest/m2/cat/verify.md @@ -82,8 +82,3 @@ rather than a set of prefixed names; `@moq/auth` stays flat. Public API: `moq_auth::cat` new, `moq auth serve` and `moq auth sign|verify` gain flags. Wire: none. - -## Required - -- [Setup token](/quest/m1/setup-token.md) - the token reaches the - server's request diff --git a/rs/moq-auth/src/request.rs b/rs/moq-auth/src/request.rs index 99efa583db..59628467da 100644 --- a/rs/moq-auth/src/request.rs +++ b/rs/moq-auth/src/request.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use serde_with::base64::{Base64, UrlSafe}; +use serde_with::formats::Unpadded; use serde_with::{DurationSecondsWithFrac, TimestampSeconds, serde_as}; use std::net::SocketAddr; use std::time::{Duration, SystemTime}; @@ -8,8 +10,8 @@ use crate::lease::Reason; /// Everything a relay knows about a session, sent to the auth server on every event. /// /// Nothing is parsed on the relay's behalf: the server keys policy on the raw -/// [`path`](Self::path) and [`query`](Self::query), so no query parameter is special -/// and a credential can be whatever the server understands. The same shape carries +/// [`path`](Self::path), [`query`](Self::query), and [`token`](Self::token), so no +/// query parameter is special and a credential can be whatever the server understands. The same shape carries /// every [`Event`]; an `end` adds what the session did. #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -46,6 +48,9 @@ pub struct Request { /// The raw query string, without the leading `?`. pub query: Option, + /// The credential a moq-transport client presented in its SETUP. + pub token: Option, + /// The direction the client declared at SETUP; absent means both. pub role: Option, @@ -73,12 +78,31 @@ impl Request { alpn: None, path: path.into(), query: None, + token: None, role: None, tls: None, } } } +/// A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed. +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Token { + /// The moq-transport Token Type, naming how [`value`](Self::value) is encoded. + pub kind: u64, + /// The token bytes, base64url without padding on the wire. + #[serde_as(as = "Base64")] + pub value: Vec, +} + +impl Token { + /// Token Type 0: a format negotiated out of band; `moq auth serve` reads it as a JWT. + pub const OUT_OF_BAND: u64 = 0x0; + /// Token Type 1: a Common Access Token. + pub const CAT: u64 = 0x1; +} + /// The lifecycle moment a [`Request`] reports. #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -259,6 +283,34 @@ mod tests { ); } + /// The exact bytes `js/auth/src/contract.test.ts` parses: the value is base64url, so + /// bytes that are not text survive the JSON unchanged. + #[test] + fn a_setup_token_serializes_as_base64url() { + let mut request = Request::new("relay-1", Transport::Quic, "/demo/room"); + request.id = "00ff".into(); + request.token = Some(Token { + kind: Token::CAT, + value: vec![0x00, 0xfb, 0xff], + }); + let json = serde_json::to_string(&request).unwrap(); + assert_eq!( + json, + r#"{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}"# + ); + assert_eq!(serde_json::from_str::(&json).unwrap(), request); + + // `js/auth` refuses the same malformed values. + for value in ["A", "AB", "APv_A", "AP+/"] { + let json = format!(r#"{{"kind":0,"value":"{value}"}}"#); + assert!(serde_json::from_str::(&json).is_err(), "{value}"); + } + for value in ["", "AA", "AAA", "AAAA", "AQ", "AAE"] { + let json = format!(r#"{{"kind":0,"value":"{value}"}}"#); + assert!(serde_json::from_str::(&json).is_ok(), "{value}"); + } + } + #[test] fn a_unix_session_has_no_addresses() { let request = Request::new("relay-1", Transport::Unix, ""); diff --git a/rs/moq-auth/src/serve.rs b/rs/moq-auth/src/serve.rs index 247381d1af..96e2de12bf 100644 --- a/rs/moq-auth/src/serve.rs +++ b/rs/moq-auth/src/serve.rs @@ -18,7 +18,7 @@ use axum::routing::post; use axum::{Json, Router}; use tokio::time::Instant; -use crate::{Event, Grant, Key, KeyId, Permissions, Request}; +use crate::{Event, Grant, Key, KeyId, Permissions, Request, Token}; /// Where the signing keys a `jwt` is verified against come from. Read per request, /// so a rotated file takes effect without a restart. @@ -48,8 +48,12 @@ pub struct Limits { } /// The decisions the server answers with, evaluated in order and stopping at the -/// first that applies: a `jwt` in the query, then a verified certificate, then the -/// anonymous rules. A malformed or expired token is a refusal, never a fall through. +/// first that applies: a JWT, then a verified certificate, then the anonymous rules. +/// A malformed or expired token is a refusal, never a fall through. +/// +/// The JWT is the `jwt` query parameter or a moq-transport SETUP token of type 0 +/// ([`Token::OUT_OF_BAND`]), verified alike. A session presenting both is refused, +/// as is a SETUP token of any other type. /// /// `#[non_exhaustive]`, so start from [`Policy::default`] and set the fields. #[derive(Clone, Debug)] @@ -110,12 +114,16 @@ pub enum Refusal { TokenLimit, #[error("too many live sessions from this address")] RemoteLimit, + #[error("both a SETUP token and a `jwt` query were presented; present one")] + TwoTokens, + #[error("SETUP token type {0:#x} is not supported; only type 0 (a JWT) is")] + UnsupportedToken(u64), } impl Policy { /// Decide `request` by the policy alone, ignoring session limits. pub async fn decide(&self, request: &Request) -> Result { - let (permissions, expires) = if let Some(jwt) = token(request) { + let (permissions, expires) = if let Some(jwt) = jwt(request)? { let key = self.key(jwt).await?; let claims = key.verify(jwt).map_err(|err| Refusal::InvalidToken(err.to_string()))?; let permissions = claims.authorize(&request.path).map_err(|err| match err { @@ -161,9 +169,21 @@ impl Policy { } } +/// The JWT the request presents: its SETUP token, or else its `jwt` query parameter. +fn jwt(request: &Request) -> Result, Refusal> { + match (&request.token, query_jwt(request)) { + (Some(_), Some(_)) => Err(Refusal::TwoTokens), + (Some(token), None) if token.kind == Token::OUT_OF_BAND => std::str::from_utf8(&token.value) + .map(Some) + .map_err(|_| Refusal::InvalidToken("the SETUP token is not UTF-8".into())), + (Some(token), None) => Err(Refusal::UnsupportedToken(token.kind)), + (None, jwt) => Ok(jwt), + } +} + /// The `jwt` query parameter, when the request carries a non-empty one. The last one /// wins, as it did on the relay, so a client that appends a fresh token is believed. -fn token(request: &Request) -> Option<&str> { +fn query_jwt(request: &Request) -> Option<&str> { let query = request.query.as_deref()?; // Borrow rather than decode: a JWT is base64url and never needs unescaping. query @@ -204,7 +224,8 @@ impl Sessions { slot.seen = Instant::now(); return Ok(()); } - let token = token(request).map(hash); + // Decided before counting, so the credential is already known to be one JWT. + let token = jwt(request).ok().flatten().map(hash); let remote = remote(request); if let (Some(cap), Some(token)) = (limits.token, token) && self.slots.values().filter(|slot| slot.token == Some(token)).count() >= cap @@ -232,7 +253,7 @@ impl Sessions { /// which survivor to revoke would be an accident of arrival order. fn revalidate(&mut self, request: &Request) { let slot = self.slots.entry(request.id.clone()).or_insert_with(|| Slot { - token: token(request).map(hash), + token: jwt(request).ok().flatten().map(hash), remote: remote(request), seen: Instant::now(), }); @@ -532,6 +553,73 @@ mod tests { assert!(policy.decide(&with_token(request("/demo"), &jwt)).await.is_ok()); } + fn with_setup_token(mut request: Request, kind: u64, value: &str) -> Request { + request.token = Some(Token { + kind, + value: value.as_bytes().to_vec(), + }); + request + } + + /// A type-0 SETUP token is the deployment's JWT, verified exactly like `?jwt=`. + #[tokio::test] + async fn a_type_zero_setup_token_is_a_jwt() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["alice/**"], &[], None); + let grant = policy + .decide(&with_setup_token(request("/demo"), Token::OUT_OF_BAND, &jwt)) + .await + .unwrap(); + assert_eq!(grant.publish, patterns(&["alice/**"])); + + // And refused like one: a stranger's key never falls through to public. + let stranger = Key::generate(Algorithm::HS256, Some(KeyId::decode("kid1").unwrap())).unwrap(); + let forged = sign(&stranger, "demo", &["**"], &[], None); + let err = policy + .decide(&with_setup_token(request("/demo"), Token::OUT_OF_BAND, &forged)) + .await + .unwrap_err(); + assert!(matches!(err, Refusal::InvalidToken(_)), "{err}"); + } + + #[tokio::test] + async fn a_setup_token_of_another_type_is_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + public: rules(&["**"], &["**"]), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + let err = policy + .decide(&with_setup_token(request("/demo"), Token::CAT, &jwt)) + .await + .unwrap_err(); + assert_eq!(err, Refusal::UnsupportedToken(Token::CAT)); + assert_eq!( + err.to_string(), + "SETUP token type 0x1 is not supported; only type 0 (a JWT) is" + ); + } + + /// Two credentials would leave the server guessing which one the client meant. + #[tokio::test] + async fn a_setup_token_and_a_query_jwt_are_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + let request = with_setup_token(with_token(request("/demo"), &jwt), Token::OUT_OF_BAND, &jwt); + let err = policy.decide(&request).await.unwrap_err(); + assert_eq!(err, Refusal::TwoTokens); + } + #[tokio::test] async fn the_last_jwt_in_the_query_wins() { let (dir, key) = key_dir(); @@ -545,7 +633,7 @@ mod tests { // relay; a trailing empty value does not blank it out. let mut request = request("/demo"); request.query = Some(format!("a=1&jwt=stale&jwt={fresh}&jwt=")); - assert_eq!(token(&request), Some(fresh.as_str())); + assert_eq!(query_jwt(&request), Some(fresh.as_str())); assert!(policy.decide(&request).await.is_ok()); request.query = Some(format!("jwt={fresh}&jwt=stale")); @@ -553,7 +641,7 @@ mod tests { assert!(matches!(err, Refusal::InvalidToken(_)), "{err}"); request.query = Some("jwt=&b=2".into()); - assert_eq!(token(&request), None); + assert_eq!(query_jwt(&request), None); } #[tokio::test] diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 8ae7dac1ec..9c8577b340 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -30,6 +30,7 @@ pub mod solicit; mod subscribe; mod subscribe_namespace; mod subscriber; +pub(crate) mod token; mod track; mod version; diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index cb0fd4538a..121f3f10e4 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -435,6 +435,9 @@ pub struct PeerSetup { /// The request path the peer advertised, for URL-less transports. pub path: Option, + /// The credential the peer presented in its `AUTHORIZATION TOKEN` option. + pub token: Option, + /// The Setup Options it declared (see [`cluster`] and [`solicit`]). pub declared: peer::Peer, } @@ -485,11 +488,13 @@ pub async fn accept_setup( ), None => None, }; + let token = super::token::from_setup(¶ms, version)?; let declared = peer_from_params(¶ms, version)?; return Ok(PeerSetup { stream: reader, path, + token, declared, }); } diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs new file mode 100644 index 0000000000..129e9e17e4 --- /dev/null +++ b/rs/moq-net/src/ietf/token.rs @@ -0,0 +1,233 @@ +//! The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). +//! +//! The value is the Token structure of section 8.9: an Alias Type, then fields that +//! type selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means +//! no alias is ever registered and every token arrives by value. + +use crate::{ + Error, SessionError, + coding::{Decode, Encode, EncodeError}, + setup::Token, +}; + +use super::{ParameterBytes, Parameters, Version}; + +/// Retire a registered alias. +const DELETE: u64 = 0x0; +/// Register an alias for this type and value, then use them. +const REGISTER: u64 = 0x1; +/// Use the type and value a registered alias names. +const USE_ALIAS: u64 = 0x2; +/// Use the type and value carried inline. +const USE_VALUE: u64 = 0x3; + +/// The token the peer's SETUP presented, if any. +/// +/// A second token is already refused as a duplicate option by [`Parameters`]: one +/// credential per connection. +pub fn from_setup(params: &Parameters, version: Version) -> Result, Error> { + params + .get_bytes(ParameterBytes::AuthorizationToken) + .map(|value| decode(value, version)) + .transpose() +} + +/// Present `token` in our SETUP, by value. +#[cfg_attr(not(test), expect(dead_code))] +pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> Result<(), EncodeError> { + let mut value = Vec::new(); + USE_VALUE.encode(&mut value, version)?; + token.kind.encode(&mut value, version)?; + value.extend_from_slice(&token.value); + params.set_bytes(ParameterBytes::AuthorizationToken, value); + Ok(()) +} + +/// Decode a Token structure, refusing what a SETUP cannot carry. +fn decode(mut buf: &[u8], version: Version) -> Result { + // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. + let malformed = |_| Error::Session(SessionError::KeyValueFormatting); + + match u64::decode(&mut buf, version).map_err(malformed)? { + USE_VALUE => {} + // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. + REGISTER => { + u64::decode(&mut buf, version).map_err(malformed)?; + } + // Section 9.1.4: nothing can have been registered before SETUP. + DELETE | USE_ALIAS => return Err(Error::ProtocolViolation), + _ => return Err(Error::Session(SessionError::KeyValueFormatting)), + } + + let kind = u64::decode(&mut buf, version).map_err(malformed)?; + Ok(Token { + kind, + value: buf.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERSIONS: [Version; 9] = [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + Version::Draft19, + Version::Draft20, + Version::Draft21, + Version::Draft22, + ]; + + fn token() -> Token { + // A kind past one varint byte and a value that is not text, so neither is mistaken + // for the other and no codec can get away with assuming UTF-8. + Token { + kind: 300, + value: vec![0x00, 0xff, 0x03, 0x80, b'j'], + } + } + + /// The option as it arrives, after a trip through the SETUP parameter block. + fn received(params: &Parameters, version: Version) -> Parameters { + let mut bytes = params.encode_bytes(version).unwrap(); + Parameters::decode(&mut bytes, version).unwrap() + } + + /// A raw Token structure: the alias type then its varint fields then a value. + fn structure(version: Version, fields: &[u64], value: &[u8]) -> Parameters { + let mut raw = Vec::new(); + for field in fields { + field.encode(&mut raw, version).unwrap(); + } + raw.extend_from_slice(value); + let mut params = Parameters::default(); + params.set_bytes(ParameterBytes::AuthorizationToken, raw); + received(¶ms, version) + } + + #[test] + fn use_value_round_trips_on_every_draft() { + for version in VERSIONS { + let mut params = Parameters::default(); + into_setup(&mut params, &token(), version).unwrap(); + let params = received(¶ms, version); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(token()), "{version:?}"); + } + } + + /// The same bytes `js/net/src/ietf/token.test.ts` asserts, so the two agree on the wire. + #[test] + fn the_encoding_matches_the_cross_language_vector() { + let token = Token { + kind: 300, + value: vec![0x00, 0xff], + }; + for (version, expected) in [ + (Version::Draft14, [0x03, 0x41, 0x2c, 0x00, 0xff]), + (Version::Draft17, [0x03, 0x81, 0x2c, 0x00, 0xff]), + ] { + let mut params = Parameters::default(); + into_setup(&mut params, &token, version).unwrap(); + assert_eq!( + params.get_bytes(ParameterBytes::AuthorizationToken), + Some(&expected[..]), + "{version:?}" + ); + } + } + + #[test] + fn an_empty_value_is_a_token() { + for version in VERSIONS { + let params = structure(version, &[USE_VALUE, Token::OUT_OF_BAND], &[]); + let expected = Token { + kind: Token::OUT_OF_BAND, + value: Vec::new(), + }; + assert_eq!(from_setup(¶ms, version).unwrap(), Some(expected), "{version:?}"); + } + } + + #[test] + fn absent_is_none() { + for version in VERSIONS { + assert_eq!(from_setup(&Parameters::default(), version).unwrap(), None); + } + } + + /// We advertise no cache, so a registration is the draft's own USE_VALUE. + #[test] + fn register_is_a_value() { + for version in VERSIONS { + let params = structure(version, &[REGISTER, 7, token().kind], &token().value); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(token()), "{version:?}"); + } + } + + /// One credential per connection: a second token is refused, not unioned or dropped. + #[test] + fn two_tokens_are_refused() { + for version in VERSIONS { + let mut value = Vec::new(); + USE_VALUE.encode(&mut value, version).unwrap(); + Token::OUT_OF_BAND.encode(&mut value, version).unwrap(); + + let key = u64::from(ParameterBytes::AuthorizationToken); + let (count, keys): (Option, [u64; 2]) = match version { + Version::Draft14 | Version::Draft15 => (Some(2), [key, key]), + // Delta-encoded from draft-16, so the repeat is a delta of zero. + Version::Draft16 => (Some(2), [key, 0]), + _ => (None, [key, 0]), + }; + let mut raw = Vec::new(); + if let Some(count) = count { + count.encode(&mut raw, version).unwrap(); + } + for key in keys { + key.encode(&mut raw, version).unwrap(); + value.encode(&mut raw, version).unwrap(); + } + + let err = Parameters::decode(&mut raw.as_slice(), version).unwrap_err(); + assert!(matches!(err, crate::DecodeError::Duplicate), "{version:?}: {err:?}"); + } + } + + #[test] + fn an_alias_reference_is_a_protocol_violation() { + for version in VERSIONS { + for alias_type in [DELETE, USE_ALIAS] { + let params = structure(version, &[alias_type, 7], &[]); + let err = from_setup(¶ms, version).unwrap_err(); + assert!( + matches!(err, Error::ProtocolViolation), + "{version:?} {alias_type}: {err:?}" + ); + } + } + } + + #[test] + fn an_undecodable_structure_is_a_formatting_error() { + for version in VERSIONS { + for (fields, why) in [ + (&[][..], "no alias type"), + (&[USE_VALUE][..], "no token type"), + (&[REGISTER][..], "no alias"), + (&[REGISTER, 7][..], "no token type after the alias"), + (&[0x4, 0][..], "an unknown alias type"), + ] { + let params = structure(version, fields, &[]); + let err = from_setup(¶ms, version).unwrap_err(); + assert!( + matches!(err, Error::Session(SessionError::KeyValueFormatting)), + "{version:?} {why}: {err:?}" + ); + } + } + } +} diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 732ce41989..ffb5a32415 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -85,7 +85,7 @@ mod lite; mod model; pub mod path; mod recv; -mod setup; +pub mod setup; mod tail; #[cfg(test)] mod test_interop; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index e26f5e632d..a6e9fd0fc9 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -151,7 +151,17 @@ impl Server { /// which is what drops the thread-affinity bounds: a pinned `!Send` /// transport can gate on the advertised path too. Anything but a moq-lite /// ALPN is refused with [`Error::Version`]. - pub async fn accept_request_lite(&self, now: Instant, mut session: S) -> Result, Error> + pub async fn accept_request_lite(&self, now: Instant, session: S) -> Result, Error> + where + S: crate::transport::poll::Session, + { + let mut refused = session.clone(); + self.handshake_lite(now, session) + .await + .inspect_err(|err| close(&mut refused, err)) + } + + async fn handshake_lite(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Session, { @@ -215,6 +225,8 @@ impl Server { path, role, origin, + // moq-lite carries no SETUP token. + token: None, assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -250,7 +262,22 @@ impl Server { /// /// The path is surfaced for moq-lite-05 and newer, and every moq-transport /// draft we speak; it's empty on versions with no in-band request path (lite 01-04). - pub async fn accept_request(&self, now: Instant, mut session: S) -> Result, Error> + /// + /// A SETUP that fails to parse or negotiate closes the session with the matching code, + /// so the peer learns why instead of seeing a bare disconnect. + pub async fn accept_request(&self, now: Instant, session: S) -> Result, Error> + where + S: crate::transport::poll::Boxable, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, + { + let mut refused = session.clone(); + self.handshake(now, session) + .await + .inspect_err(|err| close(&mut refused, err)) + } + + async fn handshake(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Boxable, S::SendStream: MaybeSync, @@ -295,7 +322,7 @@ impl Server { // Every lite ALPN goes through the same entry point, which is also // what a `!Send` transport calls directly. Some(ALPN_LITE_07_WIP | ALPN_LITE_06 | ALPN_LITE_05 | ALPN_LITE_04 | ALPN_LITE_03) => { - return self.accept_request_lite(now, session).await; + return self.handshake_lite(now, session).await; } Some(ALPN_LITE) | None => { let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; @@ -319,7 +346,7 @@ impl Server { // Pull the request path and max request ID out now (IETF only) so `ok()` // doesn't re-decode the consumed parameters. moq-transport carries the path // in its SETUP just like lite-05. - let (path, request_id_max, peer_declared) = match version { + let (path, token, request_id_max, peer_declared) = match version { Version::Ietf(v) => { let params = ietf::Parameters::decode(&mut client.parameters, v)?; let path = match params.get_bytes(ietf::ParameterBytes::Path) { @@ -330,6 +357,7 @@ impl Server { ), None => None, }; + let token = ietf::token::from_setup(¶ms, v)?; let request_id_max = params .get_varint(ietf::ParameterVarInt::MaxRequestId) .map(ietf::RequestId); @@ -338,15 +366,16 @@ impl Server { hidden: ietf::hidden::from_setup(¶ms, v), ..Default::default() }; - (path, request_id_max, peer_declared) + (path, token, request_id_max, peer_declared) } - Version::Lite(_) => (None, None, ietf::peer::Peer::default()), + Version::Lite(_) => (None, None, None, ietf::peer::Peer::default()), }; Ok(Handshake { path, role: None, origin: None, + token, assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -382,6 +411,7 @@ impl Server { // A moq-transport peer only has an identity if it negotiated the MoQ // Cluster extension and declared a non-zero Hop ID. origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), + token: peer_setup.token.clone(), assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -407,6 +437,7 @@ pub struct Handshake { path: Option, role: Option, origin: Option, + token: Option, /// The identity this session's routes are stamped with when the peer declares none /// on the wire. Fresh per request unless the caller overrides it /// ([`Handshake::with_peer_hop`]). @@ -516,9 +547,8 @@ where .maybe_boxed() } - fn close(self: Box, err: Error) { - let mut session = self.session; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + fn close(mut self: Box, err: Error) { + close(&mut self.session, &err); } } @@ -619,9 +649,8 @@ where .maybe_boxed() } - fn close(self: Box, err: Error) { - let mut session = self.session; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + fn close(mut self: Box, err: Error) { + close(&mut self.session, &err); } } @@ -663,6 +692,14 @@ where self.origin } + /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. + /// + /// Only moq-transport carries one, so moq-lite sessions return `None`. The transport + /// has not verified it: authorize on it the way you would a URL token. + pub fn token(&self) -> Option<&setup::Token> { + self.token.as_ref() + } + /// Publish to the connected client. Overrides any value from the [`Server`] /// builder; typically set after inspecting [`path`](Self::path). pub fn with_publisher(mut self, publish: impl Consume) -> Self { @@ -742,10 +779,15 @@ impl RequestInner { PausedHandshake::LiteSetup { session, .. } => session, PausedHandshake::Boxed(paused) => return paused.close(err), }; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + close(&mut session, &err); } } +/// Close `session` with `err`'s wire code. +fn close(session: &mut S, err: &Error) { + session.close(SessionError::from(err).to_code(), &err.to_string()); +} + impl Drop for Handshake { // A dropped request would otherwise leave the client hanging until its idle // timeout: it already sent SETUP and is waiting on a response. Reject loudly. @@ -789,12 +831,15 @@ mod tests { } } - /// A session that replays a queue of unidirectional streams (each a `Vec`) in - /// order from `accept_uni`; everything else is inert. + /// A session that replays a queue of streams (each a `Vec`) in order from + /// `accept_uni` and `accept_bi`, and records the code it was closed with; everything + /// else is inert. #[derive(Clone)] struct FakeSession { protocol: Option<&'static str>, uni: Arc>>>, + bi: Arc>>>, + closed: Arc>>, } impl FakeSession { @@ -802,8 +847,19 @@ mod tests { Self { protocol: Some(protocol), uni: Arc::new(Mutex::new(uni.into_iter().collect())), + bi: Default::default(), + closed: Default::default(), } } + + fn with_bi(self, bi: Vec) -> Self { + self.bi.lock().unwrap().push_back(bi); + self + } + + fn closed(&self) -> Option { + *self.closed.lock().unwrap() + } } impl web_transport_trait::poll::Session for FakeSession { @@ -824,7 +880,10 @@ mod tests { &mut self, _cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - std::task::Poll::Pending + match self.bi.lock().unwrap().pop_front() { + Some(data) => std::task::Poll::Ready(Ok((FakeSend, FakeRecv { data: data.into() }))), + None => std::task::Poll::Pending, + } } fn poll_open_bi( &mut self, @@ -857,7 +916,9 @@ mod tests { fn protocol(&self) -> Option<&str> { self.protocol } - fn close(&mut self, _code: u32, _reason: &str) {} + fn close(&mut self, code: u32, _reason: &str) { + self.closed.lock().unwrap().get_or_insert(code); + } fn poll_closed(&mut self, _cx: &mut std::task::Context<'_>) -> std::task::Poll { std::task::Poll::Pending } @@ -936,6 +997,10 @@ mod tests { if let Some(path) = path { params.set_bytes(ietf::ParameterBytes::Path, path.as_bytes().to_vec()); } + ietf_setup_with(version, params) + } + + fn ietf_setup_with(version: ietf::Version, params: ietf::Parameters) -> Vec { let parameters = params.encode_bytes(version).unwrap(); let mut buf = Vec::new(); @@ -945,6 +1010,91 @@ mod tests { buf } + /// Encode a draft 14-16 CLIENT_SETUP, sent on the control bidi stream. + fn legacy_setup(version: ietf::Version, params: ietf::Parameters) -> Vec { + let mut buf = Vec::new(); + setup::Client { + versions: crate::coding::Versions::from([crate::Version::Ietf(version).into()]), + parameters: params.encode_bytes(version).unwrap(), + } + .encode(&mut buf, crate::Version::Ietf(version)) + .unwrap(); + buf + } + + fn setup_token() -> setup::Token { + setup::Token { + kind: setup::Token::OUT_OF_BAND, + value: vec![0x00, 0xff, b'j', b'w', b't'], + } + } + + fn token_params(version: ietf::Version) -> ietf::Parameters { + let mut params = ietf::Parameters::default(); + ietf::token::into_setup(&mut params, &setup_token(), version).unwrap(); + params + } + + #[tokio::test(start_paused = true)] + async fn accept_request_exposes_the_setup_token() { + let modern = FakeSession::new( + ALPN_19, + [ietf_setup_with( + ietf::Version::Draft19, + token_params(ietf::Version::Draft19), + )], + ); + let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup( + ietf::Version::Draft16, + token_params(ietf::Version::Draft16), + )); + for (name, session) in [("draft-19", modern), ("draft-16", legacy)] { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap(); + assert_eq!(request.token(), Some(&setup_token()), "{name}"); + } + } + + #[tokio::test(start_paused = true)] + async fn accept_request_without_a_token_reports_none() { + let ietf = FakeSession::new(ALPN_19, [ietf_setup(ietf::Version::Draft19, None)]); + let lite = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); + for (name, session) in [("draft-19", ietf), ("lite-05", lite)] { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap(); + assert_eq!(request.token(), None, "{name}"); + } + } + + /// A SETUP the server refuses closes the session with the code naming why, on both + /// the draft-17+ uni stream and the draft 14-16 bidi stream. + #[tokio::test(start_paused = true)] + async fn a_refused_setup_token_closes_with_its_code() { + let delete = [0x0, 0x7]; // DELETE alias 7 + let truncated = [0x3]; // USE_VALUE with no Token Type + for (raw, code) in [ + (&delete[..], SessionError::ProtocolViolation), + (&truncated[..], SessionError::KeyValueFormatting), + ] { + let mut params = ietf::Parameters::default(); + params.set_bytes(ietf::ParameterBytes::AuthorizationToken, raw.to_vec()); + + let modern = FakeSession::new(ALPN_19, [ietf_setup_with(ietf::Version::Draft19, params.clone())]); + let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup(ietf::Version::Draft16, params)); + for (name, session) in [("draft-19", modern), ("draft-16", legacy)] { + let result = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session.clone()) + .await; + assert!(result.is_err(), "{name}"); + assert_eq!(session.closed(), Some(code.to_code()), "{name} {code}"); + } + } + } + #[tokio::test(start_paused = true)] async fn accept_request_reads_ietf_path() { // Every draft-17+ version gates on the SETUP stream before starting, so the @@ -1075,6 +1225,7 @@ mod tests { path: None, role: None, origin: None, + token: None, assigned_hop: Hop::random(), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), @@ -1088,6 +1239,7 @@ mod tests { Version::Ietf(version), ), path: None, + token: None, declared: ietf::peer::Peer::default(), }, })), diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 7d0006ecf0..3a4a96cba0 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -1,3 +1,7 @@ +//! The SETUP exchange, and the credential a peer may present in it. +//! +//! Only [`Token`] is public; the SETUP messages themselves are wire internals. + use bytes::Bytes; use crate::{ @@ -12,9 +16,27 @@ const SERVER_SETUP: u8 = 0x21; /// Draft-17 unified SETUP message type (varint 0x2F00) pub(crate) const SETUP_V17: u64 = 0x2F00; +/// A credential a moq-transport peer presented in its SETUP's `AUTHORIZATION TOKEN` option. +/// +/// The transport never reads the bytes; verifying them is the application's job. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + /// The wire Token Type, naming how [`value`](Self::value) is encoded. + pub kind: u64, + /// The token itself. + pub value: Vec, +} + +impl Token { + /// Token Type 0: a format the endpoints agreed on out of band, such as a JWT. + pub const OUT_OF_BAND: u64 = 0x0; + /// Token Type 1: a Common Access Token (draft-ietf-moq-c4m). + pub const CAT: u64 = 0x1; +} + /// Draft-17+ unified SETUP message, with the same encoding for both client and server. #[derive(Debug, Clone)] -pub struct Setup { +pub(crate) struct Setup { pub parameters: Bytes, } @@ -88,7 +110,7 @@ impl SetupVersion { /// A version-agnostic setup message sent by the client. #[derive(Debug, Clone)] -pub struct Client { +pub(crate) struct Client { /// The list of supported versions in preferred order. pub versions: coding::Versions, @@ -171,7 +193,7 @@ impl Encode for Client { /// Sent by the server in response to a client setup. #[derive(Debug, Clone)] -pub struct Server { +pub(crate) struct Server { /// The list of supported versions in preferred order. pub version: coding::Version, diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 76f663270a..ea8a6ad3b2 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -91,6 +91,7 @@ sd-notify = { workspace = true } [dev-dependencies] moq-auth = { path = "../moq-auth", features = ["client", "serve"] } moq-shaper = { path = "../moq-shaper" } +qmux = { workspace = true, features = ["tcp"] } rand = { workspace = true } rcgen = "0.14" tempfile = { workspace = true } diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 57a5316b23..bbe0261d90 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -473,6 +473,10 @@ pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request }; let mut out = auth.request(transport, path); out.query = request.query().map(str::to_owned); + out.token = request.token().map(|token| moq_auth::Token { + kind: token.kind, + value: token.value.clone(), + }); out.remote = request.remote_addr(); out.local = request.local_addr(); out.server_name = request diff --git a/rs/moq-relay/src/session.rs b/rs/moq-relay/src/session.rs index 016e49f1d9..da438de041 100644 --- a/rs/moq-relay/src/session.rs +++ b/rs/moq-relay/src/session.rs @@ -136,7 +136,7 @@ impl Registry { /// /// `id` and every scalar are exact. `path` is a [`Pattern`] against the dialed /// path. `remote` is an IP or CIDR with the port dropped and IPv4-mapped IPv6 -/// folded. `query` is not a field: it may carry the credential. +/// folded. `query` and `token` are not fields: they carry the credential. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Filter { /// Session id. @@ -402,7 +402,7 @@ impl IntoResponse for Error { } /// One live session as the list route returns it: the request the server saw, -/// minus `query`, plus when it was admitted. +/// minus its credentials (`query` and `token`), plus when it was admitted. #[serde_as] #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -453,7 +453,7 @@ impl View { /// `GET /sessions` body. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct List { - /// Matching sessions, `query` omitted. + /// Matching sessions, credentials omitted. pub sessions: Vec, } @@ -530,14 +530,21 @@ mod tests { } #[test] - fn list_omits_query() { + fn list_omits_credentials() { let registry = Registry::new(); - let _reg = registry.register(request("abc", "/room", "127.0.0.1:1")); + let mut session = request("abc", "/room", "127.0.0.1:1"); + session.query = Some("jwt=secret".into()); + session.token = Some(moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: b"secret".to_vec(), + }); + let _reg = registry.register(session); let list = registry.list(&Filter::default()); assert_eq!(list.len(), 1); assert_eq!(list[0].id, "abc"); let json = serde_json::to_value(&list[0]).unwrap(); assert!(json.get("query").is_none()); + assert!(json.get("token").is_none()); } #[tokio::test] diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 7d3922f23e..d5d39c72a8 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -348,12 +348,57 @@ async fn admits_and_reports_the_session() { assert!(connect.remote.is_some_and(|addr| addr.ip().is_loopback())); assert!(connect.local.is_some_and(|addr| addr.port() == port)); assert!(connect.tls.is_none()); + assert!(connect.token.is_none(), "moq-lite carries no SETUP token"); drop(pub_session); drop(sub_session); relay.abort(); } +/// A moq-transport client's SETUP token reaches the auth server byte for byte. +#[tokio::test] +async fn forwards_the_setup_token() { + use web_transport_trait::{RecvStream as _, SendStream as _, Session as _}; + + let script = Script::new(grant(Duration::from_secs(3600))); + let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; + + // No client presents a SETUP token yet, so write a draft-16 CLIENT_SETUP by hand. One + // parameter, AUTHORIZATION TOKEN (3), holding USE_VALUE (3), Token Type 0, then a + // value that is not text, so any lossy step on the way shows. + let value = [0x00, 0xff, 0x80, b'x']; + let token = [&[0x03, 0x00][..], &value].concat(); + let params = [&[0x01, 0x03, token.len() as u8][..], &token].concat(); + let setup = [&[0x20][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); + + let session = qmux::tcp::Config::new(qmux::Version::QMux01) + .protocols(["moqt-16"]) + .connect(("127.0.0.1", port)) + .await + .expect("connect"); + let (mut send, mut recv) = session.open_bi().await.expect("open the control stream"); + send.write_all(&setup).await.expect("send CLIENT_SETUP"); + + // The relay answers SERVER_SETUP only once the auth server has admitted the session. + let mut reply = [0u8; 1]; + tokio::time::timeout(TIMEOUT, recv.read(&mut reply)) + .await + .expect("SERVER_SETUP timeout") + .expect("SERVER_SETUP"); + + let seen = script.seen.lock().unwrap().clone(); + let connect = seen.iter().find(|r| r.event == Event::Connect).expect("a connect"); + assert_eq!( + connect.token, + Some(moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: value.to_vec(), + }) + ); + + relay.abort(); +} + /// A refusal at connect, a 5xx, and a garbage reply all refuse the session. #[tokio::test] async fn refusals_and_outages_refuse_at_connect() { diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index c06beaf474..fa498347a5 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1470,6 +1470,14 @@ impl Request { request_ref!(self, r => r.peer_hop()) } + /// The credential a moq-transport client presented in its SETUP's `AUTHORIZATION + /// TOKEN` option, unverified. moq-lite sessions return `None`. + /// + /// Like [`query`](Self::query), it can hold a credential. Avoid logging it. + pub fn token(&self) -> Option<&moq_net::setup::Token> { + request_ref!(self, r => r.token()) + } + /// The client certificate chain the peer presented, if any, validated /// against a configured [`crate::tls::Listen::root`] during the handshake. /// From 3fb076ab6442ae53bc47fdb80ce209c7a5cd176f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:50:06 -0700 Subject: [PATCH 22/87] feat(publish): encoders advertise catalog delay (#4260) Co-authored-by: Claude Opus 5.5 --- js/publish/src/audio/encoder.test.ts | 30 +++++++- js/publish/src/audio/encoder.ts | 15 ++-- js/publish/src/broadcast.ts | 7 ++ js/publish/src/jitter.test.ts | 100 ++++++++++++++++++--------- js/publish/src/jitter.ts | 57 ++++++++++----- js/publish/src/video/encoder.test.ts | 11 +-- js/publish/src/video/encoder.ts | 15 ++-- quest/m1/README.md | 1 - quest/m1/publish-delay.md | 27 -------- 9 files changed, 162 insertions(+), 101 deletions(-) delete mode 100644 quest/m1/publish-delay.md diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index a14554d0d8..b30e82ab52 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -2,6 +2,7 @@ import { describe, expect, mock, test } from "bun:test"; import * as Moq from "@moq/net"; import { Time } from "@moq/net"; import { Signal } from "@moq/signals"; +import { Baseline } from "../jitter"; import type { AudioFrame, Format } from "./capture"; import { Encoder, resolve } from "./encoder"; @@ -185,7 +186,7 @@ class Feed { } // An Encoder wired to a fake capture feed, recording each written frame as [timestamp, payload bytes]. -async function setup() { +async function setup(baseline = new Baseline()) { const configured = new Promise((resolve) => { LaggingAudioEncoder.onConfigure = resolve; }); @@ -218,7 +219,7 @@ async function setup() { }; const encoder = new Encoder("audio", { - broadcast: { audio: () => rendition } as never, + broadcast: { audio: () => rendition, baseline } as never, capture: capture as never, }); @@ -226,6 +227,7 @@ async function setup() { LaggingAudioEncoder.onConfigure = undefined; return { + encoder, track, rendition, feed, @@ -297,3 +299,27 @@ test("a push completing several frames keeps the encoder running", async () => { [78_700, 1], ]); }); + +// Another rendition on the same broadcast flushing with far less lateness leaves this one trailing +// it, which the catalog advertises as `delay`. +test("a rendition trailing the broadcast's earliest advertises delay", async () => { + using _webcodecs = installFakeWebCodecs(); + const baseline = new Baseline(); + using env = await setup(baseline); + const { encoder, feed } = env; + + expect(encoder.out.catalog.peek()?.delay).toBeUndefined(); + + // A sibling that flushes each frame the instant it is captured. + baseline.observe(0, performance.now() * 1000); + + // Captured 100ms ago, so this rendition flushes at least that late. + const start = performance.now() * 1000 - 100_000; + for (let index = 0; index < 4; index++) { + await feed.push({ timestamp: Time.Micro(start + index * 20_000), channels: [new Float32Array(960)] }); + } + await feed.drain(); + + expect(env.written.length).toBe(2); + expect(encoder.out.catalog.peek()?.delay).toBeGreaterThanOrEqual(100); +}); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 0cab7ea977..ca8a7eb365 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -5,7 +5,7 @@ import type * as Moq from "@moq/net"; import { Time } from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { RenditionJitter } from "../jitter"; +import { type Baseline, Estimator } from "../jitter"; import type { AudioFrame, Capture, Format } from "./capture"; import { Gain } from "./gain"; import { Resampler } from "./resampler"; @@ -170,7 +170,7 @@ export class Encoder { #fatal = new Signal(undefined); #signals = new Effect(); - #jitter = new RenditionJitter(); + #estimator = new Estimator(); constructor(name: string, props?: EncoderProps) { // `source` moved to Audio.Capture, which renditions share. TypeScript catches this, but a @@ -266,7 +266,7 @@ export class Encoder { const fatal = effect.get(this.#fatal); if (!enabled || !format || fatal) return; - this.#encode(rendition.track, format, effect); + this.#encode(rendition.track, broadcast.baseline, format, effect); }); // When demand disappears, end the epoch with a discontinuity marker (see @@ -350,7 +350,7 @@ export class Encoder { const catalog = decoder?.config === config ? { ...config, description: decoder.description } : config; effect.set(this.#out.catalog, { ...catalog, - jitter: this.#jitter.current ? Catalog.u53(this.#jitter.current) : undefined, + ...this.#estimator.estimate, }); } @@ -371,7 +371,7 @@ export class Encoder { // Encode captured audio frames into whichever track producer is live. The broadcast owns the // track's lifetime, so this never closes it; a fatal encoder error is reported through #fatal. - #encode(track: Getter, format: Format, effect: Effect): void { + #encode(track: Getter, baseline: Baseline, format: Format, effect: Effect): void { effect.spawn(async () => { // We're using an async polyfill temporarily for Safari support. await Util.Libav.polyfill(); @@ -423,10 +423,9 @@ export class Encoder { payload: Container.Legacy.encodeFrame(frame, frame.timestamp as Time.Micro), timestamp: Time.Timestamp.fromMicros(frame.timestamp as Time.Micro), }); - const jitter = this.#jitter.observe(frame.timestamp); - if (jitter !== undefined) { + if (this.#estimator.flush(frame.timestamp, baseline)) { const catalog = this.#out.catalog.peek(); - if (catalog) this.#out.catalog.set({ ...catalog, jitter: Catalog.u53(jitter) }); + if (catalog) this.#out.catalog.set({ ...catalog, ...this.#estimator.estimate }); } }, error: (err) => { diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 465e221dd7..4c8808a616 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -3,6 +3,7 @@ import * as Container from "@moq/hang/container"; import * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, Signal } from "@moq/signals"; import { CatalogProducer } from "./catalog"; +import { Baseline } from "./jitter"; import { type Kind, Rendition } from "./rendition"; // Signals the broadcast reads. Whoever owns the backing Signal (the element, or another component @@ -70,6 +71,12 @@ export class Broadcast { // Reacquire it via an effect, since a rename swaps in a fresh producer. readonly net = new Signal(undefined); + /** + * @internal The recent minimum flush lateness across every rendition, which each encoder + * measures its catalog `delay` against. Per broadcast, so a swapped one starts fresh. + */ + readonly baseline = new Baseline(); + // The registered renditions keyed by full track name. A plain object so deep-equality detects a // key add/remove; the Rendition values compare by identity, which is stable. readonly #renditions = new Signal>>({}); diff --git a/js/publish/src/jitter.test.ts b/js/publish/src/jitter.test.ts index 0cd0678924..f2970adc85 100644 --- a/js/publish/src/jitter.test.ts +++ b/js/publish/src/jitter.test.ts @@ -1,52 +1,86 @@ -import { expect, spyOn, test } from "bun:test"; -import { JitterClock, RenditionJitter } from "./jitter"; +import { expect, test } from "bun:test"; +import { u53 } from "@moq/hang/catalog"; +import { Baseline, Estimator } from "./jitter"; test("a batch flushed at its end counts its full media span", () => { - const clock = new JitterClock(); - clock.observe(0, 0); - expect(clock.observe(0, 120_000)).toBe(120_000); - expect(clock.observe(40_000, 120_000)).toBe(80_000); + const broadcast = new Baseline(); + const estimator = new Estimator(); + estimator.flush(0, broadcast, 0); + expect(estimator.flush(0, broadcast, 120_000)).toBe(true); + expect(estimator.estimate.jitter).toBe(u53(120)); + expect(estimator.flush(40_000, broadcast, 120_000)).toBe(false); }); test("a constant lateness is not jitter", () => { - const clock = new JitterClock(); - expect(clock.observe(0, 200_000)).toBe(0); - expect(clock.observe(240_000, 440_000)).toBe(0); + const broadcast = new Baseline(); + const estimator = new Estimator(); + expect(estimator.flush(0, broadcast, 200_000)).toBe(false); + expect(estimator.flush(240_000, broadcast, 440_000)).toBe(false); + expect(estimator.estimate).toEqual({ jitter: undefined, delay: undefined }); }); test("a sliding minimum bounds slow media clock drift", () => { - const clock = new JitterClock(); - let maximum = 0; + const broadcast = new Baseline(); + const estimator = new Estimator(); for (let second = 0; second < 100; second++) { - maximum = Math.max(maximum, clock.observe(second * 1_000_000, second * 1_001_000)); + estimator.flush(second * 1_000_000, broadcast, second * 1_001_000); } - expect(maximum).toBeLessThanOrEqual(10_000); - expect(maximum).toBeGreaterThan(0); + expect(estimator.estimate.jitter).toBeLessThanOrEqual(10); + expect(estimator.estimate.jitter).toBeGreaterThan(0); }); test("a faster-than-real-time source keeps lowering the baseline", () => { - const clock = new JitterClock(); + const broadcast = new Baseline(); + const estimator = new Estimator(); for (let second = 0; second < 100; second++) { - expect(clock.observe(second * 2_000_000, second * 1_000_000)).toBe(0); + expect(estimator.flush(second * 2_000_000, broadcast, second * 1_000_000)).toBe(false); } }); -test("each rendition measures against its own minimum", () => { - const now = spyOn(performance, "now").mockReturnValue(0); - try { - const audio = new RenditionJitter(); - const video = new RenditionJitter(); - expect(audio.observe(0)).toBeUndefined(); - now.mockReturnValue(200); - // A slower encoder's constant offset is not jitter. - expect(video.observe(0)).toBeUndefined(); - now.mockReturnValue(300); - expect(video.observe(40_000)).toBe(60); - now.mockReturnValue(440); - expect(video.observe(240_000)).toBeUndefined(); - expect(audio.current).toBeUndefined(); - expect(video.current).toBe(60); - } finally { - now.mockRestore(); +test("each rendition measures jitter against its own minimum", () => { + const broadcast = new Baseline(); + const audio = new Estimator(); + const video = new Estimator(); + audio.flush(0, broadcast, 0); + // A slower encoder's constant offset is not jitter. + video.flush(0, broadcast, 200_000); + video.flush(40_000, broadcast, 300_000); + video.flush(240_000, broadcast, 440_000); + expect(audio.estimate.jitter).toBeUndefined(); + expect(video.estimate.jitter).toBe(u53(60)); +}); + +test("a rendition trailing the broadcast's earliest advertises the gap as delay", () => { + const broadcast = new Baseline(); + const audio = new Estimator(); + const video = new Estimator(); + for (let frame = 0; frame < 10; frame++) { + const timestamp = frame * 20_000; + audio.flush(timestamp, broadcast, timestamp + 5_000); + video.flush(timestamp, broadcast, timestamp + 205_000); } + expect(audio.estimate).toEqual({ jitter: undefined, delay: undefined }); + expect(video.estimate).toEqual({ jitter: undefined, delay: u53(200) }); +}); + +test("delay is a lifetime maximum", () => { + const broadcast = new Baseline(); + const audio = new Estimator(); + const video = new Estimator(); + audio.flush(0, broadcast, 0); + expect(video.flush(0, broadcast, 150_000)).toBe(true); + expect(video.estimate.delay).toBe(u53(150)); + + // Video catches up, then audio stops long enough to leave the window: neither lowers it. + expect(video.flush(100_000, broadcast, 100_000)).toBe(false); + expect(video.flush(20_000_000, broadcast, 20_000_000)).toBe(false); + expect(video.estimate.delay).toBe(u53(150)); +}); + +test("broadcasts do not share a baseline", () => { + const audio = new Estimator(); + const video = new Estimator(); + audio.flush(0, new Baseline(), 0); + video.flush(0, new Baseline(), 200_000); + expect(video.estimate.delay).toBeUndefined(); }); diff --git a/js/publish/src/jitter.ts b/js/publish/src/jitter.ts index aaa9bab0e1..33a06b9e69 100644 --- a/js/publish/src/jitter.ts +++ b/js/publish/src/jitter.ts @@ -1,18 +1,24 @@ +import * as Catalog from "@moq/hang/catalog"; + const WINDOW = 10_000_000; // 10 seconds in microseconds. type Sample = { at: number; lateness: number }; -// One rendition's recent minimum encode lateness. The queue is ordered by lateness so its head is -// the minimum in the last window, and each sample enters/leaves once. -export class JitterClock { +// The minimum flush lateness over the last window, so a media clock drifting against the wall clock +// does not ratchet forever. The queue is ordered by lateness so its head is the minimum, and each +// sample enters/leaves once. +// +// Every js/publish timestamp is `performance.now()`, so lateness compares across renditions and one +// window can be shared by a whole broadcast. +export class Baseline { #samples: Sample[] = []; #head = 0; - observe(timestamp: number, now: number): number { + // Insert a lateness observed at `now` and return the window's minimum. + observe(lateness: number, now: number): number { const cutoff = now - WINDOW; while (this.#head < this.#samples.length && this.#samples[this.#head].at < cutoff) this.#head++; - const lateness = now - timestamp; while (this.#samples.length > this.#head) { const last = this.#samples.at(-1); if (!last || last.lateness < lateness) break; @@ -26,23 +32,40 @@ export class JitterClock { this.#head = 0; } this.#samples.push({ at: now, lateness }); - return Math.max(0, lateness - this.#samples[this.#head].lateness); + return this.#samples[this.#head].lateness; } } -// One rendition's advertised maximum spread above its own recent minimum lateness. -export class RenditionJitter { - #clock = new JitterClock(); - #maximum = 0; +// One rendition's catalog `jitter` and `delay`, each a lifetime maximum in whole milliseconds. +// Mirrors `moq_mux::catalog::Estimator`. +export class Estimator { + #baseline = new Baseline(); + #jitter = 0; + #delay = 0; - get current(): number | undefined { - return this.#maximum || undefined; + // The catalog fields measured so far, each absent until nonzero. + get estimate(): { jitter?: Catalog.U53; delay?: Catalog.U53 } { + return { + jitter: this.#jitter ? Catalog.u53(this.#jitter) : undefined, + delay: this.#delay ? Catalog.u53(this.#delay) : undefined, + }; } - observe(timestamp: number): number | undefined { - const rounded = Math.ceil(this.#clock.observe(timestamp, performance.now() * 1000) / 1000); - if (rounded <= this.#maximum) return undefined; - this.#maximum = rounded; - return rounded; + // Measure a frame handed to the transport now, against the `broadcast` baseline every rendition + // in the catalog shares. Jitter is the spread above this rendition's own recent minimum lateness, + // so a constant encoder delay is not jitter; delay is how far that minimum trails the broadcast's. + // Returns whether either estimate rose. + flush(timestamp: number, broadcast: Baseline, now = performance.now() * 1000): boolean { + const lateness = now - timestamp; + const earliest = broadcast.observe(lateness, now); + const minimum = this.#baseline.observe(lateness, now); + + const jitter = Math.ceil((lateness - minimum) / 1000); + const delay = Math.ceil((minimum - earliest) / 1000); + if (jitter <= this.#jitter && delay <= this.#delay) return false; + + this.#jitter = Math.max(this.#jitter, jitter); + this.#delay = Math.max(this.#delay, delay); + return true; } } diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index 74759200d6..c33e93964a 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -2,6 +2,7 @@ import { expect, spyOn, test } from "bun:test"; import * as Container from "@moq/hang/container"; import * as Moq from "@moq/net"; import { Signal } from "@moq/signals"; +import { Baseline } from "../jitter"; import { Encoder } from "./encoder"; class FakeVideoEncoder { @@ -63,7 +64,7 @@ test("encoding tracks encoder config in its child effect", async () => { track: new Signal(track), close: () => track.close(), }; - const broadcast = { video: () => rendition }; + const broadcast = { video: () => rendition, baseline: new Baseline() }; const capture = { in: { source: new Signal(undefined) }, out: { @@ -110,7 +111,7 @@ test("a demand gap cuts the group and leaves the broadcast-owned track open for }; const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, }); @@ -167,7 +168,7 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, bandwidth, }); @@ -248,7 +249,7 @@ test("every published config was probed for its own codec and dimensions", async const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, bandwidth, }); @@ -509,7 +510,7 @@ test.each(["encoder lag", "quiet startup"])("marks a rendition stalled for %s", }; const encoder = new Encoder("video/hd", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, }); diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index a89c58b5b6..3f88fd279d 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -13,7 +13,7 @@ import { Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { RenditionJitter } from "../jitter"; +import { type Baseline, Estimator } from "../jitter"; import { hardwareReliable } from "../support/video"; import type { Capture } from "./capture"; import { normalizeSource, type Source } from "./types"; @@ -153,7 +153,7 @@ export class Encoder { #lastCaptured?: Time.Micro; #lastAccepted?: Time.Micro; #lastCaptureWall?: number; - #jitter = new RenditionJitter(); + #estimator = new Estimator(); constructor(name: string, props?: EncoderProps) { this.name = name; @@ -195,7 +195,7 @@ export class Encoder { return; } - this.#encode(track, effect); + this.#encode(track, broadcast.baseline, effect); }); // Reserve against the connection for as long as this track is live. Wait @@ -226,7 +226,7 @@ export class Encoder { } // Encode captured frames into the track producer, reconfiguring when the resolved config changes. - #encode(track: Moq.Track.Producer, effect: Effect): void { + #encode(track: Moq.Track.Producer, baseline: Baseline, effect: Effect): void { const capture = effect.get(this.in.capture); if (!capture) { this.#observe({ demand: true, idle: true }); @@ -263,10 +263,9 @@ export class Encoder { })); producer.encode(frame, frame.timestamp as Time.Micro, key); - const jitter = this.#jitter.observe(frame.timestamp); - if (jitter !== undefined) { + if (this.#estimator.flush(frame.timestamp, baseline)) { const catalog = this.#out.catalog.peek(); - if (catalog) this.#out.catalog.set({ ...catalog, jitter: Catalog.u53(jitter) }); + if (catalog) this.#out.catalog.set({ ...catalog, ...this.#estimator.estimate }); } this.#lastAccepted = frame.timestamp as Time.Micro; this.#observe({ demand: true, idle: false, frame: true }); @@ -395,7 +394,7 @@ export class Encoder { codedHeight: Catalog.u53(config.height), optimizeForLatency: true, container: { kind: "legacy" } as const, - jitter: this.#jitter.current ? Catalog.u53(this.#jitter.current) : undefined, + ...this.#estimator.estimate, stalled: this.#stalled.flag(), }; diff --git a/quest/m1/README.md b/quest/m1/README.md index 8b424e9bd7..0855737150 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -42,7 +42,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 -- [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Data capture in bindings](/quest/m1/data-capture-bindings.md) - moq-ffi and every wrapper pass a data frame's capture time, and the JSON window producer takes one - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass diff --git a/quest/m1/publish-delay.md b/quest/m1/publish-delay.md deleted file mode 100644 index aeb4209d69..0000000000 --- a/quest/m1/publish-delay.md +++ /dev/null @@ -1,27 +0,0 @@ -# [S] js/publish: encoders advertise catalog delay - -## Goal - -The browser publisher advertises `delay` the way `moq-mux` does: each rendition -reports how far its minimum flush lateness trails the broadcast's earliest -rendition, as a lifetime maximum that is never lowered. A browser video encoder -running behind its audio encoder advertises that offset on video, so -`js/watch` holds it instead of playing video late. - -## Plan - -- `js/publish/src/jitter.ts` already measures each rendition's lateness on - `performance.now()`, and every js/publish timestamp shares that clock, so a - broadcast-wide sliding minimum beside the per-rendition one is enough. Mirror - `moq_mux::catalog::Estimator`: one window per rendition, one shared by the - broadcast, and `delay` is the gap between their minima. -- The shared minimum belongs to the `Broadcast` the encoders register on, not to - the encoders, so a swapped broadcast starts fresh. Keep it off the public API. -- The draft's rule stands: a consumer never subtracts `delay` across - renditions and holds the largest `delay + jitter` it subscribes to, so the - publisher reports its sliding-baseline maximum as is. -- `CatalogProducer` already refuses a lowered or zero `delay`. - -## Related - -- [Data jitter](/quest/m1/data-jitter.md) - the same measurement for JSON and binary tracks in Rust From 23ec0ff8368178804a6038b997a62ed145020b0d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:57:25 -0700 Subject: [PATCH 23/87] fix(cli): close the relay connection on SIGINT and SIGTERM (#4287) Co-authored-by: Claude Opus 5.5 --- rs/moq-cli/src/main.rs | 98 ++++++++++++++++++++++------------- rs/moq-tokio/src/client.rs | 17 ++++++ rs/moq-tokio/src/noq.rs | 8 +++ rs/moq-tokio/tests/backend.rs | 58 +++++++++++++++++++++ 4 files changed, 145 insertions(+), 36 deletions(-) diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 27ee260451..a3730144ac 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -402,12 +402,12 @@ impl Directions { async fn spawn_moq( moq: &MoqSide, net: &Net, + client: moq_tokio::Client, cluster: moq_relay::cluster::Cluster, directions: Directions, tasks: &mut JoinSet>, ) -> anyhow::Result<(moq_net::bandwidth::Allocator, moq_net::origin::Producer)> { let mut bandwidth = moq_net::bandwidth::Allocator::unlimited(); - let client = net.client(moq.client.clone())?; let cluster = cluster .with_client(client.clone()) .with_client_tls(moq.client.tls.build()?) @@ -471,7 +471,8 @@ async fn run_play(moq: MoqSide, args: play::Args, net: Net) -> anyhow::Result<() consume: true, ..Default::default() }; - let (_, origin) = spawn_moq(&moq, &net, cluster, directions, &mut tasks).await?; + let client = net.client(moq.client.clone())?; + let (_, origin) = spawn_moq(&moq, &net, client, cluster, directions, &mut tasks).await?; play::run(origin.consume(), name, args, tasks) } @@ -479,7 +480,7 @@ async fn run_play(moq: MoqSide, args: play::Args, net: Net) -> anyhow::Result<() /// Run every stage over one Origin and one MoQ attachment. /// /// Stages are independent: each names its own broadcast and owns its own endpoint, -/// and the first to finish (stdin EOF, Ctrl-C, or an error) ends the process. +/// and the first to finish (stdin EOF, SIGINT, SIGTERM, or an error) ends the process. async fn run_stages(moq: MoqSide, stages: Vec, net: Net) -> anyhow::Result<()> { let cluster = moq.cluster()?; let mut tasks: JoinSet> = JoinSet::new(); @@ -489,40 +490,50 @@ async fn run_stages(moq: MoqSide, stages: Vec, net: Net) -> anyhow::Res // The stage combinations were refused up front by `Invocation::validate`, before // anything bound a port or dialed out. - let (bandwidth, origin) = spawn_moq(&moq, &net, cluster, Directions::of(&stages), &mut tasks).await?; - - // stdin and stdout are one resource each, so two stages can't share them. - let mut stdin = None; - let mut stdout = None; - - for stage in stages { - let name = stage.broadcast(&moq); - match stage { - Command::Import(import) => { - if import.source.stdin_format().is_some() { - claim("stdin", &mut stdin, &name)?; - } - if let Some(publish) = spawn_import(&origin, import, name, bandwidth.clone(), &mut tasks)? { - locals.push(publish); + let client = net.client(moq.client.clone())?; + let result = async { + let (bandwidth, origin) = + spawn_moq(&moq, &net, client.clone(), cluster, Directions::of(&stages), &mut tasks).await?; + + // stdin and stdout are one resource each, so two stages can't share them. + let mut stdin = None; + let mut stdout = None; + + for stage in stages { + let name = stage.broadcast(&moq); + match stage { + Command::Import(import) => { + if import.source.stdin_format().is_some() { + claim("stdin", &mut stdin, &name)?; + } + if let Some(publish) = spawn_import(&origin, import, name, bandwidth.clone(), &mut tasks)? { + locals.push(publish); + } } - } - Command::Export(export) => { - if export.sink.is_stdout() { - claim("stdout", &mut stdout, &name)?; + Command::Export(export) => { + if export.sink.is_stdout() { + claim("stdout", &mut stdout, &name)?; + } + spawn_export(&origin, export, name, &mut tasks)?; } - spawn_export(&origin, export, name, &mut tasks)?; + other => unreachable!("`{}` is not a stage", other.name()), } - other => unreachable!("`{}` is not a stage", other.name()), } - } - if locals.is_empty() { - return drive(tasks).await; + if locals.is_empty() { + drive(tasks).await + } else { + let local = tokio::task::LocalSet::new(); + supervise(&local, locals.into_iter().map(Publish::run), &mut tasks); + local.run_until(drive(tasks)).await + } } + .await; - let local = tokio::task::LocalSet::new(); - supervise(&local, locals.into_iter().map(Publish::run), &mut tasks); - local.run_until(drive(tasks)).await + // The process exits next, even on a setup error, so the relay only hears we left + // if the close goes out now. + client.close().await; + result } /// Run the non-Send pipelines on `local`, reporting each into `tasks`. @@ -740,13 +751,10 @@ async fn run_stdout(consumer: moq_net::origin::Consumer, name: String, args: Sub Subscribe::new(source, catalog, args).run().await } -/// Run every endpoint until the first finishes (stdin EOF, Ctrl-C, or an error), -/// then drop the rest. +/// Run every endpoint until the first finishes (stdin EOF, SIGINT, SIGTERM, or an +/// error), then drop the rest. async fn drive(mut tasks: JoinSet>) -> anyhow::Result<()> { - tasks.spawn(async { - let _ = tokio::signal::ctrl_c().await; - Ok(()) - }); + tasks.spawn(shutdown_signal()); while let Some(res) = tasks.join_next().await { match res { @@ -760,6 +768,24 @@ async fn drive(mut tasks: JoinSet>) -> anyhow::Result<()> { Ok(()) } +/// Resolve on SIGINT or, on unix, SIGTERM (what process supervisors send on stop). +async fn shutdown_signal() -> anyhow::Result<()> { + #[cfg(unix)] + { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("failed to listen for SIGTERM")?; + tokio::select! { + res = tokio::signal::ctrl_c() => res.context("failed to listen for SIGINT")?, + _ = term.recv() => {} + } + Ok(()) + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await.context("failed to listen for SIGINT") + } +} + /// The listener / HTTP-serving endpoints bridge one named broadcast, so an /// empty `--broadcast` is rejected rather than silently defaulting to the root. fn require_broadcast(name: String, endpoint: &str) -> anyhow::Result { diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index e9133a8e89..562d471e48 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -237,6 +237,23 @@ impl Client { Connection::new(self.clone(), addrs.into()) } + /// Close every QUIC connection this client dialed, once each peer has been sent the + /// close. + /// + /// Clones share one endpoint, so this closes theirs too. Dropping the connections + /// only queues the close, which nothing sends once the runtime stops: a process + /// that exits without this leaves each peer waiting out its idle timeout. + /// + /// Only the noq endpoint is closed. WebSocket, TCP, and UDS sessions end when their + /// [`Connection`] is dropped (the kernel closes the socket on exit), and an iroh + /// endpoint passed to `with_iroh` is closed by its owner. + pub async fn close(self) { + #[cfg(feature = "noq")] + if let Some(noq) = self.noq { + noq.close().await; + } + } + /// Connect to the configured [`connect.url`](crate::connect::Config::url) URL, publishing /// `origin` to it. /// diff --git a/rs/moq-tokio/src/noq.rs b/rs/moq-tokio/src/noq.rs index e75d8bd708..99b4bc48aa 100644 --- a/rs/moq-tokio/src/noq.rs +++ b/rs/moq-tokio/src/noq.rs @@ -325,6 +325,14 @@ impl NoqClient { }) } + /// Close every connection, then wait until each has sent its close to the peer. + pub async fn close(self) { + self.quic.close(noq::VarInt::from_u32(0), b"client shutdown"); + // Not `wait_idle`, which also sits out each connection's 3 PTO closing + // period: that only repeats the close to a peer that already has it. + self.quic.wait_all_draining().await; + } + pub async fn connect( &self, tls: &rustls::ClientConfig, diff --git a/rs/moq-tokio/tests/backend.rs b/rs/moq-tokio/tests/backend.rs index 968f37f730..50b0d80d4b 100644 --- a/rs/moq-tokio/tests/backend.rs +++ b/rs/moq-tokio/tests/backend.rs @@ -664,6 +664,64 @@ async fn iroh_connect() { // ── Noq backend ───────────────────────────────────────────────────── +/// A client that closes before its runtime stops tells the server at once, instead of +/// leaving it to the idle timeout, which is what a process exiting on a signal does. +#[cfg(feature = "noq")] +#[tracing_test::traced_test] +#[tokio::test] +async fn noq_client_close_reaches_server() { + let quic = moq_tokio::quic::Config::default(); + assert!( + quic.idle_timeout > TIMEOUT, + "an idle timeout inside TIMEOUT would hide a lost close" + ); + + let mut server_config = moq_tokio::listen::Config::default(); + server_config.bind = Some("127.0.0.1:0".parse().unwrap()); + server_config.tls.generate = vec!["localhost".into()]; + let server = server_config.init(quic.clone()).expect("failed to init server"); + let mut server = server.listen().await.expect("failed to listen"); + let url: url::Url = format!("moqt://localhost:{}", server.local_addr().unwrap().port()) + .parse() + .unwrap(); + + // The client gets a runtime of its own, gone as soon as the client returns: nothing + // drives its endpoint afterwards, exactly as when a process exits. + let client = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("client runtime"); + runtime.block_on(async move { + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + config.bind = Some("127.0.0.1:0".parse().unwrap()); + let client = config + .init(quic) + .expect("failed to init client") + .with_subscriber(moq_tokio::origin::spawn()); + let (client, connection) = connect_once(client, url).await.expect("client connect failed"); + drop(connection); + client.close().await; + }); + }); + + let request = tokio::time::timeout(TIMEOUT, server.accept()) + .await + .expect("accept timed out") + .expect("no incoming connection"); + let session = request.ok().await.expect("server handshake failed"); + tokio::task::spawn_blocking(move || client.join()) + .await + .unwrap() + .expect("client thread panicked"); + + let err = tokio::time::timeout(TIMEOUT, session.closed()) + .await + .expect("the server never heard the close"); + assert!(!err.to_string().contains("timed out"), "{err}"); +} + #[cfg(feature = "noq")] #[tracing_test::traced_test] #[tokio::test] From 1d679a7446ee5b92dbab383375b06629beb653a7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 19:06:26 -0700 Subject: [PATCH 24/87] docs(quest): plan Firefox 155 WebTransport adoption (#4251) Co-authored-by: Claude Opus 5.5 --- quest/m1/drain/README.md | 5 +++ quest/m1/quic/scheduler.md | 15 +++++---- quest/m2/README.md | 1 + quest/m2/firefox-155-webtransport.md | 47 ++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 quest/m2/firefox-155-webtransport.md diff --git a/quest/m1/drain/README.md b/quest/m1/drain/README.md index 96d9195bb8..bd0a66979d 100644 --- a/quest/m1/drain/README.md +++ b/quest/m1/drain/README.md @@ -29,6 +29,11 @@ them (a planned-drain health state, the SIGTERM sequencing and stop timeouts, per-PoP serial deploys, a two-node PoP floor, and the gateway drain contract) is moq.pro's (downstream) fleet drain work, which consumes these quests. +Drain stays at the MoQ layer. WebTransport's `WT_DRAIN_SESSION` capsule and +the browser `draining` promise are advisory and carry no redirect URI or +timeout, and qmux and WebSocket have no equivalent, so neither the relay nor +the clients send or act on them (decided 2026-09-26). + **relay-drain-api.** A drain hook that GOAWAYs every established session and immediately GOAWAYs any new arrival, so an embedding process can enter drain on SIGTERM after the DNS window and still bound the total stop time. Expose diff --git a/quest/m1/quic/scheduler.md b/quest/m1/quic/scheduler.md index 8e2ceaafe9..58bcb30b19 100644 --- a/quest/m1/quic/scheduler.md +++ b/quest/m1/quic/scheduler.md @@ -33,12 +33,15 @@ the group's turn, and opening newer groups must not reset its accumulated fair-share credit. Map conventions only at adapters. MoQ's model remains higher value first, the -IETF wire remains lower value first, and browser `sendOrder` remains local to -its WebTransport send group. Native QUIC and qmux use the full three levels; -a browser that cannot prioritize send groups gets the lower two levels without -pretending to provide strict subscription priority. - -Give every MoQ subscription one send group. A SUBSCRIBE_UPDATE changes the +IETF wire remains lower value first. Native QUIC and qmux use the full three +levels. Browsers (js/net and web-transport-wasm) never create send groups: +browser groups are flat and byte-fair, which would trade strict priority +between subscriptions (audio over video) for fairness nobody on a browser +session needs, so they keep the default group and pack priority and group +order into `sendOrder` (decided 2026-09-26 with +[Firefox 155](/quest/m2/firefox-155-webtransport.md)). + +Give every MoQ subscription one native send group. A SUBSCRIBE_UPDATE changes the group priority atomically. Group streams use their position within the subscription, never another subscription's sequence. Remove the session-wide `lite::PriorityQueue` once every enabled backend has an honest implementation diff --git a/quest/m2/README.md b/quest/m2/README.md index 80fa09dd1b..823f21afe5 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -85,6 +85,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [Common Access Tokens](/quest/m2/cat/README.md) - a moq-transport client presents a CAT in SETUP and `moq auth serve` admits it with the scope its `moqt` claim names - [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - run exact source snapshots on accessible Linux and device hosts with retrievable debug evidence - [Media QA on other engines](/quest/m2/browser-media-qa-engines.md) - the media harness measures a Firefox or WebKit player over the fallback and names what each engine lacks +- [Firefox 155 WebTransport](/quest/m2/firefox-155-webtransport.md) - Firefox negotiates the version by subprotocol, and the other new WebTransport features stay unused on purpose - [Windows capture parity](/quest/m2/capture-windows.md) - system audio and screen cursor capture with a settled app-capture policy - [Linux capture parity](/quest/m2/capture-linux.md) - Wayland window/system-audio capture with explicit display-selection and app-capture limits - [Plan capture ergonomics](/quest/m2/capture-ergonomics.md) - scope independent crop and audio mixing quests diff --git a/quest/m2/firefox-155-webtransport.md b/quest/m2/firefox-155-webtransport.md new file mode 100644 index 0000000000..dc8af1eb6d --- /dev/null +++ b/quest/m2/firefox-155-webtransport.md @@ -0,0 +1,47 @@ +# [XS] Firefox 155 negotiates the version by WebTransport subprotocol + +## Goal + +Firefox 155 reports the negotiated `protocol`, so a Firefox watcher lands on +the version the relay picks from `WT-Available-Protocols` (lite-06 today) +instead of the draft-14 SETUP fallback it used before, as Chrome 143+ does. +js/net's comments and types stop claiming native WebTransport lacks +`protocol`. + +## Plan + +- By hand against the in-tree relay, confirm Firefox 155 reports a non-empty + `protocol` and negotiates lite-06, and Firefox 153/154 still reach the + draft-14 SETUP path. Playwright Firefox has no WebTransport, so this cannot + run in CI (see [Media QA on other engines](/quest/m2/browser-media-qa-engines.md)). +- Delete the stale comment in `negotiate` (`js/net/src/connection/connect.ts`) + and the `@ts-expect-error` on `transport.protocol` in + `js/net/src/connection/accept.ts`, typing it the way `connect.ts` does if the + DOM lib still lacks the property. The empty-string and `undefined` fallback + stays for Firefox 153/154 and draft-14 peers. +- Fix any Firefox line in `doc/lib/js/index.md` or `doc/concept/transport.md` + this makes stale. + +Firefox 155 shipped five WebTransport features; decided 2026-09-26: + +- **Subprotocols** are the only one MoQ adopts in the browser, and js/net and + every Rust backend already speak them; this quest is the Firefox check. +- **Send groups** are native only. MoQ wants strict priority between + subscriptions (audio over video), and browser send groups are flat and + byte-fair, so js/net keeps the default group and its packed `sendOrder`. + moq-noq gains send groups in the + [scheduler](/quest/m1/quic/scheduler.md), and relays use one per broadcast on + cluster links in [Scope track priority](/quest/m1/track-priority-scope.md). + Pooling several publishers on one browser connection might want them later, + but sessions sharing the same content make that murky. +- **`datagrams.createWritable()`** is already feature-detected in js/net and + web-transport-wasm; nothing to do. +- **`draining`** is not used. Drain stays at the MoQ layer: GOAWAY carries a + redirect URI (and a timeout on moq-transport draft-17+; elsewhere the + deadline is sender-local) and works over qmux and WebSocket, while + `WT_DRAIN_SESSION` is advisory and carries neither (see + [drain](/quest/m1/drain/README.md)). +- **`exportKeyingMaterial()`** has no consumer: the exporter is per hop, so it + cannot key e2ee, which is end to end. Binding auth tokens to the TLS session + is the plausible future use; moq-noq already exposes the exporter, but + web-transport-moq does not. From a655cd20116519d5286440a008bce61758d06465 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 19:30:58 -0700 Subject: [PATCH 25/87] fix(net): start a guarded group write only while the group is in budget (#4254) Co-authored-by: Claude Opus 5.5 --- js/net/src/group.ts | 10 ++-- js/net/src/ietf/publisher.test.ts | 80 +++++++++++++++++++++++++++++++ js/net/src/ietf/publisher.ts | 5 +- js/net/src/internal.ts | 4 +- js/net/src/lite/publisher.test.ts | 78 ++++++++++++++++++++++++++++++ js/net/src/lite/publisher.ts | 17 +++---- js/net/src/track.test.ts | 6 +-- quest/m1/README.md | 1 - quest/m1/js-group-guard.md | 24 ---------- quest/m1/js-timeline-scans.md | 1 - 10 files changed, 178 insertions(+), 48 deletions(-) delete mode 100644 quest/m1/js-group-guard.md diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 575f304755..f1c47f082c 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -418,11 +418,13 @@ export class Consumer { return true; } - #guard(operation: Promise): Promise { - if (this.#expire(true)) return Promise.reject(this.#terminal); + // Takes a thunk rather than a started promise, so a group already past its budget + // never begins the write: an abandoned write would reject later with no handler. + #guard(operation: () => Promise): Promise { + this.#expire(true); if (this.#terminal) return Promise.reject(this.#terminal); const expiry = this.#expiry; - if (!expiry) return operation; + if (!expiry) return operation(); return new Promise((resolve, reject) => { let settled = false; @@ -442,7 +444,7 @@ export class Consumer { }; for (const changed of expiry.changed) disposes.push(changed.subscribe(check)); - operation.then( + operation().then( (value) => finish(() => resolve(value)), (error: unknown) => finish(() => reject(error)), ); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 76fca819a2..1358e40029 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -258,6 +258,86 @@ test("a blocked group header is reset when the group expires", async () => { } }); +// A group can go stale while its stream is still opening. Serving it must abandon the group +// without starting a write: an abandoned write rejects once the stream resets, and nothing +// would handle it (Node exits on the first unhandled rejection). +test("a group that goes stale while its stream opens writes nothing", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + + const pair = createMockTransportPair(ALPN.DRAFT_19); + let requested!: () => void; + const opening = new Promise((resolve) => { + requested = resolve; + }); + let open!: () => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + let reset!: (reason: unknown) => void; + const streamReset = new Promise((resolve) => { + reset = resolve; + }); + let writes = 0; + const stale = new WritableStream({ + write() { + writes++; + throw new Error("write into an abandoned stream"); + }, + abort: (reason) => reset(reason), + }); + spyOn(pair.server, "createUnidirectionalStream").mockImplementationOnce(async () => { + requested(); + await opened; + return stale; + }); + + const { pub, origin } = publisher(pair.server); + const broadcast = publish(origin, Path.from("test")); + const track = broadcast.createTrack("video"); + const client = await Stream.open(pair.client, { version: VERSION }); + const server = await Stream.accept(pair.server, VERSION); + if (!server) throw new Error("publisher never accepted the subscribe stream"); + + try { + process.on("unhandledRejection", onUnhandled); + void pub.runSubscribe( + new Subscribe({ + requestId: 0n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + }), + server, + ); + + const write = (sequence: number, ms: number) => { + const group = new GroupProducer(sequence); + group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); + group.close(); + track.writeGroup(group); + }; + write(0, 0); + await opening; + + // A group beyond the edge, so group 0's reach (where group 1 begins) is provably past + // the budget: a successor alone never convicts it. + write(1, 10_000); + write(2, 20_000); + open(); + + expect(String(await streamReset)).toContain("max age budget"); + expect(writes).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + client.close(); + broadcast.close(); + origin.close(); + } +}); + test.each(["acknowledged", "rejected"] as const)( "a replacement waits until its predecessor's FIN is %s", async (result) => { diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index a14dbb4ce2..e79fde9349 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -513,7 +513,7 @@ export class Publisher { }); try { - await hooks.guardGroup(group, header.encode(stream, this.#session.version)); + await hooks.guardGroup(group, () => header.encode(stream, this.#session.version)); // The first written object goes on the wire as its absolute id, so a trimmed // head shows the true numbering rather than a silently renumbered group. let first = true; @@ -542,8 +542,7 @@ export class Publisher { const obj = new Frame({ payload: read.frame.payload, timestamp: read.frame.timestamp }); const delta = first ? read.sequence : 0; first = false; - await hooks.guardGroup( - group, + await hooks.guardGroup(group, () => obj.encode(stream, header.flags, timescale, this.#session.version, delta), ); } finally { diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 3f235938cd..2cff621cbf 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -137,8 +137,8 @@ export const hooks: { group: GroupConsumer, expiry: { expired: () => boolean; changed: readonly Getter[] }, ) => void; - /** Stop an in-flight group operation if the handed-out group expires. */ - guardGroup: (group: GroupConsumer, operation: Promise) => Promise; + /** Start a group operation unless the handed-out group has expired, and stop it if the group expires mid-flight. */ + guardGroup: (group: GroupConsumer, operation: () => Promise) => Promise; /** Read a frame the wire publisher completes (or skips) once written. */ readGroupFrame: (group: GroupConsumer, from?: number) => Promise; /** Make an evicted mirror terminal while its track timeline still contains it. */ diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index bbaf0a2731..2d08823181 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1525,3 +1525,81 @@ test("a version without the latency field serves a non-dropping budget", async ( } } }); + +// A group can go stale while its stream is still opening. Serving it must abandon the group +// without starting a write: an abandoned write rejects once the stream resets, and nothing +// would handle it (Node exits on the first unhandled rejection). +test("lite draft-05: a group that goes stale while its stream opens writes nothing", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + + const pair = createMockTransportPair(ALPN_05); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomHop(), origin.consume()); + const broadcast = publish(origin, Path.from("test")); + const track = broadcast.createTrack("video"); + + let requested!: () => void; + const opening = new Promise((resolve) => { + requested = resolve; + }); + let open!: () => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + let reset!: (reason: unknown) => void; + const streamReset = new Promise((resolve) => { + reset = resolve; + }); + let writes = 0; + const stale = new WritableStream({ + write() { + writes++; + throw new Error("write into an abandoned stream"); + }, + abort: (reason) => reset(reason), + }); + spyOn(pair.server, "createUnidirectionalStream").mockImplementationOnce(async () => { + requested(); + await opened; + return stale; + }); + + const client = await Stream.open(pair.client); + const server = await Stream.accept(pair.server); + if (!server) throw new Error("publisher never accepted the subscribe stream"); + + try { + process.on("unhandledRejection", onUnhandled); + void publisher.runSubscribe( + new Subscribe({ id: 0n, broadcast: Path.from("test"), track: "video", priority: 0, maxAge: 100 }), + server, + ); + + const write = (sequence: number, ms: number) => { + const group = new GroupProducer(sequence); + group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); + group.close(); + track.writeGroup(group); + }; + write(0, 0); + await opening; + + // A group beyond the edge, so group 0's reach (where group 1 begins) is provably past + // the budget: a successor alone never convicts it. + write(1, 10_000); + write(2, 20_000); + open(); + + expect(String(await streamReset)).toContain("max age budget"); + expect(writes).toBe(0); + await flush(); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + publisher.close(); + client.close(); + broadcast.close(); + origin.close(); + } +}); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index f35fce72be..dafdb4e32c 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1084,13 +1084,10 @@ export class Publisher { // follows it too rather than keeping a stale rank until it finishes. priority.add(stream, group.sequence); - await hooks.guardGroup( - group, - (async () => { - await stream.u53(0); // stream type - await msg.encode(stream, this.version); - })(), - ); + await hooks.guardGroup(group, async () => { + await stream.u53(0); // stream type + await msg.encode(stream, this.version); + }); // Lite05+ prefixes every frame with a zigzag-delta timestamp at the track's // advertised timescale; older drafts omit it. @@ -1122,12 +1119,12 @@ export class Publisher { if (timestamps) { // Convert each frame to the track's advertised timescale. const ts = BigInt(Math.round(read.frame.timestamp.as(timescale))); - await hooks.guardGroup(group, stream.u62(zigzag(ts - prevTs))); + await hooks.guardGroup(group, () => stream.u62(zigzag(ts - prevTs))); prevTs = ts; } - await hooks.guardGroup(group, stream.u53(read.frame.payload.byteLength)); - await hooks.guardGroup(group, stream.write(read.frame.payload)); + await hooks.guardGroup(group, () => stream.u53(read.frame.payload.byteLength)); + await hooks.guardGroup(group, () => stream.write(read.frame.payload)); } finally { read.complete(); } diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 60836121ce..b2efb8eb99 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -807,7 +807,7 @@ test("a handed-out frame cancels its in-flight operation when it expires", async const operation = new Promise((resolve) => { release = resolve; }); - const guarded = hooks.guardGroup(group, operation); + const guarded = hooks.guardGroup(group, () => operation); producer.writeString("new"); await expect(guarded).rejects.toThrow("max age budget"); @@ -830,7 +830,7 @@ test("a guarded write keeps the position of the frame removed from the buffer", const operation = new Promise((resolve) => { release = resolve; }); - const guarded = hooks.guardGroup(group, operation); + const guarded = hooks.guardGroup(group, () => operation); producer.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(1_000) }); // A group beyond the edge, so group 0's reach (1s) is provably behind it: a group is @@ -859,7 +859,7 @@ test("clean source closure stays provisional while a frame write can expire", as const operation = new Promise((resolve) => { release = resolve; }); - const guarded = hooks.guardGroup(group, operation); + const guarded = hooks.guardGroup(group, () => operation); const edge = producer.appendGroup(); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(1_000) }); diff --git a/quest/m1/README.md b/quest/m1/README.md index 0855737150..a725e4dbf0 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -20,7 +20,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [BBR classic ECN](/quest/m1/bbr-classic-ecn.md) - Startup and bandwidth probing respond to CE marks before the bottleneck drops packets - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` -- [JS group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection - [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` - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Go cancel test](/quest/m1/go-origin-gc.md) - the Go request-cancel test keeps its origin alive, so the collector can't close it mid-test diff --git a/quest/m1/js-group-guard.md b/quest/m1/js-group-guard.md deleted file mode 100644 index eca7b3effb..0000000000 --- a/quest/m1/js-group-guard.md +++ /dev/null @@ -1,24 +0,0 @@ -# [XS] JS group guard - -## Goal - -A `@moq/net` publisher serving a group past the subscription's max age -abandons it without an unhandled rejection, over moq-lite and IETF. Node no -longer crashes on "group exceeded the subscription max age budget". - -## Plan - -- `#guard` in `js/net/src/group.ts` rejects early without attaching a handler - to the write it was handed, which the lite and IETF publishers have already - started. -- Pass a thunk (`() => stream.write(...)`) and check expiry before calling it, - as Rust's `poll_expired` does in `rs/moq-net/src/lite/publisher.rs`. This - also skips writing into a group about to be abandoned. A bare - `operation.catch(() => {})` was rejected: it still starts the write. -- `guardGroup` is internal; no public API change. -- Regression: serve an already-stale group and assert no `unhandledRejection` - fires. - -## Closes - -- [#4247](https://github.com/moq-dev/moq/issues/4247) - Group.Consumer max-age guard leaves the guarded write without a handler diff --git a/quest/m1/js-timeline-scans.md b/quest/m1/js-timeline-scans.md index b46d4c16c7..9c0d856afa 100644 --- a/quest/m1/js-timeline-scans.md +++ b/quest/m1/js-timeline-scans.md @@ -30,4 +30,3 @@ every cached group. ## Related - [Browser benchmarks](/quest/m1/browser-benchmarks.md) - the browser media path; this bench covers the track model -- [JS group guard](/quest/m1/js-group-guard.md) - the thunk there cuts guard calls, not the scan cost From c17b8e01e1ce87f9e901d0811983e7648255d9d2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 19:45:53 -0700 Subject: [PATCH 26/87] feat(kt): end a broadcast with end() (#4259) Co-authored-by: Claude Opus 5.5 --- doc/lib/kt/index.md | 5 +++-- kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 4 ++-- .../src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt | 7 +++---- .../jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt | 13 +++++++++++++ quest/m1/README.md | 1 - quest/m1/broadcast-remove.md | 1 - quest/m1/kotlin-end.md | 14 -------------- rs/moq-ffi/uniffi.toml | 9 ++++----- 8 files changed, 25 insertions(+), 29 deletions(-) delete mode 100644 quest/m1/kotlin-end.md diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 86faab4141..d4b76efbc2 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -58,8 +58,9 @@ Call `media.discontinuity()` when the source seeks, pauses, or changes its time The three advertising operations: `moq.createBroadcast(path)` (or `origin.createBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route)` / `broadcast.unannounce()` own that exact-path -advertisement, and `broadcast.close()` (or `use { }`) releases the producer, -ending the broadcast once no `dynamic()` handle remains; `origin.dynamic(prefix, route)` claims `prefix` and every +advertisement; `broadcast.end()` ends the broadcast for good, while +`broadcast.close()` (or `use { }`) only releases the handle, ending the +broadcast once no `dynamic()` handle remains; `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned `OriginDynamic` while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announcements(config)` takes diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index dec946bc2f..f3966d10de 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -22,8 +22,8 @@ class Moq internal constructor( /** * Create an unannounced broadcast at [path], invisible to everyone until announced. * - * Advertise it with `announce` after populating tracks. `close()` (or `use`) ends it once no - * `dynamic()` handle remains. + * Advertise it with `announce` after populating tracks. `end()` ends it immediately; + * `close()` (or `use`) ends it once no `dynamic()` handle remains. */ fun createBroadcast(path: String): BroadcastProducer = session.publish().createBroadcast(path) diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt index 9257ba51dd..02e3aba2cd 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt @@ -30,11 +30,10 @@ class Server internal constructor( private val publishOrigin: OriginProducer?, ) : AutoCloseable { /** - * Create a live broadcast at [path], served to incoming sessions. + * Create an unannounced broadcast at [path], served to incoming sessions once announced. * - * The origin announces the path so subscribers can discover it, becoming visible - * Advertise it with `announce` after populating tracks. `close()` (or `use`) - * ends it once no `dynamic()` handle remains. + * Advertise it with `announce` after populating tracks. `end()` ends it immediately; + * `close()` (or `use`) ends it once no `dynamic()` handle remains. */ fun createBroadcast(path: String): BroadcastProducer { val origin = publishOrigin ?: throw IllegalStateException("no publish origin configured") diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 9cd8e32517..987808968f 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -142,6 +142,19 @@ class SmokeTest { assertFailsWith { consumer.subscribeTrack("events", null) } } + @Test + fun `end ends a broadcast while a dynamic handle is still held`() = runTest { + BroadcastProducer().use { broadcast -> + broadcast.dynamic().use { + val consumer = broadcast.consume() + broadcast.end() + broadcast.end() + assertFailsWith { consumer.subscribeTrack("events", null) } + assertFailsWith { broadcast.publishTrack("events", null) } + } + } + } + @Test fun `broadcast updates shared video properties`() { BroadcastProducer().use { broadcast -> diff --git a/quest/m1/README.md b/quest/m1/README.md index a725e4dbf0..2377d5c3f7 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -28,7 +28,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone -- [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name - [Error messages](/quest/m1/error-display.md) - Python, Go, and Dart print `MoqError` with Rust's message, as Kotlin and Swift do - [Remove finish](/quest/m1/broadcast-remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause - [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 diff --git a/quest/m1/broadcast-remove.md b/quest/m1/broadcast-remove.md index 6522ebecec..f95e2b8349 100644 --- a/quest/m1/broadcast-remove.md +++ b/quest/m1/broadcast-remove.md @@ -18,5 +18,4 @@ This is a published API break, so it targets `dev`. ## Required -- [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin keeps a forced end once `finish` is gone - `main` merged into `dev` once #4031 lands, so the deprecations exist there diff --git a/quest/m1/kotlin-end.md b/quest/m1/kotlin-end.md deleted file mode 100644 index 44516dd62c..0000000000 --- a/quest/m1/kotlin-end.md +++ /dev/null @@ -1,14 +0,0 @@ -# [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`. diff --git a/rs/moq-ffi/uniffi.toml b/rs/moq-ffi/uniffi.toml index ddf0c572fe..9fe25f30f4 100644 --- a/rs/moq-ffi/uniffi.toml +++ b/rs/moq-ffi/uniffi.toml @@ -1,5 +1,4 @@ -# Kotlin objects are AutoCloseable, and `close()` there releases the handle, -# which ends the broadcast like dropping the last Rust producer. A generated -# `close()` would collide with it. -[bindings.kotlin] -exclude = ["MoqBroadcastProducer.close"] +# Kotlin objects are AutoCloseable, and `close()` there only releases the handle. +# A generated `close()` would collide with it, so Kotlin names the method `end()`. +[bindings.kotlin.rename] +"MoqBroadcastProducer.close" = "end" From d5228123821a55b78d8f0e267228bb642be1de98 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 19:47:12 -0700 Subject: [PATCH 27/87] test(interop): reattach clears the stale canvas frame (#4308) Co-authored-by: Claude Opus 5.5 --- test/interop/clients/js/src/contract.ts | 2 +- test/interop/clients/js/src/setup.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/test/interop/clients/js/src/contract.ts b/test/interop/clients/js/src/contract.ts index d33d91d49a..d7fc63342c 100644 --- a/test/interop/clients/js/src/contract.ts +++ b/test/interop/clients/js/src/contract.ts @@ -165,7 +165,7 @@ export type InteropControl = { start(): void; /** Remove the player from the DOM. */ detach(): void; - /** Put the player back and resume sampling. */ + /** Blank the canvas the old session left behind, put the player back, and resume sampling. */ reattach(): void; /** Connect a second player and leave it behind for the leaked-session negative control. */ startLeak(): void; diff --git a/test/interop/clients/js/src/setup.ts b/test/interop/clients/js/src/setup.ts index 1ebd379e00..aeb4177abb 100644 --- a/test/interop/clients/js/src/setup.ts +++ b/test/interop/clients/js/src/setup.ts @@ -110,6 +110,11 @@ if (role === "publish") { }, reattach: () => { stop(); + // The torn-down player leaves its last frame on the canvas, which can be newer than the frame + // the driver read before detaching. Blank it so every frame read after this was presented by + // the new session, not left over from the old one. + const canvas = el.querySelector("canvas"); + canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); player.appendChild(el); stop = attach(el); }, From 7f48c9aa0bb540d1f90c4675caac68ddb28476a8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 20:08:03 -0700 Subject: [PATCH 28/87] feat(mux): detect delay and jitter on JSON and binary tracks (#4270) Co-authored-by: Claude Opus 5.5 --- doc/concept/hang.md | 5 +- doc/lib/js/binary.md | 3 + doc/lib/js/json.md | 3 + doc/lib/rs/moq-binary.md | 4 + doc/lib/rs/moq-json.md | 4 + doc/lib/rs/moq-mux.md | 11 ++ drafts/draft-lcurley-moq-hang.md | 12 +- js/binary/src/snapshot/producer.ts | 6 +- js/binary/src/snapshot/snapshot.test.ts | 11 ++ js/binary/src/stream/producer.ts | 6 +- js/binary/src/stream/stream.test.ts | 12 ++ js/hang/src/catalog/binary.ts | 10 ++ js/hang/src/catalog/json.ts | 10 ++ js/hang/src/catalog/root.test.ts | 4 + js/json/src/snapshot/producer.ts | 16 ++- js/json/src/snapshot/snapshot.test.ts | 12 ++ js/json/src/stream/producer.ts | 6 +- js/json/src/stream/stream.test.ts | 12 ++ quest/m1/README.md | 1 - quest/m1/data-capture-bindings.md | 4 - quest/m1/data-jitter.md | 47 ------ quest/m2/watch-data-sync.md | 4 - rs/hang/src/catalog/binary.rs | 9 ++ rs/hang/src/catalog/json.rs | 9 ++ rs/hang/src/catalog/root.rs | 10 +- rs/moq-binary/src/snapshot/mod.rs | 25 ++++ rs/moq-binary/src/snapshot/producer.rs | 15 +- rs/moq-binary/src/stream/mod.rs | 26 ++++ rs/moq-binary/src/stream/producer.rs | 15 +- rs/moq-json/src/snapshot/mod.rs | 34 ++++- rs/moq-json/src/snapshot/producer.rs | 43 +++--- rs/moq-json/src/stream/mod.rs | 20 +++ rs/moq-json/src/stream/producer.rs | 30 ++-- rs/moq-mux/src/binary.rs | 181 ++++++++++++++++++++---- rs/moq-mux/src/catalog/data.rs | 62 +++----- rs/moq-mux/src/catalog/mod.rs | 2 +- rs/moq-mux/src/catalog/tracks.rs | 25 +++- rs/moq-mux/src/clock.rs | 50 +++++++ rs/moq-mux/src/error.rs | 4 + rs/moq-mux/src/json.rs | 93 ++++++++++-- rs/moq-net/src/model/mod.rs | 2 + rs/moq-net/src/model/timed.rs | 60 ++++++++ 42 files changed, 722 insertions(+), 196 deletions(-) delete mode 100644 quest/m1/data-jitter.md create mode 100644 rs/moq-net/src/model/timed.rs diff --git a/doc/concept/hang.md b/doc/concept/hang.md index 20f3bfc0ed..39ec8b972f 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -105,8 +105,9 @@ document would silently discard everything but the last payload: The rest is descriptive: `compression` (`deflate`, the same group-scoped `deflate-raw` the catalog uses), `schema` on a JSON track, `mime` on a binary -one, `bitrate` and `jitter` with the same meaning as for media, plus the -optional `broadcast` reference. A +one, `bitrate`, `jitter`, and `delay` with the same meaning as for media, plus +the optional `broadcast` reference. A publisher measures `jitter` and `delay` +only from payloads stamped with their capture time on the broadcast clock. A consumer that doesn't recognize a `mode` or `compression` ignores that track and round-trips it verbatim. diff --git a/doc/lib/js/binary.md b/doc/lib/js/binary.md index 71a8729815..d761026b52 100644 --- a/doc/lib/js/binary.md +++ b/doc/lib/js/binary.md @@ -26,4 +26,7 @@ const producer = new Snapshot.Producer({ track, compression: "deflate" }); producer.update(payload); ``` +A payload is stamped when written, unless you pass its capture time: +`producer.update(payload, at)`. + The Rust twin is [`moq-binary`](/lib/rs/moq-binary). diff --git a/doc/lib/js/json.md b/doc/lib/js/json.md index 8b9b0456c1..a7f56953d1 100644 --- a/doc/lib/js/json.md +++ b/doc/lib/js/json.md @@ -33,4 +33,7 @@ for await (const value of consumer) { } ``` +A value is stamped when written, unless you pass its capture time: +`producer.update(value, at)`. + The Rust twin is [`moq-json`](/lib/rs/moq-json). diff --git a/doc/lib/rs/moq-binary.md b/doc/lib/rs/moq-binary.md index 23fd506862..f1b9a0d6e3 100644 --- a/doc/lib/rs/moq-binary.md +++ b/doc/lib/rs/moq-binary.md @@ -28,5 +28,9 @@ let mut producer = moq_binary::snapshot::Producer::new(track, config); producer.update(payload)?; ``` +A payload is stamped when written, unless it carries its capture time: +`moq_net::Timed::from(bytes).at(captured)`. Writes return the encoded frame +size. + The TypeScript twin is [`@moq/binary`](/lib/js/binary). API: [docs.rs/moq-binary](https://docs.rs/moq-binary). diff --git a/doc/lib/rs/moq-json.md b/doc/lib/rs/moq-json.md index a079bc46de..40e8ef812f 100644 --- a/doc/lib/rs/moq-json.md +++ b/doc/lib/rs/moq-json.md @@ -28,6 +28,10 @@ let mut producer = moq_json::snapshot::Producer::new(track, config); producer.update(&value)?; ``` +A value is stamped when written, unless it carries its capture time: +`moq_net::Timed::from(&value).at(captured)`. Writes return the encoded frame +size, and an unchanged snapshot `update` returns `None`. + A snapshot producer also edits in place, so independent owners each touch only their own keys instead of clobbering one another. `mutate(|value| ...)` runs a closure and publishes the result, matching `Producer.mutate` in TypeScript; diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index be641e9619..eb1fd59e7b 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -85,6 +85,17 @@ The producer sets the entry's `mode` and encodes the track with its `compression`. Read it back from `Catalog` and subscribe with `catalog::Entry::new(name, &entry.binary)`. +A payload that knows when it was captured (a datagram's arrival, a sensor read) +carries that `Instant`. The producer maps it onto the broadcast clock and writes +it as the frame timestamp, and the entry advertises `jitter` and `delay` the way +a media rendition does, so telemetry lagging its video shows up as `delay`. An +instant ahead of now is refused. A device's own clock is an unrelated epoch; +keep it in the payload. + +```rust +telemetry.append(moq_net::Timed::from(packet).at(received_at))?; +``` + The fMP4, MPEG-TS, and FLV importers publish the source's own timestamps unless built with `live()`, which translates them onto the catalog's broadcast clock: the first frame is live on arrival, every track of the input shares that one diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 04fffe9264..8384c75140 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -391,6 +391,7 @@ type JsonSchema = { "broadcast": string | undefined, "bitrate": number | undefined, "jitter": number | undefined, + "delay": number | undefined, } ~~~ @@ -406,6 +407,7 @@ type BinarySchema = { "broadcast": string | undefined, "bitrate": number | undefined, "jitter": number | undefined, + "delay": number | undefined, } ~~~ @@ -461,9 +463,14 @@ A `snapshot` group covers a single value (plus any deltas), so its window spans ### broadcast {#data-shared} The `broadcast` field carries the same meaning here as it does for a media rendition ({{field-broadcast}}). -### bitrate and jitter {#data-estimates} +### bitrate, jitter, and delay {#data-estimates} The optional `bitrate` field is the track's maximum bitrate in bits per second. -The optional `jitter` field carries the same meaning and rules as it does for a media rendition ({{field-jitter}}), with a payload in place of a frame. +The optional `jitter` and `delay` fields carry the same meaning and rules as they do for a media rendition ({{field-jitter}}, {{field-delay}}), with a payload in place of a frame. + +A payload's presentation timestamp is its capture time on the broadcast's clock, the one its media renditions use, when the publisher knows it (a datagram's arrival, a sensor read), and otherwise the time the publisher wrote it. +A publisher measures `jitter` and `delay` only from payloads that carry a capture time, and MUST omit both for a track written without one. +A publisher MUST NOT use a timestamp on an unrelated clock, such as a device's own uptime, since it would report a meaningless lateness and, through the broadcast-wide minimum, distort every other rendition's `delay`. +A `snapshot` update that writes no frame, such as an unchanged JSON value, is not a flush and is not measured. ## Binary Fields {#binary} A decoder config field carrying raw bytes, notably `description` (an `AllowSharedBufferSource` in WebCodecs), is carried in the catalog as a hex string ({{!RFC4648, Section 8}}). @@ -1101,6 +1108,7 @@ A publisher MAY estimate an unknown final duration from the frame cadence, but M - Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. - Added the optional `delay` rendition field: how far a rendition's minimum flush lateness trails the broadcast's earliest rendition, never lowered once advertised and never subtracted across renditions. - Recommended namespaced keys for application root sections. +- Added the optional `delay` field to `json` and `binary` track entries, measured only from payloads stamped with their capture time on the broadcast clock. # Acknowledgments {:numbered="false"} diff --git a/js/binary/src/snapshot/producer.ts b/js/binary/src/snapshot/producer.ts index 8b4e1e66f5..75f4fe3881 100644 --- a/js/binary/src/snapshot/producer.ts +++ b/js/binary/src/snapshot/producer.ts @@ -38,8 +38,10 @@ export class Producer { * * Unlike `@moq/json`, an identical value is republished rather than skipped: comparing two * opaque blobs costs a full scan, and only the caller knows whether its bytes changed. + * + * `at` is when the payload was captured, written as its frame timestamp. Defaults to now. */ - update(payload: Uint8Array): void { + update(payload: Uint8Array, at: Time.Timestamp = Time.Timestamp.now()): void { // Consumers all decode with `@moq/flate`'s default cap, so publishing past it would advertise // a value that always fails to read. Rejected before anything is published; unlike a stream // this is not terminal, since the previous value still stands and the next update supersedes. @@ -57,7 +59,7 @@ export class Producer { const group = this.#track.appendGroup(); try { - group.writeFrame({ payload: encoded, timestamp: Time.Timestamp.now() }); + group.writeFrame({ payload: encoded, timestamp: at }); } finally { // The group is already visible on the track, so leaving it open on a failed write would // strand a subscriber that advanced into it with nothing to read and no end. diff --git a/js/binary/src/snapshot/snapshot.test.ts b/js/binary/src/snapshot/snapshot.test.ts index bee50c6925..19a68aac19 100644 --- a/js/binary/src/snapshot/snapshot.test.ts +++ b/js/binary/src/snapshot/snapshot.test.ts @@ -138,3 +138,14 @@ test("an aborted track surfaces its error instead of spinning", async () => { await expect(consumer.next()).rejects.toThrow("subscription aborted"); }); + +test("a capture timestamp is written as the frame timestamp", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + const captured = Time.Timestamp.fromMillis(1_234); + producer.update(bytes(1), captured); + producer.finish(); + + const frame = await (await track.subscribe().ordered().nextGroup())?.readFrame(); + expect(frame?.timestamp.as(Time.Timescale.MILLI)).toBe(1_234); +}); diff --git a/js/binary/src/stream/producer.ts b/js/binary/src/stream/producer.ts index 13f6768153..8605954f6b 100644 --- a/js/binary/src/stream/producer.ts +++ b/js/binary/src/stream/producer.ts @@ -40,8 +40,10 @@ export class Producer { * log this mode promises, so the failure is surfaced rather than papered over with a second * group. The group is aborted rather than closed cleanly, so a consumer sees the failure * instead of a log that merely looks complete. Every later append fails on the closed track. + * + * `at` is when the payload was captured, written as its frame timestamp. Defaults to now. */ - append(payload: Uint8Array): void { + append(payload: Uint8Array, at: Time.Timestamp = Time.Timestamp.now()): void { // A payload no consumer could decode is as terminal as one the track rejects: consumers all // decode with `@moq/flate`'s default cap, so this would publish a record none of them could // read. Ends the track like any other lost record, and aborts the group the same way the @@ -62,7 +64,7 @@ export class Producer { const encoded = this.#flate ? this.#flate.frame(payload) : payload; try { - this.#group.writeFrame({ payload: encoded, timestamp: Time.Timestamp.now() }); + this.#group.writeFrame({ payload: encoded, timestamp: at }); } catch (err) { // The payload never reached the wire, so the log has a hole in it, which is not the // lossless log this mode promises. Continuing into a second group would hand consumers a diff --git a/js/binary/src/stream/stream.test.ts b/js/binary/src/stream/stream.test.ts index 4b21ace239..44a2430506 100644 --- a/js/binary/src/stream/stream.test.ts +++ b/js/binary/src/stream/stream.test.ts @@ -185,3 +185,15 @@ test("blocked reads leave nothing behind on the pending group read", async () => subscriber.close(); producer.finish(); }); + +test("each record keeps its capture timestamp", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + producer.append(new Uint8Array([1]), Time.Timestamp.fromMillis(1_000)); + producer.append(new Uint8Array([2]), Time.Timestamp.fromMillis(2_000)); + producer.finish(); + + const group = await track.subscribe().ordered().nextGroup(); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(1_000); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(2_000); +}); diff --git a/js/hang/src/catalog/binary.ts b/js/hang/src/catalog/binary.ts index 593eaf24d5..e62c2ee038 100644 --- a/js/hang/src/catalog/binary.ts +++ b/js/hang/src/catalog/binary.ts @@ -41,6 +41,16 @@ export const BinaryConfigSchema = z.looseObject({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this track's payloads reach the transport behind the broadcast's earliest rendition, + // with the same meaning and encoding as a video rendition's `delay`. Only measured for payloads + // that carry a capture time. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/hang/src/catalog/json.ts b/js/hang/src/catalog/json.ts index c5900559d7..374b845d94 100644 --- a/js/hang/src/catalog/json.ts +++ b/js/hang/src/catalog/json.ts @@ -40,6 +40,16 @@ export const JsonConfigSchema = z.looseObject({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this track's payloads reach the transport behind the broadcast's earliest rendition, + // with the same meaning and encoding as a video rendition's `delay`. Only measured for payloads + // that carry a capture time. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index c37154805c..e6e7c64de2 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -113,7 +113,11 @@ test("delay parses beside jitter and zero is absent", () => { renditions: { video: { codec: "avc1.64001f", container: { kind: "legacy" }, jitter: 34, delay: 200 } }, }, text: { renditions: { captions: { format: "vtt", container: { kind: "legacy" }, delay: 120 } } }, + json: { tracks: { gps: { mode: "stream", jitter: 10, delay: 250 } } }, + binary: { tracks: { frames: { mode: "snapshot", delay: 0 } } }, }); + expect(parsed.json?.tracks.gps?.delay).toBe(u53(250)); + expect(parsed.binary?.tracks.frames?.delay).toBeUndefined(); expect(parsed.audio?.renditions.audio?.delay).toBeUndefined(); expect(parsed.video?.renditions.video?.delay).toBe(u53(200)); expect(parsed.text?.renditions.captions?.delay).toBe(u53(120)); diff --git a/js/json/src/snapshot/producer.ts b/js/json/src/snapshot/producer.ts index ca9d252be3..e5b35cb978 100644 --- a/js/json/src/snapshot/producer.ts +++ b/js/json/src/snapshot/producer.ts @@ -51,18 +51,22 @@ export class Producer { group.close(); } - /** Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. */ - update(value: T): void { + /** + * Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. + * + * `at` is when the value was captured, written as its frame timestamp. Defaults to now. + */ + update(value: T, at: Time.Timestamp = Time.Timestamp.now()): void { const frame = this.#encoder.update(value); if (!frame) return; // A throw here leaves the frame uncommitted, so the next update resynchronizes with a fresh // snapshot. A delta against a snapshot no consumer ever saw would be unreadable. - this.#write(frame); + this.#write(frame, at); frame.commit(); } - #write(encoded: Encoded): void { + #write(encoded: Encoded, at: Time.Timestamp): void { // Check before touching a group. A keyframe closes the previous group and publishes its // replacement before the frame is written, so discovering the limit inside `writeFrame` would // leave an empty newest group behind: a snapshot consumer jumps to the newest, so the last @@ -77,7 +81,7 @@ export class Producer { const group = this.#track.appendGroup(); try { - group.writeFrame({ payload: encoded.payload, timestamp: Time.Timestamp.now() }); + group.writeFrame({ payload: encoded.payload, timestamp: at }); } catch (err) { // The group carries no frames, so close it rather than leaving it open on the track. A // rejected frame doesn't close the track, and a consumer that already advanced into this @@ -98,7 +102,7 @@ export class Producer { } if (!this.#group) throw new Error("delta with no open group"); - this.#group.writeFrame({ payload: encoded.payload, timestamp: Time.Timestamp.now() }); + this.#group.writeFrame({ payload: encoded.payload, timestamp: at }); } /** diff --git a/js/json/src/snapshot/snapshot.test.ts b/js/json/src/snapshot/snapshot.test.ts index 4d669e7999..668ddf7fac 100644 --- a/js/json/src/snapshot/snapshot.test.ts +++ b/js/json/src/snapshot/snapshot.test.ts @@ -403,3 +403,15 @@ test("snapshot consumer propagates a non-gap frame failure", async () => { group.close(new NetError.Stream(StreamCode.Internal)); await expect(consumer.next()).rejects.toMatchObject({ code: StreamCode.Internal }); }); + +test("a capture timestamp is written on snapshots and deltas alike", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track, deltaRatio: 100 }); + producer.update({ a: 1, b: "x".repeat(64) }, Time.Timestamp.fromMillis(1_000)); + producer.update({ a: 2, b: "x".repeat(64) }, Time.Timestamp.fromMillis(2_000)); + producer.finish(); + + const group = await track.subscribe().ordered().nextGroup(); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(1_000); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(2_000); +}); diff --git a/js/json/src/stream/producer.ts b/js/json/src/stream/producer.ts index 369aa9f161..346c50cb0e 100644 --- a/js/json/src/stream/producer.ts +++ b/js/json/src/stream/producer.ts @@ -29,8 +29,10 @@ export class Producer { * this mode promises, so the failure is surfaced rather than papered over with a second group. * The track is aborted rather than closed cleanly, so a consumer sees the failure instead of a * log that merely looks complete. + * + * `at` is when the value was captured, written as its frame timestamp. Defaults to now. */ - append(value: T): void { + append(value: T, at: Time.Timestamp = Time.Timestamp.now()): void { // Encode first, so a value that can't be serialized doesn't publish an empty group that // subscribers would advance into and wait on. Opening the group afterwards is safe because // the record stays uncommitted until the write lands. @@ -58,7 +60,7 @@ export class Producer { } try { - this.#group.writeFrame({ payload: record.payload, timestamp: Time.Timestamp.now() }); + this.#group.writeFrame({ payload: record.payload, timestamp: at }); } catch (err) { // The group is live, so the record is a hole in the log and a second group would hand // consumers that gap dressed up as a complete log. diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 1e342998c2..92fa0fe2b2 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -149,3 +149,15 @@ test("blocked reads leave nothing behind on the pending group read", async () => subscriber.close(); producer.finish(); }); + +test("each record keeps its capture timestamp", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + producer.append(1, Time.Timestamp.fromMillis(1_000)); + producer.append(2, Time.Timestamp.fromMillis(2_000)); + producer.finish(); + + const group = await track.subscribe().ordered().nextGroup(); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(1_000); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(2_000); +}); diff --git a/quest/m1/README.md b/quest/m1/README.md index 2377d5c3f7..ba0b1d6e15 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -40,7 +40,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 -- [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Data capture in bindings](/quest/m1/data-capture-bindings.md) - moq-ffi and every wrapper pass a data frame's capture time, and the JSON window producer takes one - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass - [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust diff --git a/quest/m1/data-capture-bindings.md b/quest/m1/data-capture-bindings.md index ba9de9d5e5..11f969227f 100644 --- a/quest/m1/data-capture-bindings.md +++ b/quest/m1/data-capture-bindings.md @@ -39,10 +39,6 @@ no optional arguments), and a `_with_x` twin is ruled out. The broadcast clock `now()` is additive; `window::Producer::push` accepts `Timed`, source-compatible. Wire: none. -## Required - -- [Data jitter](/quest/m1/data-jitter.md) - `Timed` and the mux capture path (#4270) - ## Related - [FFI shape](/quest/m1/ffi-shape/README.md) - moves the data producers into a json namespace diff --git a/quest/m1/data-jitter.md b/quest/m1/data-jitter.md deleted file mode 100644 index 3ccf0386a4..0000000000 --- a/quest/m1/data-jitter.md +++ /dev/null @@ -1,47 +0,0 @@ -# [S] moq-mux: detect delay and jitter on JSON and binary tracks - -## Goal - -A JSON or binary track whose payloads carry a capture time on the publisher's -own clock (a UDP datagram's arrival, a sensor read) advertises a detected -`delay` and `jitter`: how late the publisher hands payloads to the transport -relative to that time, the same measurement `moq_mux::catalog::Estimator` -makes for encoders. A -telemetry source slower than the video it accompanies shows up as `delay`. -Tracks written without a capture time advertise neither rather than a -meaningless zero. - -## Plan - -- The `moq-binary` and `moq-json` producers stamp each frame with - `Timestamp::now()` at write, so flush lateness is always zero today. Let a - payload carry its capture timestamp, written as the frame timestamp, without a - `_with_x` twin of `update`/`append` (for example, accept a type that converts - from a bare payload). -- The capture time must be on the broadcast's `moq_mux::Clock`, the timeline - media PTS use. `Timestamp::now()` has its own jittered per-process epoch, so - a data frame stamped with it cannot be compared with media; map the capture - instant through the broadcast clock, in a type that cannot be mistaken for - an unmapped `Timestamp`. A device-native clock (a flight controller's boot - time) is an unrelated epoch that would report nonsense and, through the - broadcast-wide baseline, distort every other track; it stays in the payload. - Reject a timestamp ahead of the clock's `now` rather than clamp it. -- Add optional `delay` to `JsonConfig` and `BinaryConfig` in `rs/hang`, - `js/hang`, and the draft, beside `jitter`. -- Feed the catalog's flush clock from the `moq-mux` data producers when a - capture time is present and a frame was actually emitted. A `moq-json` - snapshot `update` with an unchanged value succeeds without writing one, so - the lower producer reports whether it emitted, and a repeated value must not - move the baseline (cover it in tests). Publish the result through the embedded config's - `Estimate`, as the data producers already do for bitrate. -- Have the lower producers also report each emitted frame's encoded size, and - measure bitrate from that instead of the pre-compression payload or - serialized value: today an unchanged snapshot `update` still counts, and - DEFLATE can slightly expand an incompressible payload. -- Mirror the capture timestamp in the published `js/binary` and `js/json` - producers, so browser publishers can produce the same timed tracks. - -Public API: additive on `hang`, `moq-binary`, `moq-json`, `moq-mux`, -`@moq/hang`, `@moq/binary`, and `@moq/json`. Wire: one optional field on data -entries. - diff --git a/quest/m2/watch-data-sync.md b/quest/m2/watch-data-sync.md index 19b0b1f440..a8afced6e0 100644 --- a/quest/m2/watch-data-sync.md +++ b/quest/m2/watch-data-sync.md @@ -19,10 +19,6 @@ releases it. - Take this up when an application needs synchronized data playback; until then, a raw consumer reads payloads as they arrive. -## Required - -- [Data jitter](/quest/m1/data-jitter.md) - data tracks advertise the `delay` and `jitter` this reads - ## Related - [Cross-track correlation](/quest/m2/teleop/correlation.md) - joins recordings on the broadcast clock rather than live playout diff --git a/rs/hang/src/catalog/binary.rs b/rs/hang/src/catalog/binary.rs index 7c4ec811eb..e22aa70479 100644 --- a/rs/hang/src/catalog/binary.rs +++ b/rs/hang/src/catalog/binary.rs @@ -97,6 +97,14 @@ pub struct BinaryConfig { #[serde(default)] pub jitter: Option, + /// How far this track's payloads reach the transport behind the broadcast's earliest + /// rendition, with the same meaning and encoding as + /// [`VideoConfig::delay`](crate::catalog::VideoConfig::delay). Only measured for payloads that + /// carry a capture time. + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, + /// Fields this build doesn't recognize, kept so the entry round-trips. /// /// A future [`Mode`] or [`Compression`] almost certainly comes with fields describing it, and @@ -117,6 +125,7 @@ impl BinaryConfig { mime: None, bitrate: None, jitter: None, + delay: None, extra: Default::default(), } } diff --git a/rs/hang/src/catalog/json.rs b/rs/hang/src/catalog/json.rs index 9fa0d355c2..7a7ad831b0 100644 --- a/rs/hang/src/catalog/json.rs +++ b/rs/hang/src/catalog/json.rs @@ -95,6 +95,14 @@ pub struct JsonConfig { #[serde(default)] pub jitter: Option, + /// How far this track's payloads reach the transport behind the broadcast's earliest + /// rendition, with the same meaning and encoding as + /// [`VideoConfig::delay`](crate::catalog::VideoConfig::delay). Only measured for payloads that + /// carry a capture time. + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, + /// Fields this build doesn't recognize, kept so the entry round-trips. /// /// A future [`Mode`] or [`Compression`] almost certainly comes with fields describing it, and @@ -115,6 +123,7 @@ impl JsonConfig { schema: None, bitrate: None, jitter: None, + delay: None, extra: Default::default(), } } diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 8941e3835f..d827c3efbf 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -783,14 +783,16 @@ mod test { assert_eq!(output, encoded, "encode mismatch"); } - /// Data tracks carry the same optional `bitrate` and whole-millisecond `jitter` as media. + /// Data tracks carry the same optional `bitrate` and whole-millisecond `jitter` and `delay` as + /// media. #[test] fn data_track_bitrate_and_jitter() { - let encoded = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"json":{"tracks":{"gps":{"mode":"stream","bitrate":8000,"jitter":100}}},"binary":{"tracks":{"frames":{"mode":"snapshot","bitrate":64000,"jitter":34}}}}"#; + let encoded = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"json":{"tracks":{"gps":{"mode":"stream","bitrate":8000,"jitter":100,"delay":250}}},"binary":{"tracks":{"frames":{"mode":"snapshot","bitrate":64000,"jitter":34}}}}"#; let mut gps = JsonConfig::new(Mode::Stream); gps.bitrate = Some(8_000); gps.jitter = Some(std::time::Duration::from_millis(100)); + gps.delay = Some(std::time::Duration::from_micros(249_001)); let mut frames = BinaryConfig::new(Mode::Snapshot); frames.bitrate = Some(64_000); @@ -812,6 +814,10 @@ mod test { Some(std::time::Duration::from_millis(34)) ); assert_eq!(decoded.json.tracks["gps"].bitrate, Some(8_000)); + assert_eq!( + decoded.json.tracks["gps"].delay, + Some(std::time::Duration::from_millis(250)) + ); } /// An application lists a data track in its own section by flattening a data config beside its diff --git a/rs/moq-binary/src/snapshot/mod.rs b/rs/moq-binary/src/snapshot/mod.rs index 4c5f005a38..f24ec05165 100644 --- a/rs/moq-binary/src/snapshot/mod.rs +++ b/rs/moq-binary/src/snapshot/mod.rs @@ -111,6 +111,31 @@ mod test { assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(Some(v))) if v == "two")); } + /// A stamped payload is written at its capture time, and the returned size is the encoded frame + /// on the wire rather than the payload handed in. + #[test] + fn a_stamped_update_writes_its_capture_time() { + let (mut producer, track) = producer(true); + let mut groups = producer.consume(); + let captured = moq_net::Timestamp::from_millis(1_234).unwrap(); + let payload = Bytes::from(vec![7u8; 4096]); + let size = producer + .update(moq_net::Timed::from(payload.clone()).at(captured)) + .unwrap(); + + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { + panic!("expected a group"); + }; + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(size, frame.payload.len()); + assert!(size < payload.len(), "the size is the compressed frame"); + assert_eq!(drain(consume(track, true)), vec![payload]); + } + #[test] fn one_group_per_update() { let (mut producer, track) = producer(false); diff --git a/rs/moq-binary/src/snapshot/producer.rs b/rs/moq-binary/src/snapshot/producer.rs index 1ba32935b6..33d757ea51 100644 --- a/rs/moq-binary/src/snapshot/producer.rs +++ b/rs/moq-binary/src/snapshot/producer.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use bytes::Bytes; +use moq_net::Timed; use crate::Result; @@ -44,12 +45,12 @@ impl Producer { self.inner.lock().unwrap().track.is_used() } - /// Publish a new value, superseding the previous one. + /// Publish a new value, superseding the previous one, and return the frame's encoded size. /// /// Unlike [`moq-json`](https://docs.rs/moq-json), an identical value is republished rather than /// skipped: comparing two opaque blobs costs a full scan, and only the caller knows whether its /// bytes changed. - pub fn update(&mut self, payload: impl Into) -> Result<()> { + pub fn update(&mut self, payload: impl Into>) -> Result { self.inner.lock().unwrap().update(payload.into()) } @@ -66,7 +67,10 @@ struct Inner { } impl Inner { - fn update(&mut self, payload: Bytes) -> Result<()> { + fn update(&mut self, payload: Timed) -> Result { + let timestamp = payload.at.unwrap_or_else(moq_net::Timestamp::now); + let payload = payload.value; + // One frame per group, so the window spans a single value and starts cold every time. let payload = match self.compression { true => { @@ -88,8 +92,9 @@ impl Inner { return Err(moq_net::Error::FrameTooLarge.into()); } + let size = payload.len(); let mut group = self.track.append_group()?; - if let Err(err) = group.write_frame(moq_net::Timestamp::now(), payload) { + if let Err(err) = group.write_frame(timestamp, payload) { // `append_group` already published this group, and a rejected frame (too large) doesn't // close the track. Dropping the handle does NOT close the group, so leaving it would strand // any subscriber that advanced into it with nothing to read and no end. @@ -98,7 +103,7 @@ impl Inner { } group.finish()?; - Ok(()) + Ok(size) } fn finish(&mut self) -> Result<()> { diff --git a/rs/moq-binary/src/stream/mod.rs b/rs/moq-binary/src/stream/mod.rs index 7fa08f53a9..aa1699fbde 100644 --- a/rs/moq-binary/src/stream/mod.rs +++ b/rs/moq-binary/src/stream/mod.rs @@ -96,6 +96,32 @@ mod test { assert_eq!(drain(track, false), expected); } + /// Each record keeps its own capture time, and a bare payload is stamped when written. + #[test] + fn a_stamped_append_writes_its_capture_time() { + let (mut producer, _track) = producer(true); + let mut groups = producer.consume(); + let captured = moq_net::Timestamp::from_millis(1_234).unwrap(); + let first = producer + .append(moq_net::Timed::from(vec![1u8; 4096]).at(captured)) + .unwrap(); + producer.append(vec![2u8; 16]).unwrap(); + + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { + panic!("expected a group"); + }; + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(first, frame.payload.len(), "the size is the compressed frame"); + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_ne!(frame.timestamp.as_micros(), captured.as_micros()); + } + #[test] fn compressed_roundtrip_in_order() { let (mut producer, track) = producer(true); diff --git a/rs/moq-binary/src/stream/producer.rs b/rs/moq-binary/src/stream/producer.rs index 165b63bc9e..d24e04ba32 100644 --- a/rs/moq-binary/src/stream/producer.rs +++ b/rs/moq-binary/src/stream/producer.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use bytes::Bytes; +use moq_net::Timed; use crate::Result; @@ -52,7 +53,9 @@ impl Producer { /// log this mode promises, so the failure is surfaced rather than papered over with a second /// group. The group is aborted rather than closed cleanly, so a consumer sees the failure /// instead of a log that merely looks complete. Every later append fails on the closed track. - pub fn append(&mut self, payload: impl Into) -> Result<()> { + /// + /// Returns the frame's encoded size. + pub fn append(&mut self, payload: impl Into>) -> Result { self.inner.lock().unwrap().append(payload.into()) } @@ -74,7 +77,10 @@ struct Inner { } impl Inner { - fn append(&mut self, payload: Bytes) -> Result<()> { + fn append(&mut self, payload: Timed) -> Result { + let timestamp = payload.at.unwrap_or_else(moq_net::Timestamp::now); + let payload = payload.value; + // A payload no consumer could decode is as terminal as one the track rejects: the log is // missing a record either way, and carrying on would present that gap as a complete log. // Checked before the group is opened, so nothing is published, and routed through the same @@ -95,9 +101,10 @@ impl Inner { None => payload, }; + let size = payload.len(); let group = self.group.as_mut().expect("a group is open"); - let Err(err) = group.write_frame(moq_net::Timestamp::now(), payload) else { - return Ok(()); + let Err(err) = group.write_frame(timestamp, payload) else { + return Ok(size); }; // The payload never reached the wire, so the log has a hole in it, which is not the lossless diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index c8cdabf2b9..8e2b8032db 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -307,14 +307,44 @@ mod test { #[test] fn unchanged_value_writes_nothing() { let (mut producer, track) = producer(Config::default()); - producer.update(&json!({ "a": 1 })).unwrap(); - producer.update(&json!({ "a": 1 })).unwrap(); + assert_eq!(producer.update(&json!({ "a": 1 })).unwrap(), Some(7)); + assert_eq!( + producer.update(&json!({ "a": 1 })).unwrap(), + None, + "an unchanged value reports that nothing was written" + ); producer.finish().unwrap(); assert_eq!(track.latest(), Some(0)); assert_eq!(drain(track), vec![json!({ "a": 1 })]); } + /// A stamped value is written at its capture time, snapshot and delta alike, and the returned + /// size is the encoded frame. + #[test] + fn a_stamped_update_writes_its_capture_time() { + let (mut producer, _track) = producer(cfg(100)); + let mut groups = producer.consume(); + let first = moq_net::Timestamp::from_millis(1_000).unwrap(); + let second = moq_net::Timestamp::from_millis(2_000).unwrap(); + let value = json!({ "a": 1, "b": "x".repeat(64) }); + let size = producer.update(moq_net::Timed::from(&value).at(first)).unwrap(); + let changed = json!({ "a": 2, "b": "x".repeat(64) }); + let delta = producer.update(moq_net::Timed::from(&changed).at(second)).unwrap(); + + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { + panic!("expected a group"); + }; + for (stamp, size) in [(first, size), (second, delta)] { + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_eq!(frame.timestamp.as_micros(), stamp.as_micros()); + assert_eq!(size, Some(frame.payload.len())); + } + } + #[test] fn deltas_share_one_group() { let (mut producer, track) = producer(cfg(100)); diff --git a/rs/moq-json/src/snapshot/producer.rs b/rs/moq-json/src/snapshot/producer.rs index fea24c61df..da62df41a4 100644 --- a/rs/moq-json/src/snapshot/producer.rs +++ b/rs/moq-json/src/snapshot/producer.rs @@ -8,6 +8,8 @@ use serde::Serialize; use serde::de::DeserializeOwned; use super::{Encoded, Encoder}; +use moq_net::Timed; + use crate::{Error, Result}; pub use super::Config; @@ -85,9 +87,13 @@ impl Producer { /// Publish a new value, emitting a snapshot or a delta automatically. /// - /// Does nothing if the value is unchanged from the previous publish. - pub fn update(&mut self, value: &T) -> Result<()> { - take(&self.inner).update(value) + /// Returns the encoded size of the frame written, or `None` if the value is unchanged from the + /// previous publish and nothing was written. + pub fn update<'a>(&mut self, value: impl Into>) -> Result> + where + T: 'a, + { + take(&self.inner).update(value.into()) } /// Edit the current value in place and publish the result. @@ -226,7 +232,8 @@ impl Guard<'_, T> { self.dirty = false; // We already hold the lock, so publish through the held guard rather than re-locking. - self.inner.update(&self.value) + self.inner.update(Timed::from(&self.value))?; + Ok(()) } } @@ -318,21 +325,23 @@ impl Inner { } impl Inner { - fn update(&mut self, value: &T) -> Result<()> { + fn update(&mut self, payload: Timed<&T>) -> Result> { // Split the borrow so `frame` can hold the encoder while `track` is written through. let Inner { track, encoder, .. } = self; - let Some(frame) = encoder.update(value)? else { - return Ok(()); + let Some(frame) = encoder.update(payload.value)? else { + return Ok(None); }; // A failed write drops `frame` uncommitted, which resets the encoder so the next update // resynchronizes with a fresh snapshot. Most failures kill the track outright, but a rejected // frame (too large) doesn't, and a delta against a snapshot no consumer ever saw is unreadable. - track.write(&frame)?; + let timestamp = payload.at.unwrap_or_else(moq_net::Timestamp::now); + track.write(timestamp, &frame)?; + let size = frame.payload.len(); frame.commit(); - Ok(()) + Ok(Some(size)) } fn finish(&mut self) -> Result<()> { @@ -365,8 +374,8 @@ impl Track { Ok(()) } - /// Write one encoded frame, rolling a group when it's a snapshot. - fn write(&mut self, encoded: &Encoded) -> Result<()> { + /// Write one encoded frame at `timestamp`, rolling a group when it's a snapshot. + fn write(&mut self, timestamp: moq_net::Timestamp, encoded: &Encoded) -> Result<()> { // Check before touching a group. `write_snapshot` closes the previous group and publishes a // new one before the frame is written, so discovering the limit inside `write_frame` would // leave an empty newest group behind: a snapshot consumer jumps to the newest, so the previous @@ -376,20 +385,20 @@ impl Track { } match encoded.keyframe { - true => self.write_snapshot(encoded.payload.clone()), - false => self.write_delta(encoded.payload.clone()), + true => self.write_snapshot(timestamp, encoded.payload.clone()), + false => self.write_delta(timestamp, encoded.payload.clone()), } } /// Close the open group and write a snapshot as the first frame of a new one. - fn write_snapshot(&mut self, payload: bytes::Bytes) -> Result<()> { + fn write_snapshot(&mut self, timestamp: moq_net::Timestamp, payload: bytes::Bytes) -> Result<()> { // The previous group is complete; no more frames will be appended to it. if let Some(group) = self.group.take() { group.finish()?; } let mut group = self.inner.append_group()?; - if let Err(err) = group.write_frame(moq_net::Timestamp::now(), payload) { + if let Err(err) = group.write_frame(timestamp, payload) { // `append_group` already published this group, and a rejected frame (too large) doesn't // close the track. Dropping the handle does NOT close the group, so leaving it would strand // any subscriber that advanced into it with nothing to read and no end. @@ -408,11 +417,11 @@ impl Track { } /// Append a delta to the group the last snapshot opened. - fn write_delta(&mut self, payload: bytes::Bytes) -> Result<()> { + fn write_delta(&mut self, timestamp: moq_net::Timestamp, payload: bytes::Bytes) -> Result<()> { self.group .as_mut() .expect("the encoder only emits a delta after a snapshot opened a group") - .write_frame(moq_net::Timestamp::now(), payload)?; + .write_frame(timestamp, payload)?; Ok(()) } diff --git a/rs/moq-json/src/stream/mod.rs b/rs/moq-json/src/stream/mod.rs index 90753191fb..5c2e217fa3 100644 --- a/rs/moq-json/src/stream/mod.rs +++ b/rs/moq-json/src/stream/mod.rs @@ -116,6 +116,26 @@ mod test { assert_eq!(records, (0..5).map(|n| json!({ "n": n })).collect::>()); } + /// Each record keeps its own capture time, and the returned size is the encoded frame. + #[test] + fn a_stamped_append_writes_its_capture_time() { + let (mut producer, _track) = producer(compressed()); + let mut groups = producer.consume(); + let captured = moq_net::Timestamp::from_millis(1_234).unwrap(); + let record = json!({ "n": 1 }); + let size = producer.append(moq_net::Timed::from(&record).at(captured)).unwrap(); + + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { + panic!("expected a group"); + }; + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(size, frame.payload.len()); + } + #[test] fn compressed_roundtrip_in_order() { let (mut producer, track) = producer(compressed()); diff --git a/rs/moq-json/src/stream/producer.rs b/rs/moq-json/src/stream/producer.rs index f5de6e74a6..6e9626abd6 100644 --- a/rs/moq-json/src/stream/producer.rs +++ b/rs/moq-json/src/stream/producer.rs @@ -6,6 +6,8 @@ use std::sync::{Arc, Mutex}; use serde::Serialize; use super::Encoder; +use moq_net::Timed; + use crate::Result; pub use super::Config; @@ -70,8 +72,13 @@ impl Producer { /// failure is surfaced rather than papered over with a second group. The track is aborted rather /// than closed cleanly, so a consumer sees the failure instead of a log that merely looks /// complete. Every later append fails on the ended track. - pub fn append(&mut self, value: &T) -> Result<()> { - self.inner.lock().unwrap().append(value) + /// + /// Returns the encoded size of the frame written. + pub fn append<'a>(&mut self, value: impl Into>) -> Result + where + T: 'a, + { + self.inner.lock().unwrap().append(value.into()) } /// Finish the track, closing the group. @@ -91,14 +98,14 @@ struct Inner { } impl Inner { - fn append(&mut self, value: &T) -> Result<()> { + fn append(&mut self, payload: Timed<&T>) -> Result { // Split the borrow so `record` can hold the encoder while `track` is written through. let Inner { track, encoder } = self; // Encode first, so a value that can't be serialized doesn't publish an empty group that // subscribers would advance into and wait on. Opening the group afterwards is safe because // `record` guards the window: any failure below drops it uncommitted. - let record = match encoder.encode(value) { + let record = match encoder.encode(payload.value) { Ok(record) => record, Err(err) => { // A record that can't be encoded is as lost as one the group rejects: the log is @@ -112,7 +119,7 @@ impl Inner { let opened = track.open(); let published = opened.is_ok(); let result = match opened { - Ok(()) => track.write(record.payload()), + Ok(()) => track.write(payload.at.unwrap_or_else(moq_net::Timestamp::now), record.payload()), Err(err) => Err(err), }; @@ -138,8 +145,9 @@ impl Inner { return Err(err.into()); } + let size = record.payload().len(); record.commit(); - Ok(()) + Ok(size) } fn finish(&mut self) -> Result<()> { @@ -164,10 +172,14 @@ impl Track { Ok(()) } - /// Append one encoded record to the log's group. - fn write(&mut self, payload: &bytes::Bytes) -> std::result::Result<(), moq_net::Error> { + /// Append one encoded record to the log's group at `timestamp`. + fn write( + &mut self, + timestamp: moq_net::Timestamp, + payload: &bytes::Bytes, + ) -> std::result::Result<(), moq_net::Error> { let group = self.group.as_mut().expect("a group is open"); - group.write_frame(moq_net::Timestamp::now(), payload.clone()) + group.write_frame(timestamp, payload.clone()) } /// End the track with an error, so a consumer sees the failure rather than a clean end. diff --git a/rs/moq-mux/src/binary.rs b/rs/moq-mux/src/binary.rs index d0ad13a996..75b979df88 100644 --- a/rs/moq-mux/src/binary.rs +++ b/rs/moq-mux/src/binary.rs @@ -30,6 +30,21 @@ //! The catalog entry is written when the producer is created and removed when it drops, so a track //! is never advertised without a publisher behind it. //! +//! A payload that carries the [`Instant`] it was captured is written at that +//! time on the broadcast [`Clock`](crate::Clock), and the entry advertises how late payloads reach +//! the transport as its `jitter` and `delay`, the way a media rendition does: +//! +//! ```no_run +//! # fn example( +//! # thumbnail: &mut moq_mux::binary::Snapshot, +//! # jpeg: bytes::Bytes, +//! # captured: std::time::Instant, +//! # ) -> moq_mux::Result<()> { +//! thumbnail.update(moq_net::Timed::from(jpeg).at(captured))?; +//! # Ok(()) +//! # } +//! ``` +//! //! Read one back off the catalog, naming it once: //! //! ```no_run @@ -48,8 +63,10 @@ //! ``` use std::marker::PhantomData; +use std::time::Instant; use bytes::Bytes; +use moq_net::Timed; use hang::catalog::{BinaryConfig, Compression, Mode}; @@ -121,6 +138,8 @@ fn prepare(config: &mut impl AsMut, mode: Mode) -> crate::Result { inner: moq_binary::snapshot::Producer, listing: Listing, + /// Maps a payload's capture instant onto the broadcast timeline. + clock: crate::Clock, /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. _catalog: PhantomData E>, } @@ -136,10 +155,12 @@ impl Snapshot { binary.compression = moq_binary::Compression::Deflate; } let inner = moq_binary::snapshot::Producer::new(track, binary); + let clock = rendition.clock(); let listing = Listing::new(rendition, config)?; Ok(Self { inner, listing, + clock, _catalog: PhantomData, }) } @@ -155,11 +176,13 @@ impl Snapshot { } /// Publish a new payload, superseding the previous one. - pub fn update(&mut self, payload: impl Into) -> crate::Result<()> { - let payload = payload.into(); - let len = payload.len(); - self.inner.update(payload)?; - self.listing.record(|| len) + /// + /// A payload timed with its capture instant is written at that time and measures the entry's + /// `jitter` and `delay`; one ahead of now is refused before anything is written. + pub fn update(&mut self, payload: impl Into>) -> crate::Result<()> { + let (payload, captured) = self.clock.stamp(payload.into())?; + let size = self.inner.update(payload)?; + self.listing.record(size, captured) } /// Finish the track and retire its catalog entry. @@ -184,6 +207,8 @@ pub struct Stream { /// entry advertising a track that can no longer accept records only misleads a consumer that /// discovers it afterwards. listing: Option, + /// Maps a payload's capture instant onto the broadcast timeline. + clock: crate::Clock, /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. _catalog: PhantomData E>, } @@ -199,11 +224,13 @@ impl Stream { binary.compression = moq_binary::Compression::Deflate; } let inner = moq_binary::stream::Producer::new(track, binary); + let clock = rendition.clock(); let listing = Listing::new(rendition, config)?; Ok(Self { inner, name: listing.name().to_string(), listing: Some(listing), + clock, _catalog: PhantomData, }) } @@ -227,20 +254,22 @@ impl Stream { /// [`moq_binary::stream::Producer::append`]) and retires the catalog entry with it. A catalog /// error publishing the measured bitrate is returned after the payload was written, so the track /// stays open and a retry would duplicate it. - pub fn append(&mut self, payload: impl Into) -> crate::Result<()> { - let payload = payload.into(); - let len = payload.len(); - if let Err(err) = self.inner.append(payload) { - // The inner producer has already closed the track. Dropping the listing retires the - // catalog entry too: waiting for the handle to drop would keep advertising a track that - // can no longer accept records, so a consumer discovering it now would subscribe to an - // already-ended log. - self.listing = None; - return Err(err.into()); - } + pub fn append(&mut self, payload: impl Into>) -> crate::Result<()> { + let (payload, captured) = self.clock.stamp(payload.into())?; + let size = match self.inner.append(payload) { + Ok(size) => size, + Err(err) => { + // The inner producer has already closed the track. Dropping the listing retires the + // catalog entry too: waiting for the handle to drop would keep advertising a track + // that can no longer accept records, so a consumer discovering it now would subscribe + // to an already-ended log. + self.listing = None; + return Err(err.into()); + } + }; match &mut self.listing { - Some(listing) => listing.record(|| len), + Some(listing) => listing.record(size, captured), None => Ok(()), } } @@ -390,6 +419,103 @@ mod test { out } + /// A track whose payloads reach the transport later than another's captures advertises the gap + /// as `delay`, while one written without capture times advertises neither `delay` nor `jitter`. + #[test] + fn a_late_capture_is_delay() { + let (mut broadcast, catalog) = catalog(); + let mut fast = catalog + .binary_stream(track(&mut broadcast, "fast"), Config::default()) + .unwrap(); + let mut slow = catalog + .binary_stream(track(&mut broadcast, "slow"), Config::default()) + .unwrap(); + let mut bare = catalog + .binary_stream(track(&mut broadcast, "bare"), Config::default()) + .unwrap(); + + let anchor = std::time::Instant::now(); + for i in 0..20u64 { + let now = moq_net::Timestamp::from_micros(i * 100_000).unwrap(); + let at = |late: u64| anchor + std::time::Duration::from_millis(i * 100 + late); + let fast = fast.listing.as_mut().unwrap(); + fast.record_at(now, 100, Some((now, at(0)))).unwrap(); + let slow = slow.listing.as_mut().unwrap(); + slow.record_at(now, 100, Some((now, at(200)))).unwrap(); + bare.listing.as_mut().unwrap().record_at(now, 100, None).unwrap(); + } + + assert_eq!(entry(&catalog, "fast").delay, None); + assert_eq!( + entry(&catalog, "slow").delay, + Some(std::time::Duration::from_millis(200)) + ); + assert_eq!( + entry(&catalog, "slow").jitter, + None, + "a constant lateness is not jitter" + ); + let bare = entry(&catalog, "bare"); + assert_eq!((bare.delay, bare.jitter), (None, None)); + } + + /// A captured payload measures through the real clock: written right after capture, it + /// advertises no delay beyond the scheduling noise of the test itself. + #[test] + fn a_capture_measures_through_the_clock() { + let (mut broadcast, catalog) = catalog(); + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), Config::default()) + .unwrap(); + + let captured = std::time::Instant::now(); + telemetry.append(Timed::from(&b"now"[..]).at(captured)).unwrap(); + let late = captured - std::time::Duration::from_secs(1); + telemetry.append(Timed::from(&b"late"[..]).at(late)).unwrap(); + + // A capture ahead of now is refused before anything is written. + let ahead = std::time::Instant::now() + std::time::Duration::from_secs(1); + assert!(matches!( + telemetry.append(Timed::from(&b"ahead"[..]).at(ahead)), + Err(crate::Error::InvalidCapture) + )); + + let jitter = entry(&catalog, "telemetry").jitter.expect("a late capture is jitter"); + assert!(jitter >= std::time::Duration::from_secs(1), "{jitter:?}"); + } + + /// An untimed payload is stamped on the broadcast clock too, so mixing it with captured payloads + /// keeps the track on one timeline. + #[test] + fn an_untimed_payload_shares_the_broadcast_clock() { + let (mut broadcast, catalog) = catalog(); + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), Config::default()) + .unwrap(); + let mut subscriber = telemetry.consume(); + + let before = catalog.clock().now(); + telemetry.append(&b"untimed"[..]).unwrap(); + telemetry + .append(Timed::from(&b"timed"[..]).at(std::time::Instant::now())) + .unwrap(); + let after = catalog.clock().now(); + + let waiter = kio::Waiter::noop(); + let mut stamps = Vec::new(); + while let Poll::Ready(Ok(Some(mut group))) = subscriber.poll_recv_group(&waiter) { + while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) { + stamps.push(frame.timestamp.as_millis()); + } + } + assert_eq!(stamps.len(), 2); + assert!( + // The track stores milliseconds. + before.as_millis() <= stamps[0] && stamps[0] <= stamps[1] && stamps[1] <= after.as_millis(), + "{before:?} {stamps:?} {after:?}" + ); + } + #[test] fn a_stream_track_roundtrips() { let (mut broadcast, catalog) = catalog(); @@ -632,7 +758,7 @@ mod test { // 40ms payloads of 5 kB: 1 Mbps, over more than the bitrate window. for i in 0..60u64 { let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap(); - telemetry.listing.as_mut().unwrap().record_at(now, 5_000).unwrap(); + telemetry.listing.as_mut().unwrap().record_at(now, 5_000, None).unwrap(); } let entry = &catalog.snapshot().ext.mavlink["telemetry"]; @@ -640,10 +766,9 @@ mod test { assert_eq!(entry.binary.jitter, None, "write spacing is not a flush delay"); } - /// A supplied bitrate is authoritative, so writes aren't measured at all: for JSON that would - /// be a second serialization per write, for nothing. + /// A supplied bitrate is authoritative, while a capture time still measures jitter and delay. #[test] - fn a_supplied_bitrate_skips_measurement() { + fn a_supplied_bitrate_is_kept() { let (mut broadcast, catalog) = catalog(); let mut entry = mavlink(1); entry.binary.bitrate = Some(64_000); @@ -652,9 +777,17 @@ mod test { .unwrap(); let listing = telemetry.listing.as_mut().unwrap(); - listing - .record(|| panic!("measured a write despite a supplied bitrate")) - .unwrap(); + let anchor = std::time::Instant::now(); + for i in 0..60u64 { + let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap(); + // Every other payload reaches the transport 10ms later than the rest. + let written = anchor + std::time::Duration::from_micros(i * 40_000 + (i % 2) * 10_000); + listing.record_at(now, 5_000, Some((now, written))).unwrap(); + } + + let entry = &catalog.snapshot().ext.mavlink["telemetry"]; + assert_eq!(entry.binary.bitrate, Some(64_000)); + assert_eq!(entry.binary.jitter, Some(std::time::Duration::from_millis(10))); assert_eq!(catalog.snapshot().ext.mavlink["telemetry"].binary.bitrate, Some(64_000)); } } diff --git a/rs/moq-mux/src/catalog/data.rs b/rs/moq-mux/src/catalog/data.rs index 10f73b0a0b..4f84d42f80 100644 --- a/rs/moq-mux/src/catalog/data.rs +++ b/rs/moq-mux/src/catalog/data.rs @@ -30,15 +30,13 @@ impl + AsMut> IntoRendition for } /// A data track's catalog entry, owned for the life of its producer and kept current with the -/// bitrate its writes measure. +/// bitrate, jitter, and delay its writes measure. /// /// Erases the entry's type, so a data producer's own type doesn't depend on which section lists it. pub(crate) struct Listing { rendition: Box, + /// Measures `delay` against the catalog's other renditions, media included. estimator: Estimator, - /// Whether writes are measured: only when the entry detects its estimate and the publisher - /// didn't supply a bitrate, which detection never overrides. - measures: bool, } /// The parts of a [`Rendition`] a [`Listing`] uses, without its config type. @@ -66,12 +64,10 @@ impl Listing { mut rendition: Rendition, config: C, ) -> crate::Result { - let measures = C::detects() && config.estimate().bitrate.is_none(); rendition.set(config)?; Ok(Self { + estimator: rendition.estimator(), rendition: Box::new(rendition), - estimator: Estimator::new(), - measures, }) } @@ -80,49 +76,31 @@ impl Listing { self.rendition.name() } - /// Measure a write of `bytes`, stamped on the broadcast clock. - /// - /// `bytes` is only evaluated for an entry that measures its bitrate, since measuring can cost a - /// second serialization. - pub(crate) fn record(&mut self, bytes: impl FnOnce() -> usize) -> crate::Result<()> { - if !self.measures { - return Ok(()); - } + /// Measure a frame of `bytes` encoded bytes, just written, captured at `captured` on the + /// broadcast clock if known. + pub(crate) fn record(&mut self, bytes: usize, captured: Option) -> crate::Result<()> { let now = self.rendition.timestamp()?; - self.record_at(now, bytes()) + let flush = captured.map(|captured| (captured, std::time::Instant::now())); + self.record_at(now, bytes, flush) } /// [`record`](Self::record) at a chosen time, which a test needs since the broadcast clock only - /// moves in real time. - pub(crate) fn record_at(&mut self, now: moq_net::Timestamp, bytes: usize) -> crate::Result<()> { + /// moves in real time. `flush` is the capture time and the instant the frame was written. + pub(crate) fn record_at( + &mut self, + now: moq_net::Timestamp, + bytes: usize, + flush: Option<(moq_net::Timestamp, std::time::Instant)>, + ) -> crate::Result<()> { // Each write is its own span, closed by the next one. self.estimator.cut(Some(now)); self.estimator.write(now, bytes); - // The spacing between writes is the application's cadence, not a flush delay, so only the - // bitrate is measured. A publisher that knows its jitter sets it on the entry. - let estimate = self.estimator.estimate().with_jitter(None); - self.rendition.estimate(estimate) - } -} - -/// The serialized size of `value`, as an upper bound on what a JSON write puts on the wire: -/// compression and deltas only shrink it. -pub(crate) fn json_len(value: &T) -> usize { - struct Count(usize); - - impl std::io::Write for Count { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0 += buf.len(); - Ok(buf.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) + // The spacing between writes is the application's cadence, not a flush delay, so only a + // capture time measures jitter and delay. Without one neither is advertised. + if let Some((captured, written)) = flush { + self.estimator.flush(captured, written); } + self.rendition.estimate(self.estimator.estimate()) } - - let mut count = Count(0); - // Only reached after the producer serialized the same value, so this cannot fail. - let _ = serde_json::to_writer(&mut count, value); - count.0 } diff --git a/rs/moq-mux/src/catalog/mod.rs b/rs/moq-mux/src/catalog/mod.rs index 88ef14f672..a3f83eddc3 100644 --- a/rs/moq-mux/src/catalog/mod.rs +++ b/rs/moq-mux/src/catalog/mod.rs @@ -37,7 +37,7 @@ pub(crate) mod tracks; pub(crate) use claim::Claim; pub use consumer::Consumer; pub use data::IntoRendition; -pub(crate) use data::{Listing, json_len}; +pub(crate) use data::Listing; pub use entry::Entry; pub use estimate::{Estimate, Estimator}; pub use format::*; diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 91c0670fcc..2e040a17ba 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -51,16 +51,20 @@ use super::hang::{Catalog, CatalogExt}; /// catalog.ext.mavlink.remove(name); /// } /// -/// // Opt into bitrate detection through the embedded config. +/// // Opt into detection through the embedded config. /// fn detects() -> bool { /// true /// } /// fn estimate(&self) -> Estimate { -/// Estimate::default().with_bitrate(self.binary.bitrate).with_jitter(self.binary.jitter) +/// Estimate::default() +/// .with_bitrate(self.binary.bitrate) +/// .with_jitter(self.binary.jitter) +/// .with_delay(self.binary.delay) /// } /// fn set_estimate(&mut self, estimate: Estimate) { /// self.binary.bitrate = estimate.bitrate; /// self.binary.jitter = estimate.jitter; +/// self.binary.delay = estimate.delay; /// } /// } /// @@ -122,11 +126,15 @@ impl RenditionConfig for hang::catalog::JsonConfig { true } fn estimate(&self) -> Estimate { - Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + Estimate::default() + .with_jitter(self.jitter) + .with_bitrate(self.bitrate) + .with_delay(self.delay) } fn set_estimate(&mut self, estimate: Estimate) { self.jitter = estimate.jitter; self.bitrate = estimate.bitrate; + self.delay = estimate.delay; } } @@ -145,11 +153,15 @@ impl RenditionConfig for hang::catalog::BinaryConfig { true } fn estimate(&self) -> Estimate { - Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + Estimate::default() + .with_jitter(self.jitter) + .with_bitrate(self.bitrate) + .with_delay(self.delay) } fn set_estimate(&mut self, estimate: Estimate) { self.jitter = estimate.jitter; self.bitrate = estimate.bitrate; + self.delay = estimate.delay; } } @@ -536,6 +548,11 @@ impl> Rendition { self.catalog.estimator() } + /// The broadcast clock the catalog stamps its tracks on. + pub(crate) fn clock(&self) -> crate::Clock { + self.catalog.clock() + } + /// Resolve a timestamp on the broadcast's shared clock (see [`Producer::timestamp`]). pub fn timestamp(&self, hint: Option) -> crate::Result { self.catalog.timestamp(hint) diff --git a/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs index 39961a2773..ee2e0694b8 100644 --- a/rs/moq-mux/src/clock.rs +++ b/rs/moq-mux/src/clock.rs @@ -97,6 +97,38 @@ impl Clock { .expect("an instant elapsed duration fits in a timestamp") } + /// Map the instant a payload was captured (a datagram's arrival, a sensor read) onto this clock. + /// + /// Refuses an instant ahead of now, which would claim the payload reached the transport before + /// it existed, and one before the clock's epoch, which no timestamp can name. + pub(crate) fn capture(&self, at: Instant) -> crate::Result { + if at > Instant::now() { + return Err(crate::Error::InvalidCapture); + } + let elapsed = at + .checked_duration_since(self.epoch) + .ok_or(crate::Error::InvalidCapture)?; + Ok(moq_net::Timestamp::from_micros(elapsed.as_micros() as u64) + .expect("an instant elapsed duration fits in a timestamp")) + } + + /// Map a payload's capture instant onto this clock, stamping an untimed payload now, so timed + /// and untimed writes share one timeline. Also returns the capture time, if there was one. + pub(crate) fn stamp

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

, Option)> { + let captured = timed.at.map(|at| self.capture(at)).transpose()?; + let at = captured.unwrap_or_else(|| self.now()); + Ok(( + moq_net::Timed { + value: timed.value, + at: Some(at), + }, + captured, + )) + } + /// Units per second for [`wall`](Self::wall): [`TIMESCALE`](Self::TIMESCALE). pub fn timescale(&self) -> moq_net::Timescale { Self::TIMESCALE @@ -383,6 +415,24 @@ mod tests { assert_eq!(clock.wall(), shared.wall()); } + /// A capture maps onto the clock's own timeline, and one ahead of now or before the epoch is + /// refused rather than clamped. + #[test] + fn a_capture_maps_onto_the_clock() { + let now = Instant::now(); + let clock = Clock::at(now - Duration::from_secs(5), moq_epoch()).unwrap(); + assert_eq!(clock.capture(now - Duration::from_secs(2)).unwrap(), us(3_000_000)); + + assert!(matches!( + clock.capture(Instant::now() + Duration::from_secs(1)), + Err(crate::Error::InvalidCapture) + )); + assert!(matches!( + clock.capture(now - Duration::from_secs(6)), + Err(crate::Error::InvalidCapture) + )); + } + #[test] fn section_advertises_wall_in_clock_timescale() { // PTS zero at exactly the moq epoch advertises 0. diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 9603dfa5e5..fd676051a9 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -245,6 +245,10 @@ pub enum Error { /// A rendition tried to lower delay already advertised to subscribers. #[error("catalog delay cannot decrease for a published rendition")] DelayDecreased, + + /// A capture instant is ahead of the broadcast clock's now, or before its epoch. + #[error("capture time is outside the broadcast clock")] + InvalidCapture, } impl Error { diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 9d6572eb20..96deb633c2 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -31,6 +31,21 @@ //! The catalog entry is written when the producer is created and removed when it drops, so a track //! is never advertised without a publisher behind it. //! +//! A value that carries the [`Instant`] it was captured is written at that +//! time on the broadcast [`Clock`](crate::Clock), and the entry advertises how late values reach +//! the transport as its `jitter` and `delay`, the way a media rendition does: +//! +//! ```no_run +//! # fn example( +//! # gps: &mut moq_mux::json::Stream, +//! # fix: serde_json::Value, +//! # received: std::time::Instant, +//! # ) -> moq_mux::Result<()> { +//! gps.append(moq_net::Timed::from(&fix).at(received))?; +//! # Ok(()) +//! # } +//! ``` +//! //! Read one back off the catalog, naming it once: //! //! ```no_run @@ -52,7 +67,9 @@ //! ``` use std::marker::PhantomData; +use std::time::Instant; +use moq_net::Timed; use serde::Serialize; use serde::de::DeserializeOwned; @@ -148,6 +165,8 @@ fn delta_ratio_of(config: &C) -> Option { pub struct Snapshot { inner: moq_json::snapshot::Producer, listing: Listing, + /// Maps a value's capture instant onto the broadcast timeline. + clock: crate::Clock, /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. _catalog: PhantomData E>, } @@ -173,10 +192,12 @@ impl Snapshot { json.delta_ratio = delta_ratio; } let inner = moq_json::snapshot::Producer::new(track, json); + let clock = rendition.clock(); let listing = Listing::new(rendition, config)?; Ok(Self { inner, listing, + clock, _catalog: PhantomData, }) } @@ -197,9 +218,19 @@ impl Snapshot { } /// Publish a new value, superseding the previous one. - pub fn update(&mut self, value: &T) -> crate::Result<()> { - self.inner.update(value)?; - self.listing.record(|| crate::catalog::json_len(value)) + /// + /// A value timed with its capture instant is written at that time and measures the entry's + /// `jitter` and `delay`; one ahead of now is refused before anything is written. An unchanged + /// value writes nothing and measures nothing. + pub fn update<'a>(&mut self, value: impl Into>) -> crate::Result<()> + where + T: 'a, + { + let (value, captured) = self.clock.stamp(value.into())?; + match self.inner.update(value)? { + Some(size) => self.listing.record(size, captured), + None => Ok(()), + } } /// Finish the track and retire its catalog entry. @@ -224,6 +255,8 @@ pub struct Stream { /// entry advertising a track that can no longer accept records only misleads a consumer that /// discovers it afterwards. listing: Option

, + /// Maps a record's capture instant onto the broadcast timeline. + clock: crate::Clock, /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. _catalog: PhantomData E>, } @@ -239,11 +272,13 @@ impl Stream { json.compression = moq_json::Compression::Deflate; } let inner = moq_json::stream::Producer::new(track, json); + let clock = rendition.clock(); let listing = Listing::new(rendition, config)?; Ok(Self { inner, name: listing.name().to_string(), listing: Some(listing), + clock, _catalog: PhantomData, }) } @@ -271,17 +306,25 @@ impl Stream { /// A record that cannot be written ends the track (see [`moq_json::stream::Producer::append`]) /// and retires the catalog entry with it. A catalog error publishing the measured bitrate is /// returned after the record was written, so the track stays open and a retry would duplicate it. - pub fn append(&mut self, value: &T) -> crate::Result<()> { - if let Err(err) = self.inner.append(value) { - // The inner producer has already ended the track. Dropping the listing retires the catalog - // entry: waiting for the handle to drop would keep advertising a track that can no longer - // accept records, so a consumer discovering it now would subscribe to an already-ended log. - self.listing = None; - return Err(err.into()); - } + pub fn append<'a>(&mut self, value: impl Into>) -> crate::Result<()> + where + T: 'a, + { + let (value, captured) = self.clock.stamp(value.into())?; + let size = match self.inner.append(value) { + Ok(size) => size, + Err(err) => { + // The inner producer has already ended the track. Dropping the listing retires the + // catalog entry: waiting for the handle to drop would keep advertising a track that + // can no longer accept records, so a consumer discovering it now would subscribe to + // an already-ended log. + self.listing = None; + return Err(err.into()); + } + }; match &mut self.listing { - Some(listing) => listing.record(|| crate::catalog::json_len(value)), + Some(listing) => listing.record(size, captured), None => Ok(()), } } @@ -602,8 +645,8 @@ mod test { // 40ms records of 500 bytes: 100 kbps, over more than the bitrate window. for i in 0..60u64 { let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap(); - gps.listing.as_mut().unwrap().record_at(now, 500).unwrap(); - status.listing.record_at(now, 500).unwrap(); + gps.listing.as_mut().unwrap().record_at(now, 500, None).unwrap(); + status.listing.record_at(now, 500, None).unwrap(); } assert_eq!(entry(&catalog, "gps").bitrate, Some(100_000)); @@ -615,6 +658,28 @@ mod test { ); } + /// An unchanged snapshot value writes no frame, so its capture time must not measure a flush + /// that never happened. + #[test] + fn an_unchanged_snapshot_measures_nothing() { + let (mut broadcast, catalog) = catalog(); + let mut status = catalog + .json_snapshot::(track(&mut broadcast, "status"), Config::default()) + .unwrap(); + let now = std::time::Instant::now(); + let value = serde_json::json!({ "armed": true }); + status.update(Timed::from(&value).at(now)).unwrap(); + let stale = now - std::time::Duration::from_secs(1); + status.update(Timed::from(&value).at(stale)).unwrap(); + assert_eq!(entry(&catalog, "status").jitter, None); + + // The same stale capture on a changed value is measured. + let changed = serde_json::json!({ "armed": false }); + status.update(Timed::from(&changed).at(stale)).unwrap(); + let jitter = entry(&catalog, "status").jitter.expect("a late capture is jitter"); + assert!(jitter >= std::time::Duration::from_secs(1), "{jitter:?}"); + } + /// The catalog is the only thing that announces a data track, so walking it is the discovery /// path. Each entry carries its own name, so nothing has to be threaded alongside it. #[test] diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index fd4a21ff84..39937261fd 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -21,6 +21,7 @@ mod requests; pub(crate) mod resume; mod subscription; mod time; +mod timed; mod weak_cache; #[cfg(test)] @@ -35,6 +36,7 @@ pub(crate) use subscription::Cap; // not under a role module. pub use datagram::*; pub use time::*; +pub use timed::Timed; /// Publishing broadcasts, announcing routes, and consuming both through an origin. pub mod origin { diff --git a/rs/moq-net/src/model/timed.rs b/rs/moq-net/src/model/timed.rs new file mode 100644 index 0000000000..b146ed2723 --- /dev/null +++ b/rs/moq-net/src/model/timed.rs @@ -0,0 +1,60 @@ +use bytes::Bytes; + +use crate::Timestamp; + +/// A value to publish, and optionally when it was captured. +/// +/// `T` is the clock the time is on: a raw [`Timestamp`] by default, or whatever a higher layer +/// maps onto one (`moq-mux` takes a [`std::time::Instant`]). Converts from a bare payload (bytes, +/// or a `&V` to serialize), so a producer taking one still accepts the plain value, stamped when +/// written. +#[derive(Debug, Clone, Copy)] +pub struct Timed { + /// The value to publish. + pub value: P, + + /// When the value was captured. `None` stamps it when written. + pub at: Option, +} + +impl Timed { + /// Set when the value was captured. + pub fn at(mut self, at: T) -> Self { + self.at = Some(at); + self + } +} + +impl, T> From for Timed { + fn from(value: B) -> Self { + Self { + value: value.into(), + at: None, + } + } +} + +impl<'a, V, T> From<&'a V> for Timed<&'a V, T> { + fn from(value: &'a V) -> Self { + Self { value, at: None } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_payload_converts_untimed() { + let timed: Timed = vec![1u8, 2].into(); + assert_eq!(timed.value, Bytes::from_static(&[1, 2])); + assert_eq!(timed.at, None); + + let stamped = Timed::from(&b"x"[..]).at(Timestamp::from_millis(5).unwrap()); + assert_eq!(stamped.at, Some(Timestamp::from_millis(5).unwrap())); + + let value = 7u32; + let timed: Timed<&u32, std::time::Instant> = (&value).into(); + assert_eq!(*timed.value, 7); + } +} From 19c863bf46f9c45150168024362967ac2acf4a1c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 20:28:29 -0700 Subject: [PATCH 29/87] feat(demo): publish a pre-encoded SD rendition of BBB (#4223) Co-authored-by: GPT-6 Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 4 ++ demo/pub/bbb.py | 92 +++++++++++++++++++++++++++++++++++ demo/pub/justfile | 19 +++++++- doc/setup/dev.md | 11 +++++ quest/m0/bbb-sd.md | 23 ++++++--- 5 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 demo/pub/bbb.py diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 055ba9711f..f35363ece5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -91,6 +91,10 @@ jobs: if: ${{ !cancelled() }} run: nix develop --command cargo bench --locked -p moq-net --features fuzz --bench '*' -- --test + - name: BBB rendition alignment + if: ${{ !cancelled() }} + run: nix develop --command just pub check-bbb + - name: Audit if: ${{ !cancelled() }} run: nix develop --command just rs audit diff --git a/demo/pub/bbb.py b/demo/pub/bbb.py new file mode 100644 index 0000000000..4751e119f5 --- /dev/null +++ b/demo/pub/bbb.py @@ -0,0 +1,92 @@ +"""Encode and verify the demo's pre-encoded SD rendition.""" + +import argparse +import json +import subprocess +from fractions import Fraction +from pathlib import Path + + +def probe(path): + return json.loads(subprocess.check_output([ + "ffprobe", "-v", "error", "-show_streams", "-show_packets", + "-show_entries", "stream=index,codec_type,codec_name,width,height,time_base,duration_ts:packet=stream_index,pts,duration,flags", + "-of", "json", str(path), + ])) + + +def video(media): + stream = next(s for s in media["streams"] if s["codec_type"] == "video") + # Fragmented BBB contains duplicate packets marked discard, not visible frames. + packets = [p for p in media["packets"] if p["stream_index"] == stream["index"] and "D" not in p["flags"]] + return stream, packets + + +def check(source, target): + source_media = probe(source) + source_stream, source_packets = video(source_media) + target_media = probe(target) + target_stream, target_packets = video(target_media) + assert len(target_media["streams"]) == 1, "SD must be video only" + assert (target_stream["codec_name"], target_stream["width"], target_stream["height"]) == ("h264", 640, 360) + for stream, packets in [(source_stream, source_packets), (target_stream, target_packets)]: + assert all(a["pts"] < b["pts"] for a, b in zip(packets, packets[1:])), "Non-increasing frame timestamps" + source_frames = [(p["pts"] * Fraction(source_stream["time_base"]), "K" in p["flags"]) for p in source_packets] + target_frames = [(p["pts"] * Fraction(target_stream["time_base"]), "K" in p["flags"]) for p in target_packets] + assert source_frames == target_frames, "SD frame timestamps/keyframes differ from source" + + # Map audio too: it determines the source's loop period. A video-only probe + # would miss the 39 ms/loop drift caused by the longer audio track. + output = subprocess.check_output([ + "ffmpeg", "-v", "error", "-copyts", "-stream_loop", "2", "-i", str(source), + "-stream_loop", "2", "-i", str(target), "-map", "0", "-map", "1:v", + "-c", "copy", "-f", "framecrc", "-", + ], text=True) + timestamps = {} + time_bases = {} + for line in output.splitlines(): + if line.startswith("#tb "): + index, base = line[4:].split(": ") + time_bases[int(index)] = Fraction(base) + elif not line.startswith("#"): + fields = line.split(",") + index, _, pts = map(int, fields[:3]) + timestamps.setdefault(index, set()).add(pts * time_bases[index]) + target_index = len(source_media["streams"]) + assert timestamps[source_stream["index"]] == timestamps[target_index], "Renditions drift across loops" + print(f"Verified {len(source_frames)} frames, {sum(key for _, key in source_frames)} keyframes, and three aligned loops") + + +def encode(source, target): + media = probe(source) + stream, packets = video(media) + base = Fraction(stream["time_base"]) + # FFmpeg loops all selected source streams at the longest stream duration. + # Hold the SD's final frame for the same period, rounded to the video clock. + duration = max(s["duration_ts"] * Fraction(s["time_base"]) for s in media["streams"]) + loop_ticks = int(duration / base + Fraction(1, 2)) + final_duration = loop_ticks - (packets[-1]["pts"] - packets[0]["pts"]) + assert final_duration > 0, "Source duration ends before its final video frame" + subprocess.run([ + "ffmpeg", "-hide_banner", "-loglevel", "warning", "-nostdin", "-n", "-copyts", "-i", str(source), + "-map", "0:v:0", "-an", "-vf", "scale=-2:360", "-fps_mode", "passthrough", + "-enc_time_base", "demux", "-c:v", "libx264", "-preset", "slow", + "-b:v", "600k", "-maxrate", "600k", "-bufsize", "1200k", "-bf", "0", + "-g", "2147483647", "-sc_threshold", "0", "-force_key_frames", "source", + "-bsf:v", f"setts=duration=if(eq(N\\,{len(packets) - 1})\\,{final_duration}\\,DURATION)", + "-f", "mp4", "-movflags", "cmaf+separate_moof+delay_moov+skip_trailer", + "-frag_duration", "1000", str(target), + ], check=True) + check(source, target) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["encode", "check"]) + args = parser.parse_args() + source = Path("media/bbb.mp4") + target = Path("media/bbb-sd.mp4") + if args.action == "encode": + encode(source, target) + else: + check(source, target) diff --git a/demo/pub/justfile b/demo/pub/justfile index 4ddd0e18bb..fb780f5e73 100644 --- a/demo/pub/justfile +++ b/demo/pub/justfile @@ -38,7 +38,24 @@ list: # Publish Big Buck Bunny to a relay server. bbb url='http://localhost:4443' *args: just download bbb - just ts bbb "{{ url }}" {{ args }} + just download bbb-sd + cargo build --bin moq + ffmpeg -hide_banner -loglevel warning -copyts \ + -stream_loop -1 -re -i media/bbb.mp4 \ + -stream_loop -1 -re -i media/bbb-sd.mp4 \ + -map 0 -map 1:v -c copy -f mpegts -pes_payload_size 0 - \ + | cargo run --bin moq -- {{ args }} --connect "{{ url }}" --broadcast bbb.hang import ts + +# Encode the video-only SD rendition, preserving source timestamps and loop duration. +encode-bbb-sd: + just download bbb + python3 bbb.py encode + +# Verify the hosted rendition's frames, keyframes, and three complete loops. +check-bbb: + just download bbb + just download bbb-sd + python3 bbb.py check # Publish Tears of Steel to a relay server. tos url='http://localhost:4443' *args: diff --git a/doc/setup/dev.md b/doc/setup/dev.md index b7a3de2ffa..d8d35f9ff8 100644 --- a/doc/setup/dev.md +++ b/doc/setup/dev.md @@ -26,6 +26,17 @@ Recipes default to the local relay at `http://localhost:4443`. Pass publishers and `just pub serve` use MPEG-TS, with one audio frame per PES to avoid batching latency. Use `just pub cmaf` only when testing fMP4/CMAF. +BBB publishes the original 720p video and a pre-encoded 360p rendition at +about 600 kbps. The player can switch between them as bandwidth or viewport +size changes, without encoding while publishing. Consumers that only support +one rendition get the 720p track. + +To reproduce the hosted SD asset, run `just pub encode-bbb-sd`, then +`just pub upload bbb-sd.mp4` with access to the video bucket. The encode keeps +the source frame timestamps and keyframes, and holds the final SD frame long +enough to match the source audio's loop period. `just pub check-bbb` verifies +both assets across three loops. Remove the local SD file before re-encoding. + ## Debugging ```bash diff --git a/quest/m0/bbb-sd.md b/quest/m0/bbb-sd.md index 2ddfb3df65..287e5455ed 100644 --- a/quest/m0/bbb-sd.md +++ b/quest/m0/bbb-sd.md @@ -13,17 +13,26 @@ CPU. moq.pro's always-on demo consumes the same asset. - 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. + like the other assets. `just pub encode-bbb-sd` now reproduces the uploaded + asset; `bbb.mp4` stays unchanged. The source has 14,313 visible frames and + two duplicate packets marked discard. Preserve the visible frames, and + extend the final SD sample duration to match the source audio's loop period: + otherwise independent inputs drift by about 39 ms per loop. - `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. WHEP and non-multitrack RTMP serve the 720p one, the largest, whatever the mapping order. -- 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). +- Verified: both catalog renditions have a `bitrate`, and + `just pub check-bbb` compares all visible timestamps and keyframes across + three loops. Nightly CI runs this check against the hosted assets. +- Chromium with `just dev` switches down and back up on viewport changes and + explicit bitrate caps. A local UDP proxy capped at 1.3 Mbps also produced + rendered 720p -> 360p -> 720p transitions, with reported receive estimates + of about 1.63 Mbps while capped and 23.3 Mbps after recovery. +- Remaining: investigate an earlier throttle run that kept HD selected and + skipped groups for the 45-second observation window. The cause is not yet + established; the successful instrumented run does not explain it. Verify + moq.dev's localhost pages and the related moq.pro fleet integration. ## Related From 500517f7c352df7afcb34f8e2830b228ed05f838 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 20:42:18 -0700 Subject: [PATCH 30/87] fix(net): announce a covering route to a narrower prefix instead of panicking (#4302) Co-authored-by: Claude Opus 5.5 --- js/net/src/ietf/publisher.test.ts | 103 ++++++++++++++++- js/net/src/ietf/publisher.ts | 9 +- js/net/src/internal.ts | 30 +++++ js/net/src/lite/publisher.ts | 36 ++---- js/net/src/origin.ts | 4 +- js/net/src/wire.ts | 2 +- rs/moq-net/src/ietf/publisher.rs | 17 +-- rs/moq-net/src/lite/publisher.rs | 41 +++---- rs/moq-net/tests/announce_covering_route.rs | 118 ++++++++++++++++++++ 9 files changed, 288 insertions(+), 72 deletions(-) create mode 100644 rs/moq-net/tests/announce_covering_route.rs diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 1358e40029..f8de7d9953 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -20,7 +20,7 @@ import { PublishNamespace } from "./publish_namespace.ts"; import { Publisher } from "./publisher.ts"; import { RequestError, RequestOk } from "./request.ts"; import { Subscribe, SubscribeOk } from "./subscribe.ts"; -import { SubscribeNamespace } from "./subscribe_namespace.ts"; +import { SubscribeNamespace, SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; import { TrackStatusRequest } from "./track.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; @@ -612,6 +612,107 @@ test("a solicited legacy advertisement refused once is retried", async () => { origin.close(); }); +/** + * A SUBSCRIBE_NAMESPACE below an advertised route still hears that route: it serves the + * requested prefix, so it lands as the empty suffix, then paths beneath the prefix follow + * as their own suffixes. Matches the Lite publisher and Rust. + */ +test("a subscription below an advertised route hears it as the empty suffix", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); + publish(origin, Path.from("dash")); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 0n, namespace: Path.from("dash/nobody") }), + accepted, + ); + + const entry = async () => { + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + return (await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix; + }; + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + expect(await entry()).toBe(Path.empty()); + + publish(origin, Path.from("dash/nobody/cam")); + expect(await entry()).toBe(Path.from("cam")); + + subscription.close(); + origin.close(); +}); + +/** + * A scoped route covers only what it claims: `room` claiming `room/chat` cannot serve + * `room/video`, so a subscription there must not hear it as the empty suffix. + */ +test("a subscription outside a covering route's claim does not hear it", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); + const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("room/chat"))])); + const dynamic = chat.dynamic(Path.from("room")); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 0n, namespace: Path.from("room/video") }), + accepted, + ); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + + // The first entry is the broadcast beneath the prefix, not the out-of-claim cover. + publish(origin, Path.from("room/video/cam")); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix).toBe(Path.from("cam")); + + dynamic.close(); + subscription.close(); + origin.close(); +}); + +/** + * The claim is presented relative to the served origin's root, like the route's key: a + * publisher serving `tenant` offers `room` (claiming `tenant/room/chat`) to a + * subscription at `room/chat`. + */ +test("a covering route's claim is compared relative to the served root", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const origin = new OriginProducer(); + const tenant = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.all()])); + const pub = new Publisher({ + quic: pair.server, + session: new NativeSession(pair.server, VERSION, true), + publish: tenant.consume(), + requiresSolicitation: true, + }); + const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("tenant/room/chat"))])); + const dynamic = chat.dynamic(Path.from("tenant/room")); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 0n, namespace: Path.from("room/chat") }), + accepted, + ); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix).toBe(Path.empty()); + + dynamic.close(); + subscription.close(); + origin.close(); +}); + /** * A peer that refuses an advertisement with a retry interval of 0 is asking not to be * offered it again. Coming back anyway turns a permanent refusal (unauthorized, diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index e79fde9349..de3479b97b 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -3,7 +3,7 @@ import type * as broadcast from "../broadcast.ts"; import { controlTimeout, error, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Route, routesEqual } from "../hop.ts"; -import { hiddenBelow, hooks } from "../internal.ts"; +import { hiddenBelow, hooks, presented } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; @@ -789,12 +789,7 @@ export class Publisher { break; } - const updated = new Map(); - for (const [covered, snap] of advertised) { - const suffix = Path.stripPrefix(prefix, covered); - if (suffix === null || !carries(covered)) continue; - updated.set(suffix, snap); - } + const updated = presented(prefix, advertised, carries); // A namespace that is gone, or that a republish replaced, takes its refusal with // it: the peer refused a broadcast, not a path forever, so a different one at diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 2cff621cbf..fdda337057 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -12,6 +12,7 @@ import type { Route } from "./hop.ts"; import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; import type { Groups, Producer, Request, Subscriber } from "./track.ts"; +import type { Advertised } from "./wire.ts"; /** Normalize public group bounds into an inclusive start and exclusive end. */ export function groupBounds(groups: Groups = {}): { start: number; end?: number } { @@ -46,6 +47,35 @@ export function hiddenBelow(prefix: Path.Valid, path: Path.Valid): boolean { return below !== null && Path.parts(below).some((part) => part.startsWith(".")); } +/** + * Where each carried route lands under the requested prefix: its suffix beneath the + * prefix, or the empty suffix for a route above it, where the most specific such route + * wins the way a request through the prefix would resolve. + */ +export function presented( + prefix: Path.Valid, + table: ReadonlyMap, + carries: (covered: Path.Valid) => boolean, +): Map { + const out = new Map(); + let rootLen = -1; + const requested = Path.Pattern.subtree(prefix); + for (const [covered, snap] of table) { + if (!carries(covered)) continue; + if (Path.hasPrefix(covered, prefix)) { + // A scoped route covers only what it claims, so it cannot serve a prefix outside that. + if (snap.claim && !snap.claim.overlaps(requested)) continue; + if (covered.length < rootLen) continue; + rootLen = covered.length; + out.set(Path.empty(), snap); + continue; + } + const suffix = Path.stripPrefix(prefix, covered); + if (suffix !== null) out.set(suffix, snap); + } + return out; +} + /** Whether the announced prefix's subtree overlaps `scope`. */ export function scopeOverlaps(scope: Path.Pattern, prefix: Path.Valid): boolean { return scope.overlaps(Path.Pattern.subtree(prefix)); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index dafdb4e32c..5d89e54178 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -3,9 +3,9 @@ import type * as broadcast from "../broadcast.ts"; import { error, NotFound, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; -import { hiddenBelow, hooks } from "../internal.ts"; +import { hiddenBelow, hooks, presented } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; -import * as Path from "../path.ts"; +import type * as Path from "../path.ts"; import { type Reader, type Stream, Writer } from "../stream.ts"; import { Milli, Timescale } from "../time.ts"; import type * as track from "../track.ts"; @@ -37,31 +37,6 @@ import { Version, } from "./version.ts"; -// Where each originated route lands under the requested prefix: its suffix beneath -// the prefix, or the empty suffix for a route above it, where the most specific -// such route wins the way a request through the prefix would resolve. -function presented( - prefix: Path.Valid, - table: ReadonlyMap, - hidden: boolean, -): Map { - const out = new Map(); - let rootLen = -1; - for (const [covered, snap] of table) { - if (Path.hasPrefix(covered, prefix)) { - if (covered.length < rootLen) continue; - rootLen = covered.length; - out.set(Path.empty(), snap); - continue; - } - // A hidden route stays off the wire unless the request opted in. - if (!hidden && hiddenBelow(prefix, covered)) continue; - const suffix = Path.stripPrefix(prefix, covered); - if (suffix !== null) out.set(suffix, snap); - } - return out; -} - const PROBE_INTERVAL = 100; // ms const PROBE_MAX_AGE = 10_000; // ms const PROBE_MAX_DELTA = 0.25; @@ -479,11 +454,14 @@ export class Publisher { dispose = this.#advertised.changed(resolve); }); + // A hidden route stays off the wire unless the request opted in. + const carries = (covered: Path.Valid) => msg.hidden || !hiddenBelow(msg.prefix, covered); + try { const initial = this.#advertised.peek(); if (!initial) return; // closed - for (const [name, snap] of presented(msg.prefix, initial, msg.hidden)) { + for (const [name, snap] of presented(msg.prefix, initial, carries)) { active.set(name, snap); } @@ -528,7 +506,7 @@ export class Publisher { if (!latest) break; const updated = new Map(); - for (const [name, snap] of presented(msg.prefix, latest, msg.hidden)) { + for (const [name, snap] of presented(msg.prefix, latest, carries)) { updated.set(name, snap); } diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index af463eac79..e69cd1d907 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -94,7 +94,9 @@ class Scope { for (const [path, value] of values) { if (this.allowed && ![...this.allowed].some((pattern) => advertOverlaps(value, path, pattern))) continue; const relative = covering.relative(path); - if (relative !== undefined) out.set(relative, value); + if (relative === undefined) continue; + // The claim moves with the key, so it compares against root-relative requests. + out.set(relative, value.claim ? { ...value, claim: value.claim.rebase(this.root) } : value); } return out; } diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 1df7e62458..3af39f89ff 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -55,7 +55,7 @@ export interface OriginConsumer { export interface Advertised { readonly identity: object; readonly route: Route; - /** The absolute paths a scoped route may serve beneath its prefix; unset for the whole subtree. */ + /** The paths a scoped route may serve beneath its prefix, relative like its key; unset for the whole subtree. */ readonly claim?: Path.Patterns; } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 6177770669..2243679f4e 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1757,10 +1757,16 @@ where // erroring, which would look fatal to the peer. // The wire prefix decodes as a literal path; convert it explicitly to its // subtree grant, refusing anything that cannot be a subtree. + // The cursor is rooted at the prefix so every update arrives named as its + // wire suffix, a route covering the prefix included: that one presents at + // the root, as the empty suffix. let scope = crate::Pattern::subtree(prefix.as_str()) - .map(crate::Patterns::from) + .map(|subtree| subtree.rebase(prefix.as_str())) .unwrap_or_default(); - let origin = self.origin.scope("", &scope).unwrap_or_else(|_| self.origin.empty()); + let origin = self + .origin + .scope(&prefix, &scope) + .unwrap_or_else(|_| self.origin.empty()); // Send OK response match self.version { @@ -1909,11 +1915,8 @@ where return stream.writer.closed().await; } NamespaceEvent::Update(Some(update)) => { - let path = update.prefix; - let suffix = path - .strip_prefix(&prefix) - .expect("origin returned invalid prefix") - .to_owned(); + let suffix = update.prefix; + let path = prefix.join(&suffix); if update.kind.is_active() { // A repeat for a live suffix is a metadata update: keep the diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 668ca43a1b..df6a175afc 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -203,11 +203,10 @@ impl Publisher { stream: &mut Stream, origin: &origin::Consumer, announced: &mut announce::Consumer, - prefix: impl crate::AsPath, self_origin: Hop, version: Version, ) -> Result<(), Error> { - let mut run = AnnounceRun::new(prefix.as_path().to_owned(), self_origin, version); + let mut run = AnnounceRun::new(self_origin, version); kio::wait(|waiter| run.poll(stream, origin, announced, waiter)).await } } @@ -527,10 +526,10 @@ impl AnnounceServe { | Error::Stream(crate::StreamError::Cancel) | Error::Session(crate::SessionError::Cancel) | Error::Transport(_) => { - tracing::debug!(prefix = %origin.absolute(&run.prefix), "announcing cancelled"); + tracing::debug!(prefix = %origin.absolute(""), "announcing cancelled"); } err => { - tracing::warn!(%err, prefix = %origin.absolute(&run.prefix), "announcing error"); + tracing::warn!(%err, prefix = %origin.absolute(""), "announcing error"); } } self.stream.take().expect("stream present").writer.abort(&err); @@ -548,13 +547,16 @@ impl AnnounceServe { // fatal stream close), rather than erroring, which would reset the stream. // The wire prefix decodes as a literal path; convert it explicitly to its // subtree grant, refusing anything that cannot be a subtree. + // The cursor is rooted at the prefix so every update arrives named as its + // wire suffix, a route covering the prefix included: that one presents at + // the root, as the empty suffix. let scope = crate::Pattern::subtree(prefix.as_str()) - .map(crate::Patterns::from) + .map(|subtree| subtree.rebase(prefix.as_str())) .unwrap_or_default(); let origin = self .shared .origin - .scope("", &scope) + .scope(&prefix, &scope) .unwrap_or_else(|_| self.shared.origin.empty()); // Register the split-horizon peer on the announce cursor too. The origin // model uses this exposure to park a reflected copy before it can replace @@ -567,7 +569,7 @@ impl AnnounceServe { false => origin, }; let announced = origin.announced(); - let run = AnnounceRun::new(prefix, self.shared.self_origin, self.shared.version); + let run = AnnounceRun::new(self.shared.self_origin, self.shared.version); self.state = AnnounceState::Run { origin, announced, run }; } } @@ -575,7 +577,6 @@ impl AnnounceServe { /// The announce loop's state, minus the handles it borrows per poll so the test /// shim can supply its own. struct AnnounceRun { - prefix: crate::PathOwned, self_origin: Hop, version: Version, // Lite06+: announce ids. Every `active` we send implicitly assigns the next @@ -598,9 +599,8 @@ enum AnnouncePhase { } impl AnnounceRun { - fn new(prefix: crate::PathOwned, self_origin: Hop, version: Version) -> Self { + fn new(self_origin: Hop, version: Version) -> Self { Self { - prefix, self_origin, version, encoder: lite::AnnounceEncoder::new(version), @@ -609,16 +609,6 @@ impl AnnounceRun { } } - /// Where an update travels on this stream: its prefix relative to the requested - /// prefix, which the origin's scope guarantees it sits under. - fn suffix(&self, update: &announce::Update) -> crate::PathOwned { - update - .prefix - .strip_prefix(&self.prefix) - .expect("origin returned a route outside the requested prefix") - .to_owned() - } - /// The chain and cost to put on the wire for `route`, or `None` when it must /// not be forwarded. fn outgoing(&self, route: &crate::origin::Route, absolute: &crate::Path) -> Option<(Hops, crate::origin::Cost)> { @@ -708,7 +698,7 @@ impl AnnounceRun { // We use `try_next()` to synchronously get the initial updates. while let Some(update) = announced.try_next() { let absolute = origin.absolute(&update.prefix); - let suffix = self.suffix(&update); + let suffix = update.prefix; if update.kind.is_active() { if self.outgoing(&update.route, &absolute).is_none() { @@ -736,7 +726,7 @@ impl AnnounceRun { let mut initial: Vec<(crate::PathOwned, Hops, crate::origin::Cost)> = Vec::new(); while let Some(update) = announced.try_next() { let absolute = origin.absolute(&update.prefix); - let suffix = self.suffix(&update); + let suffix = update.prefix; if update.kind.is_active() { let Some((hops, cost)) = self.outgoing(&update.route, &absolute) else { @@ -811,7 +801,7 @@ impl AnnounceRun { }; let absolute = origin.absolute(&update.prefix); - let suffix = self.suffix(&update); + let suffix = update.prefix; if !update.kind.is_active() { self.retract(stream, suffix, &absolute)?; @@ -1754,7 +1744,7 @@ mod announce_test { let task = tokio::spawn(async move { let mut announced = consumer.announced(); let self_origin = consumer.hop(); - TestPublisher::run_announce(&mut stream, &consumer, &mut announced, "", self_origin, VERSION).await + TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, VERSION).await }); settle().await; @@ -1858,7 +1848,7 @@ mod announce_test { let task = tokio::spawn(async move { let mut announced = consumer.announced(); let self_origin = consumer.hop(); - TestPublisher::run_announce(&mut stream, &consumer, &mut announced, "", self_origin, VERSION).await + TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, VERSION).await }); settle().await; @@ -3256,7 +3246,6 @@ mod tests { &mut stream, &consumer, &mut announced, - crate::Path::new(""), self_origin, Version::Lite01, )); diff --git a/rs/moq-net/tests/announce_covering_route.rs b/rs/moq-net/tests/announce_covering_route.rs new file mode 100644 index 0000000000..44184ec11b --- /dev/null +++ b/rs/moq-net/tests/announce_covering_route.rs @@ -0,0 +1,118 @@ +//! A peer asking for announcements below a route's prefix hears that route as +//! covering the prefix it asked for, the way a local consumer rooted there does. +//! +//! The route `.dash` serves every path beneath it, so a cursor rooted at +//! `.dash/nobody` sees it at its own root. Across a session the publisher must +//! translate it into the requested scope rather than end the session. + +mod support; + +use std::time::Duration; + +use moq_net::{Hop, Pattern, Patterns, Version, origin}; +use support::harness::{MockConnectOptions, connect_mock}; + +/// Long enough, in virtual time, for anything in flight to reach the far side. +const SETTLE: Duration = Duration::from_secs(1); + +const SERVED: &str = ".dash"; +const REQUESTED: &str = ".dash/nobody"; + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer +} + +/// Drain every update the cursor has pending, as `kind prefix` lines. +fn drain(announced: &mut moq_net::announce::Consumer) -> Vec { + let mut seen = Vec::new(); + while let Some(update) = announced.try_next() { + seen.push(format!("{:?} {}", update.kind, update.prefix)); + } + seen +} + +/// Announce `.dash`, then a path beneath the requested prefix, and record what a +/// cursor rooted at `.dash/nobody` sees, either on the publishing origin or on a +/// subscriber whose announce interest is that prefix. +async fn covering(version: Option<&str>) -> Vec { + let publisher = produce_origin(1); + let everything = Patterns::from(Pattern::all()); + + let (observer, pair) = match version { + None => (publisher.consume(), None), + Some(version) => { + let subscriber = produce_origin(2); + let interest = Patterns::from(Pattern::subtree(REQUESTED).unwrap()); + let scoped = subscriber.scope("", &interest).unwrap(); + let mut options = MockConnectOptions::new(version.parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(scoped); + (subscriber.consume(), Some(connect_mock(options).await)) + } + }; + let mut announced = observer.scope(REQUESTED, &everything).unwrap().announced(); + let mut log = Vec::new(); + + let served = publisher.create_broadcast(SERVED).unwrap(); + served.announce(Default::default()).unwrap(); + tokio::time::sleep(SETTLE).await; + log.push(format!("served: {:?}", drain(&mut announced))); + + let below = publisher.create_broadcast(format!("{REQUESTED}/cam")).unwrap(); + below.announce(Default::default()).unwrap(); + tokio::time::sleep(SETTLE).await; + log.push(format!("below: {:?}", drain(&mut announced))); + + if let Some(pair) = pair { + let closed = tokio::time::timeout(SETTLE, pair.client.closed()).await; + log.push(format!("session open: {}", closed.is_err())); + } + + log +} + +const EXPECTED: &[&str] = &["served: [\"Announced \"]", "below: [\"Announced cam\"]"]; + +#[tokio::test] +async fn local_cursor_sees_the_covering_route_at_its_root() { + tokio::time::pause(); + assert_eq!(covering(None).await, EXPECTED); +} + +#[tokio::test] +async fn remote_lite_peer_hears_the_covering_route() { + tokio::time::pause(); + let mut expected = EXPECTED.to_vec(); + expected.push("session open: true"); + for version in [ + "moq-lite-03", + "moq-lite-04", + "moq-lite-05", + "moq-lite-06", + "moq-lite-07-wip", + ] { + assert_eq!(covering(Some(version)).await, expected, "{version}"); + } +} + +#[tokio::test] +async fn remote_ietf_peer_hears_the_covering_route() { + tokio::time::pause(); + let mut expected = EXPECTED.to_vec(); + expected.push("session open: true"); + for version in [ + "moq-transport-14", + "moq-transport-15", + "moq-transport-16", + "moq-transport-17", + "moq-transport-18", + "moq-transport-19", + "moq-transport-20", + "moq-transport-21", + "moq-transport-22", + ] { + assert_eq!(covering(Some(version)).await, expected, "{version}"); + } +} From ceabaaf60eeea27184137cb290717d58b17ebc3b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 20:44:54 -0700 Subject: [PATCH 31/87] quest: plan the follow-ups from PRs merged since 09-24 (#4307) Co-authored-by: Claude Opus 5.5 --- .claude/skills/plan-issues/SKILL.md | 2 +- quest/AGENTS.md | 3 +- quest/m0/README.md | 1 + quest/m0/audio-jitter-target/README.md | 16 +++- quest/m0/audio-jitter-target/watch.md | 14 ++-- .../audio-quality-harness/README.md | 12 +-- .../audio-quality-harness/browser.md | 7 +- .../audio-quality-harness/native.md | 2 +- quest/m0/plan-av-clock.md | 9 +++ quest/m1/README.md | 32 +++++++- quest/m1/archive/README.md | 1 + quest/m1/archive/enrollment-flake.md | 26 ++++++ quest/m1/audio-codecs/README.md | 1 + quest/m1/audio-codecs/encode-audiotoolbox.md | 5 ++ quest/m1/audio-codecs/named-codecs.md | 27 +++++++ quest/m1/auth/README.md | 2 + quest/m1/auth/auth-ok-preflight.md | 24 ++++++ quest/m1/auth/error-codes.md | 36 +++++++++ quest/m1/auth/narrowing.md | 11 +++ quest/m1/auth/request-token.md | 27 ++++--- quest/m1/auth/token-in-band.md | 11 ++- quest/m1/capture-control.md | 43 ++++++++++ quest/m1/capture-reanchor.md | 21 +++++ quest/m1/catalog-wall-clock.md | 28 +++++++ quest/m1/cli-given-flags.md | 24 ++++++ quest/m1/cpp/README.md | 18 +++++ quest/m1/cpp/cancel.md | 34 ++++++++ quest/m1/cpp/cxx-standard.md | 29 +++++++ quest/m1/doc-samples-go-dart.md | 30 +++++++ quest/m1/drain/README.md | 1 + quest/m1/drain/js-goaway-requests.md | 35 ++++++++ quest/m1/drill-sensitivity.md | 22 ++++++ quest/m1/dropped-sources.md | 5 ++ quest/m1/gateway-live-clock.md | 32 ++++++++ quest/m1/hls-discontinuity-sequence.md | 37 +++++++++ quest/m1/hls-linger.md | 41 ++++++++++ quest/m1/ietf-max-age.md | 8 +- quest/m1/iroh-lite-wip.md | 31 ++++++++ quest/m1/js-bundle-trims.md | 6 ++ quest/m1/js-catalog-path.md | 27 +++++++ quest/m1/js-pattern-depth.md | 21 +++++ quest/m1/js-scoped-routes.md | 37 +++++++++ quest/m1/kio-waiter-lost.md | 26 ++++++ quest/m1/merge-queue.md | 31 ++++++++ quest/m1/moqsink-keyframe-latch.md | 23 ++++++ quest/m1/moqsrc-stop.md | 27 +++++++ quest/m1/obs-moq-video/README.md | 3 +- quest/m1/obs-moq-video/adapter.md | 2 +- quest/m1/obs-moq-video/macos.md | 5 ++ quest/m1/obs-moq-video/preset-parity.md | 47 +++++++++++ quest/m1/obs-moq-video/windows.md | 7 ++ quest/m1/pipewire-dup-cameras.md | 24 ++++++ quest/m1/play-decode-schedule.md | 36 +++++++++ quest/m1/publish-codec-string.md | 33 ++++++++ quest/m1/publish-lazy-file.md | 6 ++ quest/m1/redirect-resolve.md | 29 +++++++ quest/m1/rtmp-tls-only.md | 32 ++++++++ quest/m1/session-close.md | 8 ++ quest/m1/session-death.md | 42 ++++++++++ quest/m1/splice-edges.md | 37 +++++++++ quest/m1/test-flakes-2.md | 58 ++++++++++++++ quest/m1/tooling/README.md | 1 + quest/m1/tooling/windows-cross-check.md | 36 +++++++++ quest/m1/track-tail-hardening.md | 79 +++++++++++++++++++ quest/m1/ts-export-jitter.md | 57 +++++++++++++ quest/m1/ts-import-shared-shift.md | 44 +++++++++++ quest/m1/unknown-session-logs.md | 43 ++++++++++ quest/m1/video-surface.md | 36 +++++++++ quest/m1/wire-compat.md | 61 ++++++++++++++ quest/m2/README.md | 2 + quest/m2/audio-loss-recovery.md | 2 +- quest/m2/audio-opus-backend.md | 2 +- quest/m2/cat/present.md | 16 ++-- quest/m2/cat/verify.md | 5 +- quest/m2/latency-ledger.md | 4 +- quest/m2/play-drain-tail.md | 24 ++++++ quest/m2/relay-io-uring-package.md | 41 ++++++++++ quest/m3/README.md | 1 + quest/m3/upstream-forks.md | 55 +++++++++++++ 79 files changed, 1740 insertions(+), 44 deletions(-) rename quest/{m1 => m0}/audio-quality-harness/README.md (86%) rename quest/{m1 => m0}/audio-quality-harness/browser.md (94%) rename quest/{m1 => m0}/audio-quality-harness/native.md (97%) create mode 100644 quest/m1/archive/enrollment-flake.md create mode 100644 quest/m1/audio-codecs/named-codecs.md create mode 100644 quest/m1/auth/auth-ok-preflight.md create mode 100644 quest/m1/auth/error-codes.md create mode 100644 quest/m1/capture-control.md create mode 100644 quest/m1/capture-reanchor.md create mode 100644 quest/m1/catalog-wall-clock.md create mode 100644 quest/m1/cli-given-flags.md create mode 100644 quest/m1/cpp/cancel.md create mode 100644 quest/m1/cpp/cxx-standard.md create mode 100644 quest/m1/doc-samples-go-dart.md create mode 100644 quest/m1/drain/js-goaway-requests.md create mode 100644 quest/m1/drill-sensitivity.md create mode 100644 quest/m1/gateway-live-clock.md create mode 100644 quest/m1/hls-discontinuity-sequence.md create mode 100644 quest/m1/hls-linger.md create mode 100644 quest/m1/iroh-lite-wip.md create mode 100644 quest/m1/js-catalog-path.md create mode 100644 quest/m1/js-pattern-depth.md create mode 100644 quest/m1/js-scoped-routes.md create mode 100644 quest/m1/kio-waiter-lost.md create mode 100644 quest/m1/merge-queue.md create mode 100644 quest/m1/moqsink-keyframe-latch.md create mode 100644 quest/m1/moqsrc-stop.md create mode 100644 quest/m1/obs-moq-video/preset-parity.md create mode 100644 quest/m1/pipewire-dup-cameras.md create mode 100644 quest/m1/play-decode-schedule.md create mode 100644 quest/m1/publish-codec-string.md create mode 100644 quest/m1/redirect-resolve.md create mode 100644 quest/m1/rtmp-tls-only.md create mode 100644 quest/m1/session-death.md create mode 100644 quest/m1/splice-edges.md create mode 100644 quest/m1/test-flakes-2.md create mode 100644 quest/m1/tooling/windows-cross-check.md create mode 100644 quest/m1/track-tail-hardening.md create mode 100644 quest/m1/ts-export-jitter.md create mode 100644 quest/m1/ts-import-shared-shift.md create mode 100644 quest/m1/unknown-session-logs.md create mode 100644 quest/m1/video-surface.md create mode 100644 quest/m1/wire-compat.md create mode 100644 quest/m2/play-drain-tail.md create mode 100644 quest/m2/relay-io-uring-package.md create mode 100644 quest/m3/upstream-forks.md diff --git a/.claude/skills/plan-issues/SKILL.md b/.claude/skills/plan-issues/SKILL.md index ea261e9c75..d5a88012e9 100644 --- a/.claude/skills/plan-issues/SKILL.md +++ b/.claude/skills/plan-issues/SKILL.md @@ -4,4 +4,4 @@ description: Plan quests from open GitHub issues without the quest label. --- Call /plan-quests for repository's open GitHub issues without the `quest` label. -Add the `quest` label to these issues after the PR merges. +Add the `quest` label to these issues once the PR is open, so another planner skips them. diff --git a/quest/AGENTS.md b/quest/AGENTS.md index 16eebac7aa..787c525880 100644 --- a/quest/AGENTS.md +++ b/quest/AGENTS.md @@ -80,7 +80,8 @@ Current decisions, open questions, or implementation guidance. child owns: the end-to-end test, the docs page. - New work joins the milestone matching its priority, at its rank. - Every issue under `Closes` carries the `quest` GitHub label - (`gh issue edit --add-label quest`), applied when the quest lands. + (`gh issue edit --add-label quest`), applied when the PR creating the + quest opens and removed if that PR closes unmerged. `Related` is context and gets none. - A release or pin bump that unblocks work is its own quest holding the condition as a plain-text `Required` bullet; every dependent requires it. diff --git a/quest/m0/README.md b/quest/m0/README.md index a37d03ba03..aa41bc85a5 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -94,6 +94,7 @@ 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 +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess - [A/V clock](/quest/m0/plan-av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles - [SD rendition for bbb](/quest/m0/bbb-sd.md) - `just pub bbb` publishes a pre-encoded 360p rung beside the 720p source, for localhost demos and moq.pro's fleet demo diff --git a/quest/m0/audio-jitter-target/README.md b/quest/m0/audio-jitter-target/README.md index 9f9600f3e1..6ce2effd6e 100644 --- a/quest/m0/audio-jitter-target/README.md +++ b/quest/m0/audio-jitter-target/README.md @@ -25,6 +25,20 @@ additive and target `main`: the native knob is a new field on a `#[non_exhaustive]` struct, and the browser estimator is a new module plus a new `spread` observation. +Decided for landing: the line merges to `main`, not `dev`, with a changelog +note for two behavior changes treated as fixes. `@moq/watch` `Sync` takes a +numeric delay literally instead of adding the rendition delay on top +([#3954](https://github.com/moq-dev/moq/pull/3954)), and `moq play --delay` +defaults to `auto` instead of `100ms` +([#3967](https://github.com/moq-dev/moq/pull/3967)). The old additive delay +was wrong, and both compile unchanged for existing callers. The line branch is +about 200 commits behind `main` with conflicts in `js/watch/src/sync.ts` and +`rs/moq-cli`; merge `main` in (never rebase the shared branch) before +finishing the watch quest. The raw #3477 traces are gone, so record fresh +traces with the [audio quality +harness](/quest/m0/audio-quality-harness/README.md) instead of asking the +reporter; they replace the #3477 traces wherever the quests name them. + The algorithm is written down at `doc/concept/audio-jitter.md`, with a conformance corpus beside it that both implementations will read. @@ -71,6 +85,6 @@ buffer against uneven arrivals. ## Related -- [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - the automated proof, built on its own schedule +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - the automated proof, and the recorder of the traces the watch quest replays - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - inaudible convergence, on top of this - [Plan: A/V clock](/quest/m0/plan-av-clock.md) - the clock this target eventually feeds diff --git a/quest/m0/audio-jitter-target/watch.md b/quest/m0/audio-jitter-target/watch.md index 188de90dc9..a1a258f61e 100644 --- a/quest/m0/audio-jitter-target/watch.md +++ b/quest/m0/audio-jitter-target/watch.md @@ -74,11 +74,11 @@ against it is likely cheaper than patching the branch's: came up on the 100 ms chip, so something is restoring or overriding it. Pin that down: a stored preference silently winning over the default is its own bug, and it also means auto gets far less real exposure than it looks like. -- Replay the recorded traces from #3477 rather than synthetic ones of the same - shape. They are on the reporter's fork (`fperex/moq`, branch - `debug/rt-audio`) with the raw ndjson attached to release - `rt-audio-traces-2026-09-06`. Trim a copy into the repository and replay it - through both rings in `replay.test.ts`. +- Replay recorded traces rather than synthetic ones of the same shape. The + #3477 traces are gone, so record fresh ones with the [browser + harness](/quest/m0/audio-quality-harness/browser.md) (decided with the + maintainer instead of asking the reporter). Trim a copy into the repository + and replay it through both rings in `replay.test.ts`. - Manual run against the public relay on Chrome and Safari, the two rows the issue measured. Measure the publisher's audio encoder input-to-output lag in the same run using the reporter's instrumented harness; #3518 fixed the known @@ -95,6 +95,10 @@ reading `probe`; removing it is part of the `SyncInput` reshape in [Plan: A/V clock](/quest/m0/plan-av-clock.md). Land the estimator so that quest can adopt it without a second estimator change. +## Required + +- [Browser harness](/quest/m0/audio-quality-harness/browser.md) - records the arrival traces this quest replays + ## Related - [Plan: A/V clock](/quest/m0/plan-av-clock.md) - reshapes `SyncInput` around the per-track spread this quest produces diff --git a/quest/m1/audio-quality-harness/README.md b/quest/m0/audio-quality-harness/README.md similarity index 86% rename from quest/m1/audio-quality-harness/README.md rename to quest/m0/audio-quality-harness/README.md index a40a280920..5861c81352 100644 --- a/quest/m1/audio-quality-harness/README.md +++ b/quest/m0/audio-quality-harness/README.md @@ -23,9 +23,11 @@ both passing. The starting point is not a blank page. The reporter on #3477 already built a working browser harness on their fork (`fperex/moq`, branch `debug/rt-audio`): a CDP driver, a beacon sink, a trace analyzer, a ring replay, a five-scenario -`bench.sh`, and a `compare.mjs` that prints before-and-after tables, with 130 -raw ndjson traces attached to release `rt-audio-traces-2026-09-06`. Upstream -that rather than reinventing it. +`bench.sh`, and a `compare.mjs` that prints before-and-after tables. Upstream +that rather than reinventing it. The raw traces it shipped with are gone, so +this harness records fresh ones, and the [jitter target's watch +quest](/quest/m0/audio-jitter-target/watch.md) replays them (decided with the +maintainer during the merged-PR audit). Jitter comes from the seeded userspace UDP shaper the transport drills run under (`rs/moq-shaper`, documented in `test/drill/README.md`), not from a fake @@ -44,8 +46,8 @@ each of those dimensions moves the expected floor. ## Quests -- [Browser](/quest/m1/audio-quality-harness/browser.md) - upstream the fork's harness, grade it against a budget, run it nightly -- [Native](/quest/m1/audio-quality-harness/native.md) - the same profiles and budgets through `moq play` on a dummy device +- [Browser](/quest/m0/audio-quality-harness/browser.md) - upstream the fork's harness, grade it against a budget, run it nightly +- [Native](/quest/m0/audio-quality-harness/native.md) - the same profiles and budgets through `moq play` on a dummy device ## Related diff --git a/quest/m1/audio-quality-harness/browser.md b/quest/m0/audio-quality-harness/browser.md similarity index 94% rename from quest/m1/audio-quality-harness/browser.md rename to quest/m0/audio-quality-harness/browser.md index f73d5bb8dc..887f864544 100644 --- a/quest/m1/audio-quality-harness/browser.md +++ b/quest/m0/audio-quality-harness/browser.md @@ -64,8 +64,9 @@ budget, or a schedule. each of those moves the expected floor. A profile-only key silently grades one row against another's threshold. Tightening a budget is then a visible diff and loosening one needs a reason in review. -- Trim the released ndjson traces from `rt-audio-traces-2026-09-06` into a - fixture and replay them too, so a real recorded arrival pattern is graded - next to the synthetic profiles. +- Record real arrival traces (the #3477 release traces are gone), trim them + into a fixture, and replay them too, so a real recorded arrival pattern is + graded next to the synthetic profiles. The jitter target's watch quest + replays the same fixture. - Add the lane to `nightly.yml`, and extend its header comment with why this one is not a PR gate. diff --git a/quest/m1/audio-quality-harness/native.md b/quest/m0/audio-quality-harness/native.md similarity index 97% rename from quest/m1/audio-quality-harness/native.md rename to quest/m0/audio-quality-harness/native.md index 07970c1e82..a614645dd3 100644 --- a/quest/m1/audio-quality-harness/native.md +++ b/quest/m0/audio-quality-harness/native.md @@ -42,5 +42,5 @@ difference in totals alone proves nothing about the estimator. ## Required -- [Browser](/quest/m1/audio-quality-harness/browser.md) - defines the metric schema, the budget file, and the extracted shaper +- [Browser](/quest/m0/audio-quality-harness/browser.md) - defines the metric schema, the budget file, and the extracted shaper - [Native jitter target](/quest/m0/audio-jitter-target/native.md) - the estimator this lane grades and compares against the browser; without it there is no target series and the budgets would be set against a playout path that holds nothing diff --git a/quest/m0/plan-av-clock.md b/quest/m0/plan-av-clock.md index ca329d397b..22f14a12c6 100644 --- a/quest/m0/plan-av-clock.md +++ b/quest/m0/plan-av-clock.md @@ -39,6 +39,15 @@ Recommendations for the implementation: - The text renderer is the third track: it reads `sync.now()` (`js/watch/src/text/renderer.ts:261`) to drive the cue clock and prune cues at `:263-265`. +- Close the player gaps [#4170](https://github.com/moq-dev/moq/pull/4170) + left, since the handles own them. `Sync.received` only ever lowers its + reference, so after the earliest subscribed track leaves, playback stays + anchored to it; a track's handle going away must release its part of the + reference, the same expiry the jitter target's arrival minimum has. Text + renditions register no floor with `Sync`, so their catalog `delay` is + ignored; the text handle registers one like audio and video. The MSF + catalog schema (`js/msf/src/catalog.ts`) accepts a negative `delay` and + folds it into absent; refuse it on decode instead. ## Required diff --git a/quest/m1/README.md b/quest/m1/README.md index ba0b1d6e15..df1917608b 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,6 +17,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests +- [Drill sensitivity](/quest/m1/drill-sensitivity.md) - the nightly drill-sensitivity job passes: the subscriber-leaks-broadcasts mutation applies to the current lite subscriber again - [BBR classic ECN](/quest/m1/bbr-classic-ecn.md) - Startup and bandwidth probing respond to CE marks before the bottleneck drops packets - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` @@ -39,6 +40,36 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 +- [kio waiter overflow](/quest/m1/kio-waiter-lost.md) - a retained `Waiter` past 8 lists stops adding a duplicate entry to lists it already recorded +- [moqsink keyframe latch](/quest/m1/moqsink-keyframe-latch.md) - a header-only buffer after a break no longer permanently invalidates a moqsink video pad +- [Capture re-anchor](/quest/m1/capture-reanchor.md) - a repeating or restarting device clock never rewinds native capture during a fast backlog drain +- [Splice edge cases](/quest/m1/splice-edges.md) - an unstamped successor, a pruned segment's boundary group, and a warm head during a takeover are each handled correctly +- [Track tail hardening](/quest/m1/track-tail-hardening.md) - Rust and JS wait out a track's tail by the same rules, with the known hang, count, truncation, grace, and memory holes closed +- [Session death parity](/quest/m1/session-death.md) - a local close ends tracks cleanly in both languages, and JS group readers see the session's error on session death +- [JS scoped routes](/quest/m1/js-scoped-routes.md) - a scoped JS origin reader announces the preferred route among those in its scope, like Rust, with the fan-out benchmarked +- [JS subtree at max depth](/quest/m1/js-pattern-depth.md) - `Pattern.subtree` returns the literal path at 32 segments like Rust, pinned by a shared pattern.json vector +- [moq play decode schedule](/quest/m1/play-decode-schedule.md) - `moq play` video keeps valid pictures across rewinds, reordering deeper than 100 ms, and decoder batches larger than three +- [moqsrc stop](/quest/m1/moqsrc-stop.md) - moqsrc's stop blocks until its session ends, without deadlocking on a blocked pad push +- [More tests under load](/quest/m1/test-flakes-2.md) - the second round of load-only failures, fixed at the cause +- [UnknownSession log flood](/quest/m1/unknown-session-logs.md) - streams reset before their WebTransport header stop being reported as UnknownSession at WARN +- [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main +- [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container +- [Accept-side flags](/quest/m1/cli-given-flags.md) - dial-only and local verbs refuse every `--listen-*` flag instead of ignoring it +- [JS catalog path](/quest/m1/js-catalog-path.md) - `@moq/net` broadcast consumers expose their path and `Catalog.watch` rejects escaping references, like Rust +- [Full codec string](/quest/m1/publish-codec-string.md) - browser-published video carries the encoder's full RFC 6381 codec string, so native players decode it +- [TS export jitter](/quest/m1/ts-export-jitter.md) - the video reorder bound follows later catalogs and observed reordering, so a late B-frame never reorders TS output +- [TS import shared shift](/quest/m1/ts-import-shared-shift.md) - unflagged loop wraps move audio and video by one shift, so A/V sync holds across wraps +- [PipeWire duplicate cameras](/quest/m1/pipewire-dup-cameras.md) - a webcam lists once with PipeWire enabled +- [Catalog wall clock](/quest/m1/catalog-wall-clock.md) - `Clock::wall_clock` keeps the catalog's full precision instead of truncating to milliseconds +- [Capture control](/quest/m1/capture-control.md) - on dev, `encode::Capture` replaces `CaptureOptions`, an unsupported `cut()` errors, and dropping the last `Control` cancels in-flight opens +- [Video surface](/quest/m1/video-surface.md) - on dev, moq-ffi's `native` becomes `surface`, refused on platforms with no surface +- [HLS discontinuity sequence](/quest/m1/hls-discontinuity-sequence.md) - on dev, `Segment::discontinuity` is the absolute sequence, so every cursor agrees +- [Strict Redirect::resolve](/quest/m1/redirect-resolve.md) - on dev, `Redirect::resolve` can no longer quietly turn a refused redirect into a redial +- [RTMP TLS only](/quest/m1/rtmp-tls-only.md) - an RTMP listener configured for TLS can refuse plaintext instead of sniffing and serving it +- [HLS linger](/quest/m1/hls-linger.md) - `moq_hls::Server` serves an ended broadcast for its playlist window plus grace, so the moq.pro edge drops its own pool +- [Gateway live clock](/quest/m1/gateway-live-clock.md) - moq-srt, moq-rtmp, and HLS import publish on the broadcast clock, so encoder reconnects don't restart timestamps +- [iroh versions](/quest/m1/iroh-lite-wip.md) - `iroh://` negotiates the configured versions, so `moq-lite-07-wip` can be opted into +- [Go and Dart doc samples](/quest/m1/doc-samples-go-dart.md) - Go and Dart doc samples compile against their wrappers - [Data capture in bindings](/quest/m1/data-capture-bindings.md) - moq-ffi and every wrapper pass a data frame's capture time, and the JSON window producer takes one - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass @@ -89,7 +120,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Perf](/quest/m1/perf/README.md) - eliminate measured hot-path costs across moq-uring, kio, and the moq-net model - [#2924](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every listener on both runtimes shares one reloadable served identity, so rotation is atomic and generate works with workers - [#2964](/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md) - integrate the dev worker owner with hardened socket-group formation -- [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report - [Benchmark regressions in CI](/quest/m1/bench-ci.md) - PRs get a non-blocking comparison of the Criterion benches they affect, and a nightly trend on main alerts on regressions - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - retained evidence, repeated paired runs, and uncertainty for performance claims - [#3126](/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - moq-bench reports per-interval latency percentiles so the ramp leaves the steady state diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 8c388a19bc..b772e4d8f2 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -110,6 +110,7 @@ owned by that prerequisite, not duplicated in archive storage. - [Browser archive](/quest/m1/archive/browser.md) - the same contract for browser-published broadcasts - [Offline archive HLS](/quest/m1/archive/hls.md) - render playlists from the archive timeline and fetch segment media lazily - [DVR rewind](/quest/m1/archive/dvr.md) - seek through a bounded archive and return to live playback +- [Enrollment flake](/quest/m1/archive/enrollment-flake.md) - the opening-snapshot test waits for real enrollment, not the `.info` file - [Archive proof](/quest/m1/archive/proof.md) - prove persistence ordering, selective reads, exact FETCH replay, and timeline-only HLS generation ## Related diff --git a/quest/m1/archive/enrollment-flake.md b/quest/m1/archive/enrollment-flake.md new file mode 100644 index 0000000000..4395024703 --- /dev/null +++ b/quest/m1/archive/enrollment-flake.md @@ -0,0 +1,26 @@ +# [XS] The opening-snapshot test waits for real enrollment + +## Goal + +`archive::tests::an_opening_snapshot_records_every_rendition` in +`rs/moq-cli/src/archive.rs` passes under load. It fails with "writer closed" +because it treats a rendition's `.info` file as proof the export enrolled +that track, then finishes the tracks and the catalog; under load the writer +can still be subscribing when the tracks end. Seen while landing +[#4169](https://github.com/moq-dev/moq/pull/4169). + +## Plan + +- Wait on the signal enrollment actually produces (the subscription reaching + the publisher, or an explicit event from the exporter), not a file that + appears earlier. No longer timeout and no retry. +- If the exporter can genuinely lose a rendition that ends right after it + appears in the catalog, that is a bug in the exporter, not the test; fix it + there. + +Public API: none. Wire: none. + +## Related + +- [More tests hold up under load](/quest/m1/test-flakes-2.md) - the same + round of load-only failures on `main` diff --git a/quest/m1/audio-codecs/README.md b/quest/m1/audio-codecs/README.md index 372157036a..2a23be20c5 100644 --- a/quest/m1/audio-codecs/README.md +++ b/quest/m1/audio-codecs/README.md @@ -43,6 +43,7 @@ ready now. ## Quests +- [Named codecs](/quest/m1/audio-codecs/named-codecs.md) - `decode::Kind::Named` keeps the published codec names and picks backends internally; must land before the line merges - [HE-AAC refusal](/quest/m1/audio-codecs/he-aac-refusal.md) - implicit-SBR HE-AAC over TS is refused instead of half-decoded as the LC core - [AAC PCE](/quest/m1/audio-codecs/aac-pce.md) - a channel_config of 0 parses the program config element instead of guessing stereo - [Layout](/quest/m1/audio-codecs/layout.md) - the settled `Layout` carries up to 7.1 through decode, resample, playback, and the FFI diff --git a/quest/m1/audio-codecs/encode-audiotoolbox.md b/quest/m1/audio-codecs/encode-audiotoolbox.md index 0f86953835..401142b291 100644 --- a/quest/m1/audio-codecs/encode-audiotoolbox.md +++ b/quest/m1/audio-codecs/encode-audiotoolbox.md @@ -18,6 +18,11 @@ the encode seam as the platform candidate on macOS and iOS. - Regression: a stereo and a 5.1 encode round-trip through the AudioToolbox decoder and through symphonia (stereo only), with timestamps continuous across the priming. +- Decided in [#4183](https://github.com/moq-dev/moq/pull/4183): the uniffi + `MoqAudioEncoderOutput::frame_duration_us` default moves from 20000 to 0 + (the codec's own frame) with this quest, so `aac()` works without an + explicit 0. Changing a published binding default is a break, so that + change targets `dev`. ## Required diff --git a/quest/m1/audio-codecs/named-codecs.md b/quest/m1/audio-codecs/named-codecs.md new file mode 100644 index 0000000000..4bba08d514 --- /dev/null +++ b/quest/m1/audio-codecs/named-codecs.md @@ -0,0 +1,27 @@ +# [XS] Kind::Named keeps its codec names + +## Goal + +`moq_audio::decode::Kind::Named` accepts what published moq-audio 0.1.6 +accepts, the codec names (`"opus"`, `"aac"`, `"pcm"`), so the line can merge +to `main` without breaking callers. [#4131](https://github.com/moq-dev/moq/pull/4131) +switched it to backend names (`"libopus"`, `"symphonia"`, `"pcm"`) on the line +branch, which turns every existing `Named("opus")` or `Named("aac")` into +`Error::Unsupported` after an upgrade. This must land before the line PR +[#4081](https://github.com/moq-dev/moq/pull/4081) merges to `main`. + +## Plan + +- Decided: keep codec names; backends are picked internally. Which backend + decodes AAC (AudioToolbox, Media Foundation, symphonia) is the library's + choice through `Auto` and `Software`, not a string a caller has to know and + that changes per platform. This reverses #4131's reasoning on purpose: the + published contract wins over matching moq-video's backend naming. +- Make `encode::Kind` read the same way. It is unpublished, so drop `Named` + there or give it the same codec meaning, whichever leaves the two enums + mirrored; don't keep backend names on one side only. +- Tests that forced a backend by name move to a crate-private seam. +- Docs and the `Kind` doc comments list the codec names. + +Public API: `decode::Kind::Named` returns to its published meaning, so the +line stays additive on `main`. Wire: none. diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 6ec02ba9d1..2dc399d5df 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -96,6 +96,8 @@ existing lite-06 ALPN. each lite-06 cell's grant and that a publish outside it fails loud - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that loses access resets with a dedicated UNAUTHORIZED stream code +- [AUTH_OK preflight](/quest/m1/auth/auth-ok-preflight.md) - an unencodable IETF grant answers NOT_SUPPORTED with nothing written, as JS already does +- [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends - [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in place: subscriptions outside it reset, publishes outside it abort, and relay revalidation stops closing the session diff --git a/quest/m1/auth/auth-ok-preflight.md b/quest/m1/auth/auth-ok-preflight.md new file mode 100644 index 0000000000..eecead51ac --- /dev/null +++ b/quest/m1/auth/auth-ok-preflight.md @@ -0,0 +1,24 @@ +# [XS] An AUTH_OK that cannot be sent is refused, not half-written + +## Goal + +When the Rust IETF acceptor's grant cannot be encoded as one AUTH_OK for any +reason, it answers `AUTH_ERROR { NOT_SUPPORTED }` and nothing of the AUTH_OK +reaches the wire, the same as JS. Today the preflight in +`rs/moq-net/src/ietf/auth.rs` (`serve_issue`) catches only +`EncodeError::Unsupported`, so a prefix grant too large for the `u16` message +size falls through to `encode_message`, which fails mid-write and ends the +stream with a transport error instead of a refusal the presenter can read. +Found in review of [#4124](https://github.com/moq-dev/moq/pull/4124). + +## Plan + +- Refuse on any sizing error, not only `Unsupported`, including the message + size ceiling the writer enforces. Never trim or widen the grant to make it + fit: withholding it is the only safe answer. +- Check whether the lite AUTH_OK path has the same gap and fix both if so. +- Regression test mirroring JS's "a grant too large for one message is + refused before anything is written": the presenter sees `Unsupported` and + no AUTH_OK bytes were written. + +Public API: none. Wire: none. diff --git a/quest/m1/auth/error-codes.md b/quest/m1/auth/error-codes.md new file mode 100644 index 0000000000..76dfcbe1f5 --- /dev/null +++ b/quest/m1/auth/error-codes.md @@ -0,0 +1,36 @@ +# [S] AUTH endings are exact on both sides + +## Goal + +The loose ends Codex left on [#4062](https://github.com/moq-dev/moq/pull/4062) +are closed, so every way an AUTH stream ends reports what actually happened: + +- A lite AUTH_ERROR whose code does not fit a `u32` is a protocol violation. + Today `rs/moq-net/src/lite/session.rs` saturates it with + `u32::try_from(refused.code).unwrap_or(u32::MAX)`, so distinct peer codes + collapse into one and the app sees a code the peer never sent. +- JS `AuthSession.close()` in `js/net/src/auth_session.ts` clears its tokens + but never recomputes the union, so a retained `auth.grant` keeps reporting + a live grant after the session closed. Rust already clears it. +- A JS presenter whose acceptor ends the grant with a clean FIN exits the read + loop without closing its own write half, unlike the AUTH_ERROR branch, so + the acceptor's `Issued.closed` stays pending until the session ends. +- A Rust `Issued::closed()` never resolves if the session drops the task + serving that token (`AuthServe` in the lite publisher, `Serve` in + `ietf/auth.rs`): only the task's own completion records `issue.peer`. + +## Plan + +- Fail loud on the unrepresentable code rather than widen the error type: + the codes AUTH_ERROR carries are session codes, which are `u32` everywhere + else. +- Settle a dropped serve task from a drop path (a guard that records the + session's error on `Issue` and wakes waiters), so no exit path can forget + it. +- One regression test per item, each failing without its fix. + +The fifth deferred item, a grant recheck after async origin resolution, +belongs to [Origin narrowing](/quest/m1/auth/narrowing.md) with the other +watcher races. + +Public API: none. Wire: none. diff --git a/quest/m1/auth/narrowing.md b/quest/m1/auth/narrowing.md index 0be54eceec..9a909bfbe5 100644 --- a/quest/m1/auth/narrowing.md +++ b/quest/m1/auth/narrowing.md @@ -29,6 +29,17 @@ A narrowing always succeeds. A changed root still closes the session. handle and cursor (`OriginScope` in `rs/moq-net/src/model/origin.rs`), so narrowing needs shared state. If handles need a tree to share it, keep the ceiling on the grant node itself (an `Arc` shared by clones and children). +- Close the revocation races Codex found on + [#4179](https://github.com/moq-dev/moq/pull/4179), which the same watcher + mechanism owns: an in-flight lite FETCH keeps serving after its path is + revoked (Rust and JS check only at accept, and Rust's dropped fetches reset + `CANCELLED`, not `UNAUTHORIZED`); a Rust lite subscription still in + `Establish` when the grant shrinks is dropped with `CANCELLED` instead of + aborted `UNAUTHORIZED`; and the JS lite publisher and subscriber arm their + grant watchers only after an await (`demand()`, `#openSubscribe`), and + `Getter.subscribe` does not replay, so a shrink during setup is missed for + good. Every request holds one watcher from its first check to its end, and + rechecks when the watcher is armed. - Publish side: routes and broadcasts the session published outside the narrowed grant abort, so consumers see `Unauthorized` just as the subscribe side does. Draining is not an option: a group may stay open as long as its diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index 34f77640e1..fa93750186 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -41,28 +41,35 @@ on the unknown key, and the legacy drafts silently ignore it. alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through [Origin narrowing](/quest/m1/auth/narrowing.md). -- Relay: each such request attaches its own lease through the - `Client::attach` path [Relay tokens](/quest/m1/auth/relay-refresh.md) - builds, once per request, with no sharing across requests carrying the - same bytes; the lease is dropped with the request. The request is resolved - against the origin with the path checked against that lease's grant, not - through the session's scoped origin handle. +- Relay: each such request gets its own lease from a per-request call on + `moq_auth::Client`, not the `Client::attach` that [Relay + tokens](/quest/m1/auth/relay-refresh.md) builds. `attach` connects with the + connection's id, and the auth server treats that as one more grant on the + session that never POSTs `end`, so a request token would widen the whole + connection for its life. The per-request call carries the token in + `moq_auth::Request.token` with its kind (a CAT reaches the CAT verifier, + not the JWT one) and the request's path, is never counted as a session + grant, and ends when the request ends. Two requests carrying the same bytes + get two leases. The request is resolved against the origin with the path + checked against that lease's grant, not through the session's scoped + origin handle. Name the call while implementing. - `js/net` mirrors the decode and the default refusal. - Docs: `doc/bin/relay/auth.md` states the order (session grant, then the request's token, then `UNAUTHORIZED`), that a request token covers only its request, and how a peer refreshes with REQUEST_UPDATE. - Tests: a SUBSCRIBE outside the session grant succeeds with a covering token and is refused `UNAUTHORIZED` without one; its token grants nothing to a - second SUBSCRIBE; a request inside the session grant never calls the + second SUBSCRIBE, and through the relay the auth server never counts it as + a session grant and sees its lease end with the request; a request inside the session grant never calls the verifier; a REQUEST_UPDATE token keeps a subscription alive past the old token's expiry; an expired request token ends only that request; with no consumer a token-bearing request is refused `Unsupported`; one legacy and one strict draft, Rust and JS. Public API: additive on `moq_net::auth::Request` (the request it belongs -to). Wire: none new; the parameter already exists in every supported draft. +to) and on `moq_auth::Client` (the per-request lease). Wire: none new; the parameter already exists in every supported draft. ## Required -- [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the per-token - lease and `Client::attach` path each request uses +- [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the lease + revalidation the per-request lease reuses diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index ee68cf6fb7..51aa256231 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -44,7 +44,12 @@ AUTH can carry the full grant once the pattern-interest prerequisite lands. AUTH-capable session, so no token is ever granted twice. On moq-transport the first token also rides the AUTHORIZATION TOKEN setup option (`ietf::token::into_setup`, `USE_VALUE`, token type 0), which - scopes at accept the way the URL does. + scopes at accept the way the URL does. While the URL also carries it, the + auth server sees the same credential twice. Decided by the maintainer (on + #4211): `moq auth serve` admits a SETUP token equal to the `?jwt=` value + and refuses only two different credentials. #4278 shipped refusing both + (`serve::Refusal::TwoTokens`, pinned by a test), so this quest changes that + and its test. Never send different values in the two places. - The relay admits on the URL, then widens. An anonymous connection today is admitted with the public grant when one is configured and refused otherwise; with this quest a connection with no URL credential and no @@ -62,7 +67,9 @@ AUTH can carry the full grant once the pattern-interest prerequisite lands. - Tests: a client with a configured token against an AUTH-capable relay is scoped exactly as the URL variant and the URL carries the token only while an old version is offered; the same client against a lite-05 relay still - authenticates through the URL; two configured tokens union; a client with + authenticates through the URL; a moq-transport client offering a draft + without AUTH, carrying the token in both the URL and the setup option, is + admitted by `moq auth serve`; two configured tokens union; a client with in-band tokens only and no public grant is admitted, and one that presents nothing is refused at the deadline; the cross-language harness runs with tokens configured. diff --git a/quest/m1/capture-control.md b/quest/m1/capture-control.md new file mode 100644 index 0000000000..0072552113 --- /dev/null +++ b/quest/m1/capture-control.md @@ -0,0 +1,43 @@ +# [S] Capture Control: settled name, loud cut, prompt cancel + +## Goal + +The capture handles from [#4184](https://github.com/moq-dev/moq/pull/4184), +on `dev` only, get their final shape before release: + +- `encode::CaptureOptions` is `encode::Capture` in both moq-audio and + moq-video. +- `Control::cut()` on video tells the caller when the backend cannot force a + keyframe. Today the driver logs one warning on `CutUnsupported` and keeps + the GOP cadence, so a recording or resume boundary silently never appears. +- Dropping the last `Control` ends the driver promptly, including while the + startup probe, `capture::open`, `Sink::open`, or an encode is in flight. + Today those awaits never see the handle close, so a camera or permission + prompt can outlive its owner. + +## Plan + +Decided: + +- Rename to `encode::Capture`, which reads as the capture half next to + `encode::Options`. Update moq-cli and the docs; moq-ffi and libmoq do not + call the capture paths, so no binding mirror exists today. +- `cut()` fails loud with an error rather than logging. The encoder is opened + lazily, so the handle may not know yet; the probe already opens one, which + is one place to learn it early. Choose between `cut()` returning + `Result` (refusing once the backend is known) and the driver ending with + `CutUnsupported`, and record the choice here. +- Race every await in the driver against the controls closing, rather than + only the idle wait, so the probe and the demand-driven opens both cancel. + +This is a `dev` break layered on #4184; land it on `dev` before the release +that first publishes these handles. + +Tests: a backend without forced keyframes surfaces `CutUnsupported` to the +caller; dropping the last `Control` during a slow fake open or probe returns +from `Driver::run` without finishing the open. + +## Related + +- [Video keyframe flag](/quest/m1/video-keyframe-flag.md) - the same cut throttle, counting cadence keyframes +- [Capture clock source](/quest/m2/capture-clock-source.md) - drops the `clock` field from these options diff --git a/quest/m1/capture-reanchor.md b/quest/m1/capture-reanchor.md new file mode 100644 index 0000000000..df3ebb1fb5 --- /dev/null +++ b/quest/m1/capture-reanchor.md @@ -0,0 +1,21 @@ +# [XS] Native capture re-anchors above its last timestamp + +## Goal + +A device clock that repeats or restarts never rewinds native video capture, +even while the pump drains a backlog faster than real time. `FrameChannel::push_native` +in `rs/moq-video/src/capture/channel.rs` re-anchors such a frame to its +arrival time ([#4125](https://github.com/moq-dev/moq/pull/4125)). During a +fast drain the previous frame's mapped timestamp can sit ahead of arrival, so +the re-anchor lands below it: source 0 and 40 ms arriving 1 ms apart publish +near 0 and 40 ms, and an immediate reset to zero publishes near 2 ms. Past a +closed group that is `TimestampRewind`, which stops capture. + +## Plan + +Remember the last mapped timestamp and floor the re-anchor strictly above it, +including the fallback when the checked arithmetic fails. The mapping keeps +advancing with the device clock from there. + +Unit regression in `channel.rs`'s mapping tests: two frames drained 1 ms apart +with a 40 ms source step, then a source reset, maps above the second frame. diff --git a/quest/m1/catalog-wall-clock.md b/quest/m1/catalog-wall-clock.md new file mode 100644 index 0000000000..1b25deeeef --- /dev/null +++ b/quest/m1/catalog-wall-clock.md @@ -0,0 +1,28 @@ +# [XS] Catalog wall clock keeps sub-millisecond precision + +## Goal + +`hang::catalog::Clock::wall_clock` returns the wall time at the precision the +catalog carries, not truncated to milliseconds. The wire field is `{ wall, +timescale }` with a `u32` timescale defaulting to microseconds, and `pts` +arrives at its own timescale, but `wall_clock` divides down to Unix +milliseconds before building the `SystemTime`, which holds nanoseconds. The +capture clock fixtures from [#4125](https://github.com/moq-dev/moq/pull/4125) +assert at millisecond precision because of it. + +## Plan + +- Rust: compute the offset from the moq epoch in nanoseconds (or as a + `Duration` from whole seconds plus the remainder at the clock's timescale) + and add it to `UNIX_EPOCH + MOQ_EPOCH_UNIX_MILLIS`. Keep the existing + overflow and JSON-safe range checks. Tighten the fixtures that currently + assert milliseconds. +- JS: `wallClockTime` in `js/hang/src/catalog/clock.ts` returns a `Date`, + which only holds milliseconds. Leave its return type alone unless a caller + needs more; note the platform limit in its doc so the two sides are not + mistaken for a mismatch. +- Callers that format the value (HLS `EXT-X-PROGRAM-DATE-TIME`, DASH + `availabilityStartTime`) choose their own output precision; check none + relied on the truncation. + +Public API and wire: none. diff --git a/quest/m1/cli-given-flags.md b/quest/m1/cli-given-flags.md new file mode 100644 index 0000000000..2b270033e9 --- /dev/null +++ b/quest/m1/cli-given-flags.md @@ -0,0 +1,24 @@ +# [XS] Dial-only and local verbs refuse every accept-side flag + +## Goal + +`moq fetch`, `moq ls` (on the [CLI inspect](/quest/m1/cli-inspect/README.md) +line), and the local verbs behind `Invocation::reject` refuse any listener +flag they would never use, as they already do for `--listen` and the cluster +flags. Today `MoqSide::given()` in `rs/moq-cli/src/args.rs` lists only some +of them, so `--listen-version`, `--listen-tls-*`, `--listen-preferred-*`, and +`--listen-quic-lb-*` are silently ignored. Codex found it on +[#4121](https://github.com/moq-dev/moq/pull/4121). + +## Plan + +- Cover every accept-side flag. Prefer a list that cannot fall behind the + server config, such as deriving it from the parsed partial or a test that + walks every `--listen-*` flag clap knows and asserts `given()` reports it, + over extending the hand-written array again. +- While there, check whether the local verbs also ignore `--connect-*` + flags; `reject` is meant to refuse the whole MoQ side. +- Test that each verb refuses a representative flag from every family, + naming it. + +Public API: none (CLI refuses input it used to ignore). Wire: none. diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index a48bae4194..75b88fb0ec 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -47,10 +47,28 @@ remote we own, the latter two fetching the prebuilt tarball so consumers never need a Rust toolchain or the bindgen fork. vcpkg lands first; the Conan recipe reads the same release manifest so a release bumps both. +Confirmed in [#4100](https://github.com/moq-dev/moq/pull/4100): + +- The fork's base is LiveKit's PR #1 (`uniffi-0.31-async`), not PR #5, + which runs a worker thread per in-flight future and has no `then()` or + async callback interfaces, both of which OBS needs. +- Fork tags keep upstream's `v+v` scheme with a + `-kixelated.N` pre-release (`v0.11.0-kixelated.1+v0.32.2`), matching the + Dart fork, so they never collide with an upstream tag. +- Cancelling abandons the future rather than delivering an error: a generic + `E` has no cancelled variant, and a `std::variant` would + burden every call site. +- Callback interfaces are refused under `error_style = "expected"` until a + consumer needs one; their bridge is built on `std::exception_ptr`. +- MSVC is covered by the post-merge nightly, not a branch dispatch: branches + never dispatch the nightly. + ## Quests - [Generator](/quest/m1/cpp/generator.md) - the uniffi 0.32 C++ generator with futures and expected-style errors, pinned and generating `cpp/ffi` in CI - [Package](/quest/m1/cpp/package.md) - the `cpp/moq` wrapper, CMake package, release tarball, interop client, and docs +- [Cancel](/quest/m1/cpp/cancel.md) - a cancelled or consumed future reports `valid() == false`, like `std::future`, and a read of it aborts with a message naming the misuse +- [C++ standard](/quest/m1/cpp/cxx-standard.md) - a consumer that sets C++23 only on its own target links the package - [OBS migration](/quest/m1/cpp/obs.md) - the OBS plugin moves from libmoq handles and trampolines to the generated C++ ## Related diff --git a/quest/m1/cpp/cancel.md b/quest/m1/cpp/cancel.md new file mode 100644 index 0000000000..a3974224bb --- /dev/null +++ b/quest/m1/cpp/cancel.md @@ -0,0 +1,34 @@ +# [XS] A cancelled future says so before it is read + +## Goal + +A caller can tell a cancelled or consumed future is dead before reading it, +and a read of one fails with a message naming the misuse. Today the generated +`uniffi::Future` declares `void cancel() noexcept` on an lvalue, so +`future.cancel(); future.get();` compiles and aborts with nothing to check +first; `cpp/moq/README.md` documents the abort instead of giving callers a +guard. + +## Plan + +- Mirror `std::future`, the idiom C++ callers know: once cancelled, consumed + by `then()`, or moved from, a future is invalid and `valid()` returns false. + Calling `get()` on an invalid future is a precondition violation, as it is + for `std::future`, and it aborts with a message naming `cancel`/`then`/move + rather than a bare abort. Decided with the maintainer during the #4307 + review. +- Rejected: `&&`-qualifying `cancel()`, since `std::move(f).cancel(); f.get();` + still compiles; and `get()` returning an error, since expected mode has no + error value for it: generic `E` has no invalid-state variant, and the line + README already rejects `std::variant`. +- Decided in [#4100](https://github.com/moq-dev/moq/pull/4100): cancel + abandons the future, so no continuation runs. That still holds. +- The `Future` template lives in the kixelated `uniffi-bindgen-cpp` fork, so + this is a fork change and a new `-kixelated.N` tag, then the pin bump in + `flake.nix` and every place its comment lists. +- Update the README to point callers at `valid()`, and check it in the + in-tree callers that can hold a cancelled future (`cpp/moq`, the probe, + `cpp/obs`). Test `valid()` after `cancel()`, after `then()`, and after a + move. + +Public API: additive `valid()` on the unreleased C++ package. Wire: none. diff --git a/quest/m1/cpp/cxx-standard.md b/quest/m1/cpp/cxx-standard.md new file mode 100644 index 0000000000..e051fadd9f --- /dev/null +++ b/quest/m1/cpp/cxx-standard.md @@ -0,0 +1,29 @@ +# [XS] A C++23 consumer links the package + +## Goal + +A consumer that raises the standard only on its own target +(`target_compile_features(app PRIVATE cxx_std_23)`) either links against the +`moq-cpp` package or is told clearly how to configure it. Today it gets link +errors: that requirement does not flow backward into the separate `moq-cpp` +static library, which compiles `moq.cpp` at `cxx_std_17` and so picks +`tl::expected` while the app's `` picks `std::expected`. The ABI +guard turning that into a link error is correct; the surprise is not. Codex +found it on [#4187](https://github.com/moq-dev/moq/pull/4187). + +## Plan + +- Fix the misleading comment in `cpp/moq/cmake/moq-cpp-config.cmake.in` + (and the matching one in `cpp/moq/CMakeLists.txt`): the bindings compile + with the project's standard (`CMAKE_CXX_STANDARD`) or C++17, not with + whatever the including target asks for. +- Then choose the smallest fix that makes the C++23 path work: document + `CMAKE_CXX_STANDARD` (what the probe does) in `cpp/moq/README.md` and the + C++ docs page, or add a package option that sets the standard `moq-cpp` + compiles with. Prefer documentation unless a consumer (OBS, Unreal) needs + the option. +- Test the documented configuration: the package test already builds at + several standards; add one that builds the app at C++23 the documented + way. + +Public API: none, or one CMake option. Wire: none. diff --git a/quest/m1/doc-samples-go-dart.md b/quest/m1/doc-samples-go-dart.md new file mode 100644 index 0000000000..af68aa5f44 --- /dev/null +++ b/quest/m1/doc-samples-go-dart.md @@ -0,0 +1,30 @@ +# [S] Go and Dart doc samples compile against their wrappers + +## Goal + +The Go and Dart pages in `doc/lib/go/` and `doc/lib/dart/` are checked the +way [#4049](https://github.com/moq-dev/moq/pull/4049) checks Python, Kotlin, +Swift, and C: every fenced sample is extracted by `doc/lib/samples.sh` and +type-checked against the wrapper it documents (`go vet` for `go/wrapper`, +`dart analyze` for `dart/moq`), so a renamed wrapper method breaks the +language's check instead of silently breaking the docs. Today `samples.sh` +knows neither language. + +## Plan + +- Add `go` and `dart` to `samples.sh` with the same shape: one function per + sample, imports hoisted, inputs a sample leaves undefined (`client`, + `opusInit`, `pts`) supplied by a prelude the caller compiles alongside. +- Go is stricter than the others: a file needs a `package` clause, imports + come as single lines or a parenthesized block, and unused locals and + imports are compile errors. Handle what the samples actually use; prefer + adjusting a sample so it reads naturally and still compiles over growing + the extractor. +- Wire each into its language's `check` recipe (`go/justfile`, + `dart/justfile`) the way `py/justfile` and `rs/justfile` call it, and add + `doc/lib/samples.sh` and the doc page to the paths that trigger those + checks in CI. +- Prove it by renaming one wrapper method locally and watching each check + fail on the doc sample. + +Public API: none. Wire: none. diff --git a/quest/m1/drain/README.md b/quest/m1/drain/README.md index bd0a66979d..d849afd904 100644 --- a/quest/m1/drain/README.md +++ b/quest/m1/drain/README.md @@ -54,6 +54,7 @@ by the stop deadline and encoder reconnect. - [Client goaway](/quest/m1/drain/client-goaway.md) - the JavaScript client migrates on GOAWAY with a handover and the guarded redirect the Rust client already has, and the Rust drain path gets its regression test +- [JS GOAWAY requests](/quest/m1/drain/js-goaway-requests.md) - after GOAWAY the JS client opens no new request on the old session, like Rust ## Related diff --git a/quest/m1/drain/js-goaway-requests.md b/quest/m1/drain/js-goaway-requests.md new file mode 100644 index 0000000000..bf8de798b2 --- /dev/null +++ b/quest/m1/drain/js-goaway-requests.md @@ -0,0 +1,35 @@ +# [S] JS refuses new requests after GOAWAY + +## Goal + +Once a `@moq/net` session has received GOAWAY, it opens no new subscribe, +fetch, or announce-interest stream on that session, on lite and IETF, as +moq-net already refuses them with `Error::GoingAway`. Existing subscriptions +keep flowing until the handover ends, and a refused request is served by the +replacement session rather than failing the caller. + +## Plan + +The drain line's JS migration (https://github.com/moq-dev/moq/pull/4143) +keeps the old session serving after GOAWAY but left its subscribers unaware +of it, so during the handover an origin request can still open a stream on a +session the peer asked us to leave. The lite draft says the recipient must +not open new streams after GOAWAY, and a compliant draining relay rejects +them. Rust checks before every new open in both wires (`check_going_away` in +the lite subscriber, the `going_away` checks in the IETF one). + +Guidance: + +- The drain signal already reaches the connection wire view + (`wireOf(session).goaway`). Hand it to both subscribers and check it at + every open site, including the draft-14 to -16 adapter route. +- The refusal matters as much as the check: the origin should see it as this + route declining, so the request moves to the replacement's route (the + origin already keeps the outranked route until the new one answers). Match + what the Rust origin does with `GoingAway` rather than inventing a policy. +- Test on lite and IETF with the existing mock transports: after GOAWAY a new + request opens no stream on the old session and resolves on the new one, + while an existing subscription keeps receiving groups. + +No public API change is expected; the error is internal unless a caller can +observe it today. diff --git a/quest/m1/drill-sensitivity.md b/quest/m1/drill-sensitivity.md new file mode 100644 index 0000000000..5f2232c610 --- /dev/null +++ b/quest/m1/drill-sensitivity.md @@ -0,0 +1,22 @@ +# [XS] The subscriber-leaks-broadcasts mutation applies again + +## Goal + +The nightly `Tests (test drill-sensitivity)` job passes. It fails on +[run 36240326747](https://github.com/moq-dev/moq/actions/runs/36240326747) +because `test/drill/mutations/subscriber-leaks-broadcasts.patch` no longer +applies ("1 out of 2 hunks FAILED" on `rs/moq-net/src/lite/subscriber.rs`) +after a later change to the lite subscriber, so the +`cancel_under_backpressure_releases_the_reader` drill proves nothing. + +## Plan + +- Retarget the patch the way + [#3953](https://github.com/moq-dev/moq/pull/3953) did: find where the lite + subscriber now releases the broadcasts a session fed when it ends, and + remove that behavior again. Keep the failure message the patch declares, or + update it if the drill now fails with a different but still correct one. +- If the release moved somewhere a patch cannot remove cleanly, the drill may + be pointing at the wrong layer; say so rather than force a patch. +- Prove it with `just test drill-sensitivity subscriber-leaks-broadcasts`, and + run the other two mutations to confirm they still apply. diff --git a/quest/m1/dropped-sources.md b/quest/m1/dropped-sources.md index 9e29b46896..08a0ce43bb 100644 --- a/quest/m1/dropped-sources.md +++ b/quest/m1/dropped-sources.md @@ -17,6 +17,11 @@ end carries no cause since #4031, so only track errors are in scope. over a mock session, and map JS `PUBLISH_DONE` Unauthorized to #4179's shared error. Preserve causes at the source rather than remapping `Dropped` at consumers. +- The same goes for revocation. Leftovers from #4179's review: a bridged + revocation reaches Rust IETF subscribers as `PUBLISH_DONE` InternalError, a + JS relay reports a route revocation as INTERNAL_ERROR, and the bindings + neither document nor test `is_auth` for a stream-scoped Unauthorized. Each + should surface Unauthorized. Public API: none expected; error values consumers observe change. Wire: none. diff --git a/quest/m1/gateway-live-clock.md b/quest/m1/gateway-live-clock.md new file mode 100644 index 0000000000..ee39a7b1c4 --- /dev/null +++ b/quest/m1/gateway-live-clock.md @@ -0,0 +1,32 @@ +# [S] Gateways publish on the broadcast clock + +## Goal + +`moq-srt`, `moq-rtmp`, and HLS import map source timestamps onto the +catalog's broadcast clock, as `moq import` does since +[#4122](https://github.com/moq-dev/moq/pull/4122). Today only the CLI calls +`.live()`; the gateways publish the encoder's timestamps verbatim, so the +advertised wall time is wrong for a source whose PTS does not start near zero, +and an encoder reconnect that restarts its timestamps rewinds or is refused. + +## Plan + +Decided: each gateway opts into `live()`. The importer default stays +verbatim, since tests and callers that pin `Config::with_clock` rely on it. + +Guidance: + +- SRT: `rs/moq-srt/src/ts.rs` builds the `ts::Import`. RTMP: the server's + publish path and the pull path in `dial.rs` build `FlvImport`. +- HLS import (`rs/moq-hls/src/import.rs`) runs one fMP4 importer per + rendition, and `live()` gives each its own anchor, so renditions would map + their first frames to different instants. They share one source clock and + need one mapping: share the `Anchor` across the broadcast's importers, or + pick another way to anchor the playlist once. Audio and video in separate + HLS renditions are the case to test. +- A reconnect that reuses the broadcast is where this pays off; check each + gateway's reconnect keeps the same importer (and anchor) or deliberately + starts a new one. +- Tests per gateway: a source starting at a large PTS publishes near the + broadcast clock's now, and a restart to zero continues forward. +- Docs: the gateway pages under `doc/bin/` that describe timestamps. diff --git a/quest/m1/hls-discontinuity-sequence.md b/quest/m1/hls-discontinuity-sequence.md new file mode 100644 index 0000000000..a191a9e2aa --- /dev/null +++ b/quest/m1/hls-discontinuity-sequence.md @@ -0,0 +1,37 @@ +# [S] moq-hls segments carry the absolute discontinuity sequence + +## Goal + +Every `moq_hls::export::Segment` reports the discontinuity sequence it belongs +to, the value a recorder writes as `EXT-X-DISCONTINUITY-SEQUENCE`, so two +cursors on sibling renditions agree no matter when each was created. On `dev`, +`Segment::discontinuity` is a `u64` count of breaks since the cursor's +previous segment ([#4068](https://github.com/moq-dev/moq/pull/4068)). A cursor +created after its rendition rebinds starts its count from its own first row, +so it disagrees with a sibling on the baseline. + +## Plan + +Decided: + +- Report the absolute sequence the timeline fanout already stamps on each + row, instead of the difference between rows. A recorder writes + `EXT-X-DISCONTINUITY-SEQUENCE` from the first segment and an + `EXT-X-DISCONTINUITY` wherever the value changes. +- Land it on `dev` before the next moq-hls release, so it ships in the same + breaking release as the existing `bool` to `u64` change rather than + breaking the field twice. + +Guidance: + +- `Position` in `export/segments.rs` then only tracks `after`; the + skip/emit baseline logic goes away. Check the serve path's playlist + rendering (`rendition.rs`, `playlist.rs`) already derives its tags from the + same stamp. +- The sequence is absolute within one `Broadcaster`. Document what a recorder + should do when its broadcaster is rebuilt and the sequence restarts. +- Update the field doc and the tests that assert per-cursor counts; add one + where a cursor created after a rebind reports the same sequence as a cursor + that has run since the start. +- moq.pro's recorder and index store the count today; note the change for its + pin bump. diff --git a/quest/m1/hls-linger.md b/quest/m1/hls-linger.md new file mode 100644 index 0000000000..9da2c54d1c --- /dev/null +++ b/quest/m1/hls-linger.md @@ -0,0 +1,41 @@ +# [S] moq-hls keeps an ended broadcast for its playlist window + +## Goal + +A player polling `moq_hls::Server` can finish the last segments of a broadcast +that just ended, and a republish of the same name takes over cleanly. Today +`Server` evicts a broadcaster as soon as its broadcast closes, so the playlist +404s mid-window. The moq.pro edge works around it with its own pool on top of +`Broadcaster` that holds an ended broadcast for the playlist window plus a +grace period and checks for a republish +([#3964](https://github.com/moq-dev/moq/pull/3964)). Once moq-hls owns this, +the edge deletes its pool. + +## Plan + +Decided: moq-hls owns the retention through an `export::Config` field (the +playlist window plus a grace period), so every embedder gets it and the edge +has nothing to duplicate. `Config` is `#[non_exhaustive]`, so the field is +additive on `main`. + +Guidance: + +- The eviction lives in `server/mod.rs` (`evict_closed` and the + `is_closed` checks in `Server::broadcaster`). An ended broadcaster keeps + serving its final playlist, marked ended if the renditions know it, until + the linger expires. +- A republish during the linger replaces the ended broadcaster. Decide + whether a request for the name resolves the new broadcast first and falls + back to the ended one only when nothing is announced; match what the edge + pool does today. +- Segments are fetched from the relay cache on request, so the linger is only + useful within the cache's retention; say so on the field, as `window` does. +- A zero linger keeps today's behavior. Pick the default with the edge's + current value in mind. +- Tests: a request inside the linger after close still gets the playlist and + a cached segment; after it expires the name 404s; a republish inside the + linger is served. + +## Related + +- [Export linger](/quest/m1/export-linger.md) - the CLI exporter's wait for a broadcast to return; same idea, separate code diff --git a/quest/m1/ietf-max-age.md b/quest/m1/ietf-max-age.md index cd7f99a48e..04c6f11ce2 100644 --- a/quest/m1/ietf-max-age.md +++ b/quest/m1/ietf-max-age.md @@ -30,8 +30,12 @@ survives an IETF hop the way it already survives moq-lite 05+. 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 + optional: the value plus one, with 0 meaning none. Lite05/06 have no + absent value, so `None` is a sentinel both languages can represent: send + 2^53-1 (`Number.MAX_SAFE_INTEGER`) and read any value at or above it as + `None`. Decided over the largest varint (2^62-1) because JS reads Max Age + as a u53, so that sentinel would not survive a JS hop + ([#4188](https://github.com/moq-dev/moq/pull/4188)). 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. diff --git a/quest/m1/iroh-lite-wip.md b/quest/m1/iroh-lite-wip.md new file mode 100644 index 0000000000..e733761633 --- /dev/null +++ b/quest/m1/iroh-lite-wip.md @@ -0,0 +1,31 @@ +# [XS] iroh honors the configured versions + +## Goal + +An `iroh://` client or listener configured with `moq-lite-07-wip` negotiates +it, as `https://`, `moqt://`, TCP, and Unix sockets already do. A version +list that iroh cannot offer is refused at startup, never silently replaced. + +## Plan + +https://github.com/moq-dev/moq/pull/4148 took `moq-lite-07-wip` out of +`moq_net::ALPNS` so it is opt-in only, but `rs/moq-tokio/src/iroh.rs` builds +its listener ALPNs, its dial offers, and its H3 subprotocols from that +constant rather than the configured `Versions` (`versions.alpns()`, which +`noq.rs`, `server.rs`, and `client.rs` use). So the opt-in is accepted by +config and then ignored on iroh. Thread the configured versions through +instead. + +The relay's WebSocket listener and `moq-ffi`'s transport also read +`moq_net::ALPNS` directly. Check whether they have the same hole; fix them +here if it is the same small change, otherwise report it. + +Decided by the maintainer: the finalized lite-07 ALPN stays `moq-lite-07`, +as `drafts/draft-lcurley-moq-lite.md` already says. Peers from the yanked +0.3.2 / 0.15.3 releases advertise `moq-lite-07` with an older framing, and +could land on it with a finalized peer; the maintainer accepts that risk +rather than burn the identifier. Nothing in this quest changes the ALPN. + +## Related + +- [iroh opt-in for moq-relay](/quest/m1/relay-iroh-opt-in.md) - a separate change: whether the relay builds iroh at all diff --git a/quest/m1/js-bundle-trims.md b/quest/m1/js-bundle-trims.md index e0d85fa0db..a34822567b 100644 --- a/quest/m1/js-bundle-trims.md +++ b/quest/m1/js-bundle-trims.md @@ -34,6 +34,12 @@ Guidance: puts module loading on the fallback path. Measure the connect time it adds, and drop this trim if the fallback gets noticeably slower. - Report the before and after first-load sizes in the PR. +- Add a CI check that imports the built `@moq/watch` dist outside a browser + (the `bun -e 'await import("./dist/index.js")'` that verified + [#4217](https://github.com/moq-dev/moq/pull/4217)). Unit tests run on + `src`, so a bundled browser-only dependency that breaks Node, Bun, or SSR + imports only shows up in the dist, and these trims move exactly those + imports around. Cover `@moq/publish` the same way if it is cheap. ## Related diff --git a/quest/m1/js-catalog-path.md b/quest/m1/js-catalog-path.md new file mode 100644 index 0000000000..230a37e60d --- /dev/null +++ b/quest/m1/js-catalog-path.md @@ -0,0 +1,27 @@ +# [XS] JS catalog watch rejects escaping broadcast references + +## Goal + +`Catalog.watch` in `@moq/hang` rejects a catalog whose rendition or track +`broadcast` reference escapes the broadcast, as Rust `Catalog::subscribe` +does with `EscapingBroadcast` ([#3935](https://github.com/moq-dev/moq/pull/3935)). +JS cannot check today: `watch` takes a `Broadcast.Consumer`, which has no +path to resolve the reference against, so `../../../other` passes in JS and +fails in Rust. + +## Plan + +Decided: make the path public on `@moq/net`'s `Broadcast.Consumer` and have +`Catalog.watch` read it, so the `watch()` call shape does not change. + +Guidance: + +- Mirror Rust's `broadcast::Info::path`: the origin stamps each handle with + the path it was requested or announced at, relative to the cursor's root, + and a standalone broadcast has an empty path, so any `..` escapes. +- Resolve every video, audio, text, JSON, and binary reference with + `Path.tryResolve` against it and reject the update when it returns + `undefined`, as `js/hang/src/catalog/path.ts` already describes. Keep the + relative values in the yielded catalog unchanged. +- Tests: an escaping reference is rejected, a sibling reference under the + same root passes, and a standalone broadcast rejects `..`. diff --git a/quest/m1/js-pattern-depth.md b/quest/m1/js-pattern-depth.md new file mode 100644 index 0000000000..3921c8eec1 --- /dev/null +++ b/quest/m1/js-pattern-depth.md @@ -0,0 +1,21 @@ +# [XS] JS subtree at max depth + +## Goal + +`Pattern.subtree` in `@moq/pattern` accepts a 32-segment path (the wire path +limit) and returns the literal path, as Rust's `Pattern::subtree` does since +https://github.com/moq-dev/moq/pull/4284. A 33-segment path still throws +`TooManySegments`. + +## Plan + +`js/pattern/src/index.ts` always appends `**`, so a max-depth path becomes 33 +segments and throws, which empties an announce subscription under that prefix +just as it did in Rust. Nothing can sit beneath a max-depth path, so the path +itself is the whole subtree. + +The Rust regression is a unit test in `rs/moq-pattern/src/pattern.rs`, which +JS never runs. Move the max-depth case (and the 33-segment refusal, if the +vector shape grows an error field the way `literal` has one) into the shared +`rs/moq-pattern/tests/pattern.json`, so both languages run the same vector +and the next divergence fails in whichever side lags. diff --git a/quest/m1/js-scoped-routes.md b/quest/m1/js-scoped-routes.md new file mode 100644 index 0000000000..27f91eb06c --- /dev/null +++ b/quest/m1/js-scoped-routes.md @@ -0,0 +1,37 @@ +# [S] JS scoped readers pick from their own routes + +## Goal + +A scoped `@moq/net` origin reader, and a connection publishing that scoped +view, announces a prefix whenever some route there overlaps its scope, and +presents the preferred route among those, as moq-net does. For example, with +a cheap `*/chat` route and a costlier `*/video` route both at `""`, a +`room/video` reader sees the video route instead of nothing. + +The cost of `forwardAnnounced` fanning one advertisement out across interest +streams is measured, and fixed only if the sweep shows it matters. + +## Plan + +Correctness first. `OriginState.snapshot()` in `js/net/src/origin.ts` keeps +one `preferredEntry` per path before `#listed` applies the reader's scope, +and `rebuildOriginated()` makes the same early choice, so a reader whose +scope excludes the globally preferred entry drops the path even though +`bestEntry` would route a request through another one. moq-net filters each +entry by the cursor's scope (`RouteEntry::overlaps` in +`rs/moq-net/src/model/origin.rs`) before choosing. Keep the candidates until +the scope applies. The shared unscoped snapshot is the fast path the +`js/net/bench/broadcasts.ts` sweep guards; keep it, and run that sweep before +and after. Raised by Codex on https://github.com/moq-dev/moq/pull/4234. + +Then the fan-out. `forwardAnnounced` in `js/net/src/connection/forward.ts` +opens one announce stream per interest head and keeps a `Dynamic` plus a +`drive` task per stream, so a peer advertising a prefix above several heads +(`""` for `a/**` and `b/**`) is stored and repriced once per head, and closing +one stream can drop the entry a request was using while an identical one +remains. Extend the nightly JS benchmark with a sweep over heads and routes +(the repo's fan-out rule), then decide: if cost grows with heads times routes +at realistic sizes, share or reference-count received prefixes across +streams; if not, record the numbers in the PR and leave it. + +No public API or wire change is expected. diff --git a/quest/m1/kio-waiter-lost.md b/quest/m1/kio-waiter-lost.md new file mode 100644 index 0000000000..ae4e29321b --- /dev/null +++ b/quest/m1/kio-waiter-lost.md @@ -0,0 +1,26 @@ +# [XS] kio waiter overflow stays idempotent + +## Goal + +A `kio::Waiter` that has registered on more than 8 lists still registers for +free on each list it recorded, so a retained standalone `Waiter` polled +repeatedly no longer appends a duplicate entry to those lists every time. + +## Plan + +`Waiter::record` in `rs/kio/src/waiter.rs` returns early once `lost` is set, +before it matches the list's tag against the recorded slots. `Park` retires +such a waiter, so it never notices, but `Waiter` is public and a caller that +keeps one across polls grows every list it sits on, one live entry per +register until the list drains, and gets a duplicate wake for each. + +Match the recorded tags first and only then fall back on `lost`. This was +written with the regression test `overflow_keeps_recorded_lists_idempotent` +during https://github.com/moq-dev/moq/pull/4240, which squash-merged before +it was pushed, so it needs rewriting. Keep the recorded-tag probe the tight +common-case loop it is today; `rs/kio/benches/waiter.rs` shows whether it +moved. + +A list past the 8 slots is unrecorded and still has to append on every +register, which is the pre-#4240 behavior. Say so on `Waiter::register` so a +standalone caller knows the bound, rather than growing the record array. diff --git a/quest/m1/merge-queue.md b/quest/m1/merge-queue.md new file mode 100644 index 0000000000..002b723959 --- /dev/null +++ b/quest/m1/merge-queue.md @@ -0,0 +1,31 @@ +# [S] Merges go through a merge queue + +## Goal + +A pull request cannot break `main` by merging on checks that ran against an +older `main`. [#4137](https://github.com/moq-dev/moq/pull/4137) did exactly +that: it merged cleanly, but combined with +[#4089](https://github.com/moq-dev/moq/pull/4089) it stopped `moq-ffi` from +compiling on `main` until [#4157](https://github.com/moq-dev/moq/pull/4157) +fixed it. The `main` ruleset requires `Check` and `Test` but not strictly, +and there is no merge queue, so nothing re-runs them on the combined tree. + +## Plan + +- Decided: a GitHub merge queue, not a strict ruleset. Strict status checks + would force every open PR to update and re-run whenever `main` moves; a + queue tests the combination once, at merge time. +- Make the workflows ready: every workflow providing a required check + (today `Check` and `Test` in `.github/workflows/check.yml`) also runs on + `merge_group`. `just ci` scopes by diffing against `GITHUB_BASE_REF`, + which a merge group does not set, so pass the group's base + (`github.event.merge_group.base_sha`) explicitly. Check the concurrency + group and the `closed`-only skip still behave for queue refs. +- Document it in `CONTRIBUTING.md`: PRs merge through the queue, a + dequeued PR means the combination failed, and how agents enqueue (the + merge skills use `gh pr merge`, which enqueues when a queue is on). +- The ruleset change (enable the queue, choose squash) is the maintainer's + act, after the workflow change lands on `main`. Hand it over with the + settings to use rather than changing it. + +Public API: none. Wire: none. diff --git a/quest/m1/moqsink-keyframe-latch.md b/quest/m1/moqsink-keyframe-latch.md new file mode 100644 index 0000000000..d86c1e638a --- /dev/null +++ b/quest/m1/moqsink-keyframe-latch.md @@ -0,0 +1,23 @@ +# [XS] moqsink waits for a published keyframe after a break + +## Goal + +A video pad in `moqsink` survives a pause or segment break whose first buffer +carries only codec headers. Today `Media::write` in +`rs/moq-gst/src/sink/pad.rs` clears its `keyframe` latch after any successful +decode. A header-only buffer decodes without emitting a frame, clears the +latch, and the next delta frame hits `MissingKeyframe` outside the guarded +arm, which permanently invalidates the pad +([#4239](https://github.com/moq-dev/moq/pull/4239)). + +## Plan + +Keep the latch set until the importer actually publishes a keyframe, not +until the first buffer parses. That needs `import::Track::decode` to say +whether it emitted a frame, or a comparable signal; prefer reusing what the +importer already knows over inferring it in the pad. + +Regression test beside `video_pause_drops_deltas_until_the_next_keyframe`: a +break followed by a header-only buffer (SPS/PPS alone for H.264), then a +delta, then a keyframe. The delta drops and the keyframe publishes; the pad +stays valid. diff --git a/quest/m1/moqsrc-stop.md b/quest/m1/moqsrc-stop.md new file mode 100644 index 0000000000..d3de732b61 --- /dev/null +++ b/quest/m1/moqsrc-stop.md @@ -0,0 +1,27 @@ +# [S] moqsrc waits for its session to end on stop + +## Goal + +`moqsrc`'s PAUSED to READY transition returns only after its session task and +connection have ended, as `moqsink` does since +[#4074](https://github.com/moq-dev/moq/pull/4074). Today +`SessionController::stop` in `rs/moq-gst/src/source/imp.rs` signals shutdown +and spawns a task to await the join, so an application that sets NULL and +exits right away can race aws-lc's exit destructors against an in-flight +dial, the silent SIGABRT the sink fix closed. + +## Plan + +Blocking needs care: the session task pushes buffers into src pads +(`block_in_place(|| pad.push(buffer))`), and a push blocked downstream while +`stop` waits on the task is a deadlock. Unblock the pads first (set them +flushing, or otherwise make a pending push return) before waiting, the way +GStreamer sources normally stop their streaming threads. + +Reuse the sink's wait: it parks the thread on a waker instead of entering an +executor, and uses `block_in_place` when called from a multi-thread runtime +worker. Consider sharing it between the two elements rather than copying. + +Tests mirroring the sink's: stop returns after the connection closes, stop +from a runtime worker does not deadlock, stop inside another executor does +not panic, and stop while a push is blocked downstream returns. diff --git a/quest/m1/obs-moq-video/README.md b/quest/m1/obs-moq-video/README.md index 17660ac693..3362f55a46 100644 --- a/quest/m1/obs-moq-video/README.md +++ b/quest/m1/obs-moq-video/README.md @@ -12,7 +12,7 @@ Attempt GPU delivery immediately, starting on macOS. Windows and Linux can ship Initial video decoding covers H.264, HEVC, and AV1 where moq-video has an available backend. Unsupported codecs produce an actionable error; do not retain an FFmpeg fallback. VP8/VP9 return through their own follow-up quest. Audio playback covers Opus, AAC-LC, and PCM. -Publishing remains opt-in, with one **Use MoQ encoders** choice for video and audio. Keep the existing OBS encoder mode. Internal OBS encoder adapters call moq-video/moq-audio, preserving OBS's A/V handling and the existing encoded MoQ output. The combined choice is enabled only when both adapters are present. Start with H.264, supported HEVC, and Opus; defer AV1/AAC encoding and PCM publishing UI. Keep bitrate separate from **Low latency** (default), **Balanced**, and **Quality** presets. Presets describe supported buffering/compression controls, not an end-to-end delay promise. +Publishing remains opt-in, with one **Use MoQ encoders** choice for video and audio. Keep the existing OBS encoder mode. Internal OBS encoder adapters call moq-video/moq-audio, preserving OBS's A/V handling and the existing encoded MoQ output. The combined choice is enabled only when both adapters are present. Start with H.264, supported HEVC, and Opus; defer AV1/AAC encoding and PCM publishing UI. Keep bitrate separate from **Low latency**, **Balanced** (default), and **Quality** presets. Presets describe supported buffering/compression controls, not an end-to-end delay promise. The quests separate portable decoding, platform GPU delivery, audio, and publishing so each can land and be validated independently. The existing CPU decode path is a fallback primitive, not a GPU implementation: it explicitly converts every surface to I420. Native frame ownership must cross the FFI boundary without that conversion. @@ -24,6 +24,7 @@ The quests separate portable decoding, platform GPU delivery, audio, and publish - [Linux decoded frames](/quest/m1/obs-moq-video/decode-linux.md) - present supported native decoded surfaces with visible CPU fallback - [Linux bundle](/quest/m1/obs-moq-video/linux-bundle.md) - attach a portable Linux x86_64 tarball to every obs-moq release once FFmpeg is gone - [Encoder presets](/quest/m1/obs-moq-video/presets.md) - define and measure shared low-latency, balanced, and quality policies +- [Preset parity](/quest/m1/obs-moq-video/preset-parity.md) - audio stores and reports its preset like video, defaults to Balanced, and the preset claims hold - [Audio publishing](/quest/m1/obs-moq-video/audio-publish.md) - back an internal OBS Opus encoder with moq-audio - [Video publishing](/quest/m1/obs-moq-video/adapter.md) - back an internal OBS video encoder with moq-video and expose the combined opt-in mode - [Rate control](/quest/m1/obs-moq-video/rate-control.md) - the plugin reserves its bitrate and retunes the OBS encoder to the grant diff --git a/quest/m1/obs-moq-video/adapter.md b/quest/m1/obs-moq-video/adapter.md index 752ca5a002..2338fef15b 100644 --- a/quest/m1/obs-moq-video/adapter.md +++ b/quest/m1/obs-moq-video/adapter.md @@ -9,7 +9,7 @@ One opt-in Use MoQ encoders choice publishes OBS video and audio through moq-vid - Register an internal OBS video encoder, backed by `moq_video::encode::Sink`. Retain `MoQOutput::EncodedPacket` and existing catalog handling. Do not replace the output with raw publication: OBS's encoded-output flag is output-wide, and bypassing it would duplicate A/V integration. - Add a clean codec-only moq-ffi encoder type with owned handles and packet draining, extending shared primitives from the audio adapter where appropriate. Avoid backend internals and caller cleanup callbacks. The existing raw-video publishing API couples encoding to publication and is not the packet adapter. - Start with H.264 by default and HEVC where supported. Keep reordering disabled; resolve OBS keyframe flags, Annex-B headers/decoder configuration, DTS/PTS and drain semantics explicitly, since Rust encoded output currently contains only timestamp and payload. Do not advertise unsupported AV1 encoding. -- Use the shared Low latency, Balanced, and Quality presets with bitrate separate. Expose a single Use MoQ encoders option only once audio and video adapters both work. Retain the existing OBS encoder selection as an explicit alternative; do not silently switch back to OBS codecs after a MoQ codec failure. +- Use the shared Low latency, Balanced, and Quality presets with bitrate separate, defaulting to Balanced. Expose a single Use MoQ encoders option only once audio and video adapters both work. Retain the existing OBS encoder selection as an explicit alternative; do not silently switch back to OBS codecs after a MoQ codec failure. - Establish bounded submission/packet queues, explicit raw-frame drop behavior, thread confinement, cancellation, late completion, device loss, resize and color metadata. Never block OBS's graphics thread on network backpressure. The CPU path is a correctness/fallback baseline; platform quests establish accelerated input. - Test rejection, saturation, drain, stop during encode, delayed completion, and repeated start/stop. Validate real decoded pixels and audio continuity, matched timestamps, preset reporting, and frame-to-packet latency. Validate the new binding docs, feature combinations, package dependencies and native plugin linking. diff --git a/quest/m1/obs-moq-video/macos.md b/quest/m1/obs-moq-video/macos.md index f56120b6c2..aa8265c9be 100644 --- a/quest/m1/obs-moq-video/macos.md +++ b/quest/m1/obs-moq-video/macos.md @@ -9,6 +9,11 @@ An OBS compositor frame reaches moq-video's VideoToolbox encoder without a GPU-t - Inspect OBS's OpenGL compositor, `encode_texture2`, and mac-videotoolbox input path. Determine whether the output allocation is IOSurface-backed and exportable. OBS's encoder currently copies CPU planes into its own pixel buffer; a CVPixelBuffer in moq-video alone does not remove that upstream readback. - Prefer retained IOSurface/CVPixelBuffer storage in the format VideoToolbox accepts. Otherwise prototype GPU color conversion/blit into an IOSurface-backed NV12 pool. Specify GL/Metal/CoreVideo interop, graphics-context thread affinity, completion fences, and when OBS may recycle the source. - Reuse the native PixelBuffer surface and the moq-video VideoToolbox backend. Retain the destination until encoding completes, including dropped submissions and cancellation. Bound the pool and handle resolution/HDR changes and device failure. +- Measure and map the encoder presets on VideoToolbox while on the + hardware. Today it applies real-time, no-reordering controls and reports + `LowLatency` whatever preset was asked; give Balanced and Quality the + controls VideoToolbox has (real-time off, quality or speed priority) + without reordering, and report what took. - Verify no CPU readback using GPU/API traces and copy counters. Compare direct import or GPU blit against the CPU baseline at 1080p60 and 4K where supported. Check decoded color bars and moving timestamps, latency percentiles, audio sync, stop/restart, and long-running pool reuse on Apple hardware. ## Required diff --git a/quest/m1/obs-moq-video/preset-parity.md b/quest/m1/obs-moq-video/preset-parity.md new file mode 100644 index 0000000000..d2c2cd8623 --- /dev/null +++ b/quest/m1/obs-moq-video/preset-parity.md @@ -0,0 +1,47 @@ +# [S] Audio presets mirror video, and the preset claims hold + +## Goal + +The encoder presets from [#4099](https://github.com/moq-dev/moq/pull/4099) +read the same in moq-video and moq-audio, and nothing they promise is false. +The shape is settled; this finishes it: + +- Audio mirrors video: `moq_audio::encode::Settings` stores the preset it was + given and reads it back, and the audio encoder reports an `Applied` the way + `moq_video::encode::Encoder::applied` does. Today audio only has + `Settings::with_preset`, which rewrites `frame_duration` and forgets the + preset. +- The audio default agrees with itself: `Preset::default()` is `LowLatency` + (10 ms), but `Settings::new()` builds 20 ms, which is `Balanced`. +- The `Preset` doc in `rs/moq-video/src/encode/encoder.rs` no longer claims + that no preset reorders frames: V4L2 leaves reordering and queue depth to + the driver and MediaCodec's no-B-frame setting is an unconfirmed hint. Only + `Applied::preset` confirms it. +- `rs/moq-video/examples/encode-presets.rs` scores PSNR against the right + source frames after the encoder skips some (it ignores the `.skipped` + file today), and refuses an unknown preset name instead of printing the + header and exiting 0. + +## Plan + +- Decided: `encode::Preset` and `Applied { preset: Option, controls: + String }` are the API; don't reopen them. Audio already has its `Preset` + and gains the same `Applied` under `moq_audio::encode`. +- The audio enum default follows `Settings::new()`, not the other way + round: its 20 ms is published in moq-audio 0.1.6 and matches the JS + publish path, so `Preset::default()` becomes `Balanced` for audio. OBS + defaults to Balanced as well (decided with the maintainer), so the plugin + rides the published default instead of overriding it. +- Fail loud in the example: an unknown name is an error listing the valid + ones. +- The line PR ([OBS native codecs](/quest/m1/obs-moq-video/README.md)) must + call out the video default changes #4099 made: NVENC P4 to P1, and + openh264 medium to low complexity. +- Known gap: VAAPI reports `LowLatency` whatever was asked, and V4L2 and + MediaCodec report unconfirmed; no quest owns measuring and mapping presets + for them. Media Foundation and VideoToolbox are owned by + [Windows GPU input](/quest/m1/obs-moq-video/windows.md) and + [macOS GPU input](/quest/m1/obs-moq-video/macos.md). + +Public API: additive on moq-audio (the stored preset and its `Applied` +report); the unpublished audio `Preset` default changes. Wire: none. diff --git a/quest/m1/obs-moq-video/windows.md b/quest/m1/obs-moq-video/windows.md index 787a739efd..886b0b6f4e 100644 --- a/quest/m1/obs-moq-video/windows.md +++ b/quest/m1/obs-moq-video/windows.md @@ -9,6 +9,13 @@ The moq-video OBS encoder consumes compositor output through D3D11 without CPU s - Inspect OBS `encoder_texture` shared handles and `encode_texture2` lock_key/next_key semantics. Record whether the source is packed RGB or split NV12 planes, its adapter LUID, and the lifetime OBS guarantees after the callback returns. - Reuse the native D3D11 surface where the encoder accepts the same device and format. Otherwise GPU-convert/blit into a bounded encoder-owned NV12 pool before returning the OBS synchronization key. An AddRef alone does not prevent OBS from overwriting pooled pixels. - Audit keyed mutex/fence sequencing, asynchronous completion, incompatible adapters, software fallback, resolution changes, and device removal. Start with Media Foundation, which accepts D3D11 surfaces. The current NVENC backend is Linux-only and directly imports CUDA surfaces there; Windows NVENC/D3D11 support is separate backend work. Do not infer interoperability from both APIs accepting a texture handle. +- Measure and map the encoder presets on Media Foundation while on the + hardware. Today `applied()` reports "AVLowLatencyMode requested, + unconfirmed" for every preset, because a refused knob is only logged and + the MFT is configured on the first frame. Make low-latency mode + confirmable (read it back, or fail when it is refused) so `Applied::preset` + can name a preset, and give Balanced and Quality distinct controls where + the MFT has them. - Trace readback and GPU copy counts on real Windows hardware. Verify pixels and A/V timestamps with a subscriber, compare latency and utilization to the CPU baseline, and exercise cancellation while textures remain in flight. Include hybrid-GPU and device-mismatch rejection where available. ## Required diff --git a/quest/m1/pipewire-dup-cameras.md b/quest/m1/pipewire-dup-cameras.md new file mode 100644 index 0000000000..c9ad2a46fc --- /dev/null +++ b/quest/m1/pipewire-dup-cameras.md @@ -0,0 +1,24 @@ +# [XS] A webcam lists once with PipeWire enabled + +## Goal + +`moq_video::capture::cameras()` on Linux with the `pipewire` feature lists a +webcam once. Today a UVC webcam appears as its V4L2 device and again as the +PipeWire node that wraps it ([#4022](https://github.com/moq-dev/moq/pull/4022)). + +## Plan + +Decided: hide PipeWire camera nodes with `device.api = v4l2` whose device the +V4L2 backend already listed, keeping the shorter list over exposing the +backend choice. Nodes V4L2 cannot see stay: libcamera cameras (a Raspberry Pi +CSI camera) and everything inside a sandbox, where V4L2 lists nothing. + +Guidance: + +- Match on the node's V4L2 device path property (`api.v4l2.path`), not the + description, so two identical webcams stay distinct. +- Only the listing changes. `pipewire:` still opens a hidden node, and + the `pipewire` default keeps resolving by priority. +- Update the `cameras()` doc that currently says a webcam appears once per + backend, and cover the filter with a unit test over scanned node + properties. diff --git a/quest/m1/play-decode-schedule.md b/quest/m1/play-decode-schedule.md new file mode 100644 index 0000000000..0bc9b79b54 --- /dev/null +++ b/quest/m1/play-decode-schedule.md @@ -0,0 +1,36 @@ +# [S] moq play decodes on a schedule that survives rewinds and deep reordering + +## Goal + +`moq play`'s video path keeps every valid picture and drops only stale ones, +across a timeline rewind, a B-frame reorder deeper than 100 ms, and a decoder +batch larger than the queue. Three Codex findings on +[#4241](https://github.com/moq-dev/moq/pull/4241) went unanswered and all +still hold in `rs/moq-cli/src/play/video.rs`: + +- After a discontinuity rewinds timestamps, pictures the decoder still holds + from the old generation pass the `< floor` check (an old 10 s picture is + above a new 0 s floor), get scheduled far ahead, and can make the cap evict + valid new pictures. +- Decode waits until `DECODE_AHEAD` (100 ms) before a frame's presentation. A + reference frame whose PTS sits far past the B-frames encoded after it + delays them past their own presentation when reordering is deeper than + that; H.264 allows a 16-frame DPB. +- `decoded()` inserts a whole batch under one lock and trims to + `MAX_FRAMES` (3), so a decode or final flush that returns more than three + pictures loses all but the newest three. + +## Plan + +- Rewind: identify stale output by generation, not timestamp order. Flush or + reset the decoder at the break and discard what it returns, or tag frames + with the generation they were submitted in. +- Decode ahead: schedule by the earliest presentation the pending access + units can still produce, or size the lead from the rendition's catalog + `jitter` (the reorder depth), rather than a fixed 100 ms. +- Batches: let the presenter drain a large batch rather than trimming it in + one step, or apply the cap only when the window is actually stalled. The + cap exists to bound memory while presentation lags, not to cut a flush tail. +- Regressions on the fake presenter/decoder rig from #4241: a rewind with + pictures held across it, a stream with reordering deeper than 100 ms, and a + flush returning more than three pictures. diff --git a/quest/m1/publish-codec-string.md b/quest/m1/publish-codec-string.md new file mode 100644 index 0000000000..b86f6c557b --- /dev/null +++ b/quest/m1/publish-codec-string.md @@ -0,0 +1,33 @@ +# [S] js/publish advertises the full codec string + +## Goal + +Every video rendition `@moq/publish` puts in the catalog carries a full +RFC 6381 codec string, so native players can decode what browsers publish. +Today the encoder probe in `js/publish/src/video/encoder.ts` falls back to the +bare hints `vp09`, `avc1`, `av01`, and `hev1` when no specific string is +supported, and the catalog publishes the hint as the codec. Rust `hang` parses +those as `VideoCodec::Unknown`, so moq-video and the other native consumers +refuse the track ([#4095](https://github.com/moq-dev/moq/pull/4095)). + +## Plan + +Decided: advertise the codec string from the encoder's own output, +`EncodedVideoChunkMetadata.decoderConfig.codec`, rather than the probe input. +Rust keeps refusing bare hints: the decoder gate needs the profile before it +subscribes. + +Guidance: + +- The catalog is built from the resolved config today, before any frame is + encoded. Either hold the rendition out of the catalog until the first + output reports its `decoderConfig`, or update it then; keep the stall and + jitter reporting working either way. +- Check what each browser returns for a bare hint. If one echoes the hint + back, derive the string from the bitstream (SPS for H.264/H.265, the + sequence header for AV1, the uncompressed header for VP9), or fail loud + rather than publish a string no native player accepts. +- A reconfigure (resolution or codec change) can change the string; the + catalog follows it. +- Tests with the fake `VideoEncoder`: a bare-hint probe whose output reports + a full string publishes the full string. diff --git a/quest/m1/publish-lazy-file.md b/quest/m1/publish-lazy-file.md index ea1fd906fe..fe1d333bd0 100644 --- a/quest/m1/publish-lazy-file.md +++ b/quest/m1/publish-lazy-file.md @@ -14,6 +14,12 @@ Decided in planning: - Load the file source with a dynamic `import()` when a file source is selected. +- Open the picker before anything is awaited: `File.prompt()` must run + synchronously in the click handler, and the decoder (mediabunny) loads + lazily once a `File` arrives. Awaiting the `import()` first spends the + click's transient user activation, so the browser blocks the picker + (Codex on [#4257](https://github.com/moq-dev/moq/pull/4257)). So the + picker stays in the eager bundle and only the decode path is lazy. - Keep `ALL_FORMATS`, so any container mediabunny reads still works. Leave mediabunny bundled into publish's dist rather than making it external. diff --git a/quest/m1/redirect-resolve.md b/quest/m1/redirect-resolve.md new file mode 100644 index 0000000000..5c3f7e56d9 --- /dev/null +++ b/quest/m1/redirect-resolve.md @@ -0,0 +1,29 @@ +# [XS] Strict or private Redirect::resolve + +## Goal + +`moq_tokio::Redirect` no longer offers a public way to turn a refused or +malformed GOAWAY redirect into "dial the current address". A caller either +gets the same refusal `Connection` acts on, or cannot call it at all. + +## Plan + +The drain line (https://github.com/moq-dev/moq/pull/4143) made +`Connection` end with `Error::RefusedRedirect` on a malformed or +policy-refused URI instead of redialing the peer that asked it to leave, and +made a certificate pin refuse a host change. It left the public +`Redirect::resolve` lenient to avoid a break: it falls back to the current +URL on any refusal and never sees the pin, so it answers differently from +the connection it documents. + +Recommendation: make it private. Nothing outside `moq-tokio` calls it (only +its own unit tests), and the repo keeps things private until a consumer +needs them. If a consumer turns up, the alternative is returning the same +`Result>` as the internal `target`, so empty and refused stay +distinct. Removing or changing a published method is a break, so this +targets `dev`; update `doc/lib/rs` if it mentions the method. + +## Required + +- [Graceful relay drains](/quest/m1/drain/README.md) - the stricter `Connection` lands with the line +- `dev` has merged `main` after the drain line lands diff --git a/quest/m1/rtmp-tls-only.md b/quest/m1/rtmp-tls-only.md new file mode 100644 index 0000000000..e281f2021a --- /dev/null +++ b/quest/m1/rtmp-tls-only.md @@ -0,0 +1,32 @@ +# [XS] RTMP listener can refuse plaintext + +## Goal + +An operator who configures TLS on the RTMP ingest listener can require it. +Today a TLS-configured listener sniffs the first byte and serves any +non-ClientHello connection as plaintext `rtmp://` on the same port +(`rs/moq-rtmp/src/listen.rs`), so setting `tls` silently keeps accepting +unencrypted stream keys. The sniffing behavior stays available; it is just no +longer the only choice. + +## Plan + +Decided by the maintainer during the merged-PR audit: add a mode rather than +drop the sniffing. #3964 shipped mixed mode after a bot review, not a human +one, and nobody chose it for operators who want TLS only. + +- Model it so a TLS-only listener without a TLS config cannot be expressed, + for example an enum carrying the `ServerConfig` (plaintext, TLS required, + TLS or plaintext) instead of a bool beside the `Option`. Keep today's + behavior as the default for an existing `tls` config unless that reads + wrong once written; say which in the PR. +- A plaintext connection to a TLS-only listener is refused at the first byte, + with a log line naming the peer, not left to time out. +- Thread the choice through the relay and gateway configs that build this + listener, and update `doc/bin/rtmp.md` and any relay config docs that + describe RTMPS. +- Test both modes: a plaintext client is refused by TLS-only and served by + mixed. + +Public API: additive on moq-rtmp's listen config if the default holds; a +published break (dev) if the field's type changes. Wire: none. diff --git a/quest/m1/session-close.md b/quest/m1/session-close.md index 39ad6edba2..499c51e678 100644 --- a/quest/m1/session-close.md +++ b/quest/m1/session-close.md @@ -22,3 +22,11 @@ end and returns a promise, which is a published break, so that change targets `doc/concept/moq-lite.md` says a graceful close withdraws announces and an abort does not. No new page. + +The interop runner is the consumer that found this +([#4209](https://github.com/moq-dev/moq/pull/4209)): after a successful +publish it calls `abort`, so the relay never sees `PUBLISH_NAMESPACE_DONE` and +the next run is told the namespace is already published. Switch it to the +graceful `close()` once that exists. If the calling code lives outside this +repository (moq-interop-runner), that change is a PR there and needs the +maintainer's approval before posting. diff --git a/quest/m1/session-death.md b/quest/m1/session-death.md new file mode 100644 index 0000000000..22ac9be5c5 --- /dev/null +++ b/quest/m1/session-death.md @@ -0,0 +1,42 @@ +# [S] Session death parity + +## Goal + +Rust and JS end a session's tracks and groups the same way: + +- A session closed locally, on purpose, ends the tracks it was receiving + cleanly in both languages. Groups still in flight end as they do today. +- A session that dies (peer close, transport failure) ends its tracks and + its group readers with the session's error, carrying the peer's code, + never the raw transport error, `Dropped`, or `Cancel`. + +## Plan + +https://github.com/moq-dev/moq/pull/4120 made a dying session end its tracks +with the session's error in both languages, and left two gaps. + +Decided by the maintainer: + +- **A local close is a close, not an error.** JS already ends tracks cleanly + on `close()`. Rust has no clean end short of a finished track, so #4120 + ends them with the close error (previously `Cancel` or `Dropped`). Rust + needs a clean end at the current edge that is not a declared end: readers + get what was delivered and then `None`. Keep #4120's rule that an abort + before a declared end settles still wins, and #4116's clean end for a + dropped producer after `finish_at`; the new path must not mask either. + Whether this is a new `track::Producer` method or a driver-internal path + is an API call, so keep it private unless a consumer needs it. +- **JS group readers see the session's error.** Tracks go through + `sessionCause` in `js/net/src/error.ts`, but `runGroup` in the lite and + IETF subscribers closes a group with the raw error. Route it the same way. + +Extend the existing session-death tests (Rust +`a_session_death_ends_the_track_with_its_error`, the JS lite and IETF +integration cases) with a local close and with a group reader, on lite and +IETF. Behavior change, no signature change on either side unless the Rust +clean end needs a public method. + +## Related + +- [Track tail hardening](/quest/m1/track-tail-hardening.md) - the tail rules this must not mask +- [Graceful session close](/quest/m1/session-close.md) - what a local close sends the peer diff --git a/quest/m1/splice-edges.md b/quest/m1/splice-edges.md new file mode 100644 index 0000000000..aa0d904500 --- /dev/null +++ b/quest/m1/splice-edges.md @@ -0,0 +1,37 @@ +# [S] Splice edge cases + +## Goal + +Three spliced-track cases in `rs/moq-net` stop losing or mis-judging groups, +each with a regression test that fails before its fix: + +- A group whose successor segment's first servable group is still unstamped + keeps an unbounded reach, instead of borrowing a later segment's start and + being skipped or ended with `Error::Old`. +- A reader still draining a pruned segment has its boundary group judged + against the later segments, so a stale boundary group is skipped like any + other. +- A reader draining a warm head keeps it when the upstream fails over again + before the track parks. + +## Plan + +Codex raised all three on https://github.com/moq-dev/moq/pull/4103 and +https://github.com/moq-dev/moq/pull/4104 after the fixes there landed; none +was answered. + +- `served_start` in `rs/moq-net/src/model/resume.rs` says an unstamped group + stops the search, but `find_map` reads a segment's `None` (no group, or its + first group has no frame yet) as a miss and moves on. The two cases need + to be distinguishable, as the per-track `served_start` in + `rs/moq-net/src/model/track.rs` already treats them. +- `ResumeState::successor` finds the cursor's segment by id, so a segment + `prune` removed while a reader still drains it yields no successor at all. + Resolve it from the boundary and the remaining later segments instead. +- An ordinary takeover in `rs/moq-net/src/model/origin.rs` (`Action::Splice` + with no warm copy) overwrites `io.head` with `None`. Dropping the + `WarmGroup` aborts the cached head that a reader resumed from an earlier + park may still be reading, and the next park cannot rebuild the full group. + Keep the existing head when the splice has no new one to take. + +These are independent; one PR is fine since they share the splice tests. diff --git a/quest/m1/test-flakes-2.md b/quest/m1/test-flakes-2.md new file mode 100644 index 0000000000..384faa3f0e --- /dev/null +++ b/quest/m1/test-flakes-2.md @@ -0,0 +1,58 @@ +# [S] More tests hold up under load + +## Goal + +A second round after [#4286](https://github.com/moq-dev/moq/pull/4286): +tests that pass alone but have failed under a loaded `just check` pass +reliably, each fixed at its cause, never by raising a timeout or adding a +retry. + +- moq-cli `fetch::tests::a_frame_read_times_out` asserts on a 500 ms wall + deadline ([#4084](https://github.com/moq-dev/moq/pull/4084)). +- moq-cli `complete::tests::a_stage_broadcast_picks_the_catalog_to_read` + ([#4084](https://github.com/moq-dev/moq/pull/4084)) and + `the_catalog_format_on_the_line_is_honored` + ([#4089](https://github.com/moq-dev/moq/pull/4089)). +- moq-net `model::group::test::drop_unfinished_warns` counts WARNs through a + global tracing capture, so another test's WARN, or a missed one, changes + the count ([#4104](https://github.com/moq-dev/moq/pull/4104)). The + `model::track` test of the same name uses the same helper. +- moq-tokio `broadcast_race_quic_wins` binds TCP `:0` and then UDP on the + same number, which nothing reserves: the collision + [#4084](https://github.com/moq-dev/moq/pull/4084) removed from its sibling + after [#4055](https://github.com/moq-dev/moq/pull/4055) papered over it with + a retry. +- `just test media` late join failed once after + [#4181](https://github.com/moq-dev/moq/pull/4181): "joined at frame 111, 16 + frames behind 127", against a budget of one GOP (15). + +## Plan + +- Timing tests: prefer a paused clock over wall time (`moq-cli`'s + subscribe tests already use `#[tokio::test(start_paused = true)]`), or + assert on an event instead of a deadline. If a test is slow under load + because the code under test is slow, fix that. +- WARN counting: capture per test (a scoped subscriber or a filter on the + test's own span) instead of a process-global count. +- The race test shares one port only so both transports sit behind one URL. + Separate `:0` ports fix the bind collision but not the race itself: with + `websocket.delay = 0` either arm can legitimately win under load. Make the + order deterministic the way #4084 did, holding the WebSocket arm until QUIC + has connected, rather than asserting on a real race; no retry. #4084's follow-ups + (`tests/reconnect.rs` `spawn_server`, `tests/worker.rs` `free_udp_port`) + are the same probe-and-rebind pattern; fix them here if cheap. +- Media late join: first decide whether 16 frames is a real regression (the + player joining at the previous GOP's keyframe) or an off-by-one in how the + fixture samples the live edge. Fix whichever it is; don't widen the + budget without a reason. +- Prove it by running `just check --all` several times on a loaded machine, + as the first round did. + +Public API: none. Wire: none. + +## Related + +- [Archive enrollment](/quest/m1/archive/enrollment-flake.md) - the same + kind of flake on the archive line, where its test lives +- [Interop flakes](/quest/m1/interop-flakes.md) - port reservations and the + pause click in the interop harness diff --git a/quest/m1/tooling/README.md b/quest/m1/tooling/README.md index 073918b847..a96ed7e11c 100644 --- a/quest/m1/tooling/README.md +++ b/quest/m1/tooling/README.md @@ -20,6 +20,7 @@ requires the one before it, so they land as one line of pull requests. ## Quests - [Thin justfiles](/quest/m1/tooling/justfiles.md) - recipe bodies move to `sh/`, one impact map scopes check/fix/test, self-tests and guards are deleted +- [Windows cross-check](/quest/m1/tooling/windows-cross-check.md) - a Linux cross-check of moq-video for Windows runs per PR when moq-video is selected - [Workflows call just](/quest/m1/tooling/workflows-call-just.md) - no workflow `run:` step names a `.sh`; every script a workflow needs has a recipe - [Binary release workflow](/quest/m1/tooling/release-binary.md) - moq-cli and moq-relay share one reusable workflow behind two thin callers - [FFI release workflow](/quest/m1/tooling/release-ffi.md) - the five `release-*-ffi.yml` share the moq-ffi target matrix and artifact staging diff --git a/quest/m1/tooling/windows-cross-check.md b/quest/m1/tooling/windows-cross-check.md new file mode 100644 index 0000000000..5bda413332 --- /dev/null +++ b/quest/m1/tooling/windows-cross-check.md @@ -0,0 +1,36 @@ +# [XS] Windows cfg breaks in moq-video fail the PR + +## Goal + +A pull request that breaks moq-video's `#[cfg(target_os = "windows")]` code +fails its own checks instead of the next nightly. Today only `just rs +windows` compiles that code, on a Windows runner once a day, so +[#4036](https://github.com/moq-dev/moq/pull/4036) had to repair a Media +Foundation test that had been broken on `main` for a while. That PR showed a +Linux host can catch it: +`cargo check -p moq-video --no-default-features --features capture +--all-targets --target x86_64-pc-windows-msvc` reproduced the errors and +passed with the fix, with no Windows host and no C toolchain. + +## Plan + +- Run that check per PR whenever the impact map selects moq-video, as a + recipe the workflow calls. `openh264` stays off because its vendored C++ + needs MSVC; the Media Foundation, D3D11, and capture code is what the + check is for. +- The dev shell's toolchain carries only `wasm32-unknown-unknown`; add the + `x86_64-pc-windows-msvc` std target to `flake.nix` so the check runs the + same locally and in CI. Measure what it adds to the shell and the check. +- Fix the stale `rs/justfile` comment above `windows`: it says cross-compiling + cannot stand in because openh264 is non-optional, but openh264 is now a + feature. The nightly Windows job still owns linking, the other crates, and + running the tests. +- Prove it by reintroducing one of #4036's errors on a branch and watching + the PR check fail. + +Public API: none. Wire: none. + +## Required + +- [Thin justfiles](/quest/m1/tooling/justfiles.md) - the impact map that + selects moq-video diff --git a/quest/m1/track-tail-hardening.md b/quest/m1/track-tail-hardening.md new file mode 100644 index 0000000000..d6fbb68bf4 --- /dev/null +++ b/quest/m1/track-tail-hardening.md @@ -0,0 +1,79 @@ +# [M] Track tail hardening + +## Goal + +moq-net and `@moq/net` wait out a subscription's tail by the same rules, and +the known holes in those rules are closed. A finished IETF subscription never +hangs its request task or under-reports its stream count, a group whose +header arrived is never silently dropped from a clean end, the grace never +cuts off a stream still being read, and tail bookkeeping stays bounded on a +lossy track. + +## Plan + +Track tail landed in https://github.com/moq-dev/moq/pull/4086 (JS) and +https://github.com/moq-dev/moq/pull/4116 (Rust), and Codex's findings on both +were left for this pass. Fix each in the language named and check whether +the other has the same hole. + +Bugs: + +- **Rust IETF publisher, END_OF_TRACK not cancellable.** After the group + streams drain, `write_end_of_track` in `rs/moq-net/src/ietf/publisher.rs` + runs outside the race against `stream.reader.poll_closed` and the session, + so with uni-stream credit exhausted an unsubscribe leaves the request task + parked forever, never sending PUBLISH_DONE. +- **Rust IETF publisher, stream count.** END_OF_TRACK is counted only if the + whole write succeeds, while every other stream counts once opened. A reset + END_OF_TRACK whose header still arrives can then land after the subscriber + met the count and retired the alias. +- **Rust IETF subscriber, truncated first object.** `open_group` peeks the + first object with `?` before creating the group, so a stream reset or + truncated after its header drops that group, and since the stream still + counts, the track can end clean without it. Keep the END_OF_TRACK peek, but + create and abort the named group on any other peek failure. +- **Rust grace cuts off an arrived stream.** `Settle::poll` in + `rs/moq-net/src/tail.rs` returns when the grace fires even if an + END_OF_TRACK stream is mid-read, so the track finishes at the live edge and + the later marker is ignored. JS's `Tail.settle` already waits for active + streams; do the same. +- **JS lite floor.** `Math.max(entry.start, bounds.start)` in + `js/net/src/lite/subscriber.ts` ignores a SUBSCRIBE_UPDATE that lowered the + floor after SUBSCRIBE_START, so a newly requested lower group reordered + behind the FIN is dropped. `bounds.start` alone is wrong too: a subscriber + that asked below the announced start would wait for groups never promised. + The owed start has to remember the floor each request was answered at. + Check whether Rust's `SubStream::owed` has the same shape. + +Open questions. The maintainer chose to plan these without settling them; +decide in the PR, applying the choice to both languages: + +- **Are lost datagrams owed before a track ends?** Both `Tail`s account a + datagram only when it arrives, so a lost one leaves a hole in the owed span + and the end waits out the whole grace (JS was reported to wait and Rust + not, but Rust's `covers(owed)` reads the same way despite the comment in + `route_datagram`; confirm with a test first). Recommendation: no, datagrams + are best effort. On lite-07 the SUBSCRIBE_END stream count + ([lite-count-settle](/quest/m1/lite-count-settle.md)) settles without + looking at sequences, which makes this moot there; older versions keep the + grace as the documented stopgap. +- **Does a group at or past the declared end abort the track, or only that + group?** Rust aborts the track with `ProtocolViolation`; JS aborts the group + and ends clean. Recommendation: abort the track in both. The peer + contradicted its own end, and the repo fails loud on malformed input. +- **How is tail memory bounded?** Rust `Tail.accounted` gains a range per + permanent gap for the life of the subscription, and so does JS's. A gap + older than the grace can no longer be waited for, so folding it in as + accounted loses nothing. Recommendation: that, which bounds the ranges by + the gaps inside the grace window; lite-07 needs only the stream count. + +Regression tests go in `rs/moq-net/tests/track_tail.rs` (its `hold_unis` +mock makes the reorder deterministic) and the JS tail tests. Wire output only +changes if the stream-count fix does, and that is a correction to what the +drafts already require. + +## Related + +- [Track tail interop](/quest/m1/track-tail-interop.md) - the Rust-JS check that both sides now agree +- [lite-07 count settle](/quest/m1/lite-count-settle.md) - replaces sequence coverage with the stream count on lite-07 +- [Session death](/quest/m1/session-death.md) - how a tail ends when the session dies under it diff --git a/quest/m1/ts-export-jitter.md b/quest/m1/ts-export-jitter.md new file mode 100644 index 0000000000..5e0b138748 --- /dev/null +++ b/quest/m1/ts-export-jitter.md @@ -0,0 +1,57 @@ +# [S] moq export ts: video reorder bound follows the stream + +## Goal + +Two `moq export ts` legs of one broadcast interleave video and audio +identically even when a B-frame arrives late. Today the media-time interleave +from [#4001](https://github.com/moq-dev/moq/pull/4001) treats a video track as +advanced past a candidate once its high-water mark, less its DTS reserve, +passes it. Without catalog `jitter` that reserve is the 16-tick +`DEFAULT_DTS_RESERVE`, so a B-frame presenting below the mark can still land +after audio it should precede, and the order depends on arrival again. A +`jitter` that shows up in a later catalog is ignored too: `update_catalog` +returns early once the PAT/PMT is built, before the per-track refresh. + +## Plan + +Decided: + +- Keep refreshing a video track's reserve from catalog `jitter` after the PMT + is emitted. The early return exists to lock the track layout, not the + per-track timing, and the importer often fills `jitter` only once it has + seen reordering. +- When `jitter` is absent, derive the reserve from the stream rather than + refuse it. The bitstream's reorder depth (H.264 VUI + `max_num_reorder_frames`, HEVC `sps_max_num_reorder_pics`) counts + pictures, not time, so it becomes a deterministic 90 kHz bound only with + fixed timing: the VUI `fixed_frame_rate_flag` with its tick, or the + catalog framerate. With both, the reserve is depth times frame duration, + known from the first keyframe before any frame is emitted. AV1 and VP9 + present in decode order and need no reserve. Otherwise (variable frame + rate, or nothing declared), grow the reserve from the reordering actually + observed (how far a frame's PTS falls below the track's high-water mark); + it only grows, so a stream that never reorders keeps today's tiny reserve. +- Refusing to export video without `jitter` was rejected as too extreme. +- Treating unknown jitter as unbounded was tried in #4001 and rejected: the + stall never clears when video leads, breaking + `quiet_track_is_emitted_around_then_rejoins`. + +Guidance: + +- The same reserve feeds `author_dts`, so growing it mid-stream shifts DTS + further behind PTS. The existing monotonic clamp keeps DTS from stepping + back; check the PCR, which backs off by the largest reserve, stays ahead of + every DTS written. +- Only the observed fallback is nondeterministic: a B-frame deeper than any + seen before can reorder output once per new maximum (Codex on #4307). + Refusing such streams was ruled out by the maintainer. Say + so in a comment and log each growth, so an undeclared stream is visible + rather than silently misordered. +- Tests in `export_test.rs`: two exporters with a late B-frame, no `jitter`, + and a declared reorder depth at a fixed frame rate produce byte-identical + output from the first frame; an undeclared stream converges after its deepest reorder; and a + `jitter` arriving in a catalog after the PMT raises the reserve. + +## Related + +- [TS export byte schedule](/quest/m1/ts-export-byte-schedule.md) - also reshapes PCR placement in `export.rs` diff --git a/quest/m1/ts-import-shared-shift.md b/quest/m1/ts-import-shared-shift.md new file mode 100644 index 0000000000..6dd1fa513a --- /dev/null +++ b/quest/m1/ts-import-shared-shift.md @@ -0,0 +1,44 @@ +# [S] moq import ts: one re-anchor shift per program + +## Goal + +An unflagged loop wrap in `moq import ts` moves audio and video forward by the +same amount, so A/V sync holds across any number of wraps. Today each +elementary stream owns a `Reanchor` and grows its shift to reach its own live +edge ([#3997](https://github.com/moq-dev/moq/pull/3997)). Audio and video +edges sit on their own last frame starts, so each wrap drifts A/V by the +difference in frame durations: about 12 ms at 30 fps with 48 kHz AAC, or +1.7 s a day on a 10-minute loop. + +## Plan + +Decided: a program-level shared shift in `rs/moq-mux/src/container/ts/import.rs`. +Every stream of a program applies the same shift, grown once per wrap by the +largest amount any stream needs to clear its edge, which preserves the source's +inter-stream offsets. A timebase break (PCR discontinuity) clears it for the +whole program, as it already does per stream. + +Guidance: + +- The shift must be known before any stream emits a frame of the new + generation (Codex on #4307): a first stream that grows only enough for + itself leaves a later-arriving stream below its edge, and growing again + then drifts the two. Decided: hold each stream's post-wrap frames until + every live stream of the program has shown its new PTS, then take the + maximum growth once. A stream that stays silent is bounded by the + existing liveness timeout (#3489), not waited on forever. The + `Anchor`/`Lane` split behind `live()` in `moq_mux::clock` solves the same + problem for restarts and may be reusable. +- A stream whose own edge is still above the shifted timestamp after the + shared growth (its tail ran longer) is the case that forces growing by the + maximum. Landing on its edge is accepted today; keep that trade-off. +- Sections already take the video shift, so cues keep following pictures. + MPEG-2 video (`Stream::Clock`) has no track and so no edge, but it should + read the shared shift too. +- Tests beside the existing loop-wrap tests: a muxed H.264 + AAC loop whose + period is not a multiple of either frame duration keeps the first audio and + video timestamps of each pass at the source offset across three wraps. + +## Related + +- [#3489](/quest/m1/3489-ts-import-stream-liveness.md) - per-PID liveness in the same importer; touches `Stream` but not the shift diff --git a/quest/m1/unknown-session-logs.md b/quest/m1/unknown-session-logs.md new file mode 100644 index 0000000000..f8285c35a4 --- /dev/null +++ b/quest/m1/unknown-session-logs.md @@ -0,0 +1,43 @@ +# [S] UnknownSession log flood + +## Goal + +moq.pro relays stop logging +`web_transport_moq::session: failed to decode unidirectional stream err=WebTransportError(UnknownSession)` +for streams that were simply reset or cut off before their WebTransport +header arrived. A stream that really names another session is still reported +as `UnknownSession`, and the error a caller sees says what happened. + +## Plan + +Seen in production at a rate like the old-group warnings +https://github.com/moq-dev/moq/pull/4208 fixed, on the same hosts. + +Likely root cause, to confirm with a test first: `decode_uni` (and +`decode_bi`) in `web-transport-moq`'s `session.rs` (repository +`moq-dev/noq`) map every failure to read the stream type or session ID to +`UnknownSession`. A read fails whenever the peer resets the stream before +those bytes arrive, which moq does routinely: a publisher resets a group's +stream when the group is superseded or expires, and without reliable reset +the header can be discarded with it. `poll_accept_uni` then logs it at WARN, +though its own comment says the stream "was probably reset early". + +- Reproduce in `web-transport-moq`'s tests: open a WebTransport uni stream, + reset it before the header is delivered, and assert the accept loop + reports a reset rather than `UnknownSession`. +- Fix the mapping at the source: carry the read's real cause (reset, closed, + truncated), keep `UnknownSession` for a session-ID mismatch, and log the + expected reset at debug. Not a log filter on the moq side. +- `web-transport-quinn` in `moq-dev/web-transport` carries the same code; + fix it too if it is still published. +- Release and bump the pin here. Confirm on a moq.pro relay that the rate + drops, and that any remaining `UnknownSession` lines are real mismatches. + +If the reproduction shows something other than early resets (for example a +peer really using another session ID), stop and report before changing the +log level. + +## Related + +- [Close codes on every transport](/quest/m1/close-codes.md) - another error mapping fix in the same crate +- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - keeps a reset stream's header, which removes most of these diff --git a/quest/m1/video-surface.md b/quest/m1/video-surface.md new file mode 100644 index 0000000000..d5e7cdc9f5 --- /dev/null +++ b/quest/m1/video-surface.md @@ -0,0 +1,36 @@ +# [XS] FFI decoded frames expose a surface, only where one exists + +## Goal + +moq-ffi names the decoder's retained picture the way moq-video does, and a +caller cannot opt into it on a platform that has none. On `dev` +([#4094](https://github.com/moq-dev/moq/pull/4094)) the frame's view is +`MoqVideoNative` from `native()`, enabled by `MoqVideoDecoderOutput.native`. +Only macOS has a variant (`PixelBuffer`). Elsewhere the opt-in still decodes +to native surfaces, `native()` always returns `None`, and a tiled VAAPI +DMA-BUF also fails `pixels()`, so the caller gets frames it cannot read. + +## Plan + +Decided: + +- Rename to surface naming, mirroring `moq_video::Surface`: + `MoqVideoSurface`, `surface()`, and the matching `MoqVideoDecoderOutput` + flag. "Native" named today's implementation, not the role. +- Refuse the opt-in at subscribe time on platforms with no surface variant + (Windows and Linux today) with a clear unsupported error. Each platform + lifts the refusal when its variant lands with hardware proof, in + [decode-windows](/quest/m1/obs-moq-video/decode-windows.md) and + [decode-linux](/quest/m1/obs-moq-video/decode-linux.md). + +Guidance: + +- Whether `moq_video::Output::Native` should follow the rename is a + separate call; ask before touching it. +- Update the wrappers and docs per the root `AGENTS.md` sync table: the Go, + Python, Swift, and Kotlin option docs mention the native surface flag, and + `quest/m1/obs-moq-video/source.md` and the release plan on `dev` name + `native`. +- A `dev` break on top of #4094, so it rides the same release. +- Test: the opt-in is refused where there is no variant, and on macOS + `surface()` returns the pixel buffer. diff --git a/quest/m1/wire-compat.md b/quest/m1/wire-compat.md new file mode 100644 index 0000000000..98cef18c3e --- /dev/null +++ b/quest/m1/wire-compat.md @@ -0,0 +1,61 @@ +# [M] Nightly wire compatibility against the last release + +## Goal + +A nightly run pits this checkout against the last published release and +fails when they stop understanding each other, before the break ships. +It covers, in both directions: + +- **Tokens:** the current `moq-auth` signs and the last published `moq-cli` + and `@moq/auth` verify, and the published side signs while the current + side verifies. +- **Session wire:** the current relay and clients against the last published + `moq-cli`, `moq-relay`, and `@moq/net`, on every lite and IETF version + both sides publish: publish, subscribe, announce, and fetch. +- **Catalog and container:** the last published `hang` and `@moq/hang` read + the current catalog and frames, and the current ones read theirs. + +## Plan + +Motivated by https://github.com/moq-dev/moq/pull/4190, where a token format +change broke every published credential and only moq-dev/smoke#49 noticed. +Neither existing harness asks this question: moq-dev/smoke tests published +against published, and `test/interop` builds every client from the checkout +(see `test/interop/README.md`). + +Decided by the maintainer: + +- **Nightly only**, not per PR. Installing released packages is slow, and + a break only needs catching before the next release. +- **Resolve the last release at run time** (the crates.io and npm registries' + newest non-yanked version), never a hand-bumped pin that goes stale. Log + the resolved versions in the run so a failure names both sides. +- **Not bindings.** Python, Go, Swift, Kotlin, and C stay with smoke and + `test/interop`. They wrap the same Rust, so the Rust lanes see their wire. + +Guidance: + +- Reuse `test/interop`'s relay and client drivers rather than build a second + harness. The new axis is which side comes from the registry, so a + "published" client source next to the checkout one may be enough. +- Derive the version matrix from what both sides accept (for example each + CLI's `--connect-version` choices), not a hand-kept list, so a new draft + joins the matrix and a dropped one leaves it without an edit. A version + only the checkout offers is skipped and logged. A version the last release + supports but the checkout no longer offers fails the run unless it is + acknowledged in the same skip list as planned breaks (decided with the + maintainer; silently dropping a published version is the regression this + exists to catch). +- Prefer released binaries (GitHub release assets) over `cargo install` of + the published crates if the build time threatens the nightly budget. +- Subscribers must decode the catalog and frames, not only see a non-empty + frame, or the container lane proves nothing. +- A deliberate break on `dev` is expected to fail against `main`'s release. + Run against `main` only, and document in the harness how a planned break + is acknowledged (for example a skip list that the next release clears). +- Wire the job into the existing nightly (`interop.yml` already has a + schedule) and document the recipe beside `just test interop`. + +## Related + +- [Track tail interop](/quest/m1/track-tail-interop.md) - another cross-language case in the same harness diff --git a/quest/m2/README.md b/quest/m2/README.md index 823f21afe5..5f1a80717d 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -20,6 +20,8 @@ upstream release waits in [m4](/quest/m4/README.md). - [Catalog track identity](/quest/m2/catalog-tracks.md) - compare immutable track definitions with explicit catalog-to-group binding - [Archive recovery listing](/quest/m2/archive-recovery-listing.md) - a resumed DVR lists what changed since its checkpoint, not every stored group - [Archive backward timestamps](/quest/m2/archive-backward-timestamps.md) - a resumed recording refuses a track whose timestamps go backward +- [moq play drain tail](/quest/m2/play-drain-tail.md) - retired renditions and finite tracks play their last 10 ms of audio +- [Relay io_uring packages](/quest/m2/relay-io-uring-package.md) - Linux relay packages ship io_uring once the ring is on par with tokio - [Mobile ownership](/quest/m2/mobile-ownership.md) - decide whether Rust or platform code owns mobile capture, codecs, and rendering - [iOS capture](/quest/m2/mobile-capture-ios.md) - camera and screen capture if the mobile ownership decision selects Rust - [Android capture](/quest/m2/mobile-capture-android.md) - NDK/JNI capture using the existing codecs if mobile ownership selects Rust diff --git a/quest/m2/audio-loss-recovery.md b/quest/m2/audio-loss-recovery.md index 33480c6391..9be8b40dc0 100644 --- a/quest/m2/audio-loss-recovery.md +++ b/quest/m2/audio-loss-recovery.md @@ -25,4 +25,4 @@ audio settings. Existing wire compatibility must be demonstrated. ## Related -- [Audio quality](/quest/m1/audio-quality-harness/README.md) - quality and latency measurements +- [Audio quality](/quest/m0/audio-quality-harness/README.md) - quality and latency measurements diff --git a/quest/m2/audio-opus-backend.md b/quest/m2/audio-opus-backend.md index 3f1e682071..a981f0e007 100644 --- a/quest/m2/audio-opus-backend.md +++ b/quest/m2/audio-opus-backend.md @@ -24,4 +24,4 @@ before a separately scoped backend implementation. ## Related -- [Audio quality](/quest/m1/audio-quality-harness/README.md) - shared measurement infrastructure +- [Audio quality](/quest/m0/audio-quality-harness/README.md) - shared measurement infrastructure diff --git a/quest/m2/cat/present.md b/quest/m2/cat/present.md index 7a2cf3d821..82d11a0334 100644 --- a/quest/m2/cat/present.md +++ b/quest/m2/cat/present.md @@ -19,16 +19,22 @@ the option fails loud instead of dropping the credential. keeps taking a JWT; `--connect-cat ` (and `MOQ_CONNECT_CAT`) adds a CAT. One CAT per connection: it is the connection credential, so a configured CAT takes the setup option and the JWT that would have gone - there rides its AUTH stream instead. + there rides its AUTH stream instead, never the URL. `moq auth serve` + refuses a SETUP token beside a different `?jwt=` (see [Token in + band](/quest/m1/auth/token-in-band.md)), so a JWT on the URL would refuse the + CAT's connect; on a session without AUTH the JWT is simply absent, like + any extra token. - A CAT with any offered version that lacks the setup option (every lite version) fails `init` with `Unsupported` naming the token; `js/net` rejects the same way before dialing. - `moq` CLI publish and subscribe get the flag through the shared connect config; `doc/bin/cli.md` and `doc/lib/rs/moq-net.md` gain it. -- Tests: the server request sees kind `0x01` and the bytes on every draft - in Rust, JS, and across; a JWT still goes to the URL or AUTH stream when - a CAT holds the option; a lite offer with a CAT refuses at init; end to - end against `moq auth serve` with a CAT from `moq auth sign --format cat`. +- Tests: a Rust server's `Handshake::token()` sees kind `0x01` and the + bytes on every draft from both a Rust and a JS client (a JS server is out + of scope: #4278 added no JS accept-side API, since nothing in `js/net` authorizes an IETF session); a JWT rides its + AUTH stream and not the URL when a CAT holds the option; a lite offer with + a CAT refuses at init; end to end against `moq auth serve` with a CAT from + `moq auth sign --format cat`. Public API: additive on `moq-tokio` and `js/net`. Wire: none. diff --git a/quest/m2/cat/verify.md b/quest/m2/cat/verify.md index c77c2c532e..46df67ba0e 100644 --- a/quest/m2/cat/verify.md +++ b/quest/m2/cat/verify.md @@ -78,7 +78,10 @@ rather than a set of prefixed names; `@moq/auth` stays flat. vectors round-tripped both ways; expiry and not-before; the grant produced from every c4m-01 example; serve admitting a CAT over a real moq-transport session on every supported draft and refusing an unknown - token kind, a bad MAC, and a token plus `jwt`; the CLI round trip. + token kind, a bad MAC, and a CAT beside any `jwt` (a CAT is type `0x01` + and a `jwt` is a JWT, so equal bytes are still two credentials; only a + type-0 SETUP token equal to `jwt` is admitted once, per [Token in + band](/quest/m1/auth/token-in-band.md)); the CLI round trip. Public API: `moq_auth::cat` new, `moq auth serve` and `moq auth sign|verify` gain flags. Wire: none. diff --git a/quest/m2/latency-ledger.md b/quest/m2/latency-ledger.md index de59283362..9c5f1edad4 100644 --- a/quest/m2/latency-ledger.md +++ b/quest/m2/latency-ledger.md @@ -29,9 +29,9 @@ trade to get it running. This quest promotes them. ## Required -- [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - defines the stage schema and lands the probes this promotes +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - defines the stage schema and lands the probes this promotes ## Related -- [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - defines the stages and is the first consumer +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - defines the stages and is the first consumer - [QoS](/quest/m1/qos/README.md) - relay-side health, the same idea from the other end diff --git a/quest/m2/play-drain-tail.md b/quest/m2/play-drain-tail.md new file mode 100644 index 0000000000..bed7d7e0ed --- /dev/null +++ b/quest/m2/play-drain-tail.md @@ -0,0 +1,24 @@ +# [XS] moq play plays a finished track's last samples + +## Goal + +When `moq play` retires an audio rendition or reaches the end of a finite +track, every sample it wrote reaches the speaker. Today `drain` in +`rs/moq-cli/src/play/media.rs` returns once 10 ms or less is buffered and +drops the sink, which removes it from the mix, so each retired rendition and +finite track loses up to its last 10 ms +([#4154](https://github.com/moq-dev/moq/pull/4154)). The rendition-switch test +tolerates the gap. + +## Plan + +The 10 ms stop exists because polling the last partial period costs a wakeup +per iteration and never settles. Fix it at the sink instead of the poll: let +a dropped or finished `moq_audio::playback::Sink` play out what it holds +before leaving the mix, or give it an end-of-stream that the mixer honors, so +`drain` no longer needs a threshold. If that belongs in moq-audio's playback +API, keep the change additive. + +Tighten `an_audio_rendition_switch_leaves_no_gap` (the `play::fake::Recorder` +already models the cut on drop) so the lost tail fails it, and add a finite +track that asserts its final samples are heard. diff --git a/quest/m2/relay-io-uring-package.md b/quest/m2/relay-io-uring-package.md new file mode 100644 index 0000000000..23dc0e46d9 --- /dev/null +++ b/quest/m2/relay-io-uring-package.md @@ -0,0 +1,41 @@ +# [S] Linux relay packages ship io_uring + +## Goal + +The official Linux moq-relay builds (the `.deb`, `.rpm`, and tarballs from +`.github/workflows/moq-relay.yml`, and the Nix package in `nix/overlay.nix`) +are compiled with the `io-uring` feature, so `--runtime-io-uring` works on a +packaged relay. Today none of them enable it, yet the packaged systemd unit +sets `LimitMEMLOCK=infinity` for io_uring +([#4197](https://github.com/moq-dev/moq/pull/4197)): the unit prepares for a +runtime the binary cannot run. The runtime stays opt-in; this changes what +ships, not the default. + +## Plan + +- Decided: ship it, but only once the io_uring runtime is on par with the + tokio one for what a packaged relay promises: every configured protocol + served, the `[quic]` tuning honored, and closes delivered. Offering a flag + that silently serves less than the default runtime would be worse than not + offering it. Performance work is not a prerequisite. +- Enable the feature only on Linux targets. Confirm the zigbuild glibc 2.34 + build still links and that nothing new is needed at runtime. +- A kernel without the io_uring features the workers need must make + `--runtime-io-uring` fail loud at startup with the reason, not fall back. +- Package smoke check in the release workflow: start the packaged binary + with `--runtime-io-uring` on the runner, accept one session, and exit, + failing the release if it cannot. Check it under the unit's limits too, + since memlock is why #4197 touched the unit. +- Docs: `doc/bin/relay/` says packaged Linux builds include the runtime, how + to turn it on, and the memlock note. + +Public API: none. Wire: none. + +## Required + +- [moq-transport on io_uring](/quest/m1/uring-ietf.md) - a packaged relay + must not drop protocols when the ring is on +- [Flow-control windows](/quest/m1/uring-flow-control-windows.md) - the + `[quic]` section must not be refused at startup on the ring +- [Close before teardown](/quest/m1/quic/uring-close.md) - sessions on the + ring must end with their application close diff --git a/quest/m3/README.md b/quest/m3/README.md index 232e225d3b..2f76365b3d 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -24,3 +24,4 @@ condition clears, move the quest to the milestone its work belongs in. - [libmoq shutdown](/quest/m3/libmoq-shutdown.md) - OBS exits cleanly with the plugin loaded: a C ABI `moq_shutdown` stops the libmoq thread before the module is unloaded - [libmoq CMake library](/quest/m3/libmoq-cmake-lib.md) - the in-tree CMake build links the `libmoq.a` cargo reports, not a hardcoded `target/` path - [libmoq fetch](/quest/m3/libmoq-fetch.md) - libmoq gains an additive cached-group fetch entry point +- [Upstream forks](/quest/m3/upstream-forks.md) - offer the uniffi generator fixes our cpp, dart, and Python forks carry upstream, lowest priority diff --git a/quest/m3/upstream-forks.md b/quest/m3/upstream-forks.md new file mode 100644 index 0000000000..9ae49440ac --- /dev/null +++ b/quest/m3/upstream-forks.md @@ -0,0 +1,55 @@ +# [S] Offer the uniffi generator fixes upstream + +## Goal + +Every general fix our uniffi generator forks carry has been offered to its +upstream, or recorded as declined or MoQ-specific, so each fork shrinks +toward a pin on an upstream tag. Very low priority: the forks work, and the +first step is someone else's review. Every external post, issue, or PR needs +the maintainer's approval at the time it is made. + +One outcome per candidate: + +- **uniffi-bindgen-cpp** (`kixelated/uniffi-bindgen-cpp`, forked from + LiveKit's `uniffi-0.31-async`, PR #1; + [#4100](https://github.com/moq-dev/moq/pull/4100)): the two leak fixes (a + ready Rust future freed without `rust_future_complete`, and a dropped + foreign future that never completed its oneshot), the missing + `#include ` MSVC needs, the uniffi 0.32 port, and + `error_style = "expected"`. The bug fixes are the easy offer; the 0.32 port + and the expected style depend on LiveKit taking async at all. +- **uniffi-dart** (`kixelated/uniffi-dart`; + [#4072](https://github.com/moq-dev/moq/pull/4072)): the + `nix/uniffi-dart-record-error.patch` and the RustBuffer release fixes. Fix + the latent `lowerForeignBytes` leak first (borrowed `&[u8]` arguments + allocate a `ForeignBytes` nothing frees; the free belongs after the call, + not inside the lowering), and audit callback interfaces for the same + RustBuffer leak, so the upstream offer is complete. `moq_ffi` uses neither + today. +- **uniffi-rs Python typing** of data-carrying enum variants + ([#4049](https://github.com/moq-dev/moq/pull/4049)): the generated type + makes `moq.VideoEncoderKind.AUTO()` fail pyright, so + `doc/lib/py/index.md` carries a `pyright: ignore`. Fix it at the source + and drop the ignore once a release carries it. + +## Plan + +- Decided in #4100: fork tags keep upstream's `v+v` + scheme with a `-kixelated.N` pre-release, e.g. + `v0.11.0-kixelated.1+v0.32.2`, matching the Dart fork. It never collides + with an upstream tag. Under SemVer a pre-release sorts before its base, so + this only works because every pin names the exact tag; never let tooling + pick "the newest" fork tag (Codex on #4307). The Go fork (`v0.9.0+v0.32.0`) moves to it + at its next bump. +- Offer each fix with the regression test it landed with. When upstream + merges one, move the pin in `flake.nix` (and the places its comment lists) + and delete the carried patch. +- Record each outcome (merged, declined with the reason, or not offered + because it is MoQ-specific) in the PR that finishes this quest. + +Public API: none. Wire: none. + +## Related + +- [Upstream the fork](/quest/m1/quic/upstream.md) - the same practice for the noq fork +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the line that forked the C++ generator From 8ef17ee79f661a3a6a981224593219e1e132655f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 21:22:55 -0700 Subject: [PATCH 32/87] fix(net): settle lite-07 tails on stream counts (#4224) Co-authored-by: GPT-6 --- doc/concept/moq-lite.md | 13 ++ js/net/src/lite/subscriber.ts | 25 ++- js/net/src/lite/tail.test.ts | 242 +++++++++++++++++------------- quest/m1/lite-count-settle.md | 21 ++- rs/moq-net/src/lite/subscriber.rs | 26 +++- rs/moq-net/tests/track_tail.rs | 58 ++++--- 6 files changed, 236 insertions(+), 149 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 9f14e3659a..2752bc1f0d 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -34,6 +34,19 @@ Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts still in progress: it negotiates as `moq-lite-07-wip`, and only when both sides explicitly enable it. +## Subscription completion + +On moq-lite 07, `SUBSCRIBE_END` counts the group streams opened for the +subscription. Rust and TypeScript stop waiting for missing streams once that +many headers have arrived; skipped group sequences add no wait. Groups already +being received continue until their own stream ends or resets. + +A stream reset before its header arrived cannot be counted, so the subscriber +still allows a grace period for late streams. The grace uses the subscription's +nonzero effective maximum age, or one second when no maximum age is set. +moq-lite 05 and 06 instead account for group sequences using received headers +and `SUBSCRIBE_DROP`. + ## Discovery A session can ask for announcements matching a path prefix. The peer replies diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index b031f4044f..7640b3ce32 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -40,7 +40,15 @@ import { SubscribeUpdate, } from "./subscribe.ts"; import { TrackInfo, Track as TrackMessage } from "./track.ts"; -import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, restartSupported, Version } from "./version.ts"; +import { + hasAnnounceId, + hasAnnounceOk, + hasDatagrams, + hasProbeRtt, + hasStreamCount, + restartSupported, + Version, +} from "./version.ts"; // Bound on how long stream-open plus the first response (SUBSCRIBE_OK on older // drafts, or TRACK_INFO on lite-05+) may take. Browsers cap concurrent QUIC streams @@ -83,6 +91,8 @@ interface SubscribeEntry { // (SUBSCRIBE_END), once it declares them. start?: number; end?: number; + // Group streams opened by the publisher, when SUBSCRIBE_END carries the count. + streams?: number; } /** @@ -833,6 +843,7 @@ export class Subscriber { } else if ("end" in resp) { if (entry.end !== undefined) throw new ProtocolViolation("duplicate SUBSCRIBE_END"); entry.end = resp.end.group; + if (hasStreamCount(this.version)) entry.streams = resp.end.streams; // A local close can win the race with the response; there is nothing left to end. if (entry.track.closed.peek() !== undefined) continue; try { @@ -848,13 +859,10 @@ export class Subscriber { // Wait for the group streams the publisher still owes once it has ended the subscription. // - // Its FIN says every group below the end is accounted for, but QUIC does not order streams, - // so one can still be in flight. Wait until each group from SUBSCRIBE_START to the end has - // a stream (read to its end) or a SUBSCRIBE_DROP. A group reset before its header arrived - // never shows up, so give up on missing groups after the subscription's effective max age, - // then end cleanly with them skipped like any stale group. That is a wall-clock stopgap for - // a presentation-time budget; a publisher sending SUBSCRIBE_DROP for every group it reset - // would account for them with no timer at all. + // lite-07 counts streams, so skipped sequences owe nothing. Older drafts account for + // the range using received headers and SUBSCRIBE_DROP. A counted stream reset before + // its header leaves no trace, so the grace still bounds that wait. Streams whose + // headers arrived keep reading until their own FIN or reset. #settleTail(entry: SubscribeEntry): Promise { const { tail, track } = entry; // Already the smaller of the subscriber's and the track's max age. @@ -862,6 +870,7 @@ export class Subscriber { const grace = maxAge > 0 ? maxAge : TAIL_GRACE_MS; const complete = () => { + if (entry.streams !== undefined) return tail.streams >= entry.streams; // Without SUBSCRIBE_END (older drafts) nothing says which groups are owed. if (entry.end === undefined) return false; // Without SUBSCRIBE_START the publisher served no group at all. diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index 4edfe88c5c..245791287d 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; import { randomHop } from "../hop.ts"; @@ -20,12 +20,10 @@ import { Subscriber } from "./subscriber.ts"; import { TrackInfo, Track as TrackMessage } from "./track.ts"; import { ALPN_05, Version } from "./version.ts"; -const VERSION = Version.DRAFT_05; - // The subscription's max age, which is also how long it waits for a group that never arrives. const GRACE = Milli(100); -/** One lite-05 frame: a zero timestamp delta, then the length-prefixed payload. */ +/** One lite-05+ frame: a zero timestamp delta, then the length-prefixed payload. */ function frame(payload: string): Uint8Array { const bytes = new TextEncoder().encode(payload); // Every field is under 64, so each is a one-byte varint. @@ -49,31 +47,31 @@ function groupStream(subscriber: Subscriber, sequence: number) { } /** - * A lite-05 subscriber with one track subscribed, whose publisher the test plays by hand: + * A lite-05+ subscriber with one track subscribed, whose publisher the test plays by hand: * it answers TRACK_INFO, then writes whatever responses the test asks for on the subscribe * stream and FINs it when told. */ -async function subscribed(maxAge = GRACE) { +async function subscribed(version: Version, maxAge = GRACE) { const pair = createMockTransportPair(ALPN_05); - const subscriber = new Subscriber(pair.client, VERSION, randomHop()); + const subscriber = new Subscriber(pair.client, version, randomHop()); const reader = subscriber.consume(Path.from("room")).track("video").subscribe({ maxAge }); const info = await Stream.accept(pair.server); if (!info) throw new Error("the subscriber never asked for TRACK_INFO"); expect(await info.reader.u53()).toBe(StreamId.Track); - await TrackMessage.decode(info.reader, VERSION); - await new TrackInfo({ maxAge: 60_000 }).encode(info.writer, VERSION); + await TrackMessage.decode(info.reader, version); + await new TrackInfo({ maxAge: 60_000 }).encode(info.writer, version); info.close(); const sub = await Stream.accept(pair.server); if (!sub) throw new Error("the subscriber never subscribed"); expect(await sub.reader.u53()).toBe(StreamId.Subscribe); - await Subscribe.decode(sub.reader, VERSION); + await Subscribe.decode(sub.reader, version); return { subscriber, reader, - respond: (resp: SubscribeResponse) => encodeSubscribeResponse(sub.writer, resp, VERSION), + respond: (resp: SubscribeResponse) => encodeSubscribeResponse(sub.writer, resp, version), fin: () => sub.writer.close(), reset: (error: Error) => sub.writer.reset(error), }; @@ -103,126 +101,160 @@ async function settlesWithin(promise: Promise, ms: number): Promise { - const { subscriber, reader, respond, fin } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const first = groupStream(subscriber, 0); - first.write("0.0"); - first.finish(); - await respond({ end: new SubscribeEnd(2) }); - await fin(); +describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (version) => { + test("a group stream that arrives after the subscribe stream's FIN is delivered", async () => { + const { subscriber, reader, respond, fin } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const first = groupStream(subscriber, 0); + first.write("0.0"); + first.finish(); + await respond({ end: new SubscribeEnd(2, 2) }); + await fin(); - // The end is known before the last group arrives. - expect(await reader.finished()).toBe(2); + // The end is known before the last group arrives. + expect(await reader.finished()).toBe(2); - // QUIC does not order streams, so group 1 lands after the FIN. - const late = groupStream(subscriber, 1); - late.write("1.0"); - late.finish(); + // QUIC does not order streams, so group 1 lands after the FIN. + const late = groupStream(subscriber, 1); + late.write("1.0"); + late.finish(); - expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); - expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); - expect(await reader.recvGroup()).toBeUndefined(); - expect(await reader.closed).toBeNull(); - expect(reader.final()).toBe(2); -}); + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(2); + }); -test("a group read across the subscribe stream's FIN is delivered whole", async () => { - const { subscriber, reader, respond, fin } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const group = groupStream(subscriber, 0); - group.write("0.0"); + test("a group read across the subscribe stream's FIN is delivered whole", async () => { + const { subscriber, reader, respond, fin } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 0); + group.write("0.0"); - await respond({ end: new SubscribeEnd(1) }); - await fin(); - const received = await reader.recvGroup(); - expect(await received?.readString()).toBe("0.0"); - - // The FIN does not end the group: only its own stream does. - group.write("0.1"); - group.finish(); - expect(await readAll(received)).toEqual(["0.1"]); - expect(await reader.recvGroup()).toBeUndefined(); - expect(await reader.closed).toBeNull(); -}); + await respond({ end: new SubscribeEnd(1, 1) }); + await fin(); + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); -test("a group reset after the subscribe stream's FIN is not presented as complete", async () => { - const { subscriber, reader, respond, fin } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const group = groupStream(subscriber, 0); - group.write("0.0"); - await respond({ end: new SubscribeEnd(1) }); - await fin(); + // The FIN does not end the group: only its own stream does. + group.write("0.1"); + group.finish(); + expect(await readAll(received)).toEqual(["0.1"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + }); - const received = await reader.recvGroup(); - expect(await received?.readString()).toBe("0.0"); - group.reset(); - await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow(); + test("a group reset after the subscribe stream's FIN is not presented as complete", async () => { + const { subscriber, reader, respond, fin } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 0); + group.write("0.0"); + await respond({ end: new SubscribeEnd(1, 1) }); + await fin(); - // The track still ends cleanly: the group was accounted for, as a reset. - expect(await reader.recvGroup()).toBeUndefined(); - expect(await reader.closed).toBeNull(); -}); + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); + group.reset(); + await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow(); -test("a group that never arrives is given up on after the subscription's max age", async () => { - const { subscriber, reader, respond, fin } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const group = groupStream(subscriber, 1); - group.write("1.0"); - group.finish(); - await respond({ end: new SubscribeEnd(2) }); - await fin(); + // The track still ends cleanly: the group was accounted for, as a reset. + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + }); - // Group 0 was reset before its header arrived, so nothing ever accounts for it. - expect((await reader.recvGroup())?.sequence).toBe(1); - const started = performance.now(); - expect(await reader.recvGroup()).toBeUndefined(); - expect(performance.now() - started).toBeGreaterThanOrEqual(GRACE - 5); - expect(await reader.closed).toBeNull(); - expect(reader.final()).toBe(2); + test("a group that never arrives is given up on after the subscription's max age", async () => { + const { subscriber, reader, respond, fin } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 1); + group.write("1.0"); + group.finish(); + await respond({ end: new SubscribeEnd(2, 2) }); + await fin(); + + // Group 0 was reset before its header arrived, so nothing ever accounts for it. + expect((await reader.recvGroup())?.sequence).toBe(1); + const started = performance.now(); + expect(await reader.recvGroup()).toBeUndefined(); + expect(performance.now() - started).toBeGreaterThanOrEqual(GRACE - 5); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(2); + }); + + test.skipIf(version === Version.DRAFT_07)( + "a subscription ends without waiting once every group is accounted for", + async () => { + // A max age far past the test's patience: only the accounting may end it. + const { subscriber, reader, respond, fin } = await subscribed(version, Milli(60_000)); + await respond({ start: new SubscribeStart(0) }); + await respond({ drop: new SubscribeDrop({ start: 0, end: 0, error: 0 }) }); + const group = groupStream(subscriber, 1); + group.write("1.0"); + group.finish(); + await respond({ end: new SubscribeEnd(2, 2) }); + await fin(); + + expect((await reader.recvGroup())?.sequence).toBe(1); + expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); + expect(await reader.closed).toBeNull(); + }, + ); + + test("a subscription that served nothing ends at SUBSCRIBE_END", async () => { + const { reader, respond, fin } = await subscribed(version, Milli(60_000)); + await respond({ end: new SubscribeEnd(0) }); + await fin(); + + expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(0); + }); + + test("a SUBSCRIBE_END below a group already received aborts the track", async () => { + const { subscriber, reader, respond } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 3); + group.finish(); + await group.handled; + await respond({ end: new SubscribeEnd(2, 2) }); + + const closed = await reader.closed; + expect(closed).toBeInstanceOf(Error); + }); }); -test("a subscription ends without waiting once every group is accounted for", async () => { - // A max age far past the test's patience: only the accounting may end it. - const { subscriber, reader, respond, fin } = await subscribed(Milli(60_000)); +test("lite-07 ends without grace when every counted stream arrived despite a skipped group", async () => { + const { subscriber, reader, respond, fin } = await subscribed(Version.DRAFT_07, Milli(60_000)); await respond({ start: new SubscribeStart(0) }); - await respond({ drop: new SubscribeDrop({ start: 0, end: 0, error: 0 }) }); - const group = groupStream(subscriber, 1); - group.write("1.0"); - group.finish(); - await respond({ end: new SubscribeEnd(2) }); + for (const sequence of [0, 2]) { + const group = groupStream(subscriber, sequence); + group.write(`${sequence}.0`); + group.finish(); + await group.handled; + } + await respond({ end: new SubscribeEnd(3, 2) }); await fin(); - expect((await reader.recvGroup())?.sequence).toBe(1); + expect((await reader.recvGroup())?.sequence).toBe(0); + expect((await reader.recvGroup())?.sequence).toBe(2); expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); }); -test("a subscription that served nothing ends at SUBSCRIBE_END", async () => { - const { reader, respond, fin } = await subscribed(Milli(60_000)); - await respond({ end: new SubscribeEnd(0) }); +test("lite-07 zero count ends without grace even when the announced range is nonempty", async () => { + const { reader, respond, fin } = await subscribed(Version.DRAFT_07, Milli(60_000)); + await respond({ start: new SubscribeStart(0) }); + await respond({ end: new SubscribeEnd(3, 0) }); await fin(); expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); - expect(reader.final()).toBe(0); -}); - -test("a SUBSCRIBE_END below a group already received aborts the track", async () => { - const { subscriber, reader, respond } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const group = groupStream(subscriber, 3); - group.finish(); - await group.handled; - await respond({ end: new SubscribeEnd(2) }); - - const closed = await reader.closed; - expect(closed).toBeInstanceOf(Error); + expect(reader.final()).toBe(3); }); for (const started of [false, true]) { test(`a bare FIN ${started ? "after SUBSCRIBE_START" : "without responses"} aborts the track`, async () => { - const { reader, respond, fin } = await subscribed(); + const { reader, respond, fin } = await subscribed(Version.DRAFT_05); if (started) await respond({ start: new SubscribeStart(0) }); await fin(); expect(await reader.closed).toBeInstanceOf(ProtocolViolation); @@ -231,7 +263,7 @@ for (const started of [false, true]) { } test("a subscribe stream reset preserves the publisher's failure", async () => { - const { reader, reset } = await subscribed(); + const { reader, reset } = await subscribed(Version.DRAFT_05); reset(new StreamError(StreamCode.NotFound)); const closed = await reader.closed; expect(closed).toBeInstanceOf(StreamError); diff --git a/quest/m1/lite-count-settle.md b/quest/m1/lite-count-settle.md index 0ff1f18a71..2cb41dcf52 100644 --- a/quest/m1/lite-count-settle.md +++ b/quest/m1/lite-count-settle.md @@ -11,17 +11,16 @@ 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. +- Rust and JS subscribers now settle on the received header count for lite-07. + lite-05 and -06 keep their range and DROP accounting. The grace still covers + counted streams reset before their headers arrive. +- Local Rust and JS regressions cover late streams, missing/reset streams, + skipped sequences, and zero streams. JS also verifies a reset after its header + and groups still being read across the subscription's FIN. +- Remaining: add the count-specific Rust-JS case to track-tail interop. That + harness currently exposes a relay start-floor defect: when a newer group + arrives first, earlier in-flight groups can be lost. Finish the count proof + once that defect is resolved; keep this quest and its PR open until then. ## Related diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index fdaa29b201..8a0259fec9 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2715,8 +2715,8 @@ struct SubStream { tail: kio::Producer, /// The first group the publisher serves (SUBSCRIBE_START), once declared. served: Option, - /// The track's exclusive end (SUBSCRIBE_END), once declared. - end: Option, + /// The track's exclusive end and stream count (SUBSCRIBE_END), once declared. + end: Option, } impl SubStream { @@ -2725,7 +2725,7 @@ impl SubStream { /// `None` when nothing says which: drafts before SUBSCRIBE_END only have the FIN. /// Without a SUBSCRIBE_START the publisher served no group at all. fn owed(&self, requested_end: Option) -> Option> { - let end = self.end?; + let end = self.end.as_ref()?.group; let end = requested_end.map_or(end, |requested| requested.min(end)); Some(self.served.unwrap_or(end)..end) } @@ -3501,11 +3501,13 @@ enum ServeMode { /// exactly like the old inline await. Establish(Establish), /// The upstream FIN'd, so the track is over, but QUIC does not order streams: keep - /// the subscription routable until every group it owes is accounted for (a stream's - /// header or a SUBSCRIBE_DROP), or the grace gives up on one reset before its header. + /// the subscription routable until the counted headers arrive (lite-07), or every + /// owed group is accounted for (older drafts). The grace bounds a stream reset + /// before its header arrived. Tail { settle: Settle, owed: Option>, + streams: Option, }, } @@ -3552,10 +3554,13 @@ impl ServeLoop { } } } - ServeMode::Tail { settle, owed } => { + ServeMode::Tail { settle, owed, streams } => { let _ = self.fetches.poll(waiter); if settle - .poll(waiter, |tail| owed.clone().is_some_and(|owed| tail.covers(owed))) + .poll(waiter, |tail| match streams { + Some(streams) => tail.streams() >= *streams, + None => owed.clone().is_some_and(|owed| tail.covers(owed)), + }) .is_ready() { return Poll::Ready(ServeEnd::Finished); @@ -3668,7 +3673,7 @@ impl ServeLoop { if let Err(err) = self.serving.finish_at(end.group) { tracing::warn!(track = %serve.name, group = end.group, %err, "invalid subscribe end"); } - active.end = Some(end.group); + active.end = Some(end.clone()); } // SUBSCRIBE_START names the first group this feed serves: // the publisher skipped everything below it (e.g. it could @@ -3730,6 +3735,11 @@ impl ServeLoop { self.mode = ServeMode::Tail { settle: Settle::new(&serve.subscriber.runtime, active.tail.consume(), grace), owed: active.owed(requested_end), + streams: active + .end + .as_ref() + .filter(|_| serve.subscriber.version.has_stream_count()) + .map(|end| end.streams), }; continue; } diff --git a/rs/moq-net/tests/track_tail.rs b/rs/moq-net/tests/track_tail.rs index f9dd8ca201..a2e21403bf 100644 --- a/rs/moq-net/tests/track_tail.rs +++ b/rs/moq-net/tests/track_tail.rs @@ -56,12 +56,12 @@ struct Outcome { elapsed: Duration, } -/// Publish a one-group track, end it while its group stream is held back, then deliver or -/// lose that stream, and return what the subscriber read. -async fn round(version: &str, late: Late) -> Outcome { +/// Publish a group below the declared end (or none for an empty track), hold its stream +/// back until the subscription ends, then deliver or lose it. +async fn round(version: &str, late: Late, final_sequence: u64) -> Outcome { let publisher = produce_origin(1); let broadcast = publisher.create_broadcast("bcast").unwrap(); - let track = broadcast.create_track("video", None).unwrap(); + let mut track = broadcast.create_track("video", None).unwrap(); broadcast.announce(Default::default()).unwrap(); let subscriber = produce_origin(2); @@ -112,19 +112,23 @@ async fn round(version: &str, late: Late) -> Outcome { .unwrap(); pair.server_transport.hold_unis(); - let mut group = track.append_group().unwrap(); - group.write_frame(Timestamp::ZERO, PAYLOAD).unwrap(); - group.finish().unwrap(); - track.finish().unwrap(); + if final_sequence > 0 { + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, PAYLOAD).unwrap(); + group.finish().unwrap(); + } + track.finish_at(final_sequence).unwrap(); drop(track); - // Paused time only advances once every task is idle, so this runs the publisher to its - // end of the subscription and the subscriber through reading it. - tokio::time::sleep(GRACE / 10).await; - assert!( - !reader.is_finished(), - "{version}: the track ended before its group arrived" - ); + if final_sequence > 0 { + // Paused time advances only when every task is idle, after the publisher and + // subscriber have processed the subscription's end. + tokio::time::sleep(GRACE / 10).await; + assert!( + !reader.is_finished(), + "{version}: the track ended before its group arrived" + ); + } let released = tokio::time::Instant::now(); match late { @@ -150,7 +154,7 @@ async fn round(version: &str, late: Late) -> Outcome { async fn a_group_after_the_end_is_delivered() { tokio::time::pause(); for version in VERSIONS { - let outcome = round(version, Late::Delivered).await; + let outcome = round(version, Late::Delivered, 1).await; assert!( outcome.err.is_none() && outcome.frames == [PAYLOAD], "{version}: got {} frame(s), err={:?}", @@ -176,7 +180,7 @@ async fn a_group_after_the_end_is_delivered() { async fn a_lost_group_ends_the_track_after_the_grace() { tokio::time::pause(); for version in VERSIONS { - let outcome = round(version, Late::Lost).await; + let outcome = round(version, Late::Lost, 1).await; assert!( outcome.err.is_none() && outcome.frames.is_empty(), "{version}: got {} frame(s), err={:?}", @@ -190,3 +194,23 @@ async fn a_lost_group_ends_the_track_after_the_grace() { ); } } + +/// Skipped sequences have no stream and must not hold lite-07's counted tail open. +#[tokio::test] +async fn lite07_skipped_groups_end_without_the_grace() { + tokio::time::pause(); + let outcome = round("moq-lite-07-wip", Late::Delivered, 3).await; + assert!(outcome.err.is_none()); + assert_eq!(outcome.frames, [PAYLOAD]); + assert!(outcome.elapsed < GRACE / 10, "ended after {:?}", outcome.elapsed); +} + +/// A zero stream count leaves no tail to wait for. +#[tokio::test] +async fn lite07_zero_streams_end_without_the_grace() { + tokio::time::pause(); + let outcome = round("moq-lite-07-wip", Late::Delivered, 0).await; + assert!(outcome.err.is_none()); + assert!(outcome.frames.is_empty()); + assert!(outcome.elapsed < GRACE / 10, "ended after {:?}", outcome.elapsed); +} From 7458c85814e162dda90e87ad0dd21d600a586e09 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 21:23:17 -0700 Subject: [PATCH 33/87] fix(test): isolate concurrent interop harness runs (#4228) Co-authored-by: GPT-6 Co-authored-by: Claude Opus 5.5 --- .github/workflows/interop.yml | 4 ++ quest/m1/README.md | 1 - quest/m1/interop-flakes.md | 23 ---------- test/README.md | 12 ++--- test/interop/clients/js/driver.ts | 13 +++--- test/interop/clients/js/harness.browser.ts | 24 ++++++++++ test/interop/clients/js/harness.ts | 6 +++ test/interop/clients/js/media.ts | 5 +-- test/interop/clients/js/tsconfig.json | 10 ++++- test/interop/interop.sh | 2 +- test/justfile | 6 +++ test/lib/harness.sh | 18 +++++--- test/lib/harness.test.sh | 52 ++++++++++++++++++++++ 13 files changed, 130 insertions(+), 46 deletions(-) delete mode 100644 quest/m1/interop-flakes.md create mode 100644 test/interop/clients/js/harness.browser.ts create mode 100755 test/lib/harness.test.sh diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 8cfe5d49fa..9c18e20531 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -82,6 +82,10 @@ jobs: run: nix develop --command bash -c "bun install --frozen-lockfile && bunx playwright install --with-deps chromium" shell: bash -leo pipefail {0} + - name: Harness regressions + run: nix develop --command just test harness + shell: bash -leo pipefail {0} + - name: Subscription termination interop run: nix develop --command just test bare-fin shell: bash -leo pipefail {0} diff --git a/quest/m1/README.md b/quest/m1/README.md index df1917608b..f43d9b0c6b 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -22,7 +22,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` - [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` -- [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Go cancel test](/quest/m1/go-origin-gc.md) - the Go request-cancel test keeps its origin alive, so the collector can't close it mid-test - [Worker socket count](/quest/m1/worker-socket-count.md) - the moq-tokio worker test counts only its own listener's sockets - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race diff --git a/quest/m1/interop-flakes.md b/quest/m1/interop-flakes.md deleted file mode 100644 index 679c0344cf..0000000000 --- a/quest/m1/interop-flakes.md +++ /dev/null @@ -1,23 +0,0 @@ -# [S] Interop harness runs clean in parallel - -## Goal - -`just test interop --all` passes reliably while other harness runs share the -machine. Two known flakes: concurrent Nix shells reserve the same port because -each keeps its reservations under its own `TMPDIR`, and the browser driver's -pause click is sometimes blocked by the canvas (`js -> js`). - -## Plan - -- Port reservations live under one root shared by every shell (not - `TMPDIR`), or ports come from the OS (bind to 0 and pass the result). Prefer - whichever removes the reservation file entirely. -- The pause control is driven in a way the canvas cannot intercept (an API or - keyboard path, or waiting for the element to be actionable), not a retry. -- Prove it by running two `--all` harnesses at once, several times. - -Public API: none. Wire: none. - -## Related - -- [#4181](https://github.com/moq-dev/moq/pull/4181) - where both flakes were seen diff --git a/test/README.md b/test/README.md index 5c4f994c43..0e9ad969f7 100644 --- a/test/README.md +++ b/test/README.md @@ -21,7 +21,7 @@ Every run owns three things and touches nothing else. holding every log, generated config, and capture. `MOQ_TEST_RUNS` moves the root. **Reserved ports.** A port is claimed by creating a directory under -`$TMPDIR/moq-test-ports-` (`MOQ_TEST_PORTS`), held for the whole run, and +`/tmp/moq-test-ports-` (`MOQ_TEST_PORTS`), held for the whole run, and released on the way out. That reservation is the point: probing for a free port and then releasing it is a race, and two runs that probe at the same moment pick the same number. The walk starts at `MOQ_TEST_PORT_BASE` (4500). A reservation whose owner @@ -30,10 +30,12 @@ Replacement is serialized by `flock` on Linux or `lockf` on macOS, and the reservation records the owner's process start so a reused PID is not mistaken for the original run. -Both roots carry the user id because `TMPDIR` is usually unset on Linux: a fixed -name in a world-writable `/tmp` belongs to whoever ran first, and everyone else -would fail to create anything under it. Two worktrees still share, since they run -as the same user, which is what makes the reservations mean anything. +The reservation root ignores `TMPDIR`: Nix shells have private temporary +directories but share the host's ports. The user id avoids ownership conflicts in +world-writable `/tmp`. An override via `MOQ_TEST_PORTS` must be the same for every +run sharing the network. The root must belong to the current user and cannot be +a symlink; new roots are private. Runs by different users still need disjoint +ports. The reservation settles contention between harness runs, not with the rest of the machine, so each harness still refuses a port something unrelated is already diff --git a/test/interop/clients/js/driver.ts b/test/interop/clients/js/driver.ts index efa0c1ecd4..b04fceca21 100644 --- a/test/interop/clients/js/driver.ts +++ b/test/interop/clients/js/driver.ts @@ -22,6 +22,7 @@ import { type PlayerState, POLL_INTERVAL_MS, pageUrl, + pause, readPlayerState, SELECTORS, serve, @@ -88,7 +89,12 @@ const browser = await launch([ let code = 1; try { - const [page, errors] = await open(browser, pageUrl(server.origin, role, { url, broadcast }), role, role === "subscribe"); + const [page, errors] = await open( + browser, + pageUrl(server.origin, role, { url, broadcast }), + role, + role === "subscribe", + ); if (role === "subscribe") await waitForWatch(page); if (role === "publish") { @@ -129,10 +135,7 @@ try { }); } - // The chrome auto-hides while playing. Pointer activity reveals the real - // control, then the click must flow through the public player API. - await page.dispatchEvent(SELECTORS.ui, "pointermove"); - await page.locator(SELECTORS.ui).locator(SELECTORS.pauseControl).click(); + await pause(page); await waitForState(page, errors, { deadline: interactionDeadline, description: "paused player UI", diff --git a/test/interop/clients/js/harness.browser.ts b/test/interop/clients/js/harness.browser.ts new file mode 100644 index 0000000000..ec07edd378 --- /dev/null +++ b/test/interop/clients/js/harness.browser.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { launch, pause } from "./harness"; + +test("pause activates its button when a canvas intercepts pointer input", async () => { + const browser = await launch(); + try { + const page = await browser.newPage(); + await page.setContent(` + + + `); + await pause(page); + expect(await page.locator("body").getAttribute("data-paused")).toBe("true"); + } finally { + await browser.close(); + } +}); diff --git a/test/interop/clients/js/harness.ts b/test/interop/clients/js/harness.ts index 019d4a003f..4a7dd6ed16 100644 --- a/test/interop/clients/js/harness.ts +++ b/test/interop/clients/js/harness.ts @@ -63,6 +63,12 @@ export const SELECTORS = { fixture: "#fixture", } as const; +/** Activate the player's pause button without depending on pointer hit testing. */ +export async function pause(page: Page): Promise { + // Enter focuses and activates the real button even when the chrome auto-hides. + await page.locator(SELECTORS.ui).locator(SELECTORS.pauseControl).press("Enter"); +} + /** How often a wait re-reads the page. */ export const POLL_INTERVAL_MS = 100; diff --git a/test/interop/clients/js/media.ts b/test/interop/clients/js/media.ts index 6ac033f31b..787b00665e 100644 --- a/test/interop/clients/js/media.ts +++ b/test/interop/clients/js/media.ts @@ -30,6 +30,7 @@ import { type PlayerState, POLL_INTERVAL_MS, pageUrl, + pause, readFixtureState, readPlayerState, SELECTORS, @@ -526,9 +527,7 @@ try { // ── pause and resume ───────────────────────────────────────────────────── if (wants("pause")) { console.error("=== pause and resume ==="); - // The chrome auto-hides while playing; pointer activity reveals the real control. - await player.dispatchEvent(SELECTORS.ui, "pointermove"); - await player.locator(SELECTORS.ui).locator(SELECTORS.pauseControl).click(); + await pause(player); await waitForState(player, playerErrors, { deadline: Date.now() + SETTLE_MS, assertion: "pause takes effect", diff --git a/test/interop/clients/js/tsconfig.json b/test/interop/clients/js/tsconfig.json index a02418e962..af3d0a2b27 100644 --- a/test/interop/clients/js/tsconfig.json +++ b/test/interop/clients/js/tsconfig.json @@ -3,5 +3,13 @@ "compilerOptions": { "types": ["bun", "vite/client"] }, - "include": ["driver.ts", "harness.ts", "media.ts", "src", "vite.config.ts", "../../../../js/common/worklet.d.ts"] + "include": [ + "driver.ts", + "harness.ts", + "harness.browser.ts", + "media.ts", + "src", + "vite.config.ts", + "../../../../js/common/worklet.d.ts" + ] } diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 5400c55be4..66688c1922 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -256,7 +256,7 @@ prepare_go() { } echo "building go client (workspace moq-go via uniffi-bindgen-go)..." local staged ffi_pkg wrapper_pkg src="$HARNESS_RUN/go-client" - if ! staged=$(bash "$WORKSPACE/go/scripts/stage.sh" 2>"$HARNESS_RUN/go-stage.log"); then + if ! staged=$(bash "$WORKSPACE/go/scripts/stage.sh" --output "$HARNESS_RUN/go-stage" 2>"$HARNESS_RUN/go-stage.log"); then mark_broken go "go/scripts/stage.sh failed" sed 's/^/ /' "$HARNESS_RUN/go-stage.log" >&2 || true return diff --git a/test/justfile b/test/justfile index 5cb8ba2342..a9a777d330 100644 --- a/test/justfile +++ b/test/justfile @@ -22,6 +22,12 @@ set working-directory := '.' default: @just --justfile {{ justfile() }} --list test +# Port isolation across private temp roots and keyboard activation under a canvas. +# The browser check is not `*.test.ts`: plain `bun test` runs lack Playwright Chromium. +harness: + ./lib/harness.test.sh + bun test ./interop/clients/js/harness.browser.ts + # Cross-language media interop test, built from this checkout. Stands up a # relay and runs the publisher x subscriber matrix. Default: rust only (fast # sanity check). `interop --all` runs the full matrix: rust + python + go + diff --git a/test/lib/harness.sh b/test/lib/harness.sh index 7180d384c5..7cd50c85e9 100644 --- a/test/lib/harness.sh +++ b/test/lib/harness.sh @@ -105,13 +105,11 @@ harness_begin() { # Where port reservations live. Shared across worktrees on purpose: the point is # that a run in one worktree cannot hand out a port another already took. # -# The default is suffixed with the user id, like the run root. On Linux TMPDIR is -# usually unset, so both would land in a world-writable /tmp under a fixed name -# owned by whoever ran first: a second user's `mkdir` would then fail for every -# port and the walk would report the whole range taken with nothing reserved. Two -# worktrees still share, because they run as the same user. +# Nix shells give TMPDIR a private directory, but their sockets still share the +# host network. Keep claims in /tmp regardless of each shell's scratch root. +# The user id avoids ownership conflicts with another user's harnesses. harness_port_root() { - local root="${MOQ_TEST_PORTS:-${TMPDIR:-/tmp}}" + local root="${MOQ_TEST_PORTS:-/tmp}" root="${root%/}" [[ -n "${MOQ_TEST_PORTS:-}" ]] || root="$root/moq-test-ports-$(id -u)" echo "$root" @@ -146,7 +144,13 @@ harness_port() { local label="$1" wanted="${2:-}" local root port last status root=$(harness_port_root) - mkdir -p "$root" + # Only the reservation root needs private permissions, not its parents. + # shellcheck disable=SC2174 + mkdir -m 700 -p "$root" + if [[ -L "$root" || ! -O "$root" ]]; then + echo "error: port reservation root must be owned by this user and not a symlink: $root" >&2 + return 2 + fi if [[ -n "$wanted" ]]; then harness_valid_port "$wanted" || { diff --git a/test/lib/harness.test.sh b/test/lib/harness.test.sh new file mode 100755 index 0000000000..e1bb411478 --- /dev/null +++ b/test/lib/harness.test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Independent shells must reserve different ports even with private temp roots. +set -euo pipefail + +lib=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +scratch=$(mktemp -d) +owner="" +trap '[[ -z "$owner" ]] || { kill "$owner" 2>/dev/null || true; wait "$owner" 2>/dev/null || true; }; rm -rf "$scratch"' EXIT +mkdir "$scratch/one" "$scratch/two" +mkfifo "$scratch/ready" "$scratch/release" +export MOQ_TEST_RUNS="$scratch/runs" +unset MOQ_TEST_PORTS + +TMPDIR="$scratch/one" bash -euo pipefail -c ' + source "$1/harness.sh" + harness_begin ports >&2 + harness_port first + printf "%s\n" "$HARNESS_PORT" >"$2/ready" + read -r _ <"$2/release" +' bash "$lib" "$scratch" & +owner=$! +read -r first <"$scratch/ready" +TMPDIR="$scratch/two" bash -euo pipefail -c ' + source "$1/harness.sh" + harness_begin ports >&2 + harness_port second + if [[ "$HARNESS_PORT" == "$2" ]]; then + echo "FAIL: separate TMPDIR shells both reserved $2" >&2 + exit 1 + fi + if harness_port pinned "$2"; then + echo "FAIL: pinned port ignored another shell reservation" >&2 + exit 1 + fi +' bash "$lib" "$first" +printf 'done\n' >"$scratch/release" +wait "$owner" +owner="" +# Refuse an unexpected owner-controlled target under the predictable root. +ln -s "$scratch/one" "$scratch/ports-link" +MOQ_TEST_PORTS="$scratch/ports-link" bash -euo pipefail -c ' + source "$1/harness.sh" + harness_begin ports >&2 + if harness_port symlink; then + echo "FAIL: accepted a symlink reservation root" >&2 + exit 1 + else + status=$? + [[ "$status" == 2 ]] + fi +' bash "$lib" +echo "cross-shell port reservations passed" From 8336c469f83d291c92850905fc82fa8d8ec6841b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:20:30 +0000 Subject: [PATCH 34/87] chore(deps-dev): bump ruff from 0.16.7 to 0.16.8 in the uv group (#4310) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/uv.lock b/uv.lock index feb20b9b8a..e9766ea3bf 100644 --- a/uv.lock +++ b/uv.lock @@ -706,27 +706,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, - { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, - { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, - { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, - { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, - { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, - { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, ] [[package]] From 3321bc56f12d6deeeb2fbd9e9534faebeac801bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:22:45 +0000 Subject: [PATCH 35/87] chore(deps): bump code_assets from 2.0.0 to 2.1.0 in /dart/moq_ffi in the pub group across 1 directory (#4311) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dart/moq_ffi/pubspec.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dart/moq_ffi/pubspec.lock b/dart/moq_ffi/pubspec.lock index f947f7e68c..39fa5f0025 100644 --- a/dart/moq_ffi/pubspec.lock +++ b/dart/moq_ffi/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: "direct main" description: name: code_assets - sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" collection: dependency: transitive description: From e773259d44398aadb77bbcafd0890e29777ed458 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:24:39 +0000 Subject: [PATCH 36/87] chore(deps): bump the github-actions group with 2 updates (#4313) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cache.yml | 2 +- .github/workflows/check.yml | 4 ++-- .github/workflows/interop.yml | 2 +- .github/workflows/nightly.yml | 6 +++--- .github/workflows/obs.yml | 2 +- .github/workflows/release-kt-ffi.yml | 2 +- .github/workflows/release-kt-lib.yml | 2 +- .github/workflows/release-rs.yml | 2 +- .github/workflows/wasm.yml | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/cache.yml b/.github/workflows/cache.yml index f6a5e4d433..49d77e8adc 100644 --- a/.github/workflows/cache.yml +++ b/.github/workflows/cache.yml @@ -51,7 +51,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4b26c72456..e3183cfdc6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false @@ -89,7 +89,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 9c18e20531..9302e8b973 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -45,7 +45,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f35363ece5..5885f89c74 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false @@ -142,7 +142,7 @@ jobs: recipe: ["rs doctest --workspace", "rs loom", "test drill-sensitivity", "rs uring"] steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false - name: Checkout @@ -245,7 +245,7 @@ jobs: target: [lite, announce, ietf, varint, path, pattern] steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false - name: Checkout diff --git a/.github/workflows/obs.yml b/.github/workflows/obs.yml index 4433d39ec8..8b4c03420c 100644 --- a/.github/workflows/obs.yml +++ b/.github/workflows/obs.yml @@ -74,7 +74,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false diff --git a/.github/workflows/release-kt-ffi.yml b/.github/workflows/release-kt-ffi.yml index 2d72acfbf0..4fb1c76a66 100644 --- a/.github/workflows/release-kt-ffi.yml +++ b/.github/workflows/release-kt-ffi.yml @@ -166,7 +166,7 @@ jobs: java-version: "17" - name: Set up Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 with: # The action's default adds the legacy `tools` package, which Google # dropped from the SDK repository. Gradle fetches the rest itself. diff --git a/.github/workflows/release-kt-lib.yml b/.github/workflows/release-kt-lib.yml index d135754329..93508bc8e6 100644 --- a/.github/workflows/release-kt-lib.yml +++ b/.github/workflows/release-kt-lib.yml @@ -189,7 +189,7 @@ jobs: java-version: "17" - name: Set up Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 with: # The action's default adds the legacy `tools` package, which Google # dropped from the SDK repository. Gradle fetches the rest itself. diff --git a/.github/workflows/release-rs.yml b/.github/workflows/release-rs.yml index 983cf84b2f..bc111c9d81 100644 --- a/.github/workflows/release-rs.yml +++ b/.github/workflows/release-rs.yml @@ -48,7 +48,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 6cfb329d56..a1409cc72f 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -69,7 +69,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false From 6cc151f4665cbb15141d545696fad6c5ab79a8de Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 11:46:56 -0700 Subject: [PATCH 37/87] quest: plan generated @moq/net from moq-net (rs2ts) (#4315) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 2 +- quest/m1/cpp/README.md | 1 - quest/m1/cpp/generator.md | 1 - quest/m1/mux-wasm-target.md | 4 - quest/m1/rs2ts/README.md | 75 +++++++++++++++++++ quest/m1/rs2ts/ietf.md | 20 +++++ quest/m1/rs2ts/js-varint.md | 27 +++++++ quest/m1/rs2ts/lite.md | 31 ++++++++ quest/m1/rs2ts/mock-clock.md | 26 +++++++ quest/m1/rs2ts/remove-wasm.md | 20 +++++ quest/m1/rs2ts/sans-io/README.md | 23 ++++++ quest/m1/rs2ts/sans-io/ietf.md | 19 +++++ quest/m1/rs2ts/sans-io/lite.md | 26 +++++++ quest/m1/rs2ts/sans-io/model.md | 18 +++++ quest/m1/rs2ts/translator.md | 53 +++++++++++++ quest/m1/rs2ts/varint-codec.md | 31 ++++++++ quest/m1/test-flakes-2.md | 2 - quest/m1/wasm-opt.md | 26 ------- ...ser-through-moq-ffi-uniffi-instead-of-a.md | 54 ------------- quest/m4/README.md | 1 - 20 files changed, 370 insertions(+), 90 deletions(-) create mode 100644 quest/m1/rs2ts/README.md create mode 100644 quest/m1/rs2ts/ietf.md create mode 100644 quest/m1/rs2ts/js-varint.md create mode 100644 quest/m1/rs2ts/lite.md create mode 100644 quest/m1/rs2ts/mock-clock.md create mode 100644 quest/m1/rs2ts/remove-wasm.md create mode 100644 quest/m1/rs2ts/sans-io/README.md create mode 100644 quest/m1/rs2ts/sans-io/ietf.md create mode 100644 quest/m1/rs2ts/sans-io/lite.md create mode 100644 quest/m1/rs2ts/sans-io/model.md create mode 100644 quest/m1/rs2ts/translator.md create mode 100644 quest/m1/rs2ts/varint-codec.md delete mode 100644 quest/m1/wasm-opt.md delete mode 100644 quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md diff --git a/quest/m1/README.md b/quest/m1/README.md index f43d9b0c6b..e78483fdba 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -126,6 +126,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 +- [Generated @moq/net](/quest/m1/rs2ts/README.md) - the browser runs moq-net as TypeScript generated from the Rust source, retiring js/net's hand-written protocol and model code - [Plan: watch worker](/quest/m1/plan-watch-worker.md) - prototype an invisible page worker against app-spawned workers, and land the jank harness that decides - [Watch worker](/quest/m1/watch-worker.md) - watch playback runs in a worker onto an OffscreenCanvas, so main-thread jank never stalls video or audio - [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw @@ -176,7 +177,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS bundle trims](/quest/m1/js-bundle-trims.md) - minified worklets, no bowser, split pako, and lazy qmux and captions - [Slim Docker images](/quest/m1/docker-slim.md) - images carry only the package's nix closure, not ~170 MiB of nixos/nix - [Bindings size profile](/quest/m1/ffi-size-profile.md) - a benchmark decides whether the moq-ffi builds ship at opt-level "s", which halves the dylib -- [wasm-opt](/quest/m1/wasm-opt.md) - `just wasm` size-optimizes the moq-wasm module with binaryen - [Go mirror delivery](/quest/m1/go-mirror-delivery.md) - the Go binding's staticlibs stop growing git history by ~210 MiB per release - [Relay iroh opt-in](/quest/m1/relay-iroh-opt-in.md) - moq-relay drops iroh from its defaults and shipped builds, while moq-cli keeps it for P2P - [Dart on iOS](/quest/m1/dart-ios.md) - prove the shipped iOS native asset actually loads on a device, which no CI can diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index 75b88fb0ec..ea7c38b463 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -75,6 +75,5 @@ Confirmed in [#4100](https://github.com/moq-dev/moq/pull/4100): - [C# through moq-ffi](/quest/m2/cs/README.md) - the same recipe with NordSecurity's C# generator - [Unreal prototype](/quest/m2/unreal.md) - a UE5 module consumes the package with exceptions disabled -- [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser reaches moq-ffi through a generator too; shares the Task-per-target findings - [vcpkg registry](/quest/m2/cpp-vcpkg.md) - a registry we own serves the prebuilt package to `vcpkg` manifests - [Conan remote](/quest/m2/cpp-conan.md) - a remote we own serves the same tarball to `conan install` diff --git a/quest/m1/cpp/generator.md b/quest/m1/cpp/generator.md index 19ac2fe6c2..1a3c917d0d 100644 --- a/quest/m1/cpp/generator.md +++ b/quest/m1/cpp/generator.md @@ -44,5 +44,4 @@ stop here and write down why. ## Related -- [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser generator spike; same Task and `#[cfg]`-inside-export gotchas apply - [C# generator](/quest/m2/cs/generator.md) - the same 0.32 port against NordSecurity's C# generator diff --git a/quest/m1/mux-wasm-target.md b/quest/m1/mux-wasm-target.md index 74f9054a83..17fc354710 100644 --- a/quest/m1/mux-wasm-target.md +++ b/quest/m1/mux-wasm-target.md @@ -24,7 +24,3 @@ work. and `moq-net`, so it stays green. Public API: none. Wire: none. - -## Related - -- [Browser through moq-ffi](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the study these blockers were found by diff --git a/quest/m1/rs2ts/README.md b/quest/m1/rs2ts/README.md new file mode 100644 index 0000000000..726f33ff0f --- /dev/null +++ b/quest/m1/rs2ts/README.md @@ -0,0 +1,75 @@ +# Generated @moq/net + +## Goal + +moq-net is the single implementation of the MoQ protocol and model layer. +The browser runs it as TypeScript generated from the Rust source, retiring +js/net's hand-written equivalent with no regression in bundle size, CPU, or +usability. Lite comes first, IETF after. Transport glue (the WebTransport and +WebSocket pumps, timers) stays hand-written TypeScript. + +## Plan + +Decided in planning (2026-09-27), with the spike data in +: + +- Generated TypeScript, not WASM. Today's `moq-wasm` is 527 KB gzip against + js/net's 84 KB, and loses on CPU to async wasm-bindgen glue (~600 ns per + async call against 26 ns in JS). A hand-carved sans-IO lite core was 6 KB + gzip and 1.7-9x faster than js/net, but a plain-JS port of the same + synchronous decoder was faster still: the win is the sans-IO shape, not + WASM. The model layer is shared too, and every call on a model handle would + cross the WASM boundary, so generated TS is the path. +- The translator is `rs/rs2ts`, built on Charon (Rust MIR restructured into + LLBC). Charon's `--precise-drops` gives exact drop points, which the + close-on-last-drop handles depend on, and `--start-from` extracts a subset. + rust-js was evaluated and rejected as a base: no Drop, no generic traits, + JS only, all-or-nothing extraction, 32-bit `usize`. Its MIT oxc printer is + worth borrowing for formatting and source maps. +- moq-net itself becomes the sans-IO core: bytes and timestamps in, events + and bytes out, no runtime. The async helper methods move behind an `async` + cargo feature; rs2ts reads the crate without it and JS reimplements the + helpers with Promises. No second crate. +- Varints stay 62-bit on the wire; the spec is not bounded to 2^53. Rust's + `VarInt` newtype carries Encode/Decode and JS gets a matching `VarInt` type + with checked conversion to and from `number`. +- The generated TypeScript is committed and a CI lane regenerates it and + fails on drift, so JS contributors and npm publishing never need the + nightly toolchain Charon pins. It lives inside js/net and `@moq/net` stays + the package. +- The `@moq/net` API may change (disposable handles, `VarInt`) as long as it + is no worse to use; watch, publish, hang, and the demos update in the same + change. +- Parity: `just test interop --all`, plus moq-net's own tests translated with + the code once they run on a mock clock instead of tokio. +- The line lands on `dev`: the Rust refactors break moq-net's published API, + and the translator and generated code build on them. Only the additive + [JS VarInt](/quest/m1/rs2ts/js-varint.md) lands on `main`. +- Hand-written js/net fixes keep landing until the generated path replaces + them; it is months out. + +This README's own work is the no-downgrade report once generated lite ships: +bundle size, per-frame CPU, and first-frame latency against the hand-written +js/net it replaces, measured with the [browser benchmarks](/quest/m1/browser-benchmarks.md). + +## Quests + +- [VarInt codec](/quest/m1/rs2ts/varint-codec.md) - moq-net encodes through a `VarInt` newtype and a concrete slice-based codec, not generic traits on primitives +- [JS VarInt](/quest/m1/rs2ts/js-varint.md) - js/net has a 62-bit `VarInt` type with checked `number` conversion and no BigInt on the hot path +- [rs2ts](/quest/m1/rs2ts/translator.md) - a Charon-based translator emits readable TypeScript for moq-net's lite codec, committed and checked for drift in CI +- [Sans-IO moq-net](/quest/m1/rs2ts/sans-io/README.md) - moq-net builds and runs without a runtime; async helpers sit behind an `async` feature +- [Mock-clock tests](/quest/m1/rs2ts/mock-clock.md) - moq-net's tests run on the sans-IO clock instead of tokio, so they translate with the code +- [Generated lite](/quest/m1/rs2ts/lite.md) - @moq/net's lite session and model layer are generated from moq-net +- [Generated IETF](/quest/m1/rs2ts/ietf.md) - @moq/net's moq-transport session is generated too +- [Remove moq-wasm](/quest/m1/rs2ts/remove-wasm.md) - the WASM experiment is deleted once generated lite ships + +## Closes + +- [#2907](https://github.com/moq-dev/moq/issues/2907) - close this issue when the quest finishes +- [#2822](https://github.com/moq-dev/moq/issues/2822) - close this issue when the quest finishes +- [#2835](https://github.com/moq-dev/moq/issues/2835) - close this issue when the quest finishes + +## Related + +- [#2850](/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md) - the same synchronous decode shape, in hand-written js/net today +- [Browser benchmarks](/quest/m1/browser-benchmarks.md) - the harness the no-downgrade report uses diff --git a/quest/m1/rs2ts/ietf.md b/quest/m1/rs2ts/ietf.md new file mode 100644 index 0000000000..7c6ee4fea8 --- /dev/null +++ b/quest/m1/rs2ts/ietf.md @@ -0,0 +1,20 @@ +# [L] Generated IETF + +## Goal + +@moq/net's moq-transport session is generated from moq-net like lite, and +the hand-written js/net IETF code (about 8.7k lines) is deleted, with +`just test interop --all` passing. + +## Plan + +Values above 2^53 are legal on the IETF wire (request ids, track aliases); +they stay exact as `VarInt` and only fail where code converts them to +`number`. + +Public API: breaks `@moq/net`; retargets to `dev`. Wire: none. + +## Required + +- [Generated lite](/quest/m1/rs2ts/lite.md) - the pipeline this reuses +- [Sans-IO IETF session](/quest/m1/rs2ts/sans-io/ietf.md) - the session shape it translates diff --git a/quest/m1/rs2ts/js-varint.md b/quest/m1/rs2ts/js-varint.md new file mode 100644 index 0000000000..0c17bc4432 --- /dev/null +++ b/quest/m1/rs2ts/js-varint.md @@ -0,0 +1,27 @@ +# [S] JS VarInt + +## Goal + +js/net has a `VarInt` type that holds the full 62-bit range, converts to and +from `number` with a loud error outside the safe range, and encodes and +decodes without BigInt on the hot path. It is the TypeScript type rs2ts maps +Rust's `VarInt` to. + +## Plan + +Measured in node 24 for an 8-byte encode plus decode: `number` written as two +`u32` halves 2.4 ns, a `{hi, lo}` pair 4.8 ns, `bigint` 10 ns, and js/net +today (BigInt on the wire, then `Number()`) 32 ns. The leading-ones encoder +also converts every value to BigInt, even small ones. + +Guidance: + +- Store two `u32` halves; offer `fromNumber` and `toNumber` (throwing above + 2^53 or on a negative or fractional input), `fromBigInt` and `toBigInt`, + and comparison and increment methods so sequence logic never converts. +- Move js/net's varint reading and writing onto it, dropping the BigInt + round trip for QUIC and leading-ones varints. +- Unit-test the boundaries (2^30, 2^53, 2^62 - 1) against Rust's encoder in + `just test interop`. + +Public API: additive to `@moq/net`; lands on `main`. Wire: none. diff --git a/quest/m1/rs2ts/lite.md b/quest/m1/rs2ts/lite.md new file mode 100644 index 0000000000..6cade8266c --- /dev/null +++ b/quest/m1/rs2ts/lite.md @@ -0,0 +1,31 @@ +# [XL] Generated lite + +## Goal + +@moq/net's lite session and model layer are generated from moq-net by rs2ts, +and the hand-written TypeScript they replace is deleted. Hand-written +TypeScript remains only for the transport pumps, timers, and the Promise +helpers over the poll API. The translated moq-net tests and +`just test interop --all` pass, and bundle size, per-frame CPU, and +first-frame latency are no worse than the hand-written js/net. + +## Plan + +- The `@moq/net` API may change where the generated shape is no worse to + use: disposable handles (`using`), `VarInt` for sequences and ids. Update + watch, publish, hang, room, and the demos in the same change, and the + `doc/` pages for anything user-facing. +- A forgotten `drop()` leaves a track open forever: add a debug-only + `FinalizationRegistry` that reports handles collected without one, and + runtime guards against double drop and use after drop. +- Size budget: js/net's `lite/*` is 15 KB gzip today; keep generated output + near it. Watch for std shims and fmt/tracing pulling in weight. + +Public API: breaks `@moq/net`; retargets to `dev`. Wire: none. + +## Required + +- [rs2ts](/quest/m1/rs2ts/translator.md) - the translator +- [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the session shape it translates +- [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - the model shape it translates +- [Mock-clock tests](/quest/m1/rs2ts/mock-clock.md) - the tests that prove parity diff --git a/quest/m1/rs2ts/mock-clock.md b/quest/m1/rs2ts/mock-clock.md new file mode 100644 index 0000000000..3f1ea344c9 --- /dev/null +++ b/quest/m1/rs2ts/mock-clock.md @@ -0,0 +1,26 @@ +# [L] Mock-clock tests + +## Goal + +moq-net's tests run on the sans-IO clock instead of tokio, so they need no +runtime and rs2ts translates them alongside the code. The generated +TypeScript runs the same tests under bun. + +## Plan + +tokio is in moq-net's tests only for paused, advanceable time +(`start_paused`, `advance`): 591 `tokio::test`s on dev, 86 of them paused. +Drive them from the model's injectable clock and a small synchronous +executor instead. + +Guidance: + +- Port mechanically where possible; keep each test's assertions unchanged. +- Tests that exercise the `async` helpers stay behind that feature and are + not translated. + +Public API: none. Wire: none. + +## Required + +- [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - supplies the clock seam diff --git a/quest/m1/rs2ts/remove-wasm.md b/quest/m1/rs2ts/remove-wasm.md new file mode 100644 index 0000000000..2f8d0a2c21 --- /dev/null +++ b/quest/m1/rs2ts/remove-wasm.md @@ -0,0 +1,20 @@ +# [S] Remove moq-wasm + +## Goal + +`rs/moq-wasm`, `js/wasm`, `just wasm`, and the WASM path in `test/wasm` are +deleted: the browser runs moq-net as generated TypeScript instead. + +## Plan + +Decided in planning: keep the experiment until generated lite ships, then +delete it rather than polish it. Remove its entries from the size report, +the justfiles, the wasm clippy lane (keep `moq-net` and `moq-mux` there if +anything still targets wasm32), and the docs, and update the P2P questline's +note about `moq-wasm`. + +Public API: removes the unpublished `@moq/wasm` package. Wire: none. + +## Required + +- [Generated lite](/quest/m1/rs2ts/lite.md) - the replacement diff --git a/quest/m1/rs2ts/sans-io/README.md b/quest/m1/rs2ts/sans-io/README.md new file mode 100644 index 0000000000..223b68d8af --- /dev/null +++ b/quest/m1/rs2ts/sans-io/README.md @@ -0,0 +1,23 @@ +# Sans-IO moq-net + +## Goal + +moq-net builds and runs with no async runtime: bytes and timestamps go in, +events and bytes come out. The async helper methods sit behind an `async` +cargo feature, and a CI lane builds and tests the crate without it. + +## Plan + +Decided in planning: moq-net itself is the core, not a second crate. JS +reimplements the async helpers natively with Promises, so the translator +reads the crate without the `async` feature. Split by layer so each lands on +`dev` independently. + +This README's own work is the `async` feature and the CI lane that builds +moq-net without it. + +## Quests + +- [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the lite session is driven by bytes, stream events, and `tick(now)` +- [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - origin, broadcast, track, and group handles run without a runtime, with time supplied by the caller +- [Sans-IO IETF session](/quest/m1/rs2ts/sans-io/ietf.md) - the moq-transport session is driven the same way as lite diff --git a/quest/m1/rs2ts/sans-io/ietf.md b/quest/m1/rs2ts/sans-io/ietf.md new file mode 100644 index 0000000000..df8fad3ab9 --- /dev/null +++ b/quest/m1/rs2ts/sans-io/ietf.md @@ -0,0 +1,19 @@ +# [L] Sans-IO IETF session + +## Goal + +The moq-transport session is driven like the [sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md): +bytes, stream events, and `tick(now)` in, bytes and model events out. + +## Plan + +Follow whatever shape the lite session settles on. The IETF code is the +largest module (about 12.7k non-test lines) and today compiles part of itself +twice (for `Session` and `ControlStreamAdapter`); collapse that while +here. + +Public API: breaks moq-net's IETF session API; retargets to `dev`. Wire: none. + +## Required + +- [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - sets the driver shape diff --git a/quest/m1/rs2ts/sans-io/lite.md b/quest/m1/rs2ts/sans-io/lite.md new file mode 100644 index 0000000000..51e426c403 --- /dev/null +++ b/quest/m1/rs2ts/sans-io/lite.md @@ -0,0 +1,26 @@ +# [L] Sans-IO lite session + +## Goal + +The lite session is a state machine fed bytes, stream open and close events, +and `tick(now)`; it returns bytes to write and events for the model. No +`web_transport_trait` stream types, no timers of its own, no async outside +the `async` feature. + +## Plan + +A hand-carved spike (lite-06 subscriber: SETUP, ANNOUNCE, SUBSCRIBE, group +streams, deadlines via `tick`) came to about 600 lines with frames surfaced +as `[offset, len]` ranges into the caller's chunk, so payload bytes are never +copied by the core. Decode every complete frame already buffered in one pass; +awaiting per frame is where js/net loses 2.5-3.5 µs per frame. + +Guidance: + +- Keep the session's existing behavior and wire exactly; the interop suite is + the check. +- Stream handles, write backpressure, and close codes become explicit events + or return values the driver acts on. +- Keep maps as Vec slabs where the key space is small. + +Public API: breaks moq-net's session API; retargets to `dev`. Wire: none. diff --git a/quest/m1/rs2ts/sans-io/model.md b/quest/m1/rs2ts/sans-io/model.md new file mode 100644 index 0000000000..9060397847 --- /dev/null +++ b/quest/m1/rs2ts/sans-io/model.md @@ -0,0 +1,18 @@ +# [L] Sans-IO model + +## Goal + +The origin, broadcast, track, group, and frame producers and consumers run +without an async runtime. Their waiting is poll-based on kio, and anything +time-based reads a clock the caller supplies, so the model translates to +TypeScript and its tests can run on a mock clock. + +## Plan + +The model is already poll-based on kio waiters; what remains is every place +that reaches a runtime or wall clock directly (`runtime::Deadline`, the cache +pool's expiry, stats timers). Route time through one injectable clock, the +seam the [mock-clock tests](/quest/m1/rs2ts/mock-clock.md) use. + +Public API: may break moq-net's model constructors; retargets to `dev`. +Wire: none. diff --git a/quest/m1/rs2ts/translator.md b/quest/m1/rs2ts/translator.md new file mode 100644 index 0000000000..1eecf983f1 --- /dev/null +++ b/quest/m1/rs2ts/translator.md @@ -0,0 +1,53 @@ +# [L] rs2ts + +## Goal + +`rs/rs2ts` translates moq-net's lite codec into readable TypeScript inside +js/net. The output is committed, a CI lane regenerates it and fails on +drift, and the generated codec passes `just test interop --all` in place of +the hand-written one. + +## Plan + +A prototype exists in the planning spike: about 1,800 lines on `charon_lib` +plus a 200-line runtime shim. It turned a sample crate into TypeScript that +passed behavioral tests, including close-on-last-drop, and ran on moq-net's +`coding` and lite message modules via `--start-from` (about 7,000 lines of +output, 500 untranslated calls, mostly std, tracing, and atomics shims). + +Mapping decided in planning: + +- Structs become classes, enums discriminated unions, traits interfaces. + `Option` is `T | undefined`, `&[u8]` a `Uint8Array` view with no copy. +- `Drop` becomes an explicit `drop()` at each MIR drop point, exposed as + `[Symbol.dispose]`; `Arc`/`Rc` of a type with drop glue become an explicit + refcount. JS is single-threaded, so `Mutex` and atomics become plain + access. +- Rust `VarInt` maps to the [JS VarInt](/quest/m1/rs2ts/js-varint.md) type. + Integers up to 32 bits and `usize` map to `number` with checked arithmetic + that throws on overflow; never wrap silently. A `u64` or `i64` never maps + to a lossy `number`: the model accepts `u64::MAX` (e.g. + `model/subscription.rs`), so each one either becomes `VarInt` or an + `Option` in the source, or maps to a full-width 64-bit TypeScript type. + +Guidance: + +- Pin Charon and its nightly in the nix shell for the regeneration lane only. +- Borrow rust-js's MIT oxc printer for formatting and source maps. +- Readability pass: inline single-use temporaries and keep source branch + order, so a reviewer can read a generated diff. +- Add a lint on moq-net (clippy or dylint) for the accepted subset: no + `unsafe`, no `async` outside the `async` feature, no `u64` bit operations, + no trait impls on foreign or primitive types, no `&mut` out-params to + scalar or `Option` locals. Translator gaps become compile errors, not + runtime `todo()`s. Document the subset in `rs/rs2ts/README.md`. +- Charon stalled for 40+ minutes on the whole crate; extract only the modules + being generated. + +Public API: none (internal tool). Lands on `dev` with the codec it +translates. Wire: none. + +## Required + +- [VarInt codec](/quest/m1/rs2ts/varint-codec.md) - the codec shape the translator targets +- [JS VarInt](/quest/m1/rs2ts/js-varint.md) - the TypeScript type `VarInt` maps to diff --git a/quest/m1/rs2ts/varint-codec.md b/quest/m1/rs2ts/varint-codec.md new file mode 100644 index 0000000000..39557cb31b --- /dev/null +++ b/quest/m1/rs2ts/varint-codec.md @@ -0,0 +1,31 @@ +# [M] VarInt codec + +## Goal + +Every varint moq-net puts on the wire is a `VarInt`, and `VarInt` is the only +integer type with Encode/Decode. Messages encode and decode through a +concrete, slice-based codec instead of generic traits implemented on `u64`, +`usize`, `bool`, `String`, `Option`, and `Vec`. + +## Plan + +Two reasons, one refactor. Not every `u64` is a valid varint, so the type +should say which fields are. And the rs2ts translator cannot map generic +traits on primitives without dictionary passing, its most expensive feature; +the same generics are most of why moq-net's lite codec built to 47 KB gzip in +WASM against 6 KB for a hand-carved one. + +Guidance: + +- Keep the 62-bit range; the wire is not bounded to 2^53. +- Message types keep a local trait; the primitives become inherent methods + on concrete reader and writer types (`varint`, `string`, `bytes`, ...). + Make the version a concrete type rather than a generic `V` where possible. +- Avoid bit operations on `u64` in the codec: write the 8-byte form as two + `u32` halves, so the generated TypeScript never needs 64-bit bitwise math. +- `Parameters` becomes Vec-backed, and decode paths stop branching on + `tracing::enabled!` (log after decoding instead). +- Benchmark the codec before and after (Criterion); it is on every message. + +Public API: breaks moq-net's `coding` module (Encode/Decode on primitives +go away), so this retargets to `dev`. Wire: none. diff --git a/quest/m1/test-flakes-2.md b/quest/m1/test-flakes-2.md index 384faa3f0e..c1742598ca 100644 --- a/quest/m1/test-flakes-2.md +++ b/quest/m1/test-flakes-2.md @@ -54,5 +54,3 @@ Public API: none. Wire: none. - [Archive enrollment](/quest/m1/archive/enrollment-flake.md) - the same kind of flake on the archive line, where its test lives -- [Interop flakes](/quest/m1/interop-flakes.md) - port reservations and the - pause click in the interop harness diff --git a/quest/m1/wasm-opt.md b/quest/m1/wasm-opt.md deleted file mode 100644 index 79b6362671..0000000000 --- a/quest/m1/wasm-opt.md +++ /dev/null @@ -1,26 +0,0 @@ -# [XS] wasm-opt for moq-wasm - -## Goal - -`just wasm` runs binaryen's `wasm-opt -Oz` after `wasm-bindgen`, so the -module is size-optimized the way browser wasm usually is. The module is -528 KB gzip today, up from the ~471 KB in the `wasm-release` comment in the -workspace `Cargo.toml`, which is now stale. - -## Plan - -Decided in planning: do it now, even though the only consumer is -`test/wasm`. The cost is small and the module is meant to ship. - -Guidance: - -- Add binaryen to the nix dev shell so CI and local builds match. -- wasm-opt must allow the wasm features rustc emits (bulk memory, sign-ext, - and others), or it will reject the module. Pass the matching `--enable-*` - flags. -- Update the comment with measured sizes, and confirm `test/wasm` still - passes. - -## Related - -- [Size report](/quest/m1/size-report.md) - tracks the module nightly diff --git a/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md b/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md deleted file mode 100644 index 37bbd3470f..0000000000 --- a/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md +++ /dev/null @@ -1,54 +0,0 @@ -# [L] Reach the browser through moq-ffi instead of a second hand-written wasm binding - -## Goal - -The browser holds the moq-ffi surface through a generated TypeScript binding -over `moq-ffi` compiled to wasm32, instead of `moq-wasm` growing a second -hand-written copy of the model (#2814 was closed for that reason). The two -`moq-wasm` gaps close by construction, because moq-ffi already binds them: -datagrams (#2822) and `track::Dynamic` so a browser publisher can serve a -cache-miss fetch (#2835). `@moq/net` stays the browser API; this is the -Rust-in-browser path `moq-wasm` experiments with today. - -## Plan - -The #2907 spike measured the shape: with `moq-native`, the codecs, and the -tokio runtime gated behind `cfg(not(target_arch = "wasm32"))` and uniffi's -`wasm-unstable-single-threaded` feature, 82 of 160 exported methods survive -on wasm32, the whole raw `moq-net` model among them; `uniffi-bindgen-js` -emits a `tsc --strict`-clean package where `u64` is `bigint` and a pending -call owns its own `Arc`, the two points that sank #2814; and the raw wasm is -about 8 % larger than `moq-wasm` for roughly ten times the surface. - -What waits on the outside world is the generator: `uniffi-bindgen-js` is -0.2.1 with a few hundred downloads and `uniffi-bindgen-react-native` calls -itself not for production. Nothing in the spike ran in a browser. - -When the gate clears: - -- Gate `moq-ffi` for wasm32 as the spike did, with separate `#[cfg]`'d - `#[uniffi::export] impl` blocks (a `#[cfg]` on a method inside one block is - ignored by the macro). Decide `Task` semantics per target (native spawns, - wasm awaits in place so a dropped promise cancels) and keep one - WebTransport adapter rather than a second copy of `moq-wasm/src/transport.rs`. -- Decouple `MoqBroadcastProducer` from the hang catalog so a raw-model - publish exists; it is the one non-`#[cfg]` change. -- Pick the generator with a browser harness in place (`test/wasm` runs the - package in headless Chromium) and prove datagrams and a served cache-miss - fetch end to end, including the documented drop on versions that cannot - carry datagrams. - -## Required - -- [moq-mux compiles for wasm32](/quest/m1/mux-wasm-target.md) - the two latent blockers, fixed now -- A `uniffi-bindgen-js` release its authors call stable, or `uniffi_bindgen` shipping a JS backend - -## Closes - -- [#2907](https://github.com/moq-dev/moq/issues/2907) - close this issue when the quest finishes -- [#2822](https://github.com/moq-dev/moq/issues/2822) - close this issue when the quest finishes -- [#2835](https://github.com/moq-dev/moq/issues/2835) - close this issue when the quest finishes - -## Related - -- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the same generate-not-hand-roll rule, on a generator that exists diff --git a/quest/m4/README.md b/quest/m4/README.md index de01a3059f..7b975c7340 100644 --- a/quest/m4/README.md +++ b/quest/m4/README.md @@ -15,6 +15,5 @@ the milestone its priority belongs in. - [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 encode and decode, and pre-generated bindings that remove the libclang build dependency, gated on a moq-dev/vaapi release - [Pool VAAPI resize surfaces](/quest/m4/vaapi-resize-pool.md) - a resize reuses one output surface per size once moq-vaapi ships that pool -- [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser reaches moq-ffi through a generated TypeScript binding once a JS generator is stable - [Safari WebTransport](/quest/m4/safari-webtransport.md) - WebKit browsers return to WebTransport once WebKit 319818 ships fixed - [MSFTS convergence](/quest/m4/msfts-convergence.md) - the demultiplexed TS lane maps onto MSFTS ES-level carriage once msfts#33 settles the payload unit From df2fc83dbb540281be9dfa72da684dde74f38e42 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 11:59:26 -0700 Subject: [PATCH 38/87] perf(net): decode buffered group frames and objects synchronously (#4316) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 7 + js/net/bench/frames.ts | 212 +++++++++++++ js/net/src/ietf/ietf.test.ts | 69 ++++- js/net/src/ietf/object.ts | 43 ++- js/net/src/ietf/publisher.test.ts | 2 +- js/net/src/ietf/subscriber.test.ts | 28 ++ js/net/src/ietf/subscriber.ts | 24 +- js/net/src/integration.test.ts | 44 +++ js/net/src/lite/group.test.ts | 93 ++++++ js/net/src/lite/group.ts | 56 ++-- js/net/src/lite/subscriber.ts | 71 ++--- js/net/src/stream.test.ts | 54 +++- js/net/src/stream.ts | 286 +++++++++++++----- ...r-a-synchronous-decode-so-the-publisher.md | 29 +- 14 files changed, 804 insertions(+), 214 deletions(-) create mode 100644 js/net/bench/frames.ts create mode 100644 js/net/src/lite/group.test.ts diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5885f89c74..91aadf86e0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -82,6 +82,13 @@ jobs: if: ${{ !cancelled() }} run: nix develop --command bun js/net/bench/reader.ts + # Fails if a chunk carrying many small frames costs as much per frame as one + # carrying a single frame, on either the lite or moq-transport group stream, + # which means decoding went back to a wakeup per frame. + - name: JS group frame benchmark + if: ${{ !cancelled() }} + run: nix develop --command bun js/net/bench/frames.ts + # Runs each moq-net Criterion routine once, so a bench that panics (an # unparsable version, a shape that deadlocks) fails here instead of on the # next manual run. Timing is not compared; nextest never runs a diff --git a/js/net/bench/frames.ts b/js/net/bench/frames.ts new file mode 100644 index 0000000000..34752d5262 --- /dev/null +++ b/js/net/bench/frames.ts @@ -0,0 +1,212 @@ +/** Sweep frame and chunk size for a group stream decoded into a group and drained by a reader. */ +import type { Consumer as GroupConsumer } from "../src/group.ts"; +import { Producer } from "../src/group.ts"; +import { NativeSession } from "../src/ietf/adapter.ts"; +import { Group as IetfGroup } from "../src/ietf/object.ts"; +import { Subscribe, SubscribeOk } from "../src/ietf/subscribe.ts"; +import { Subscriber as IetfSubscriber } from "../src/ietf/subscriber.ts"; +import { ALPN, Version as IetfVersion } from "../src/ietf/version.ts"; +import { readFrames } from "../src/lite/group.ts"; +import { createMockTransportPair } from "../src/mock.ts"; +import * as Path from "../src/path.ts"; +import { Reader, Stream } from "../src/stream.ts"; +import * as Varint from "../src/varint.ts"; + +const frameSizes = [16, 100, 1000]; +const chunkSizes = [1200, 16 * 1024, 64 * 1024]; +const framesPerGroup = 50; +// A backlog of tiny frames in one large chunk: the worst case for how long batching holds the first. +const burst = { frameSize: 16, chunkSize: 64 * 1024, frames: 3000 }; +// A chunk carrying many frames must cost less per frame than one carrying a single frame, or +// decoding has gone back to paying a wakeup per frame. Loose, since the fixed per-chunk cost is +// what the batched case amortizes and the machine is noisy. +const maxBatchedRatio = 0.8; +let checksum = 0; + +/** Turns one group stream into a readable group, resolving `done` once the stream is handled. */ +type Open = (stream: ReadableStream) => { group: Promise; done: Promise }; + +/** A wire format's group stream: how a frame is encoded, and where its groups are read. */ +interface Protocol { + name: string; + // Decode about this many frames per row so each takes similar time. + frames: number; + encode(frameSize: number, index: number): Uint8Array[]; + // A fresh subscription per row, so one row's retained groups don't slow the next. + subscribe(): Promise<{ open: Open; close: () => void }>; +} + +// A lite-05+ group: a zigzag timestamp delta, a size, and the payload. +const lite: Protocol = { + name: "lite", + frames: 200_000, + encode: (frameSize, index) => [ + Varint.encode(2 * 33_333), + Varint.encode(frameSize), + new Uint8Array(frameSize).fill(index), + ], + async subscribe() { + const open: Open = (stream) => { + const producer = new Producer(0); + const done = readFrames(new Reader(stream), producer, 1_000_000).then(() => producer.close()); + return { group: Promise.resolve(producer.consume()), done }; + }; + return { open, close: () => {} }; + }, +}; + +// A moq-transport subgroup stream with no properties, through the subscriber that routes it to its +// track. Every retained group costs each later one a timeline scan (#4246), so a row stays at a +// few hundred groups. +const IETF_VERSION = IetfVersion.DRAFT_19; +const IETF_ALIAS = 9n; +const IETF_FLAGS = { + hasExtensions: false, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, +}; + +const ietf: Protocol = { + name: "ietf", + frames: 20_000, + encode: (frameSize, index) => [ + Varint.encodeLeadingOnes(0), + Varint.encodeLeadingOnes(frameSize), + new Uint8Array(frameSize).fill(index), + ], + async subscribe() { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const subscriber = new IetfSubscriber({ session: new NativeSession(pair.server, IETF_VERSION, true) }); + const track = subscriber.consume(Path.from("bench")).track("video").subscribe(); + + const peer = await Stream.accept(pair.client, IETF_VERSION); + if (!peer) throw new Error("the subscriber never subscribed"); + await peer.reader.u53(); + const request = await Subscribe.decode(peer.reader, IETF_VERSION); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ requestId: request.requestId, trackAlias: IETF_ALIAS }).encode( + peer.writer, + IETF_VERSION, + ); + + const ordered = track.ordered(); + let groupId = 0; + const open: Open = (stream) => { + const header = new IetfGroup({ + trackAlias: IETF_ALIAS, + groupId: groupId++, + subGroupId: 0, + publisherPriority: 0, + flags: IETF_FLAGS, + }); + const done = subscriber.handleGroup(header, new Reader(stream, undefined, IETF_VERSION)); + return { group: ordered.nextGroup(), done }; + }; + return { open, close: () => track.close() }; + }, +}; + +/** `count` frames of `frameSize` bytes, split into chunks. */ +function encode(protocol: Protocol, frameSize: number, chunkSize: number, count: number): Uint8Array[] { + const parts: Uint8Array[] = []; + for (let index = 0; index < count; index++) parts.push(...protocol.encode(frameSize, index)); + const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + + const chunks: Uint8Array[] = []; + for (let start = 0; start < bytes.byteLength; start += chunkSize) { + chunks.push(bytes.subarray(start, Math.min(start + chunkSize, bytes.byteLength))); + } + return chunks; +} + +/** Decode one group, returning nanoseconds until the reader has every frame and until it had the first. */ +async function run(open: Open, chunks: Uint8Array[], count: number): Promise<{ total: number; first: number }> { + // Queue every chunk up front so only the decode and the handoff are timed, not the source. + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + + const start = performance.now(); + const { group, done } = open(stream); + const consumer = await group; + if (!consumer) throw new Error("no group"); + + let first = 0; + let read = 0; + for (;;) { + const frame = await consumer.readFrame(); + if (!frame) break; + if (read++ === 0) first = performance.now() - start; + checksum += frame.payload[0]; + } + await done; + const total = performance.now() - start; + if (read !== count) throw new Error(`read ${read} of ${count} frames`); + return { total: total * 1e6, first: first * 1e6 }; +} + +/** Decode the protocol's frames in groups of `count`, returning ns per frame and the mean µs to the first frame. */ +async function measure( + protocol: Protocol, + chunks: Uint8Array[], + count: number, +): Promise<{ ns: number; first: number }> { + const groups = Math.max(1, Math.floor(protocol.frames / count)); + const { open, close } = await protocol.subscribe(); + + // Warm up so the first row doesn't pay for the JIT. + for (let index = 0; index < 20; index++) await run(open, chunks, count); + + let elapsed = 0; + let first = 0; + for (let index = 0; index < groups; index++) { + const result = await run(open, chunks, count); + elapsed += result.total; + first += result.first; + } + close(); + return { ns: elapsed / (groups * count), first: first / groups / 1000 }; +} + +console.log("protocol,frame_bytes,chunk_bytes,frames_per_chunk,ns_per_frame,first_frame_us"); + +for (const protocol of [lite, ietf]) { + const row = (frameSize: number, chunkSize: number, perChunk: number, result: { ns: number; first: number }) => + console.log( + `${protocol.name},${frameSize},${chunkSize},${perChunk.toFixed(1)},${result.ns.toFixed(0)},${result.first.toFixed(2)}`, + ); + + for (const frameSize of frameSizes) { + let single: number | undefined; + for (const chunkSize of [frameSize + 8, ...chunkSizes]) { + const chunks = encode(protocol, frameSize, chunkSize, framesPerGroup); + const perChunk = framesPerGroup / chunks.length; + const result = await measure(protocol, chunks, framesPerGroup); + row(frameSize, chunkSize, perChunk, result); + + if (single === undefined) { + single = result.ns; + } else if (perChunk >= 10 && result.ns > single * maxBatchedRatio) { + throw new Error( + `${protocol.name}: ${frameSize} byte frames at ${perChunk.toFixed(1)} per chunk cost ${result.ns.toFixed(0)} ns/frame, over ${maxBatchedRatio}x the ${single.toFixed(0)} ns of one per chunk`, + ); + } + } + } + + const chunks = encode(protocol, burst.frameSize, burst.chunkSize, burst.frames); + row(burst.frameSize, burst.chunkSize, burst.frames / chunks.length, await measure(protocol, chunks, burst.frames)); +} + +if (checksum === 0) throw new Error("benchmark did no work"); diff --git a/js/net/src/ietf/ietf.test.ts b/js/net/src/ietf/ietf.test.ts index 1f2e04f37d..ec6d906c20 100644 --- a/js/net/src/ietf/ietf.test.ts +++ b/js/net/src/ietf/ietf.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import * as Path from "../path.ts"; -import { Reader, Writer } from "../stream.ts"; +import { type Cursor, Reader, Writer } from "../stream.ts"; import { Timescale, Timestamp } from "../time.ts"; import * as Varint from "../varint.ts"; import * as GoAway from "./goaway.ts"; @@ -1590,11 +1590,8 @@ test("Frame object time: draft-15 uses absolute property types", async () => { expect(await props.u62()).toBe(96_000n); expect(await props.done()).toBe(true); - const decoded = await Frame.decode( - new Reader(undefined, encoded, Version.DRAFT_15), - flags, - Timescale.MILLI, - Version.DRAFT_15, + const decoded = await new Reader(undefined, encoded, Version.DRAFT_15).decode((c) => + Frame.decode(c, flags, Timescale.MILLI), ); expect(decoded.timestamp?.value).toBe(96_000); expect(decoded.timestamp?.scale).toBe(Timescale.MILLI); @@ -1658,16 +1655,66 @@ test("Frame object time: draft-16 starts delta property types", async () => { expect(await props.u62()).toBe(96_000n); expect(await props.done()).toBe(true); - const decoded = await Frame.decode( - new Reader(undefined, encoded, Version.DRAFT_16), - flags, - Timescale.MILLI, - Version.DRAFT_16, + const decoded = await new Reader(undefined, encoded, Version.DRAFT_16).decode((c) => + Frame.decode(c, flags, Timescale.MILLI), ); expect(decoded.timestamp?.value).toBe(96_000); expect(decoded.timestamp?.scale).toBe(Timescale.MILLI); }); +test("Frame decodes objects split at every byte", async () => { + const flags: GroupFlags = { + hasExtensions: true, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, + }; + const frames = [ + new Frame({ payload: new Uint8Array([1]), timestamp: new Timestamp(96_000, Timescale.MILLI) }), + new Frame({ payload: new Uint8Array(300).fill(2), timestamp: new Timestamp(96_033, Timescale.MILLI) }), + ]; + const bytes = concatChunks(await Promise.all(frames.map((f) => encodeFrameVersioned(f, flags, Version.DRAFT_20)))); + const reader = new Reader( + new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(new Uint8Array([byte])); + controller.close(); + }, + }), + undefined, + Version.DRAFT_20, + ); + + const decode = (c: Cursor) => Frame.decode(c, flags, Timescale.MILLI); + for (const frame of frames) { + const decoded = await reader.decodeMaybe(decode); + expect(decoded?.payload).toEqual(frame.payload); + expect(decoded?.timestamp?.value).toBe(frame.timestamp?.value); + } + expect(await reader.decodeMaybe(decode)).toBeUndefined(); +}); + +// The properties block is sized on the wire, so a property running past it is malformed, not +// a reason to wait for more of the stream. +test("Frame rejects a property that runs past its block", async () => { + const flags: GroupFlags = { + hasExtensions: true, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, + }; + // Delta 0, a 2-byte block holding a Timestamp id and the first byte of a 2-byte value, a + // 1-byte payload, then bytes the block must not borrow. + const reader = new Reader(undefined, new Uint8Array([0, 2, 0x10, 0x80, 1, 0xaa, 0x01]), Version.DRAFT_20); + await expect(reader.decode((c) => Frame.decode(c, flags, Timescale.MILLI))).rejects.toThrow( + "message is shorter than its fields", + ); +}); + // A fetch stream's first object is the only one carrying absolute ids, so a wrong flag byte // there silently renumbers every object after it. This codec is the sole serialization path // for a served fill. diff --git a/js/net/src/ietf/object.ts b/js/net/src/ietf/object.ts index 974b44706b..ab37261e0a 100644 --- a/js/net/src/ietf/object.ts +++ b/js/net/src/ietf/object.ts @@ -1,4 +1,4 @@ -import { Reader, Writer } from "../stream.ts"; +import { type Cursor, type Reader, Writer } from "../stream.ts"; import { Timescale, Timestamp } from "../time.ts"; import { type IetfVersion, Version } from "./version.ts"; @@ -100,32 +100,27 @@ async function encodeObjectExtensions( return result; } -async function decodeObjectTime( - r: Reader, - timescale: Timescale, - version: IetfVersion | undefined, -): Promise { +function decodeObjectTime(c: Cursor, timescale: Timescale): Timestamp | undefined { let timestamp: bigint | undefined; let overrideScale: bigint | undefined; let prevType = 0n; let first = true; - while (!(await r.done())) { - const step = await r.u62(); - const id = !hasDeltaObjectPropertyTypes(version) || first ? step : prevType + step; + while (c.remaining > 0) { + const step = c.u62(); + const id = !hasDeltaObjectPropertyTypes(c.version) || first ? step : prevType + step; first = false; prevType = id; if (id % 2n === 0n) { - const value = await r.u62(); + const value = c.u62(); if (id === PROP_TIMESTAMP || id === PROP_TIMESTAMP_DRAFT03) { timestamp = value; } else if (id === PROP_TIMESCALE) { overrideScale = value; } } else { - const size = await r.u53(); - await r.read(size); + c.read(c.u53()); } } @@ -312,41 +307,37 @@ export class Frame { } } - /** Decode a frame using the group flags and negotiated IETF version. */ - static async decode( - r: Reader, - flags: GroupFlags, - timescale: Timescale | undefined, - version = r.version, - ): Promise { + /** Decode a frame using the group flags, at the cursor's negotiated IETF version. */ + static decode(c: Cursor, flags: GroupFlags, timescale: Timescale | undefined): Frame { // The first object's delta is its absolute Object ID; every later one is the prior ID // plus the delta plus one. moq-lite groups start at object 0 and never skip one, so // a sequential group is a zero delta throughout, and any other value means the group // either starts partway through or has a gap that would renumber the frames after it. - const delta = await r.u53(); + const delta = c.u53(); if (delta !== 0) { throw new Error(`object IDs must start at 0 and increment by 1, got a delta of ${delta}`); } let timestamp: Timestamp | undefined; if (flags.hasExtensions) { - const extensionsLength = await r.u53(); - const extensions = await r.read(extensionsLength); + const extensionsLength = c.u53(); // A track that declared no timescale opted out of timestamps, so its objects // are stamped on arrival even if one carries a Timestamp we cannot interpret. if (timescale !== undefined) { - timestamp = await decodeObjectTime(new Reader(undefined, extensions, version), timescale, version); + timestamp = c.exact(extensionsLength, (e) => decodeObjectTime(e, timescale)); + } else { + c.read(extensionsLength); } } - const payloadLength = await r.u53(); + const payloadLength = c.u53(); if (payloadLength > 0) { - const payload = await r.read(payloadLength); + const payload = c.read(payloadLength); return new Frame({ payload, timestamp }); } - const status = await r.u53(); + const status = c.u53(); // Defined on every implemented draft, whether or not the header marks the group's end. if (status === END_OF_TRACK) return new Frame({ endOfTrack: true }); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index f8de7d9953..72b489e537 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1094,7 +1094,7 @@ async function readGroup(stream: ReadableStream): Promise): Promise { const reader = new Reader(stream, undefined, V20); const header = await GroupMessage.decode(reader, V20); - const frame = await Frame.decode(reader, header.flags, undefined, V20); + const frame = await reader.decode((c) => Frame.decode(c, header.flags, undefined)); expect(frame.endOfTrack).toBe(true); expect(await reader.done()).toBe(true); return header.groupId; diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index a0f560d42a..fe2bbe0a2a 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -964,6 +964,34 @@ test("a group that claims its first object must start at zero", async () => { track.close(); }); +test("every object in a chunk reaches the reader before it wakes", async () => { + const { subscriber, track } = await subscribeTrack(); + + const header = new GroupMessage({ + trackAlias: ALIAS, + groupId: 3, + subGroupId: 0, + publisherPriority: 0, + flags: groupFlags(true), + }); + const objects = encodeObjects(Array.from({ length: 10 }, () => 0)); + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(objects); + controller.close(); + }, + }); + const handled = subscriber.handleGroup(header, new Reader(readable, undefined, VERSION)); + + const group = await track.ordered().nextGroup(); + if (!group) throw new Error("no group"); + expect(await group.readString()).toBe("object 0"); + expect(group.frameCount).toBe(10); + + await handled; + track.close(); +}); + // Hold the actual legacy cancellation write so returning demand lands in the teardown gap. test("returning demand survives a blocked unsubscribe", async () => { const version = Version.DRAFT_16; diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index b36ccfebbd..9b9833d15e 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -7,7 +7,7 @@ import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; -import type { Reader, Stream } from "../stream.ts"; +import type { Cursor, Reader, Stream } from "../stream.ts"; import { TAIL_GRACE_MS, Tail } from "../tail.ts"; import { type Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; @@ -1045,18 +1045,18 @@ export class Subscriber { // header priority inherits it (draft-21 section 10.4). if (!group.flags.hasPriority) group.publisherPriority = toWire((await track.info()).priority); + const decode = (c: Cursor) => Frame.decode(c, group.flags, this.#timescales.get(group.trackAlias)); for (;;) { - // Only the group's own stream ends it: a track that closes first has already - // closed (or aborted) this group through its cache. - const done = await (producer ? race([stream.done(), producer.closed]) : stream.done()); - if (done !== false) break; - - const frame = await Frame.decode( - stream, - group.flags, - this.#timescales.get(group.trackAlias), - this.#session.version, - ); + // Every object already buffered is written without an await, so the reader wakes + // once per batch rather than once per object. Only the group's own stream ends it: + // a track that closes first has already closed (or aborted) this group through its + // cache. + const frame = + stream.tryDecode(decode) ?? + (await (producer + ? race([stream.decodeMaybe(decode), producer.closed]) + : stream.decodeMaybe(decode))); + if (!frame || frame instanceof Error) break; if (frame.endOfTrack) { // No object at or past this location exists: after the group's last object diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 249f4b1bb9..c59f511f1e 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1437,6 +1437,50 @@ test("integration: lite coalesced fetch stays until every reader abandons the op server.close(); }); +// A fetch that coalesces after the last reader left, but before the FETCH is cancelled, re-arms +// the demand watch. The frame read in flight across that re-arm must still reach the group. The +// window is a few microtasks wide, so the late reader arrives after every delay across it. +test("integration: lite fetch re-armed by a late reader keeps every frame", async () => { + const pair = createMockTransportPair(Lite.ALPN_06); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + + const broadcast = publish(origin, Path.from("test")); + const video = broadcast.createTrack("video"); + const remote = wireOf(client).consume(Path.from("test")); + + for (let delay = 0; delay <= 12; delay++) { + const group = video.appendGroup(); // open + group.writeString("hello"); + + const f1 = await remote.track("video").fetchGroup(group.sequence); + expect(await f1.readString()).toBe("hello"); + + f1.close(); + for (let i = 0; i < delay; i++) await Promise.resolve(); + const f2 = await remote.track("video").fetchGroup(group.sequence); + expect(await f2.readString()).toBe("hello"); + + group.writeString("more"); + const more = await Promise.race([ + f2.readString(), + new Promise((resolve) => setTimeout(() => resolve("dropped"), 500)), + ]); + expect(`${delay}: ${more}`).toBe(`${delay}: more`); + + f2.close(); + group.close(); + } + + broadcast.close(); + remote.close(); + client.close(); + server.close(); +}); + // A finite group must still deliver every frame and end cleanly (the demand watch must not disturb // normal completion), exercising the per-frame loop many times. test("integration: lite fetch delivers every frame of a finite multi-frame group", async () => { diff --git a/js/net/src/lite/group.test.ts b/js/net/src/lite/group.test.ts new file mode 100644 index 0000000000..e0df4212ed --- /dev/null +++ b/js/net/src/lite/group.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { Producer } from "../group.ts"; +import { Reader } from "../stream.ts"; +import * as Varint from "../varint.ts"; +import { readFrames } from "./group.ts"; + +const SCALE = 1000; + +/** Frames as a group stream carries them: a zigzag timestamp delta when timestamped, then the sized payload. */ +function encode(frames: { delta?: number; payload: number[] }[]): Uint8Array { + const bytes: number[] = []; + for (const { delta, payload } of frames) { + if (delta !== undefined) bytes.push(...Varint.encode(delta < 0 ? -2 * delta - 1 : 2 * delta)); + bytes.push(...Varint.encode(payload.length), ...payload); + } + return new Uint8Array(bytes); +} + +function streamOf(chunks: Uint8Array[]): Reader { + return new Reader( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }), + ); +} + +test("every frame in a chunk reaches the reader before it wakes", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + const frames = Array.from({ length: 10 }, (_, index) => ({ delta: 1, payload: [index] })); + const done = readFrames(streamOf([encode(frames)]), producer, SCALE); + + expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([0])); + expect(consumer.frameCount).toBe(10); + await done; +}); + +test("frames split at every byte keep their payloads and timestamps", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + // Deltas wide enough for multi-byte varints, and one that steps back. + const frames = [ + { delta: 100, payload: [1] }, + { delta: 20_000, payload: [2, 2] }, + { delta: -50, payload: [] }, + { delta: 0, payload: Array.from({ length: 300 }, () => 4) }, + ]; + const bytes = encode(frames); + const chunks = Array.from(bytes, (byte) => new Uint8Array([byte])); + + await readFrames(streamOf(chunks), producer, SCALE); + producer.close(); + + let ts = 0; + for (const { delta, payload } of frames) { + const frame = await consumer.readFrame(); + ts += delta; + expect(frame?.payload).toEqual(new Uint8Array(payload)); + expect(frame?.timestamp.value).toBe(ts); + } + expect(await consumer.readFrame()).toBeUndefined(); +}); + +test("frames without a timescale carry no timestamp prefix", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + await readFrames(streamOf([encode([{ payload: [7] }, { payload: [8, 9] }])]), producer, 0); + producer.close(); + + expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([7])); + expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([8, 9])); + expect(await consumer.readFrame()).toBeUndefined(); +}); + +test("a stream that ends inside a frame rejects", async () => { + const producer = new Producer(0); + const bytes = encode([{ delta: 1, payload: [1, 2, 3] }]); + await expect(readFrames(streamOf([bytes.subarray(0, bytes.byteLength - 1)]), producer, SCALE)).rejects.toThrow( + "unexpected end of stream", + ); +}); + +test("stops once the group closes", async () => { + const producer = new Producer(0); + // Never ends: only the close can stop the read. + const stream = new Reader(new ReadableStream()); + const done = readFrames(stream, producer, SCALE); + producer.close(); + await done; +}); diff --git a/js/net/src/lite/group.ts b/js/net/src/lite/group.ts index 598b2893bf..2bb058051f 100644 --- a/js/net/src/lite/group.ts +++ b/js/net/src/lite/group.ts @@ -1,4 +1,7 @@ -import type { Reader, Writer } from "../stream.ts"; +import { race } from "@moq/signals"; +import type * as netGroup from "../group.ts"; +import type { Cursor, Reader, Writer } from "../stream.ts"; +import * as Time from "../time.ts"; import * as Message from "./message.ts"; import { hasFrameBounds, type Version } from "./version.ts"; @@ -61,27 +64,44 @@ export class Group { } } -export class Frame { - payload: Uint8Array; - - constructor(payload: Uint8Array) { - this.payload = payload; - } +/** Decode an unsigned zigzag varint back to a signed delta (mirrors Rust `VarInt::to_zigzag`). */ +function unzigzag(v: bigint): bigint { + return (v >> 1n) ^ -(v & 1n); +} - async #encode(w: Writer) { - await w.write(this.payload); +/** + * A synchronous decode for one frame of a group or FETCH response stream. + * + * A non-zero `scale` means every frame is prefixed with a zigzag-delta timestamp (the lite-05 + * FRAME format), decoded into a Timestamp at that scale. Scale 0 (pre-lite-05) carries no + * timestamp, so frames are wall-clock stamped on arrival. + */ +export function frameDecoder(scale: number): (c: Cursor) => netGroup.Frame { + if (scale === 0) { + return (c) => ({ payload: c.read(c.u53()), timestamp: Time.Timestamp.now() }); } - static async #decode(r: Reader): Promise { - const payload = await r.readAll(); - return new Frame(payload); - } + const timescale = Time.Timescale(scale); + let prevTs = 0n; + return (c) => { + const delta = unzigzag(c.u62()); + const payload = c.read(c.u53()); + // After the last read, so a decode that ran short and gets retried adds the delta once. + prevTs += delta; + return { payload, timestamp: new Time.Timestamp(Number(prevTs), timescale) }; + }; +} - async encode(w: Writer): Promise { - return Message.encode(w, this.#encode.bind(this)); - } +/** Write a group stream's frames into `producer` until the stream ends or the producer closes. */ +export async function readFrames(stream: Reader, producer: netGroup.Producer, scale: number): Promise { + const decode = frameDecoder(scale); - static async decode(r: Reader): Promise { - return Message.decode(r, Frame.#decode); + for (;;) { + // Every frame already buffered is written without an await, so the reader wakes once + // per batch rather than once per frame. Only the group's own stream ends it: a track + // that closes first has already closed (or aborted) this group through its cache. + const frame = stream.tryDecode(decode) ?? (await race([stream.decodeMaybe(decode), producer.closed])); + if (!frame || frame instanceof Error) return; + producer.writeFrame(frame); } } diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 7640b3ce32..eb55837234 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -24,7 +24,7 @@ import { import { Datagram as DatagramMessage } from "./datagram.ts"; import * as DatagramStream from "./datagram_stream.ts"; import { Fetch as FetchMessage } from "./fetch.ts"; -import type { Group as GroupMessage } from "./group.ts"; +import { frameDecoder, type Group as GroupMessage, readFrames } from "./group.ts"; import { sendOrder } from "./priority.ts"; import { Probe } from "./probe.ts"; import { ProbeLevel, type Setup } from "./setup.ts"; @@ -56,11 +56,6 @@ import { // until the peer frees a slot. The timeout turns a stall into a clear error. const SUBSCRIBE_SETUP_TIMEOUT_MS = 10_000; -/** Decode an unsigned zigzag varint back to a signed delta (mirrors Rust `VarInt::to_zigzag`). */ -function unzigzag(v: bigint): bigint { - return (v >> 1n) ^ -(v & 1n); -} - // The TRACK stream and implicit SUBSCRIBE acceptance are lite-05+. function supportsTrackStream(version: Version): boolean { switch (version) { @@ -788,32 +783,36 @@ export class Subscriber { // FIN. A stream-level failure aborts the group so its reader observes the gap. async #runFetchResponse(stream: Stream, group: netGroup.Producer, timescale: Time.Timescale): Promise { try { - let prevTs = 0n; + const decode = frameDecoder(timescale); // Serve until the stream FINs, the group closes, or every reader leaves. A group can // stay open indefinitely (a catalog or JSON stream), so an abandoned fetch is stopped by // demand, not by the stream ending. `unused` is watched across frames as one stable // promise; the check is level-triggered, so a coalesced fetch that arrives before we // cancel re-arms and resumes. - const idle = Symbol("idle"); - let unused = group.unused().then(() => idle); + const idle: unique symbol = Symbol("idle"); + let unused = group.unused().then((): typeof idle => idle); + // A decode consumes its frame whenever it lands, so one outstanding across a re-arm is + // kept and awaited again rather than abandoned with its frame. + let pending: Promise | undefined; for (;;) { - const done = await race([stream.reader.done(), group.closed, unused]); - if (done === idle) { - if (!group.isClosed && group.used.peek()) { - unused = group.unused().then(() => idle); - continue; + // Buffered frames are written without an await, as in a group stream. + let frame = pending === undefined ? stream.reader.tryDecode(decode) : undefined; + if (!frame) { + pending ??= stream.reader.decodeMaybe(decode); + const next = await race([pending, group.closed, unused]); + if (next === idle) { + if (!group.isClosed && group.used.peek()) { + unused = group.unused().then((): typeof idle => idle); + continue; + } + break; } - break; + pending = undefined; + if (!next || next instanceof Error) break; + frame = next; } - if (done !== false) break; - - prevTs += unzigzag(await stream.reader.u62()); - const timestamp = new Time.Timestamp(Number(prevTs), timescale); - const size = await stream.reader.u53(); - const payload = await stream.reader.read(size); - if (!payload) break; - group.writeFrame({ payload, timestamp }); + group.writeFrame(frame); } group.close(); @@ -989,31 +988,7 @@ export class Subscriber { scale = timescale.peek(); } - // A non-zero scale means every frame is prefixed with a zigzag-delta timestamp - // (the lite-05 FRAME format), which we decode into a Timestamp at that scale. - // Scale 0 (pre-lite-05) carries no timestamp, so we wall-clock-stamp. - let prevTs = 0n; - - for (;;) { - // Only the group's own stream ends it: a track that closes first has already - // closed (or aborted) this group through its cache. - const done = await race([stream.done(), producer.closed]); - if (done !== false) break; - - let timestamp: Time.Timestamp; - if (scale !== 0) { - prevTs += unzigzag(await stream.u62()); - timestamp = new Time.Timestamp(Number(prevTs), Time.Timescale(scale)); - } else { - timestamp = Time.Timestamp.now(); - } - - const size = await stream.u53(); - const payload = await stream.read(size); - if (!payload) break; - - producer.writeFrame({ payload, timestamp }); - } + await readFrames(stream, producer, scale); producer.close(); stream.stop(new StreamError(StreamCode.Cancel, { message: "cancel" })); diff --git a/js/net/src/stream.test.ts b/js/net/src/stream.test.ts index c02eb411ac..b84af33f50 100644 --- a/js/net/src/stream.test.ts +++ b/js/net/src/stream.test.ts @@ -11,7 +11,7 @@ import { StreamError, } from "./error.ts"; import { Version } from "./ietf/version.ts"; -import { Reader, Stream, Writer } from "./stream.ts"; +import { type Cursor, Reader, Stream, Writer } from "./stream.ts"; import { TimeoutError } from "./util/timeout.ts"; // Helper to create a writable stream that captures written data @@ -238,22 +238,18 @@ test("Reader u53 rejects integers that cannot be represented exactly", async () const wireValues = [firstUnsafe, firstUnsafe + 1n]; expect(Number(wireValues[0])).toBe(Number(wireValues[1])); - const { stream, written } = createTestWritableStream(); - const writer = new Writer(stream); - for (const value of wireValues) { + const { stream, written } = createTestWritableStream(); + const writer = new Writer(stream); await writer.u62(value); - } - - writer.close(); - await writer.closed; + writer.close(); + await writer.closed; - const reader = new Reader(undefined, concatChunks(written)); - for (const value of wireValues) { + // A failed decode consumes nothing; the stream is unusable after it anyway. + const reader = new Reader(undefined, concatChunks(written)); await expect(reader.u53()).rejects.toThrow(`value larger than 53-bits: ${value}`); + expect(await reader.done()).toBe(false); } - - expect(await reader.done()).toBe(true); }); test("Reader u62 varint decoding", async () => { @@ -414,6 +410,40 @@ test("Reader u53 decodes two-byte stream type prefixes", async () => { expect(await reader.done()).toBe(true); }); +/** A length-prefixed payload. */ +const sized = (c: Cursor) => c.read(c.u53()); + +test("Reader tryDecode drains every buffered message, then consumes nothing from a partial one", async () => { + let controller!: ReadableStreamDefaultController; + const reader = new Reader(new ReadableStream({ start: (c) => (controller = c) })); + controller.enqueue(new Uint8Array([1, 0xa, 2, 0xb, 0xc, 3, 0xd])); + expect(await reader.done()).toBe(false); + + expect(reader.tryDecode(sized)).toEqual(new Uint8Array([0xa])); + expect(reader.tryDecode(sized)).toEqual(new Uint8Array([0xb, 0xc])); + expect(reader.tryDecode(sized)).toBeUndefined(); + expect(reader.tryDecode(sized)).toBeUndefined(); + + const pending = reader.decode(sized); + controller.enqueue(new Uint8Array([0xe])); + controller.enqueue(new Uint8Array([0xf])); + expect(await pending).toEqual(new Uint8Array([0xd, 0xe, 0xf])); + controller.close(); + expect(await reader.decodeMaybe(sized)).toBeUndefined(); +}); + +test("Reader tryDecode holds only the decode that ran short to the bytes it needs", () => { + const reader = new Reader(undefined, new Uint8Array([2, 0xa])); + expect(reader.tryDecode(sized)).toBeUndefined(); + expect(reader.tryDecode((c) => c.u8())).toBe(2); +}); + +test("Reader decode rejects a stream that ends inside a message", async () => { + const reader = new Reader(undefined, new Uint8Array([3, 0xa])); + expect(reader.tryDecode(sized)).toBeUndefined(); + await expect(reader.decode(sized)).rejects.toThrow("unexpected end of stream"); +}); + /** A stream reset as a transport delivers one: the peer's code, and nothing else useful. */ class Reset extends Error { readonly source = "stream" as const; diff --git a/js/net/src/stream.ts b/js/net/src/stream.ts index 7cd2b65e07..5848accad2 100644 --- a/js/net/src/stream.ts +++ b/js/net/src/stream.ts @@ -188,6 +188,8 @@ export class Reader { #stream?: ReadableStream; // if undefined, the buffer is consumed then EOF #reader?: ReadableStreamDefaultReader; #closed?: Promise; + // The decode that last ran short and how far, so a retry can wait for those bytes. + #short?: { decode: (c: Cursor) => unknown; err: Short }; version?: IetfVersion; // Either stream or buffer MUST be provided. @@ -276,11 +278,59 @@ export class Reader { return result; } + /** + * Run a synchronous decode over the buffered bytes and consume what it read. + * + * Returns undefined and consumes nothing when the decode ran past the buffered bytes, so a + * caller can drain every complete message already here without waiting on the stream. + */ + tryDecode>(decode: (c: Cursor) => T): T | undefined { + const result = this.#try(decode); + return result instanceof Short ? undefined : result; + } + + /** Run a synchronous decode, filling from the stream until it has the bytes it needs. */ + async decode(decode: (c: Cursor) => T): Promise { + for (;;) { + const result = this.#try(decode); + if (!(result instanceof Short)) return result; + await this.#fillTo(result.need); + } + } + + /** Like {@link decode}, but returns undefined if the stream ends cleanly first. */ + async decodeMaybe(decode: (c: Cursor) => T): Promise { + if (await this.done()) return undefined; + return this.decode(decode); + } + + #try(decode: (c: Cursor) => T): T | Short { + // A retry of the decode that last ran short, before the bytes it needs have arrived, + // would only throw again. Every decode reads at least a byte, so none can succeed on + // an empty buffer either. + const available = this.#buffer.byteLength + this.#chunked; + if (available === 0) return EMPTY; + if (decode === this.#short?.decode && available < this.#short.err.need) return this.#short.err; + + this.#join(); + const cursor = new Cursor(this.#buffer, this.version); + try { + const result = decode(cursor); + this.#slice(cursor.offset); + this.#short = undefined; + return result; + } catch (err: unknown) { + if (!(err instanceof Short)) throw err; + // Filling could never satisfy it, so retrying would spin. + if (err.need <= this.#buffer.byteLength) throw new Error("decode ran short of bytes it already had"); + this.#short = { decode, err }; + return err; + } + } + async read(size: number): Promise { if (size === 0) return new Uint8Array(); - - await this.#fillTo(size); - return this.#slice(size); + return this.decode((c) => c.read(size)); } async readAll(): Promise { @@ -292,81 +342,187 @@ export class Reader { } async string(): Promise { - const length = await this.u53(); - const buffer = await this.read(length); - return decodeUtf8(buffer); + return this.decode(STRING); } async bool(): Promise { - const v = await this.u8(); - if (v === 0) return false; - if (v === 1) return true; - throw new Error("invalid bool value"); + return this.decode(BOOL); } async u8(): Promise { - await this.#fillTo(1); - return this.#slice(1)[0]; + return this.decode(U8); } async u16(): Promise { - await this.#fillTo(2); - const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, 2); - const result = view.getUint16(0); - this.#slice(2); - return result; + return this.decode(U16); } // Returns a Number using 53-bits, the max Javascript can use for integer math. async u53(): Promise { - const v = await this.u62(); - if (v > Varint.MAX_U53) { - throw new Error(`value larger than 53-bits: ${v.toString()}`); - } - - return Number(v); + return this.decode(U53); } // NOTE: Returns a bigint instead of a number since it may be larger than 53-bits async u62(): Promise { - if (isLeadingOnes(this.version)) { - return this.#readLeadingOnes(); - } - return this.#readQuicVarint(); + return this.decode(U62); } - async #readQuicVarint(): Promise { - await this.#fillTo(1); - const size = (this.#buffer[0] & 0xc0) >> 6; + // Returns false if there is more data to read, blocking if it hasn't been received yet. + async done(): Promise { + if (this.#buffer.byteLength > 0 || this.#chunked > 0) return false; + return !(await this.#fill()); + } + + stop(reason: unknown) { + this.#reader?.cancel(withCode(reason, this.version)).catch(() => void 0); + } + + // Decoded like #fill: a caller racing this against a read must not get a different error + // shape depending on which one won. Derived once, so racing it per frame doesn't allocate. + get closed(): Promise { + this.#closed ??= (this.#reader?.closed ?? Promise.resolve()).catch((err: unknown) => { + throw fromTransport(err, { version: this.version }); + }); + return this.#closed; + } +} + +// Thrown by a Cursor read that runs past the buffered bytes, carrying how many bytes from the +// start of the buffer the decode needs. Not an Error: it ends every chunk, so it must not +// capture a stack. +class Short { + readonly need: number; + + constructor(need: number) { + this.need = need; + } +} + +const EMPTY = new Short(1); - if (size === 0) { - const first = this.#slice(1)[0]; - return BigInt(first) & 0x3fn; +/** + * A synchronous view over a {@link Reader}'s buffered bytes, handed to {@link Reader.decode}. + * + * A read past the buffered bytes throws an internal signal that the Reader catches: it consumes + * nothing, fills, and runs the decode again from the start. A decode must therefore not mutate + * anything before its last read, must not swallow what it throws, and must read at least a byte. + */ +export class Cursor { + readonly version?: IetfVersion; + #buffer: Uint8Array; + #offset = 0; + + constructor(buffer: Uint8Array, version?: IetfVersion) { + this.#buffer = buffer; + this.version = version; + } + + /** How many bytes have been read. */ + get offset(): number { + return this.#offset; + } + + /** How many buffered bytes are left to read. */ + get remaining(): number { + return this.#buffer.byteLength - this.#offset; + } + + /** + * Decode the next `size` bytes on their own. Running past them, or leaving any unread, is + * malformed rather than a reason to wait for more. + */ + exact(size: number, decode: (c: Cursor) => T): T { + const inner = new Cursor(this.read(size), this.version); + let result: T; + try { + result = decode(inner); + } catch (err: unknown) { + if (err instanceof Short) throw new Error(`message is shorter than its fields: ${size} bytes`); + throw err; } - if (size === 1) { - await this.#fillTo(2); - const slice = this.#slice(2); - const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); + if (inner.remaining > 0) throw new Error(`message has ${inner.remaining} unread bytes`); + return result; + } + + #ensure(size: number) { + const need = this.#offset + size; + if (need > this.#buffer.byteLength) throw new Short(need); + } + + /** Read `size` bytes, as a view onto the buffer rather than a copy. */ + read(size: number): Uint8Array { + this.#ensure(size); + const start = this.#offset; + this.#offset += size; + return this.#buffer.subarray(start, this.#offset); + } + + string(): string { + return decodeUtf8(this.read(this.u53())); + } + + bool(): boolean { + const v = this.u8(); + if (v === 0) return false; + if (v === 1) return true; + throw new Error("invalid bool value"); + } - return BigInt(view.getUint16(0)) & 0x3fffn; + u8(): number { + this.#ensure(1); + return this.#buffer[this.#offset++]; + } + + u16(): number { + this.#ensure(2); + const b = this.#buffer; + const o = this.#offset; + this.#offset += 2; + return (b[o] << 8) | b[o + 1]; + } + + // Returns a Number using 53-bits, the max Javascript can use for integer math. + u53(): number { + // Most varints fit in 4 bytes, which decode without a bigint. + if (!isLeadingOnes(this.version)) { + this.#ensure(1); + const b = this.#buffer; + const o = this.#offset; + const size = 1 << (b[o] >> 6); + if (size < 8) { + this.#ensure(size); + this.#offset += size; + if (size === 1) return b[o] & 0x3f; + if (size === 2) return ((b[o] & 0x3f) << 8) | b[o + 1]; + return (b[o] & 0x3f) * 2 ** 24 + ((b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]); + } } - if (size === 2) { - await this.#fillTo(4); - const slice = this.#slice(4); - const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); - return BigInt(view.getUint32(0)) & 0x3fffffffn; + const v = this.u62(); + if (v > Varint.MAX_U53) { + throw new Error(`value larger than 53-bits: ${v.toString()}`); } - await this.#fillTo(8); - const slice = this.#slice(8); - const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); + return Number(v); + } + + // NOTE: Returns a bigint instead of a number since it may be larger than 53-bits + u62(): bigint { + return isLeadingOnes(this.version) ? this.#leadingOnes() : this.#quicVarint(); + } + + #quicVarint(): bigint { + this.#ensure(1); + const size = 1 << (this.#buffer[this.#offset] >> 6); + if (size < 8) return BigInt(this.u53()); + const slice = this.read(8); + const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); return view.getBigUint64(0) & 0x3fffffffffffffffn; } - async #readLeadingOnes(): Promise { - await this.#fillTo(1); - const b = this.#buffer[0]; + #leadingOnes(): bigint { + this.#ensure(1); + const b = this.#buffer[this.#offset]; // Count leading 1-bits let ones = 0; @@ -386,33 +542,19 @@ export class Reader { else if (ones === 7) totalSize = 8; else totalSize = 9; // ones === 8 - await this.#fillTo(totalSize); - const slice = this.#slice(totalSize); - - const [value] = Varint.decodeLeadingOnes(slice); + const [value] = Varint.decodeLeadingOnes(this.read(totalSize)); return value; } - - // Returns false if there is more data to read, blocking if it hasn't been received yet. - async done(): Promise { - if (this.#buffer.byteLength > 0 || this.#chunked > 0) return false; - return !(await this.#fill()); - } - - stop(reason: unknown) { - this.#reader?.cancel(withCode(reason, this.version)).catch(() => void 0); - } - - // Decoded like #fill: a caller racing this against a read must not get a different error - // shape depending on which one won. Derived once, so racing it per frame doesn't allocate. - get closed(): Promise { - this.#closed ??= (this.#reader?.closed ?? Promise.resolve()).catch((err: unknown) => { - throw fromTransport(err, { version: this.version }); - }); - return this.#closed; - } } +// Shared decodes for the Reader's async primitives, so a read allocates no closure. +const STRING = (c: Cursor) => c.string(); +const BOOL = (c: Cursor) => c.bool(); +const U8 = (c: Cursor) => c.u8(); +const U16 = (c: Cursor) => c.u16(); +const U53 = (c: Cursor) => c.u53(); +const U62 = (c: Cursor) => c.u62(); + // Writer wraps a stream and writes chunks of data export class Writer { #writer: WritableStreamDefaultWriter; diff --git a/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md b/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md index 0a151ec965..4496a84f70 100644 --- a/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md +++ b/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md @@ -1,4 +1,4 @@ -# [L] js/net: decode messages synchronously from buffered bytes +# [M] js/net: decode messages synchronously from buffered bytes ## Goal @@ -20,21 +20,22 @@ write converts flow-controlled bytes into heap objects. A single-message slot was tried in #2820 and broke ordering, because `take()` cannot yield to the decoder without letting a group pop slip in between. -Every `Reader` primitive in `js/net/src/stream.ts` (`u62`, `u53`, `read`, -`string`, ...) is async and routes through a fill, even when the bytes are -already buffered. All 23 `static async decode` message decoders under -`js/net/src/lite/` are written against it, plus four `decodeMaybe` variants. - -- Give `Reader` a synchronous decode over its buffer: the primitives read from - `#buffer` and signal "incomplete" when it runs short, and one generic async - driver fills and retries. The decoders then have a single synchronous body - each; the async form is the driver applied to it, not a second copy. -- Convert all 23 decoders, so the `Reader` has one contract rather than a - sync path for control messages and an async one for the rest. +`Reader` in `js/net/src/stream.ts` already decodes synchronously: a decode is +a function over a `Cursor`, whose reads throw an internal short signal when the +buffered bytes run out. `tryDecode` returns undefined and consumes nothing in +that case, and `decode`/`decodeMaybe` are the one async driver that fills and +retries. The primitives (`u62`, `u53`, `read`, `string`, ...) are that driver +applied to the `Cursor` reads, and the group and FETCH frame loops drain every +buffered frame with `tryDecode` (`js/net/bench/frames.ts`). The 23 +`static async decode` message decoders under `js/net/src/lite/`, plus four +`decodeMaybe` variants, still await a primitive per field. + +- Convert all 23 decoders to a single synchronous body over a `Cursor`, with + the async form as `reader.decode(...)` rather than a second copy. `Message` + in `lite/message.ts` becomes a sync size-prefixed wrapper. - The publisher drains controls synchronously in its loop and `SubscriptionControls` goes away. -- Tests: a decoder given a partial buffer reports incomplete without - consuming; the publisher applies N buffered updates before the next group +- Tests: the publisher applies N buffered updates before the next group pop; a partial update with a group already ready waits for the second fill and pops the group under the new range, so incomplete is never read as "no control pending"; the flood case stays bounded. From 15514b831d6a9d32478e8bdab3e8bd63a194d419 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 12:44:41 -0700 Subject: [PATCH 39/87] chore(quest): run the standalone quest binary via the flake (#4252) Co-authored-by: Claude Opus 5.5 --- .claude/shared | 2 +- .claude/skills/close | 1 - .claude/skills/merge | 1 - .claude/skills/plan-issues/SKILL.md | 7 - .claude/skills/plan-issues/agents/openai.yaml | 4 - .claude/skills/plan-quests/SKILL.md | 41 - .claude/skills/plan-quests/agents/openai.yaml | 4 - .claude/skills/quest-close/SKILL.md | 7 + .claude/skills/quest-issues/SKILL.md | 7 + .claude/skills/quest-merge/SKILL.md | 7 + .claude/skills/quest-plan/SKILL.md | 7 + .claude/skills/quest-spawn-merge/SKILL.md | 7 + .claude/skills/quest-spawn/SKILL.md | 7 + .claude/skills/quest-start/SKILL.md | 7 + .claude/skills/quest-takeover/SKILL.md | 7 + .claude/skills/spawn-merge | 1 - .claude/skills/spawn-quests/SKILL.md | 35 - .../skills/spawn-quests/agents/openai.yaml | 4 - .claude/skills/start-quest/SKILL.md | 22 - .claude/skills/start-quest/agents/openai.yaml | 4 - .claude/skills/takeover | 1 - .gitignore | 2 +- AGENTS.md | 4 + CONTRIBUTING.md | 2 +- Cargo.lock | 27 - Cargo.toml | 3 - flake.lock | 31 + flake.nix | 13 +- justfile | 11 +- quest/AGENTS.md | 116 --- rs/quest/Cargo.toml | 21 - rs/quest/src/branch.rs | 32 - rs/quest/src/doc.rs | 318 ------- rs/quest/src/lib.rs | 67 -- rs/quest/src/main.rs | 91 -- rs/quest/src/ready.rs | 178 ---- rs/quest/src/rules.rs | 394 -------- rs/quest/tests/tree.rs | 871 ------------------ 38 files changed, 112 insertions(+), 2252 deletions(-) delete mode 120000 .claude/skills/close delete mode 120000 .claude/skills/merge delete mode 100644 .claude/skills/plan-issues/SKILL.md delete mode 100644 .claude/skills/plan-issues/agents/openai.yaml delete mode 100644 .claude/skills/plan-quests/SKILL.md delete mode 100644 .claude/skills/plan-quests/agents/openai.yaml create mode 100644 .claude/skills/quest-close/SKILL.md create mode 100644 .claude/skills/quest-issues/SKILL.md create mode 100644 .claude/skills/quest-merge/SKILL.md create mode 100644 .claude/skills/quest-plan/SKILL.md create mode 100644 .claude/skills/quest-spawn-merge/SKILL.md create mode 100644 .claude/skills/quest-spawn/SKILL.md create mode 100644 .claude/skills/quest-start/SKILL.md create mode 100644 .claude/skills/quest-takeover/SKILL.md delete mode 120000 .claude/skills/spawn-merge delete mode 100644 .claude/skills/spawn-quests/SKILL.md delete mode 100644 .claude/skills/spawn-quests/agents/openai.yaml delete mode 100644 .claude/skills/start-quest/SKILL.md delete mode 100644 .claude/skills/start-quest/agents/openai.yaml delete mode 120000 .claude/skills/takeover delete mode 100644 quest/AGENTS.md delete mode 100644 rs/quest/Cargo.toml delete mode 100644 rs/quest/src/branch.rs delete mode 100644 rs/quest/src/doc.rs delete mode 100644 rs/quest/src/lib.rs delete mode 100644 rs/quest/src/main.rs delete mode 100644 rs/quest/src/ready.rs delete mode 100644 rs/quest/src/rules.rs delete mode 100644 rs/quest/tests/tree.rs diff --git a/.claude/shared b/.claude/shared index e12b7a9dee..763fb4c986 160000 --- a/.claude/shared +++ b/.claude/shared @@ -1 +1 @@ -Subproject commit e12b7a9dee41b1faf8b3d4344e4233d3f5311818 +Subproject commit 763fb4c9865a0625716de8cde80d4af1b5edcef7 diff --git a/.claude/skills/close b/.claude/skills/close deleted file mode 120000 index 31284b7508..0000000000 --- a/.claude/skills/close +++ /dev/null @@ -1 +0,0 @@ -../shared/close \ No newline at end of file diff --git a/.claude/skills/merge b/.claude/skills/merge deleted file mode 120000 index 4735484baf..0000000000 --- a/.claude/skills/merge +++ /dev/null @@ -1 +0,0 @@ -../shared/merge \ No newline at end of file diff --git a/.claude/skills/plan-issues/SKILL.md b/.claude/skills/plan-issues/SKILL.md deleted file mode 100644 index d5a88012e9..0000000000 --- a/.claude/skills/plan-issues/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: plan-issues -description: Plan quests from open GitHub issues without the quest label. ---- - -Call /plan-quests for repository's open GitHub issues without the `quest` label. -Add the `quest` label to these issues once the PR is open, so another planner skips them. diff --git a/.claude/skills/plan-issues/agents/openai.yaml b/.claude/skills/plan-issues/agents/openai.yaml deleted file mode 100644 index e52345a27a..0000000000 --- a/.claude/skills/plan-issues/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "plan-issues" - short_description: "Turn unplanned GitHub issues into repository quests" - default_prompt: "Use $plan-issues to invoke $plan-quests for open GitHub issues without the quest label." diff --git a/.claude/skills/plan-quests/SKILL.md b/.claude/skills/plan-quests/SKILL.md deleted file mode 100644 index 74c233d4d6..0000000000 --- a/.claude/skills/plan-quests/SKILL.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: plan-quests -description: Scope, create, and publish a quest through an interactive grilling interview. Use when the user invokes /plan-quests, asks to plan a quest, or wants unsettled work split into quests. ---- - -Before you begin, read `quest/AGENTS.md` completely. - -Interview the user until you reach a shared understanding. - -Work the tree in **rounds**. -The **frontier** is every decision whose prerequisites are already settled. -Ask the whole frontier in one round, interactively if supported. -Select at least one answer as (recommended) and wait for the user's answers (never guess) before the next round. - -Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. -Recompute the frontier and ask the next round. -A question whose answer depends on another question still open in this round belongs to a *later* round, not this one. - -Finding *facts* is your job, never the user's. -When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it. -Don't block on it, ask the rest of the frontier now. -The *decisions* are the user's: put each to them and wait. - -Search other quests and questlines to keep the larger plan consistent. -When the work changes what a user sees (a wire, an API, a flag, a dashboard), ask whether it needs documentation the feature quest cannot carry inline (a new page or guide), and recommend a quest for that; docs a change makes stale stay in that change. -When the frontier disagrees with a settled quest/plan, challenge the user and resolve the conflict. - -Begin the interview by scoping the goal: the observable outcome, why it matters, and its important boundaries and non-goals. -Restate the goal in one sentence and get it confirmed before moving on to implementation decisions. -If the goal contains independently completable outcomes, split them before planning. -Map the implementation plan as a design tree: every material decision branches into the decisions that hang off it. - -The session is done when the frontier is empty. -The result may be one quest or multiple quests and questlines, split based on what can be completed independently. -Prefix each quest title with `[XS]`, `[S]`, `[M]`, `[L]`, or `[XL]`, including implementation, verification, and landing work. -Once complete, create, update, or delete the relevant quests and questlines. -Record each settled decision and its reason in the quest's Plan, so later sessions don't ask it again. -New work joins the milestone matching its priority, at its rank; a questline groups only quests that ship together, and its README holds the work no child owns (the end-to-end test, the docs page). - -When done, commit and create a draft PR following `CONTRIBUTING.md`. -After local checks pass, run `/merge` on it. diff --git a/.claude/skills/plan-quests/agents/openai.yaml b/.claude/skills/plan-quests/agents/openai.yaml deleted file mode 100644 index 0aa796e75f..0000000000 --- a/.claude/skills/plan-quests/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "plan-quests" - short_description: "Scope and publish a repository quest" - default_prompt: "Use $plan-quests to scope and publish a repository quest." diff --git a/.claude/skills/quest-close/SKILL.md b/.claude/skills/quest-close/SKILL.md new file mode 100644 index 0000000000..db3d093ee4 --- /dev/null +++ b/.claude/skills/quest-close/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-close +description: Close a GitHub PR. +--- + +Run `quest skill quest-close` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-issues/SKILL.md b/.claude/skills/quest-issues/SKILL.md new file mode 100644 index 0000000000..74e65c239a --- /dev/null +++ b/.claude/skills/quest-issues/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-issues +description: Plan quests from open GitHub issues without the quest label. +--- + +Run `quest skill quest-issues` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-merge/SKILL.md b/.claude/skills/quest-merge/SKILL.md new file mode 100644 index 0000000000..8196069889 --- /dev/null +++ b/.claude/skills/quest-merge/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-merge +description: Merge a GitHub PR once reviews and CI pass. +--- + +Run `quest skill quest-merge` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-plan/SKILL.md b/.claude/skills/quest-plan/SKILL.md new file mode 100644 index 0000000000..45e6f68fea --- /dev/null +++ b/.claude/skills/quest-plan/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-plan +description: Scope, create, and publish a quest through an interactive grilling interview. Use when the user invokes /quest-plan, asks to plan a quest, or wants unsettled work split into quests. +--- + +Run `quest skill quest-plan` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-spawn-merge/SKILL.md b/.claude/skills/quest-spawn-merge/SKILL.md new file mode 100644 index 0000000000..8cedf12224 --- /dev/null +++ b/.claude/skills/quest-spawn-merge/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-spawn-merge +description: Decide and merge GitHub PRs in parallel. +--- + +Run `quest skill quest-spawn-merge` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-spawn/SKILL.md b/.claude/skills/quest-spawn/SKILL.md new file mode 100644 index 0000000000..da9567055c --- /dev/null +++ b/.claude/skills/quest-spawn/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-spawn +description: Spawn background agents to work on quests in parallel. +--- + +Run `quest skill quest-spawn` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-start/SKILL.md b/.claude/skills/quest-start/SKILL.md new file mode 100644 index 0000000000..8ab6bf60bf --- /dev/null +++ b/.claude/skills/quest-start/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-start +description: Start work on a quest. +--- + +Run `quest skill quest-start` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-takeover/SKILL.md b/.claude/skills/quest-takeover/SKILL.md new file mode 100644 index 0000000000..004e163189 --- /dev/null +++ b/.claude/skills/quest-takeover/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-takeover +description: Adopt someone else's open PR and drive it to landable. +--- + +Run `quest skill quest-takeover` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/spawn-merge b/.claude/skills/spawn-merge deleted file mode 120000 index 669e016de4..0000000000 --- a/.claude/skills/spawn-merge +++ /dev/null @@ -1 +0,0 @@ -../shared/spawn-merge \ No newline at end of file diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md deleted file mode 100644 index b8e0f44ffa..0000000000 --- a/.claude/skills/spawn-quests/SKILL.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: spawn-quests -description: Spawn background agents work on quests in parallel. ---- - -Before you begin, read `quest/AGENTS.md` completely. - -Your goal is to execute, plan, and/or merge quests in parallel. -If you are unsure of the best course of action, ask the user for clarification before proceeding. - -The scope consists of all ready quests that are not claimed. -Use the argument (if provided) to filter to specific quests/questlines. -Main's tree lags its lines: a child finished on its line branch, or on `dev`, still looks ready from `main`. -Judge readiness from each line branch's own quest directory, and treat a quest deleted on `dev` as done. -Inspect any blocked quests, and determine if they can be unblocked. -A line whose `Quests` list has emptied is a ready quest too: finishing it marks the line's PR ready. - -Recommend an action for each quest: /start-quest, /plan-quests, skip, or delete. -Start the quests you'd start with no open question right away. -Interactively prompt the user about the rest, a few per prompt, each with a short summary and your recommendation. - -Spawn a background sub-agent for each /start-quest. -Create a fresh worktree on the base `quest branch` prints, creating that line branch first if it is missing. -Agents share no writable files: each keeps its scratch files in its own worktree's `.scratch/`, and anything you hand every agent goes in its prompt, not a shared file. -Each agent blocks on its own checks and reports back only when done or blocked. -Limit the concurrency to at most N agents in parallel, where N is half the number of physical CPU cores. -Other sessions share this machine: hold new agents while the load average exceeds the core count. - -Report each sub-agent's final status, staying silent on interim notifications, but do not monitor their PRs. -Prompt the user if they want to /plan-quests for any suggested follow-ups. - -Run /plan-quests for any selected quests in the foreground. -Perform any research and monitoring in the background. - -Finally, create a PR for any created/updated quests. diff --git a/.claude/skills/spawn-quests/agents/openai.yaml b/.claude/skills/spawn-quests/agents/openai.yaml deleted file mode 100644 index 84bd50b05b..0000000000 --- a/.claude/skills/spawn-quests/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "spawn-quests" - short_description: "Triage a milestone's quests and spawn agents for the ready ones" - default_prompt: "Use $spawn-quests to triage a scope's quests and spawn agents for the ones worth starting now." diff --git a/.claude/skills/start-quest/SKILL.md b/.claude/skills/start-quest/SKILL.md deleted file mode 100644 index 73d0911e40..0000000000 --- a/.claude/skills/start-quest/SKILL.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: start-quest -description: Start work on a quest. ---- - -Before you begin, read `quest/AGENTS.md` completely. - -Your goal is to implement the quest, or as much of it as possible, and create a draft PR. -The argument is the quest to work on. - -If you are unsure on the best course of action, ask the user for direction. - -Confirm the quest is ready and unclaimed. -Claim it as `quest/AGENTS.md` describes: `quest branch` names the branch and its bases, missing line branches get a draft PR, and the quest branch gets an empty commit. - -Implement the quest until it is complete, or some blocker is hit, then create a PR against the base. -Keep scratch files (PR body, logs, notes) in the worktree's gitignored `.scratch/`. -Never write to or clean up a directory other agents share, such as a session scratchpad. - -When done, summarize any issues encounted, and suggest potential follow-up. -If you're happy with the outcome, switch the draft PR to ready for review. -If you want another set of eyes on it, keep it a draft. diff --git a/.claude/skills/start-quest/agents/openai.yaml b/.claude/skills/start-quest/agents/openai.yaml deleted file mode 100644 index 11280e5c90..0000000000 --- a/.claude/skills/start-quest/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "start-quest" - short_description: "Find and start an unblocked repository quest" - default_prompt: "Use $start-quest to find unblocked repository quests and start my choice." diff --git a/.claude/skills/takeover b/.claude/skills/takeover deleted file mode 120000 index f004a334ef..0000000000 --- a/.claude/skills/takeover +++ /dev/null @@ -1 +0,0 @@ -../shared/takeover \ No newline at end of file diff --git a/.gitignore b/.gitignore index ec8f692718..af3a5c21ac 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ /.claude/tmp/ /.claude/worktrees/ -# Per-worktree agent scratch (PR bodies, logs, notes); see the start-quest skill. +# Per-worktree agent scratch (PR bodies, logs, notes); see the quest-start skill. /.scratch/ # IDE diff --git a/AGENTS.md b/AGENTS.md index 109cfb070f..e2b594df12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,10 @@ just fix # Auto-fix lint/formatting, same scope These diff the branch against its base and only run the affected packages. +When work mentions a quest, run `quest guide` and follow it. +The `quest` binary comes from the kixelated/quest flake input and serves the quest skills; change them upstream and bump the input. +A quest deleted on `dev` is done, even while `main` still lists it. + # Cross-Package Sync | Change in | Also update | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eed93b1532..28673e0f47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ For each finding: If you encounter issues, or findings that are out of scope, create follow-up quests. Focus on the core problem, offering a potential solution only if its obvious. -For non-trivial tasks, file an issue or offer to run `/plan-quests`. +For non-trivial tasks, file an issue or offer to run `/quest-plan`. # Forks diff --git a/Cargo.lock b/Cargo.lock index 43ba79c076..39a13627cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6807,17 +6807,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags 2.13.2", - "memchr", - "unicase", -] - [[package]] name = "pulseaudio" version = "0.3.1" @@ -6853,16 +6842,6 @@ dependencies = [ "web-transport-trait", ] -[[package]] -name = "quest" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "pulldown-cmark", - "tempfile", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -9214,12 +9193,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index b6da7a249e..9ceb5a4d1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,6 @@ members = [ "rs/moq-v4l", "rs/moq-video", "rs/moq-wasm", - "rs/quest", "rs/uniffi-bindgen", ] default-members = [ @@ -79,7 +78,6 @@ default-members = [ "rs/moq-v4l", "rs/moq-video", # "rs/moq-wasm", # wasm32-unknown-unknown target only - "rs/quest", # Maturin runs the workspace's `uniffi-bindgen` binary from the root to # generate the Python wheel, and that only searches default members. A # `cargo build -p moq-ffi` still never compiles it. @@ -202,7 +200,6 @@ objc2-screen-capture-kit = "0.3.2" object_store = { version = "0.14", default-features = false } percent-encoding = "2" pollster = "1.0" -pulldown-cmark = { version = "0.13", default-features = false } qmux = { version = "0.5.1", default-features = false } rand = "0.10.1" # default-features off so each consumer picks its own TLS backend and body diff --git a/flake.lock b/flake.lock index bf8e740574..05b62f209b 100644 --- a/flake.lock +++ b/flake.lock @@ -49,11 +49,42 @@ "type": "github" } }, + "quest": { + "inputs": { + "crane": [ + "crane" + ], + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ], + "rust-overlay": [ + "rust-overlay" + ] + }, + "locked": { + "lastModified": 1790533850, + "narHash": "sha256-6cBgVpfIBVQ9tTXyV7Xj8AAYH+Qtp6aOmFx/dwEUNLw=", + "owner": "kixelated", + "repo": "quest", + "rev": "a4c3debebeacc142dc31b170f3255d1ccf6e4c9b", + "type": "github" + }, + "original": { + "owner": "kixelated", + "repo": "quest", + "rev": "a4c3debebeacc142dc31b170f3255d1ccf6e4c9b", + "type": "github" + } + }, "root": { "inputs": { "crane": "crane", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", + "quest": "quest", "rust-overlay": "rust-overlay" } }, diff --git a/flake.nix b/flake.nix index 028422fd1b..24c1685036 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,15 @@ url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; + # The quest CLI, which also serves the quest guide and skills the stubs in + # .claude/skills call. Bump the rev to upgrade them. + quest = { + url = "github:kixelated/quest/a4c3debebeacc142dc31b170f3255d1ccf6e4c9b"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + inputs.crane.follows = "crane"; + inputs.rust-overlay.follows = "rust-overlay"; + }; }; outputs = @@ -33,6 +42,7 @@ flake-utils, crane, rust-overlay, + quest, ... }: let @@ -480,7 +490,8 @@ ++ ktDeps ++ goDeps ++ dartDeps - ++ devTools; + ++ devTools + ++ [ quest.packages.${system}.default ]; # jemalloc's configure uses -O0 test builds, which conflict with # Nix's _FORTIFY_SOURCE hardening (requires -O). diff --git a/justfile b/justfile index 9d8af7a663..af3866c399 100644 --- a/justfile +++ b/justfile @@ -441,7 +441,8 @@ _tools $FILES="": # `_check-common` runs on every invocation, so its tools are unconditional. tools=(actionlint bun jq nix nixfmt shellcheck shfmt taplo python3 nfpm dpkg-deb envsubst rpm) scoped '^(drafts/|doc/\.vitepress/drafts\.ts$)' && tools+=(kramdown-rfc xml2rfc) - scoped '^(bench/|quest/|rs/|Cargo\.(toml|lock)$|rust-toolchain\.toml$)' && tools+=(cargo envsubst) + scoped '^(bench/|rs/|Cargo\.(toml|lock)$|rust-toolchain\.toml$)' && tools+=(cargo envsubst) + scoped '^(quest/|flake\.lock$)' && tools+=(quest) scoped '^(py/|pyproject\.toml$|uv\.lock$|rs/moq-ffi/|doc/lib/py/|doc/lib/samples\.sh$)' && tools+=(uv) scoped '^(kt/|rs/moq-ffi/|doc/lib/kt/|doc/lib/samples\.sh$)' && tools+=(gradle java) # cargo because `go check` builds moq-ffi for the host, and skips on a @@ -542,7 +543,7 @@ _check $BASE $TEST: just rs tokio-features just rs media-features just --justfile bench/justfile check - cargo run --quiet --locked --package quest -- check + quest check # Not covered by the line above: moq-wasm only exists on the wasm32 target. just rs wasm just py check @@ -565,9 +566,9 @@ _check $BASE $TEST: just drafts check fi # Quest documents form one graph, so validate the whole living tree when - # either a quest or its validator changes. - if echo "$files" | grep -qE '^(quest/|rs/quest/)'; then - cargo run --quiet --locked --package quest -- check + # either a quest or the flake-pinned validator changes. + if echo "$files" | grep -qE '^(quest/|flake\.lock$)'; then + quest check fi just py check "$files" just kt check "$files" diff --git a/quest/AGENTS.md b/quest/AGENTS.md deleted file mode 100644 index 787c525880..0000000000 --- a/quest/AGENTS.md +++ /dev/null @@ -1,116 +0,0 @@ -# Quests - -Read this file whenever work mentions a quest or questline. - -Quests are versioned plans checked into the repository under `quest/`. GitHub -issues remain the public front door; prefer a quest for work that needs -durable scope or coordination. - -## Model - -- A quest is a Markdown file, completed in one PR. A questline is a directory - whose `README.md` is its quest: its `Quests` section lists the children, and - it completes when its own work is done and every child has merged. -- The root's entries are milestones, `m0`, `m1`, ..., grouping work by - priority horizon; lower numbers matter more. [README.md](README.md) says - what each holds. Priority, not breakage, decides the milestone, and starting - a quest does not move it. -- A document's branch is its path without `.md`: `quest/m1/foo/bar.md` is - branch `quest/m1/foo/bar`, and its line is `quest/m1/foo/README`. A quest - merges into its line's branch, a line into its parent's, and a milestone's - direct children into `main`. Milestones have no branch. -- A published API or wire break retargets to `dev` at PR time, per the root - `AGENTS.md`; a quest's Plan may note it. -- Every `Quests` list is ordered by priority. Insert at rank, never append. -- Link with root-absolute paths. Finished documents are deleted; git history - keeps them. Merge conflicts are expected; resolve them by aligning quests. - -## Format - -```markdown -# [S] Short title - -## Goal - -The observable outcome and important boundaries. - -## Plan - -Current decisions, open questions, or implementation guidance. - -## Quests - -- [Child quest](/quest/foo/bar.md) - the outcome, so the list reads without opening it -- [Nested questline](/quest/foo/baz/README.md) - what the whole line delivers - -## Required - -- [Blocker](/quest/bar.md) - work that must finish before this can start - -## Closes - -- [#701](https://github.com/moq-dev/moq/issues/701) - close this issue when the quest finishes - -## Related - -- [Other](/quest/other.md) - similar work that is not a blocker -``` - -- `Goal` is required; everything else is optional. Use these exact headings. -- Size the title `[XS]` to `[XL]` for implementation, verification, and - landing. A README with children carries no size; one without is a plain - quest and needs one. -- Only a README has `Quests`. `Required` lists what must finish before the - work starts; no section means ready. A required questline clears when the - whole line has merged. A plain-text bullet names a condition outside the - repository; remove it when it clears. `Required` must be acyclic. -- `quest check` enforces this structure; `just check` runs it on any branch - touching `quest/`. `quest ready []` prints what blocks a quest, or - every ready quest. `quest branch ` prints the branch and every branch - it merges through, nearest first and ending at `main`. Run them as - `cargo run --quiet --locked --package quest -- ...`. They read the tree - alone: whether a PR already claims a quest is GitHub's question. - -## Creation - -- Quests are created in PRs and reviewed. Search the tree and git history - first. -- Split independently completable work into separate quests. Group them in a - questline only when they ship together, and give the README the work no - child owns: the end-to-end test, the docs page. -- New work joins the milestone matching its priority, at its rank. -- Every issue under `Closes` carries the `quest` GitHub label - (`gh issue edit --add-label quest`), applied when the PR creating the - quest opens and removed if that PR closes unmerged. - `Related` is context and gets none. -- A release or pin bump that unblocks work is its own quest holding the - condition as a plain-text `Required` bullet; every dependent requires it. - -## Execution - -- Start only ready quests. `quest branch` names the branch and its bases: push - each missing line branch from the one after it and open its draft PR against - that base, then push the quest's branch with an empty commit. The remote - branch is the claim; continue only if an existing one is stale (old, no open - PR). -- Set the upstream to the base so `just check` scopes against - it. Keep a line current by merging its base in; never rebase a shared branch. -- Update the quest as the plan changes. Complete it when no work remains, and - suggest follow-ups as new quests. -- Open the PR per [CONTRIBUTING.md](../CONTRIBUTING.md) against the base, with - a closing keyword for every issue under `Closes`, including those of any - questline the same PR completes. -- A line's PR stays a draft until its `Quests` list is empty. The PR that - removes the last child sizes the README's title; the README is then a ready - quest whose completion marks the line's PR ready and merges it. - -## Deletion - -- A quest that is no longer needed or cannot be completed is abandoned: delete - it and explain why in the PR. Remove the `quest` label from issues no other - quest tracks. -- Delete a quest in the PR that completes or abandons it. Grep its absolute - path and remove every reference; that reveals what it unblocks. Remove a - heading with its last entry. -- Deleting a README deletes its directory. The root and the milestones are - permanent: an empty milestone stays as a horizon. diff --git a/rs/quest/Cargo.toml b/rs/quest/Cargo.toml deleted file mode 100644 index 0bef17e7ee..0000000000 --- a/rs/quest/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "quest" -version = "0.1.0" -edition = "2024" -rust-version.workspace = true -authors = ["Luke Curley"] -license = "MIT OR Apache-2.0" -publish = false -description = "Validator and readiness gate for the quest tree: the interlinked Markdown plans under quest/, whose contract is quest/AGENTS.md. Enforces link resolution, the questline index, the heading vocabulary, and an acyclic Required graph, so a structural mistake fails the PR instead of the next reader, reports what blocks a quest so a flow does not reconstruct that by grepping, and names the branches a quest lands on." - -[[bin]] -name = "quest" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -clap.workspace = true -pulldown-cmark.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/rs/quest/src/branch.rs b/rs/quest/src/branch.rs deleted file mode 100644 index 000e039a92..0000000000 --- a/rs/quest/src/branch.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Which branch carries a quest, and which branches it merges through. -//! -//! The mapping is the path alone: a quest's branch is its path without `.md` -//! and a questline's is its README's. Milestones have no branch, so a -//! milestone's direct children merge into `main`. Nothing here asks git, so the -//! answer is the same on every machine and a missing line branch is simply -//! created from the next one in the chain. - -use std::collections::BTreeMap; -use std::path::Path; - -use anyhow::{Result, bail}; - -use crate::doc::Doc; - -/// The branch for `path`, then every branch it merges through, nearest first -/// and ending at `main`. -pub fn chain(root: &Path, path: &Path) -> Result> { - let docs = crate::load(root)?; - let by_path: BTreeMap<&Path, &Doc> = docs.iter().map(|d| (d.path.as_path(), d)).collect(); - let mut cur = crate::ready::locate(root, path, &by_path)?; - let mut out = Vec::new(); - while !Doc::permanent(&cur) { - out.push(cur.with_extension("").display().to_string()); - cur = Doc::owner(&cur).join("README.md"); - } - if out.is_empty() { - bail!("{} is the root or a milestone, which has no branch", path.display()); - } - out.push("main".to_string()); - Ok(out) -} diff --git a/rs/quest/src/doc.rs b/rs/quest/src/doc.rs deleted file mode 100644 index 11c2f0794c..0000000000 --- a/rs/quest/src/doc.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Parsing one quest document into the facts every rule reads. -//! -//! This is a real Markdown AST rather than line matching. The shell version -//! that came before it was defeated by a fence longer than three backticks -//! (which inverted its fence tracking and silently skipped the rest of the -//! file), by a `Required` bullet that wrapped onto a second line (which moved -//! the link out of reach of the check that exists to catch it), and by -//! reference-style links (which produced a rendered dependency with no edge). -//! None of those are special cases here; the parser simply reports the -//! structure that Markdown actually has. - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; - -/// Where a link sits inside its `## Section`, which is what separates a -/// dependency edge from prose that merely mentions one. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Position { - /// The link opens a list item, ignoring any emphasis around it. This is the - /// shape every `Required` blocker and every `Quests` entry must have. - Entry, - /// Somewhere else inside a list item: a sentence that happens to link. - Inside, - /// Outside any list item. - Prose, -} - -/// One Markdown link, located precisely enough to be a graph edge. -#[derive(Clone, Debug)] -pub struct Link { - /// 1-based source line, for findings. - pub line: usize, - /// The enclosing `## Heading`, or `None` above the first one. - pub section: Option, - /// The destination exactly as written, fragment included. - pub target: String, - /// Where the link sits in its section; see [`Position`]. - pub position: Position, -} - -/// One top-level list entry: the shape a `Required` blocker and a `Quests` -/// index entry both have. -#[derive(Clone, Debug)] -pub struct Entry { - /// 1-based source line, for findings. - pub line: usize, - /// The enclosing `## Heading`, or `None` above the first one. - pub section: Option, - /// The rendered text with whitespace collapsed, so a bullet that wrapped - /// onto a second line reads as the one line it renders as. - pub text: String, - /// The destination of the link that opens the entry, if one does. This is - /// the [`Position::Entry`] link, so it is a dependency rather than a - /// sentence that happens to mention one. - pub target: Option, -} - -/// One `# ` or `## ` heading as the rules need to see it. -#[derive(Clone, Debug)] -pub struct Heading { - /// 1-based source line, for findings. - pub line: usize, - /// The rendered heading text, trimmed. - pub text: String, - /// The source really is `# Text` or `## Text` on its own line. Setext and - /// decorated headings render the same, but literal syntax is the contract. - pub literal: bool, -} - -/// One parsed quest document: the structure the rules read, nothing else. -#[derive(Clone, Debug)] -pub struct Doc { - /// Repository-relative, e.g. `quest/m0/one.md`. - pub path: PathBuf, - /// The document's `# ` title, used for a quest's t-shirt size. - pub title: Option, - /// `## ` headings only. Deeper levels are free-form prose structure. - pub headings: Vec, - /// Every link in the document, in source order. - pub links: Vec, - /// Every top-level list item, in source order. A heading left behind by its - /// last entry has none, and a `Required` blocker is one of these whether or - /// not it carries a link. - pub entries: Vec, -} - -impl Doc { - /// Whether the document has this exact `## ` heading. - pub fn has(&self, heading: &str) -> bool { - self.headings.iter().any(|h| h.text == heading) - } - - /// The top-level entries under a `## ` section, in source order. - pub fn entries(&self, section: &str) -> impl Iterator { - self.entries - .iter() - .filter(move |e| e.section.as_deref() == Some(section)) - } - - /// A questline is a `README.md` with a `Quests` section. Any other README is - /// what a line becomes when its last child merges: the line's own remaining - /// work, executed like any other quest. The root and the milestones are the exception. - pub fn is_questline(&self) -> bool { - self.is_readme() && (self.has("Quests") || Self::permanent(&self.path)) - } - - /// The root and the milestones outlive their quests, so an empty one is not - /// a leaf. - pub fn permanent(path: &Path) -> bool { - path.file_name().is_some_and(|n| n == "README.md") && path.components().count() <= 3 - } - - fn is_readme(&self) -> bool { - self.path.file_name().is_some_and(|n| n == "README.md") - } - - /// The questline directory this document belongs to. A questline is a - /// DIRECTORY, so its own entry sits one level further out than a quest's: - /// `quest/m1/drain/README.md` belongs to `quest/m1`, not to `quest/m1/drain`. - pub fn owner(path: &Path) -> PathBuf { - let parent = path.parent().unwrap_or(Path::new("")); - if path.file_name().is_some_and(|n| n == "README.md") { - parent.parent().unwrap_or(Path::new("")).to_path_buf() - } else { - parent.to_path_buf() - } - } - - /// Read and parse `/`; `path` stays repository-relative. - pub fn parse(root: &Path, path: PathBuf) -> Result { - let text = std::fs::read_to_string(root.join(&path)).with_context(|| format!("reading {}", path.display()))?; - Ok(Self::from_str(path, &text)) - } - - /// Parse already-loaded Markdown; this is the whole parser, `parse` just adds IO. - pub fn from_str(path: PathBuf, text: &str) -> Doc { - let lines = LineIndex::new(text); - let text_src = text; - - let mut title = None; - let mut headings = Vec::new(); - let mut links = Vec::new(); - let mut entries: Vec = Vec::new(); - let mut entry: Option = None; - let mut section: Option = None; - - // Depth of nesting, so a sub-list inside an entry does not read as a - // second entry, and so `fresh` tracks the innermost item. - let mut item_depth = 0usize; - // Entries are the flat, top-level list. A bullet nested under a prose - // lead-in, or one inside a block quote, is illustration - counting it - // would let `- evidence:` + an indented link become a real blocker. - let mut quote_depth = 0usize; - // Nothing but emphasis has been seen since the current item opened, so a - // link here is the item's opening link. `**[Blocker](/quest/b.md)**` is - // still an opener; `A customer who justifies [b](/quest/b.md)` is not. - let mut fresh = false; - let mut heading: Option<(HeadingLevel, usize, String)> = None; - - let mut options = Options::empty(); - options.insert(Options::ENABLE_STRIKETHROUGH); - options.insert(Options::ENABLE_TABLES); - - for (event, range) in Parser::new_ext(text, options).into_offset_iter() { - match event { - Event::Start(Tag::Heading { level, .. }) if matches!(level, HeadingLevel::H1 | HeadingLevel::H2) => { - heading = Some((level, lines.line_of(range.start), String::new())); - } - Event::End(TagEnd::Heading(level)) if matches!(level, HeadingLevel::H1 | HeadingLevel::H2) => { - if let Some((start_level, line, text)) = heading.take() { - debug_assert_eq!(start_level, level); - let text = text.trim().to_string(); - // Exact, not trimmed: `rg '^## Required$'` does not match a - // line with trailing spaces either, and this rule exists - // precisely so the two can never disagree. - let prefix = if level == HeadingLevel::H1 { "#" } else { "##" }; - let parsed = Heading { - line, - literal: lines.text_of(text_src, line) == format!("{prefix} {text}"), - text: text.clone(), - }; - if level == HeadingLevel::H1 { - title = Some(parsed); - } else { - section = Some(text); - headings.push(parsed); - } - } - item_depth = 0; - entry = None; - fresh = false; - } - Event::Start(Tag::BlockQuote(..)) => { - quote_depth += 1; - fresh = false; - } - Event::End(TagEnd::BlockQuote(..)) => { - quote_depth = quote_depth.saturating_sub(1); - fresh = false; - } - Event::Start(Tag::Item) => { - if item_depth == 0 && quote_depth == 0 { - entry = Some(Entry { - line: lines.line_of(range.start), - section: section.clone(), - text: String::new(), - target: None, - }); - } - item_depth += 1; - fresh = true; - } - Event::End(TagEnd::Item) => { - if item_depth == 1 - && let Some(mut done) = entry.take() - { - done.text = collapse(&done.text); - entries.push(done); - } - item_depth = item_depth.saturating_sub(1); - fresh = false; - } - Event::Start(Tag::Link { dest_url, .. }) => { - // Reference-style links arrive here already resolved to their - // destination, so they carry an edge like any other link. - // Only a top-level, unquoted item can hold an entry. - let position = if item_depth == 0 { - Position::Prose - } else if fresh && item_depth == 1 && quote_depth == 0 { - Position::Entry - } else { - Position::Inside - }; - fresh = false; - if position == Position::Entry - && let Some(entry) = entry.as_mut() - { - entry.target = Some(dest_url.to_string()); - } - links.push(Link { - line: lines.line_of(range.start), - section: section.clone(), - target: dest_url.to_string(), - position, - }); - } - // Emphasis wraps an opening link without displacing it, and so does - // the paragraph a LOOSE list puts around every item: clearing on - // its START would classify every entry in a blank-line-separated - // list as mid-sentence prose. Its END does clear, so a link that - // opens a SECOND paragraph is inside the item, not opening it. - Event::Text(ref t) | Event::Code(ref t) => { - if let Some((_, _, buf)) = heading.as_mut() { - buf.push_str(t); - } else if let Some(entry) = entry.as_mut() { - entry.text.push_str(t); - } - if !t.trim().is_empty() { - fresh = false; - } - } - Event::Start(Tag::Emphasis | Tag::Strong | Tag::Paragraph) - | Event::End(TagEnd::Emphasis | TagEnd::Strong) => {} - Event::End(TagEnd::Paragraph) => fresh = false, - Event::SoftBreak | Event::HardBreak => { - if let Some(entry) = entry.as_mut() { - entry.text.push(' '); - } - } - _ => { - if item_depth > 0 { - fresh = false; - } - } - } - } - - Doc { - path, - title, - headings, - links, - entries, - } - } -} - -/// One line of whitespace-separated words, so a wrapped or loosely indented -/// bullet prints the way it renders. -fn collapse(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") -} - -/// Byte offset -> 1-based line number, so findings can name a line. -struct LineIndex { - starts: Vec, -} - -impl LineIndex { - fn new(text: &str) -> LineIndex { - let mut starts = vec![0]; - starts.extend(text.match_indices('\n').map(|(i, _)| i + 1)); - LineIndex { starts } - } - - fn line_of(&self, offset: usize) -> usize { - self.starts.partition_point(|&start| start <= offset) - } - - /// The source of a 1-based line, without its newline. - fn text_of<'a>(&self, text: &'a str, line: usize) -> &'a str { - let start = self.starts.get(line - 1).copied().unwrap_or(text.len()); - let end = self.starts.get(line).copied().unwrap_or(text.len()); - text[start..end].trim_end_matches('\n') - } -} diff --git a/rs/quest/src/lib.rs b/rs/quest/src/lib.rs deleted file mode 100644 index 2845f241ac..0000000000 --- a/rs/quest/src/lib.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! The quest tree, whose contract is quest/AGENTS.md: structural validation of -//! it, whether a given quest can be started, and which branches carry it. -//! -//! The whole tree is validated on every run, never just the changed files: the -//! link graph and the questline index are global, so completing one quest -//! breaks files the diff never mentions. That is not hypothetical - it is how -//! the index entry for a completed quest survived a rebase that produced no -//! conflict at all. - -pub mod branch; -pub mod doc; -pub mod ready; -pub mod rules; - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; - -pub use doc::Doc; -pub use ready::Blocker; -pub use rules::Finding; - -/// AGENTS.md is the contract rather than a quest: not executable work, so -/// neither indexed nor validated. -const NOT_QUESTS: [&str; 1] = ["AGENTS.md"]; - -/// Every quest document under `/quest`, sorted, repository-relative. -pub fn collect(root: &Path) -> Result> { - let mut out = Vec::new(); - walk(root, &root.join("quest"), &mut out).with_context(|| format!("scanning {}", root.join("quest").display()))?; - out.sort(); - Ok(out) -} - -fn walk(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - // `file_type` does not follow symlinks, so quest/AGENTS.md is a file - // here and a directory symlink can never make this recurse forever. - let kind = entry.file_type()?; - if kind.is_dir() { - walk(root, &path, out)?; - } else if path.extension().is_some_and(|e| e == "md") - && !path - .file_name() - .is_some_and(|n| NOT_QUESTS.iter().any(|skip| n == *skip)) - { - out.push(path.strip_prefix(root).unwrap_or(&path).to_path_buf()); - } - } - Ok(()) -} - -/// Parse and validate the tree. Returns every finding, worst-case empty. -pub fn check(root: &Path) -> Result> { - Ok(rules::check(root, &load(root)?)) -} - -/// Every quest document, parsed, in tree order. -fn load(root: &Path) -> Result> { - let paths = collect(root)?; - if paths.is_empty() { - bail!("no quest documents found under {}", root.join("quest").display()); - } - paths.into_iter().map(|p| Doc::parse(root, p)).collect() -} diff --git a/rs/quest/src/main.rs b/rs/quest/src/main.rs deleted file mode 100644 index 27135981cd..0000000000 --- a/rs/quest/src/main.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::PathBuf; -use std::process::ExitCode; - -use anyhow::Result; -use clap::{Parser, Subcommand}; - -/// The quest tree, the interlinked Markdown plans under quest/ whose contract is -/// quest/AGENTS.md: validate its structure, report what blocks a quest, or name -/// the branches a quest lands on. -#[derive(Parser)] -#[command(version, about)] -struct Cli { - /// Repository root holding the quest/ directory. - #[arg(long, default_value = ".", global = true)] - root: PathBuf, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Report every structural mistake in the tree; exit non-zero if any. - Check, - - /// Print what blocks a quest, or list every ready quest. - /// - /// Exits 0 either way: the blocker list on stdout is the result, so no - /// output means ready and a caller tests that rather than parsing prose. A - /// non-zero exit means the command itself failed. - Ready { - /// Quest to explain. Omit to list every ready quest in tree order. - path: Option, - }, - - /// Print the branch carrying a quest, then every branch it merges through. - /// - /// One per line, nearest first, ending at `main`: a quest merges into its - /// questline's branch, a questline into its parent's, and a milestone's - /// children into `main`. A line missing from the remote is created from the - /// one printed after it. - Branch { - /// Quest or questline to locate. - path: PathBuf, - }, -} - -fn main() -> Result { - let cli = Cli::parse(); - match cli.command { - Command::Check => { - let findings = quest::check(&cli.root)?; - if findings.is_empty() { - let total = quest::collect(&cli.root)?.len(); - println!("quest: {total} documents ok"); - return Ok(ExitCode::SUCCESS); - } - for finding in &findings { - eprintln!("quest: {finding}"); - } - Ok(ExitCode::FAILURE) - } - Command::Ready { path: Some(path) } => { - let blockers = quest::ready::blockers(&cli.root, &path)?; - for blocker in &blockers { - print!("{blocker}"); - } - if !blockers.is_empty() { - // Blocked is not a verdict on the whole plan: the piece of it - // that does not need the blocker is split into its own quest. - eprintln!( - "quest: {} is blocked; split any independently landable piece into its own quest (quest/AGENTS.md, Creation) rather than starting this one as it stands", - path.display() - ); - } - Ok(ExitCode::SUCCESS) - } - Command::Ready { path: None } => { - for path in quest::ready::quests(&cli.root)? { - println!("{}", path.display()); - } - Ok(ExitCode::SUCCESS) - } - Command::Branch { path } => { - for branch in quest::branch::chain(&cli.root, &path)? { - println!("{branch}"); - } - Ok(ExitCode::SUCCESS) - } - } -} diff --git a/rs/quest/src/ready.rs b/rs/quest/src/ready.rs deleted file mode 100644 index 62ade835ee..0000000000 --- a/rs/quest/src/ready.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Whether a quest can be started, read from the same `Required` sections the -//! rules validate. -//! -//! Readiness is a property of the tree alone, which is what makes it cheap and -//! deterministic: a finished quest is deleted, so a blocker that still resolves -//! is still open. Liveness is the other question - a quest can be ready, -//! coherent, and already done by some other PR - and answering it means asking -//! GitHub, so it belongs to the flow that is already talking to it. - -use std::collections::BTreeMap; -use std::fmt; -use std::path::{Path, PathBuf}; - -use anyhow::{Result, bail}; - -use crate::doc::Doc; -use crate::rules; - -/// One thing standing between a quest and being started. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Blocker { - /// The quest or questline that has to finish first, when the blocker is one - /// of ours. `None` is a plain-text condition, which nothing in the tree can - /// ever clear. - pub path: Option, - /// The `Required` bullet as written, whitespace collapsed. - pub text: String, - /// The still-open quests under a required questline, which is what a - /// questline blocker actually means. Empty for every other blocker: a - /// required quest's own blockers are its readiness, not this one's. - pub blockers: Vec, -} - -impl Blocker { - /// What names the blocker: the document, or the condition's own words. - pub fn label(&self) -> String { - match &self.path { - Some(path) => path.display().to_string(), - None => self.text.clone(), - } - } - - fn write(&self, f: &mut fmt::Formatter<'_>, depth: usize) -> fmt::Result { - writeln!(f, "{}{}", " ".repeat(depth), self.label())?; - for blocker in &self.blockers { - blocker.write(f, depth + 1)?; - } - Ok(()) - } -} - -impl fmt::Display for Blocker { - /// The whole chain, one blocker per line and indented by depth, newline - /// included: a caller prints these back to back. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.write(f, 0) - } -} - -/// What blocks `path`, a required questline expanded into the quests it still -/// holds. Empty means ready. -/// -/// `path` is the quest as the tree writes it (`/quest/m0/one.md`), as the shell -/// completes it (`quest/m0/one.md`), or as an absolute filesystem path. -pub fn blockers(root: &Path, path: &Path) -> Result> { - let docs = crate::load(root)?; - let by_path: BTreeMap<&Path, &Doc> = docs.iter().map(|d| (d.path.as_path(), d)).collect(); - let path = locate(root, path, &by_path)?; - Ok(expand(&by_path, by_path[path.as_path()], &mut vec![path.clone()])) -} - -/// Every quest that can be started now, in tree order. -/// -/// A questline is not listed while it still indexes children; a README with -/// no `## Quests` left is the line's own remaining work and lists like any -/// other quest. The absence of a `## Required` heading is what quest/AGENTS.md -/// defines as ready. -pub fn quests(root: &Path) -> Result> { - let docs = crate::load(root)?; - let mut remaining: BTreeMap = docs.iter().map(|doc| (doc.path.clone(), doc)).collect(); - let mut pending = vec![PathBuf::from("quest/README.md")]; - let mut ready = Vec::new(); - while let Some(path) = pending.pop() { - let Some(doc) = remaining.remove(&path) else { continue }; - if doc.is_questline() { - let children: Vec<_> = doc - .entries("Quests") - .filter_map(|entry| entry.target.as_deref().and_then(rules::rooted)) - .collect(); - pending.extend(children.into_iter().rev()); - } else if !doc.has("Required") { - ready.push(path); - } - } - ready.extend( - remaining - .into_iter() - .filter(|(_, doc)| !doc.is_questline() && !doc.has("Required")) - .map(|(path, _)| path), - ); - Ok(ready) -} - -/// The blockers of one document: a quest waits on its `Required` entries, and a -/// questline is complete only when all of its quests are, so it waits on those. -fn expand(by_path: &BTreeMap<&Path, &Doc>, doc: &Doc, stack: &mut Vec) -> Vec { - let section = if doc.is_questline() { "Quests" } else { "Required" }; - - // A heading left standing after its last blocker still reads as blocked to - // everything that greps for it, including `quest check`, which reports it. - // Calling it ready here would make this the one tool that disagrees. - if !doc.is_questline() && doc.has("Required") && doc.entries("Required").next().is_none() { - return vec![Blocker { - path: None, - text: "an empty '## Required' section, which blocks the quest until the heading is removed".to_string(), - blockers: Vec::new(), - }]; - } - - doc.entries(section) - .map(|entry| blocker(by_path, entry, stack)) - .collect() -} - -fn blocker(by_path: &BTreeMap<&Path, &Doc>, entry: &crate::doc::Entry, stack: &mut Vec) -> Blocker { - // A bullet that does not open with a link into the tree is a plain-text - // condition: an issue, a release, a customer. Nothing here can clear it, so - // it is a blocker with nothing under it. - let path = entry - .target - .as_deref() - .and_then(rules::rooted) - .filter(|path| by_path.contains_key(path.as_path())); - - // Only a questline expands. A required QUEST is the blocker itself, and its - // own chain is the answer to running this on that quest instead; printing - // it here buries the entries that were asked for under a repeated subtree. - // The stack guard is for a tree nobody has run `quest check` on yet, where a - // questline listing an ancestor must print rather than recurse forever. - let blockers = match &path { - Some(path) if by_path[path.as_path()].is_questline() && !stack.contains(path) => { - stack.push(path.clone()); - let blockers = expand(by_path, by_path[path.as_path()], stack); - stack.pop(); - blockers - } - _ => Vec::new(), - }; - - Blocker { - path, - text: entry.text.clone(), - blockers, - } -} - -/// Resolve a quest path the way a caller is likely to have it to the -/// repository-relative one the tree is keyed on. -pub(crate) fn locate(root: &Path, path: &Path, by_path: &BTreeMap<&Path, &Doc>) -> Result { - let mut candidates = vec![rules::normalize(path)]; - if let Some(rooted) = path.to_str().and_then(|p| p.strip_prefix('/')) { - candidates.push(rules::normalize(Path::new(rooted))); - } - if let (Ok(absolute), Ok(root)) = (path.canonicalize(), root.canonicalize()) - && let Ok(relative) = absolute.strip_prefix(root) - { - candidates.push(relative.to_path_buf()); - } - - match candidates.into_iter().find(|c| by_path.contains_key(c.as_path())) { - Some(found) => Ok(found), - None => bail!( - "{} is not a quest document under {}", - path.display(), - root.join("quest").display() - ), - } -} diff --git a/rs/quest/src/rules.rs b/rs/quest/src/rules.rs deleted file mode 100644 index 923865ecf2..0000000000 --- a/rs/quest/src/rules.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! The rules, and the findings they produce. -//! -//! The contract is quest/AGENTS.md. Every rule here is one that has already -//! been broken by hand, and the expensive failure is a rule that stops firing: -//! a validator that quietly enforces nothing looks exactly like a clean tree. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; - -use crate::doc::{Doc, Position}; - -/// The `## ` headings a quest document may use. Readiness greps `## Required` -/// literally, so a typo turns a blocked quest ready and fails nowhere else: -/// the closed vocabulary is what catches it. -const HEADINGS: [&str; 6] = ["Goal", "Plan", "Required", "Closes", "Related", "Quests"]; -const SIZES: [&str; 5] = ["XS", "S", "M", "L", "XL"]; - -/// Sections whose whole content is a list. A heading left standing after its -/// last entry was removed is a bug in both directions: an empty `Required` -/// blocks its quest forever, and an empty `Quests` leaves a questline that -/// should have been deleted with its last quest. -/// `Quests` is deliberately absent: a bullet is not an entry (`- TBD` is a list -/// item with no quest in it), so its emptiness is decided by counting valid -/// entries in `index` instead. -const LIST_SECTIONS: [&str; 3] = ["Required", "Closes", "Related"]; - -/// The permanent root questline; the one document nothing has to list. -pub const ROOT: &str = "quest/README.md"; - -/// One violation, addressed like a compiler diagnostic: `path:line: message`. -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct Finding { - /// Repository-relative document the violation is in. - pub path: PathBuf, - /// 1-based line, when the violation has one; `None` for whole-file findings. - pub line: Option, - /// What is wrong, and often why the rule exists. - pub message: String, -} - -impl std::fmt::Display for Finding { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.line { - Some(line) => write!(f, "{}:{}: {}", self.path.display(), line, self.message), - None => write!(f, "{}: {}", self.path.display(), self.message), - } - } -} - -struct Findings(Vec); - -impl Findings { - fn at(&mut self, path: &Path, line: usize, message: impl Into) { - self.0.push(Finding { - path: path.to_path_buf(), - line: Some(line), - message: message.into(), - }); - } - - fn on(&mut self, path: &Path, message: impl Into) { - self.0.push(Finding { - path: path.to_path_buf(), - line: None, - message: message.into(), - }); - } -} - -/// `root` is the repository root; every path in `docs` is relative to it. -pub fn check(root: &Path, docs: &[Doc]) -> Vec { - let mut found = Findings(Vec::new()); - let known: BTreeSet<&Path> = docs.iter().map(|d| d.path.as_path()).collect(); - - for doc in docs { - headings(&mut found, doc); - links(&mut found, root, &known, doc); - } - - let index = index(&mut found, &known, docs); - cycles(&mut found, docs, &index); - - found.0.sort(); - found.0 -} - -fn headings(found: &mut Findings, doc: &Doc) { - if !doc.is_questline() { - let valid = doc.title.as_ref().is_some_and(|title| { - let Some((size, name)) = title.text.strip_prefix('[').and_then(|s| s.split_once("] ")) else { - return false; - }; - title.literal && SIZES.contains(&size) && !name.is_empty() - }); - if !valid { - found.on(&doc.path, "quest title must be '# [XS|S|M|L|XL] Title'"); - } - } - - if !doc.has("Goal") { - found.on(&doc.path, "missing '## Goal'"); - } - - for heading in &doc.headings { - // A setext underline and a decorated ``## `Required` `` both render as - // the same heading, and this validator would treat the quest as blocked. - // Readiness greps `^## Required$` literally and would call it READY. - if HEADINGS.contains(&heading.text.as_str()) && !heading.literal { - found.at( - &doc.path, - heading.line, - format!( - "'{}' must be written literally as '## {}'; readiness greps that form and would not see this one", - heading.text, heading.text - ), - ); - } - if !HEADINGS.contains(&heading.text.as_str()) { - found.at( - &doc.path, - heading.line, - format!("unknown '## {}' (allowed: {})", heading.text, HEADINGS.join(", ")), - ); - } - } - - // Only a README indexes children; a quest is a leaf and must not, since the - // index is what makes a file a questline and questlines are not picked up. - if doc.has("Quests") && !doc.is_questline() { - found.on(&doc.path, "only a README may have '## Quests'"); - } - for heading in &doc.headings { - if LIST_SECTIONS.contains(&heading.text.as_str()) && doc.entries(&heading.text).next().is_none() { - let why = match heading.text.as_str() { - "Required" => "; an empty one blocks the quest forever, so remove the heading with its last entry", - "Quests" => "; a questline with no quests left should be deleted", - _ => "; remove the heading with its last entry", - }; - found.at(&doc.path, heading.line, format!("'## {}' is empty{why}", heading.text)); - } - } -} - -/// Strip a `#fragment`, which addresses a place inside a file rather than a -/// different file. Leaving it on made a phantom graph node that no quest could -/// ever match, so a cycle through an anchored link went unreported. -fn without_fragment(target: &str) -> &str { - target.split('#').next().unwrap_or(target) -} - -/// A root-absolute target as a repository-relative path, or `None` if it is not -/// root-absolute. Fragment-stripped AND normalized: the index and the cycle walk -/// both key on this, and a `..` left in one of them is a node nothing matches. -pub(crate) fn rooted(target: &str) -> Option { - target - .strip_prefix('/') - .map(|r| normalize(Path::new(without_fragment(r)))) -} - -fn resolve(doc_path: &Path, target: &str) -> PathBuf { - match target.strip_prefix('/') { - Some(rooted) => normalize(Path::new(rooted)), - None => normalize(&doc_path.parent().unwrap_or(Path::new("")).join(target)), - } -} - -/// Collapse `..` textually. `Path::canonicalize` would need the file to exist, -/// which is the very thing being tested. -pub(crate) fn normalize(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); - for part in path.components() { - match part { - std::path::Component::ParentDir => { - // Popping unconditionally let `../..` cancel itself, so a link - // with too many `..` climbed above the root and then walked back - // down to a real file. - if out.components().next_back() == Some(std::path::Component::ParentDir) || !out.pop() { - out.push(".."); - } - } - std::path::Component::CurDir => {} - other => out.push(other.as_os_str()), - } - } - out -} - -fn links(found: &mut Findings, root: &Path, known: &BTreeSet<&Path>, doc: &Doc) { - for link in &doc.links { - if link.target.contains("://") || link.target.starts_with("mailto:") { - continue; - } - let target = without_fragment(&link.target); - if target.is_empty() { - continue; - } - - // A normalized path that still opens with `..` points above the - // repository root. Joining it to `root` and testing existence would - // follow it into whatever sits beside the checkout, so the repo's own - // directory name (or a sibling worktree) could make a broken link pass. - let path = resolve(&doc.path, target); - if path.starts_with("..") || !root.join(&path).exists() { - found.at(&doc.path, link.line, format!("link does not resolve: {}", link.target)); - continue; - } - - // Quests and questlines reference each other with root-absolute links. - // A relative one still renders, so nothing else would notice - but it is - // invisible to the dependency graph below, which only speaks /quest/... - if known.contains(path.as_path()) && !link.target.starts_with('/') { - found.at( - &doc.path, - link.line, - format!( - "link to a quest must be root-absolute: {} (write /{})", - link.target, - path.display() - ), - ); - } - - // A `Required` bullet is either a dependency edge (the link opens it) or - // a plain-text external condition (no quest link at all). - // moq-dev/moq.pro#1170 shipped the third shape: a customer-gate sentence - // mentioning a questline mid-line, which reads as context but IS a - // blocker, and so silently required all of m2. - if link.section.as_deref() == Some("Required") - && known.contains(path.as_path()) - && link.position != Position::Entry - { - found.at( - &doc.path, - link.line, - format!( - "Required links {} mid-sentence; that reads as prose but IS a blocker - open the bullet with the link, or drop the link", - link.target - ), - ); - } - } -} - -/// Which questline lists each document. The index must be exactly the file -/// tree: every document is listed by the questline it sits under, and a -/// questline lists nothing but its own children - together these also give -/// "listed by exactly one questline". -fn index<'a>(found: &mut Findings, known: &BTreeSet<&Path>, docs: &'a [Doc]) -> BTreeMap { - let mut listed: BTreeMap = BTreeMap::new(); - let mut entries: BTreeMap<&Path, usize> = BTreeMap::new(); - - for doc in docs { - for link in &doc.links { - if link.section.as_deref() != Some("Quests") { - continue; - } - // The index is a list of entries, not prose that happens to link: - // `See [One](/quest/m0/one.md)` must not make One look indexed. - if link.position != Position::Entry { - found.at( - &doc.path, - link.line, - format!("a Quests entry must open its bullet: {}", link.target), - ); - continue; - } - let Some(child) = rooted(&link.target) else { - found.at( - &doc.path, - link.line, - format!( - "a Quests entry must be a root-absolute /quest/... link: {}", - link.target - ), - ); - continue; - }; - // Existence is not enough: the index points readers at work to pick - // up, and quest/AGENTS.md is a file under quest/ that is not a quest. - if !known.contains(child.as_path()) { - found.at( - &doc.path, - link.line, - format!("lists {}, which is not a quest document", link.target), - ); - continue; - } - if Doc::owner(&child) != doc.path.parent().unwrap_or(Path::new("")) { - found.at( - &doc.path, - link.line, - format!("lists {}, which does not sit under this questline", link.target), - ); - continue; - } - if listed.insert(child, doc.path.as_path()).is_some() { - found.at(&doc.path, link.line, format!("lists {} twice", link.target)); - } - *entries.entry(doc.path.as_path()).or_default() += 1; - } - } - - for doc in docs { - // A questline lists at least one quest. Completing its last one is - // supposed to delete the directory; a heading holding a bullet with no - // quest in it (`- TBD`) leaves the husk standing just as well as a bare - // one does. - if doc.is_questline() && doc.has("Quests") && entries.get(doc.path.as_path()).copied().unwrap_or(0) == 0 { - found.on( - &doc.path, - "'## Quests' lists no quest; a questline with none left should be deleted", - ); - } - - // The root questline is permanent and has nothing above it to list it. - if doc.path == Path::new(ROOT) || listed.contains_key(&doc.path) { - continue; - } - let owner = Doc::owner(&doc.path).join("README.md"); - found.on( - &doc.path, - format!( - "not listed in {}'s '## Quests'; an unlisted quest is unreachable", - owner.display() - ), - ); - } - - listed -} - -/// `Required` must be acyclic. A cycle is a set of quests none of which can -/// ever start, and walking the links to rule one out is exactly the manual step -/// AGENTS.md asks of an author before adding a blocker. -fn cycles(found: &mut Findings, docs: &[Doc], listed: &BTreeMap) { - let mut blockers: BTreeMap<&Path, Vec> = BTreeMap::new(); - - for doc in docs { - let edges: Vec = doc - .links - .iter() - .filter(|l| l.section.as_deref() == Some("Required") && l.position == Position::Entry) - .filter_map(|l| rooted(&l.target)) - .collect(); - blockers.entry(doc.path.as_path()).or_default().extend(edges); - } - - // A quest may require a whole QUESTLINE, and a questline is complete only - // when all of its quests are, so a questline waits on its own children. - // Without these edges the walk stops at the README and misses the deadlock - // that spans it: a quest requiring the questline that holds the quest - // requiring it back. - for (child, questline) in listed { - blockers.entry(questline).or_default().push(child.clone()); - } - - #[derive(Clone, Copy, PartialEq)] - enum State { - Open, - Done, - } - - fn walk( - node: &Path, - blockers: &BTreeMap<&Path, Vec>, - state: &mut BTreeMap, - stack: &mut Vec, - found: &mut Findings, - ) { - match state.get(node) { - Some(State::Done) => return, - Some(State::Open) => { - let from = stack.iter().position(|p| p == node).unwrap_or(0); - let mut path: Vec = stack[from..].iter().map(|p| p.display().to_string()).collect(); - path.push(node.display().to_string()); - found.on(node, format!("Required cycle: {}", path.join(" -> "))); - return; - } - None => {} - } - state.insert(node.to_path_buf(), State::Open); - stack.push(node.to_path_buf()); - for next in blockers.get(node).map(Vec::as_slice).unwrap_or_default() { - walk(next, blockers, state, stack, found); - } - stack.pop(); - state.insert(node.to_path_buf(), State::Done); - } - - let mut state = BTreeMap::new(); - for doc in docs { - walk(&doc.path, &blockers, &mut state, &mut Vec::new(), found); - } -} diff --git a/rs/quest/tests/tree.rs b/rs/quest/tests/tree.rs deleted file mode 100644 index e5577755a3..0000000000 --- a/rs/quest/tests/tree.rs +++ /dev/null @@ -1,871 +0,0 @@ -//! Every rule, each proven to actually fail, and the readiness the same tree -//! answers. -//! -//! A validator that silently stopped enforcing a rule is indistinguishable from -//! a clean tree, so each case starts from the same valid fixture and breaks -//! exactly one thing. The cases that must still PASS matter just as much: the -//! shell version this replaced was rewritten precisely because it rejected -//! legitimate Markdown and accepted the shapes it was written to catch. - -use std::path::Path; - -use tempfile::TempDir; - -const ROOT_README: &str = "\ -# Quests - -## Goal - -The permanent root questline. - -## Quests - -- [m0](/quest/m0/README.md) -"; - -const M0_README: &str = "\ -# m0 - -## Goal - -A milestone. - -## Quests - -- [Line](/quest/m0/line/README.md) -"; - -const LINE_README: &str = "\ -# Line - -## Goal - -A questline. - -## Quests - -- [One](/quest/m0/line/one.md) -- [Two](/quest/m0/line/two.md) -"; - -const ONE: &str = "\ -# [S] One - -## Goal - -A quest. -"; - -const TWO: &str = "\ -# [S] Two - -## Goal - -Another quest. - -## Required - -- [One](/quest/m0/line/one.md) - must finish first -"; - -/// A minimal but complete tree: root questline -> milestone -> questline -> two -/// quests, one blocking the other. -struct Tree(TempDir); - -impl Tree { - fn new() -> Tree { - let tree = Tree(TempDir::new().expect("tempdir")); - tree.write("quest/README.md", ROOT_README); - tree.write("quest/m0/README.md", M0_README); - tree.write("quest/m0/line/README.md", LINE_README); - tree.write("quest/m0/line/one.md", ONE); - tree.write("quest/m0/line/two.md", TWO); - tree - } - - fn path(&self) -> &Path { - self.0.path() - } - - fn write(&self, rel: &str, body: &str) -> &Tree { - let path = self.path().join(rel); - std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir"); - std::fs::write(path, body).expect("write"); - self - } - - fn append(&self, rel: &str, body: &str) -> &Tree { - let path = self.path().join(rel); - let existing = std::fs::read_to_string(&path).expect("read"); - std::fs::write(path, format!("{existing}{body}")).expect("write"); - self - } - - fn findings(&self) -> Vec { - quest::check(self.path()) - .expect("check") - .iter() - .map(ToString::to_string) - .collect() - } - - /// The rendered blocker chain: one line per blocker, nesting indented. - fn blockers(&self, path: &str) -> Vec { - quest::ready::blockers(self.path(), Path::new(path)) - .expect("blockers") - .iter() - .flat_map(|blocker| blocker.to_string().lines().map(str::to_owned).collect::>()) - .collect() - } - - fn ready(&self) -> Vec { - quest::ready::quests(self.path()) - .expect("ready") - .iter() - .map(|path| path.display().to_string()) - .collect() - } - - #[track_caller] - fn accepts(&self) { - let findings = self.findings(); - assert!(findings.is_empty(), "expected the tree to pass, got: {findings:#?}"); - } - - #[track_caller] - fn without(&self, unexpected: &str) { - let findings = self.findings(); - assert!( - !findings.iter().any(|f| f.contains(unexpected)), - "expected NO finding containing {unexpected:?}, got: {findings:#?}" - ); - } - - #[track_caller] - fn rejects(&self, expected: &str) { - let findings = self.findings(); - assert!( - findings.iter().any(|f| f.contains(expected)), - "expected a finding containing {expected:?}, got: {findings:#?}" - ); - } -} - -/// The fixture itself has to pass, or every case below proves nothing. -#[test] -fn baseline_is_valid() { - Tree::new().accepts(); -} - -#[test] -fn dangling_absolute_link() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Related\n\n- [Gone](/quest/m0/line/gone.md) - completed and deleted\n", - ); - tree.rejects("link does not resolve: /quest/m0/line/gone.md"); -} - -/// Relative links escape the tree (AGENTS.md points at ../CONTRIBUTING.md), so -/// they resolve against the LINKING FILE's directory. The pair of cases pins the -/// direction: resolving against the wrong base would flip both verdicts. -#[test] -fn relative_link_resolves_against_the_linking_file() { - let tree = Tree::new(); - tree.write("AGENTS.md", "# Guide\n"); - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../AGENTS.md).\n", - ); - tree.accepts(); -} - -#[test] -fn relative_link_above_the_repository_root() { - let tree = Tree::new(); - tree.write("AGENTS.md", "# Guide\n"); - // One `..` too many, which is exactly what a file flattened up a level - // keeps: it still renders, and points at nothing. - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../../AGENTS.md).\n", - ); - tree.rejects("link does not resolve: ../../../../AGENTS.md"); -} - -/// Quests reference each other root-absolutely. A relative one renders fine, so -/// nothing else would notice - but it is invisible to the dependency graph. -#[test] -fn relative_link_to_a_quest() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Related\n\n- [Two](two.md) - a sibling\n"); - tree.rejects("link to a quest must be root-absolute: two.md (write /quest/m0/line/two.md)"); -} - -/// Templates inside fenced blocks are illustrations. Flagging AGENTS.md's own -/// example would make this a check everyone learns to skip. -#[test] -fn fenced_templates_are_not_links() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\n```markdown\n## Required\n\n- [Blocker](/quest/foo/bar.md) - must finish first\n```\n", - ); - tree.accepts(); -} - -/// A fence longer than three backticks may contain shorter ones, and `~~~` is a -/// fence too. Miscounting either inverts the fence state and silently skips the -/// REST OF THE FILE - the worst failure this tool has, because it looks clean. -#[test] -fn nested_and_tilde_fences_do_not_leak() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\n````markdown\n```bash\njust check\n```\n````\n\n~~~text\n```\n~~~\n\n## Requires\n\n- [Gone](/quest/m0/line/gone.md) - typo'd heading and a dangling link\n", - ); - tree.rejects("unknown '## Requires'"); - tree.rejects("link does not resolve: /quest/m0/line/gone.md"); -} - -#[test] -fn missing_goal() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/one.md", - "# [S] One\n\n## Plan\n\nA quest with no stated outcome.\n", - ); - tree.rejects("missing '## Goal'"); -} - -#[test] -fn quest_title_needs_a_size() { - let tree = Tree::new(); - tree.write("quest/m0/line/one.md", &ONE.replace("# [S] One", "# One")); - tree.rejects("quest title must be '# [XS|S|M|L|XL] Title'"); -} - -#[test] -fn quest_title_accepts_xl() { - let tree = Tree::new(); - tree.write("quest/m0/line/one.md", &ONE.replace("# [S] One", "# [XL] One")); - tree.accepts(); -} - -#[test] -fn quest_title_rejects_xxl() { - let tree = Tree::new(); - tree.write("quest/m0/line/one.md", &ONE.replace("# [S] One", "# [XXL] One")); - tree.rejects("quest title must be '# [XS|S|M|L|XL] Title'"); -} - -/// The closed vocabulary exists for this: readiness greps `## Required` -/// literally, so a typo makes a blocked quest read as ready and fails nowhere. -#[test] -fn typo_in_a_heading() { - let tree = Tree::new(); - tree.write("quest/m0/line/two.md", &TWO.replace("## Required", "## Requires")); - tree.rejects("unknown '## Requires'"); -} - -#[test] -fn quest_with_a_questline_index() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Quests\n\n- [Two](/quest/m0/line/two.md)\n", - ); - tree.rejects("only a README may have '## Quests'"); -} - -/// A README whose last child merged is the line's own remaining work: a leaf -/// quest, sized and listed as ready like any other. -#[test] -fn readme_without_an_index_is_a_quest() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/sub/README.md", - "# Sub\n\n## Goal\n\nThe end-to-end test once every child has merged.\n", - ); - tree.append("quest/m0/line/README.md", "- [Sub](/quest/m0/line/sub/README.md)\n"); - tree.rejects("quest title must be"); - tree.write( - "quest/m0/line/sub/README.md", - "# [S] Sub\n\n## Goal\n\nThe end-to-end test once every child has merged.\n", - ); - tree.accepts(); - assert!(tree.ready().contains(&"quest/m0/line/sub/README.md".to_string())); -} - -/// Completing a questline's last quest deletes the directory. A bare `## Quests` -/// heading otherwise satisfies the questline rule and leaves the husk standing. -/// (The other direction - a heading holding a non-quest bullet - is -/// `questline_listing_no_quest`; one rule, both shapes.) -#[test] -fn empty_questline_index() { - let tree = Tree::new(); - tree.write( - "quest/m0/husk/README.md", - "# Husk\n\n## Goal\n\nIts last quest was completed.\n\n## Quests\n", - ); - tree.append("quest/m0/README.md", "- [Husk](/quest/m0/husk/README.md)\n"); - tree.rejects("lists no quest"); -} - -/// The absence of `## Required` means ready. A heading left behind by its last -/// blocker reads as blocked to every readiness check, forever. -#[test] -fn empty_required_section() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Required\n"); - tree.rejects("'## Required' is empty"); -} - -#[test] -fn unlisted_quest() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/three.md", - "# [S] Three\n\n## Goal\n\nA quest nobody indexed.\n", - ); - tree.rejects("not listed in quest/m0/line/README.md's '## Quests'"); -} - -/// Quests are indexed where they sit, so a milestone cannot reach past its own -/// questlines to list a grandchild. -#[test] -fn questline_listing_a_grandchild() { - let tree = Tree::new(); - tree.append("quest/m0/README.md", "- [One](/quest/m0/line/one.md)\n"); - tree.rejects("does not sit under this questline"); -} - -#[test] -fn quest_listed_twice() { - let tree = Tree::new(); - tree.append("quest/m0/line/README.md", "- [One again](/quest/m0/line/one.md)\n"); - tree.rejects("lists /quest/m0/line/one.md twice"); -} - -#[test] -fn relative_index_entry() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/README.md", - &LINE_README.replace("(/quest/m0/line/one.md)", "(one.md)"), - ); - tree.rejects("must be a root-absolute /quest/... link: one.md"); -} - -/// The index points readers at work to pick up, so a target that merely exists -/// is not enough: quest/AGENTS.md is a file under quest/ that is not a quest. -#[test] -fn index_entry_that_is_not_a_quest() { - let tree = Tree::new(); - tree.write("quest/AGENTS.md", "# Contract\n"); - tree.append("quest/README.md", "- [Contract](/quest/AGENTS.md)\n"); - tree.rejects("lists /quest/AGENTS.md, which is not a quest document"); -} - -/// The index is a list of entries, not prose that happens to link. -#[test] -fn prose_under_the_index() { - let tree = Tree::new(); - tree.append("quest/m0/line/README.md", "\nSee also [One](/quest/m0/line/one.md).\n"); - tree.rejects("a Quests entry must open its bullet"); -} - -#[test] -fn direct_required_cycle() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - tree.rejects("Required cycle:"); -} - -/// A quest may require a whole questline, so the deadlock can span the README. -/// The cycle here runs strictly OUTSIDE-IN: `outer` (in m0) requires the line -/// questline, and `three` inside that questline requires `outer` back. No quest -/// requires its own questline, so containment edges are the only thing that can -/// close it. -#[test] -fn cycle_through_a_questline() { - let tree = Tree::new(); - tree.append("quest/m0/README.md", "- [Outer](/quest/m0/outer.md)\n"); - tree.write( - "quest/m0/outer.md", - "# [S] Outer\n\n## Goal\n\nBlocked on a whole questline.\n\n## Required\n\n- [Line](/quest/m0/line/README.md) - the whole questline must finish\n", - ); - tree.append("quest/m0/line/README.md", "- [Three](/quest/m0/line/three.md)\n"); - tree.write( - "quest/m0/line/three.md", - "# [S] Three\n\n## Goal\n\nInside the questline that blocks it.\n\n## Required\n\n- [Outer](/quest/m0/outer.md) - must finish first\n", - ); - tree.rejects( - "Required cycle: quest/m0/line/README.md -> quest/m0/line/three.md -> quest/m0/outer.md -> quest/m0/line/README.md", - ); -} - -/// A `#fragment` addresses a place inside a file, not a different file. Keeping -/// it on made a phantom graph node that no quest could match, so a cycle through -/// an anchored link went unreported. -#[test] -fn cycle_through_an_anchored_link() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two](/quest/m0/line/two.md#plan) - must finish first\n", - ); - tree.rejects("Required cycle:"); -} - -/// Reference-style links render as real dependencies, so they must carry real -/// edges. A line-oriented parser saw no `](` here and produced none. -#[test] -fn cycle_through_a_reference_style_link() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two][two] - must finish first\n\n[two]: /quest/m0/line/two.md\n", - ); - tree.rejects("Required cycle:"); -} - -/// moq-dev/moq.pro#1170: a plain-text external condition that happens to link a -/// questline mid-sentence reads as context but IS a dependency edge. -#[test] -fn required_link_mid_sentence() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A customer who also justifies [the line](/quest/m0/line/README.md).\n", - ); - tree.rejects("mid-sentence"); -} - -/// The same failure, reflowed onto a second line. The link is no longer on the -/// bullet's first line, which is all it took to slip past the shell version. -#[test] -fn required_link_on_a_wrapped_bullet() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A customer who also justifies\n [the line](/quest/m0/line/README.md).\n", - ); - tree.rejects("mid-sentence"); -} - -/// The other half of that rule: an external condition with no link at all is the -/// shape AGENTS.md prescribes, and must stay legal. -#[test] -fn required_external_condition() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A customer who justifies the work.\n", - ); - tree.accepts(); -} - -/// A LOOSE list - blank lines between entries - wraps every item in a paragraph. -/// Treating that paragraph as text would classify every entry in the list as -/// mid-sentence prose, which is a false positive on ordinary Markdown and would -/// fire on every blocker and every index entry at once. -#[test] -fn loose_index_list() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/README.md", - "# Line\n\n## Goal\n\nA questline.\n\n## Quests\n\n- [One](/quest/m0/line/one.md)\n\n- [Two](/quest/m0/line/two.md)\n", - ); - tree.accepts(); -} - -#[test] -fn loose_required_list() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two](/quest/m0/line/two.md) - must finish first\n\n- A customer who justifies the work.\n", - ); - // The edge registered (hence the cycle) without reading as prose. - tree.rejects("Required cycle:"); - tree.without("mid-sentence"); -} - -/// The other side of the same seam: a link opening a SECOND paragraph is inside -/// the item, not opening it. -#[test] -fn required_link_in_a_later_paragraph() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A customer who justifies the work.\n\n [The line](/quest/m0/line/README.md) would follow.\n", - ); - tree.rejects("mid-sentence"); -} - -/// A blocker written with emphasis still opens its bullet. Rejecting it would -/// be a false positive on legitimate Markdown. -#[test] -fn required_link_with_emphasis() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- **[Two](/quest/m0/line/two.md)** - must finish first\n", - ); - tree.rejects("Required cycle:"); -} - -/// A setext underline renders as an H2 and this validator would read the quest -/// as blocked - but readiness greps `^## Required$` and would call it READY. -/// Two tools disagreeing about the same file is the whole failure. -#[test] -fn setext_heading() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\nRequired\n--------\n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - tree.rejects("must be written literally as '## Required'"); -} - -#[test] -fn decorated_heading() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## `Required`\n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - tree.rejects("must be written literally as '## Required'"); -} - -/// A bullet nested under a prose lead-in is illustration, not a blocker. Reading -/// it as one is #1170 again, wearing an indent. -#[test] -fn nested_required_entry() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- Customer evidence:\n - [The line](/quest/m0/line/README.md)\n", - ); - tree.rejects("mid-sentence"); -} - -#[test] -fn blockquoted_required_entry() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- Quoting the old plan:\n\n > - [The line](/quest/m0/line/README.md)\n", - ); - tree.rejects("mid-sentence"); -} - -/// Repeated `..` must not cancel each other on the way up. Popping -/// unconditionally let a link climb above the root and walk back down to a real -/// file, so a badly flattened path resolved and reported nothing. -#[test] -fn repeated_parent_components() { - let tree = Tree::new(); - tree.write("AGENTS.md", "# Guide\n"); - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../../../AGENTS.md).\n", - ); - tree.rejects("link does not resolve: ../../../../../AGENTS.md"); -} - -/// Escaping the root must fail even when the joined path happens to exist: -/// the repository's own directory name (or a sibling worktree) sits beside the -/// root, so `/..//AGENTS.md` is a real file that readers of the -/// repository-relative link can never reach. -#[test] -fn escaped_link_resolving_beside_the_root() { - let tree = Tree::new(); - tree.write("AGENTS.md", "# Guide\n"); - let name = tree.path().file_name().unwrap().to_str().unwrap(); - tree.append( - "quest/m0/line/one.md", - &format!("\n## Plan\n\nSee [the guide](../../../../{name}/AGENTS.md).\n"), - ); - tree.rejects(&format!("link does not resolve: ../../../../{name}/AGENTS.md")); -} - -/// A bullet is not an entry. `- TBD` satisfies "the heading has a list" while -/// leaving a questline that should have been deleted with its last quest. -#[test] -fn questline_listing_no_quest() { - let tree = Tree::new(); - tree.write( - "quest/m0/husk/README.md", - "# Husk\n\n## Goal\n\nIts last quest was completed.\n\n## Quests\n\n- TBD\n", - ); - tree.append("quest/m0/README.md", "- [Husk](/quest/m0/husk/README.md)\n"); - tree.rejects("lists no quest"); -} - -#[test] -fn empty_related_section() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Related\n"); - tree.rejects("'## Related' is empty"); -} - -#[test] -fn empty_closes_section() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Closes\n"); - tree.rejects("'## Closes' is empty"); -} - -/// Trailing whitespace is invisible in a diff and `rg '^## Required$'` does not -/// match it either, so accepting it recreates the same disagreement a setext -/// heading does. -#[test] -fn heading_with_trailing_space() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required \n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - tree.rejects("must be written literally as '## Required'"); -} - -/// The index and the cycle walk key on the same normalized path. A `..` left in -/// either makes a node nothing can match, so the cycle through it disappears. -#[test] -fn cycle_through_an_unnormalized_link() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two](/quest/m0/line/../line/two.md) - must finish first\n", - ); - tree.rejects("Required cycle:"); -} - -// Readiness: what `quest ready` reports about the same fixture. A blocker list -// is the machine-readable result, so the cases that must come back EMPTY carry -// as much weight as the ones that must not. - -/// The absence of `## Required` is the whole definition of ready. -#[test] -fn ready_quest_has_no_blockers() { - let tree = Tree::new(); - assert!(tree.blockers("quest/m0/line/one.md").is_empty()); -} - -#[test] -fn blocked_by_a_quest() { - let tree = Tree::new(); - assert_eq!(tree.blockers("quest/m0/line/two.md"), ["quest/m0/line/one.md"]); -} - -/// A plain-text bullet names a condition outside the repository, so nothing in -/// the tree can ever clear it: it is a blocker, printed as written. Wrapped -/// here because that is what the bullets in the tree actually look like. -#[test] -fn blocked_by_plain_text() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A `moq-video` release that carries\n the encoder\n", - ); - assert_eq!( - tree.blockers("quest/m0/line/one.md"), - ["A moq-video release that carries the encoder"] - ); -} - -/// A questline blocker clears only when the whole line is complete, so the -/// useful answer is which of its quests are still open - all of them, since a -/// completed quest is deleted. -#[test] -fn blocked_by_a_questline() { - let tree = Tree::new(); - tree.append("quest/m0/README.md", "- [Outer](/quest/m0/outer.md)\n"); - tree.write( - "quest/m0/outer.md", - "# [S] Outer\n\n## Goal\n\nBlocked on a whole questline.\n\n## Required\n\n- [Line](/quest/m0/line/README.md) - the whole questline must finish\n", - ); - assert_eq!( - tree.blockers("quest/m0/outer.md"), - [ - "quest/m0/line/README.md", - " quest/m0/line/one.md", - " quest/m0/line/two.md", - ] - ); -} - -/// A required QUEST does not expand: its own blockers are its readiness, and -/// running this on it is how you ask. Expanding buried the entries that were -/// asked for under a subtree repeated once per path through it. -#[test] -fn a_required_quest_is_not_expanded() { - let tree = Tree::new(); - tree.append("quest/m0/line/README.md", "- [Three](/quest/m0/line/three.md)\n"); - tree.write( - "quest/m0/line/three.md", - "# [S] Three\n\n## Goal\n\nLast in the chain.\n\n## Required\n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - assert_eq!(tree.blockers("quest/m0/line/three.md"), ["quest/m0/line/two.md"]); -} - -/// `quest check` reports an empty `## Required` as a defect, and every reader -/// that greps for the heading calls the quest blocked. Reading it as ready here -/// would make this the one tool that disagrees. -#[test] -fn empty_required_section_still_blocks() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Required\n"); - assert_eq!( - tree.blockers("quest/m0/line/one.md"), - ["an empty '## Required' section, which blocks the quest until the heading is removed"] - ); -} - -/// The listing is the query the start flow reproduces by grepping. Questlines -/// are never executed, so they are not in it, and `two.md` is blocked. -#[test] -fn ready_listing() { - let tree = Tree::new(); - assert_eq!(tree.ready(), ["quest/m0/line/one.md"]); - - tree.append("quest/m0/README.md", "- [Outer](/quest/m0/outer.md)\n"); - tree.write("quest/m0/outer.md", "# [S] Outer\n\n## Goal\n\nReady too.\n"); - assert_eq!(tree.ready(), ["quest/m0/line/one.md", "quest/m0/outer.md"]); -} - -/// `quest check` proves the graph acyclic, but readiness also runs on trees -/// nobody has checked yet - the branch that just introduced the cycle - and a -/// cycle there has to print rather than recurse forever. -#[test] -fn cycle_terminates() { - let tree = Tree::new(); - tree.append("quest/m0/line/README.md", "- [Itself](/quest/m0/line/README.md)\n"); - tree.append("quest/m0/README.md", "- [Outer](/quest/m0/outer.md)\n"); - tree.write( - "quest/m0/outer.md", - "# [S] Outer\n\n## Goal\n\nBlocked on a questline that lists itself.\n\n## Required\n\n- [Line](/quest/m0/line/README.md) - the whole questline must finish\n", - ); - assert_eq!( - tree.blockers("quest/m0/outer.md"), - [ - "quest/m0/line/README.md", - " quest/m0/line/one.md", - " quest/m0/line/two.md", - " quest/m0/line/README.md", - ] - ); -} - -#[test] -fn ready_listing_follows_nested_priority_and_terminates_cycles() { - let tree = Tree::new(); - tree.write("quest/m0/line/two.md", "# [S] Two\n\n## Goal\n\nReady.\n"); - tree.write("quest/m0/line/README.md", "# Line\n\n## Quests\n\n- [Two](/quest/m0/line/two.md)\n- [Self](/quest/m0/line/README.md)\n- [One](/quest/m0/line/one.md)\n"); - assert_eq!(tree.ready(), ["quest/m0/line/two.md", "quest/m0/line/one.md"]); -} - -#[test] -fn ready_listing_appends_unindexed_quests() { - let tree = Tree::new(); - tree.write("quest/m0/aaa.md", "# [S] Unindexed\n\n## Goal\n\nDiscover me.\n"); - tree.write( - "quest/m0/blocked.md", - "# [S] Blocked\n\n## Goal\n\nWait.\n\n## Required\n\n- External condition\n", - ); - assert_eq!(tree.ready(), ["quest/m0/line/one.md", "quest/m0/aaa.md"]); -} - -impl Tree { - fn branch(&self, path: &str) -> Vec { - quest::branch::chain(self.path(), Path::new(path)).expect("branch") - } - - fn branch_err(&self, path: &str) -> String { - quest::branch::chain(self.path(), Path::new(path)) - .expect_err("expected no branch") - .to_string() - } -} - -/// The chain is the path: the leaf, its line's README, then `main`. Nothing -/// consults git. -#[test] -fn branch_chain_of_a_quest() { - let tree = Tree::new(); - assert_eq!( - tree.branch("quest/m0/line/one.md"), - ["quest/m0/line/one", "quest/m0/line/README", "main"] - ); - assert_eq!( - tree.branch("/quest/m0/line/README.md"), - ["quest/m0/line/README", "main"] - ); -} - -/// Neither the root nor a milestone has a branch; their children merge into `main`. -#[test] -fn root_and_milestone_have_no_branch() { - let tree = Tree::new(); - assert!(tree.branch_err("quest/README.md").contains("no branch")); - assert!(tree.branch_err("quest/m0/README.md").contains("no branch")); -} - -/// Every milestone's work branches from `main`, whatever its priority: starting a -/// quest never moves it. -#[test] -fn later_milestone_branches_from_main() { - let tree = Tree::new(); - tree.write( - "quest/m2/README.md", - "# m2\n\n## Goal\n\nLater work.\n\n## Quests\n\n- [Later](/quest/m2/later.md)\n", - ); - tree.write("quest/m2/later.md", "# [S] Later\n\n## Goal\n\nNot started.\n"); - tree.append("quest/README.md", "- [m2](/quest/m2/README.md)\n"); - tree.accepts(); - assert_eq!(tree.branch("quest/m2/later.md"), ["quest/m2/later", "main"]); -} - -/// A milestone with nothing left is not its own work, so it needs no size and -/// never lists as ready. -#[test] -fn milestone_may_be_empty() { - let tree = Tree::new(); - tree.write("quest/m0/README.md", "# m0\n\n## Goal\n\nEmpty for now.\n"); - std::fs::remove_dir_all(tree.path().join("quest/m0/line")).expect("rm"); - tree.accepts(); - assert!(tree.ready().is_empty(), "{:?}", tree.ready()); -} - -/// Lines nest to any depth: every branch ends in a leaf component (the quest -/// or `README`), so no line's branch is a path prefix of its children's, which -/// is the one shape git refuses. -#[test] -fn branch_chain_of_a_nested_line() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/sub/README.md", - "# Sub\n\n## Goal\n\nA nested line.\n\n## Quests\n\n- [Three](/quest/m0/line/sub/three.md)\n", - ); - tree.write( - "quest/m0/line/sub/three.md", - "# [S] Three\n\n## Goal\n\nA nested quest.\n", - ); - tree.append("quest/m0/line/README.md", "- [Sub](/quest/m0/line/sub/README.md)\n"); - tree.accepts(); - assert_eq!( - tree.branch("quest/m0/line/sub/three.md"), - [ - "quest/m0/line/sub/three", - "quest/m0/line/sub/README", - "quest/m0/line/README", - "main" - ] - ); -} From 34e67d105ce50ae934f46d61197eedfc3c466e3a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 13:55:46 -0700 Subject: [PATCH 40/87] quest: restore 0.14 public rules and auth parity (#4317) Co-authored-by: Claude Opus 5.5 --- quest/m0/README.md | 2 + quest/m0/auth-parity.md | 143 +++++++++++++++++++++++++++++++++++++++ quest/m0/public-rules.md | 121 +++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 quest/m0/auth-parity.md create mode 100644 quest/m0/public-rules.md diff --git a/quest/m0/README.md b/quest/m0/README.md index aa41bc85a5..8d15de06cc 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -93,6 +93,8 @@ do not add another media abstraction or a renderer crate during stabilization. ## Quests +- [Public rules parity](/quest/m0/public-rules.md) - public and mTLS rules authorize like a JWT rooted at `/`, so `anon/**` works at `/anon` and an anonymous client can no longer reach into every other path +- [Auth parity](/quest/m0/auth-parity.md) - 0.14 configs and tokens keep working or are refused with the fix named, and every credential a session presents is evaluated or refused - [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 - [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess diff --git a/quest/m0/auth-parity.md b/quest/m0/auth-parity.md new file mode 100644 index 0000000000..98e28d9c69 --- /dev/null +++ b/quest/m0/auth-parity.md @@ -0,0 +1,143 @@ +# [M] Auth parity + +## Goal + +A moq-relay 0.14.18 deployment either keeps working on 0.15 or is refused +with the fix named, never silently degraded or widened. Every credential a +session presents is either evaluated or refused: nothing is ignored, and no +two credentials are combined. A token that 0.14 accepted is still accepted, +unless it carries a claim outside the registered JWT set; that token is +refused with the claim named. + +Non-goals: the redesign that #3688 documented stays. That covers +`--auth-key` moving to `moq auth serve`, the removal of `--auth-api`, +`--auth-domain`, and key URLs, and refusing an mTLS-only relay. + +## Plan + +Each fix lands with a regression test that fails without it. + +### Startup + +- **Refuse removed auth settings.** `[auth] key`, `key_dir`, `auth_api`, + `domains`, `mtls_tier`, and `tls`, and the `MOQ_AUTH_KEY`, `_KEY_DIR`, + `_API`, `_DOMAIN`, `_MTLS_TIER`, `_PUBLIC_API`, and `_TLS_*` env vars are + ignored today, because `auth::Config` has no `deny_unknown_fields` and + `Config::deprecated()` (`rs/moq-relay/src/config.rs`) skips `[auth]`. A 0.14 + config with `key` and `public` boots with JWTs ignored. Refuse each one at + startup and name its replacement, as `doc/setup/upgrade.md` already + promises. Also fix `demo/relay/prod.toml`, which still suggests `key_dir`. +- **No re-checks or expiry by default.** `moq auth serve` defaults + `--expires` to 1 day and `--revalidate` to 1 minute, so anonymous sessions + and tokens without `exp` drop daily. 0.14's static `key` and `public` never + closed those; it did close at a token's `exp` and a certificate's + notAfter, which stays. Both flags default to off. Refuse to start on: + - `--revalidate` without `--expires`, since the contract refuses a cadence + without a bound; + - `--limit-*` without `--revalidate`, since the session table ages out + slots by the cadence. Without it, limits either undercount or leak the + slots of a relay that died. + + Document that without `--revalidate`, rotating or deleting a key does not + close live sessions. + +### Tokens + +- **Registered claims only.** Tokens are wire. 0.14 ignored every unknown + claim; today both languages refuse them. Ignoring them fails open, because + `root` defaults to `""`: an issuer's typo such as + `{"rooot":"room/123","publish":["**"]}` would grant the whole relay, and so + would a future narrowing claim read by an older verifier. So: + - `iss`, `sub`, and `jti` are accepted and ignored. `iat` is read and not + enforced, as today. + - `nbf` is enforced, like `exp`. JS (`jose`) enforces it today; Rust + ignores it. + - `aud` is refused. 0.14 refused it (jsonwebtoken's default + `validate_aud`), and so does Rust today, but JS accepts it. No audience + is configured, so there is nothing to check it against. + - Any other claim is refused with its name in the error, including + `cluster`, which 0.14 accepted and ignored. List this in the upgrade notes: + an issuer adding app claims such as `user_id` must drop them. + + Both languages must agree on every case above; add them to the shared + vectors. +- **0.14 verifies what we sign.** Signing already writes subtree-only grants + as legacy `put`/`get` prefixes (`foo/**` as `foo`, `**` as `""`), and + anything else as `publish`/`subscribe`. 0.14 reads the latter as granting + nothing and refuses the token. The field names are the version, so nothing + new is needed on the wire. Pin it with a test that verifies tokens from + `moq auth sign` and `@moq/auth` against the published `moq-token` 0.7. + Subtree grants must be accepted with the same scope, and exact grants + refused. + +### Credentials + +Today a session can present a JWT, a verified certificate, or both, and the +answer depends on which mode the relay runs in. Make it one rule: a relay +evaluates the credentials it is configured for, and refuses anything else. + +| Presented | `--auth-public` | `--auth-url` to `moq auth serve` | +|---|---|---| +| nothing | public rules | `--public-*`, or refused | +| JWT | refused | verified, or refused | +| certificate | never requested (see below) | `--mtls-*`, or refused | +| JWT and certificate | never requested | refused | + +- **Refuse a JWT on a public-only relay.** Under `--auth-public`, a client that + sends `?jwt=` is admitted on the public grant. 0.14 answered 401 + `UnexpectedToken`. Refuse every form: the query, a SETUP token of any + type, and the HTTP endpoints. Peers that send `cluster.token` to a + public-only relay are refused too, so say so in the upgrade notes. +- **Refuse a client CA on a public-only relay.** Under `--auth-public`, a + certificate grants the public grant, so `listen.tls.root` and + `web.https.root` only make browsers offer certificates for nothing. Refuse + them at startup, pointing at `moq auth serve --mtls-*`. Watch for the + rolling-upgrade exemption in `connection::authorize`, which shows hidden + routes to any verified certificate. It widens nothing beyond the grant, but + check that no documented mesh depends on it before refusing. +- **Refuse a JWT and a certificate together** in `moq auth serve`, like + `TwoTokens`. Today the JWT wins, so a mesh that follows the migration docs + (`--mtls-*` without `--key`) refuses peers that also send `cluster.token` + with `NoKeys`. 0.14 checked the certificate first. Neither order is safe: + certificate-first lets any certificate from the client CA override a JWT + meant to narrow it, and JWT-first refuses or narrows a peer by accident. A + refusal that names both makes the operator drop one. The migration docs + must say that `cluster.token` and a peer certificate are alternatives. + +Declined: failing a client whose configured certificate was never requested. +One `connect.tls` identity serves both the auth server and peers, and a +dialer cannot know which credential the server means to check. + +### Declined + +Each of these was considered and kept as 0.15 behavior: + +- A 0.14 peer that dials with a cluster JWT no longer sees + `.internal/origins` gossip (#4060). Peers identify by certificate or LAN + path. +- An alias `grant.root` of any depth is accepted. 0.14 required the dialed + path's depth, which hard-codes one aliasing scheme; a server that moves the + root owns the scope it hands out. +- A path in `--cluster-connect` is not refused, although it shifts the mesh + frame. +- `moq auth serve --key` takes a file path only, not 0.14's https or JWKS URL. +- `moq auth sign --root` (token root) and JS `verify --root` (dialed path) + keep their names. +- `/.cluster*` roots are reserved. +- `--auth-public a,b` splits on commas. + +### Docs + +- Put the credentials table in `doc/bin/relay/auth.md`, and replace the + "Policy runs in this order" list with it. +- Record each restored or refused behavior in `doc/bin/relay/auth.md` and + `doc/setup/upgrade.md`. List the declined differences in the upgrade notes, + since none of them is documented today. + +## Required + +- [Public rules parity](/quest/m0/public-rules.md) - same auth code; one owner at a time + +## Related + +- [Release](/quest/m0/release.md) - ships this parity diff --git a/quest/m0/public-rules.md b/quest/m0/public-rules.md new file mode 100644 index 0000000000..c1bfecec9f --- /dev/null +++ b/quest/m0/public-rules.md @@ -0,0 +1,121 @@ +# [M] Public rules parity + +## Goal + +Public and mTLS rules (`[auth] public*`, `--auth-public*`, and +`moq auth serve --public-*`/`--mtls-*`) are authorized like a JWT with root +`/`, as static public rules were in moq-relay 0.14.18. `public = "anon/**"` +(0.14 spelled it `public = "anon"`) lets an anonymous client dialed at `/anon` +publish `test.hang` (absolute `anon/test.hang`) and refuses a session dialed +outside `anon/`. Relay tests cover it. + +This is a security fix for released 0.15: today an anonymous client can reach +into any path. It lands first and on its own, ahead of +[Auth parity](/quest/m0/auth-parity.md). + +Non-goals: the pattern grammar (owned by [Path patterns](/quest/m1/path-patterns.md)) +and JWT grant semantics, which already behave this way. + +## Plan + +### Model + +A grant names what it may access from an absolute root. The session root is +the dialed path, and the patterns are rebased onto it by +`Claims::authorize`. For access to `demo/**`: + +| Dialed | Session root | Patterns | +|---|---|---| +| `/demo/bar` | `demo/bar` | `**` | +| `/demo` | `demo` | `**` | +| `/` | `` | `demo/**` | +| `/other` | refused | | + +JWTs already work this way. Public and mTLS rules must too, in the relay and +in `moq auth serve`. The Grant contract is unchanged: `root: None` still means +the dialed path. + +### Cause + +0.14 built static public access as `Claims::default().with_root("")` and ran +it through `Claims::authorize(path)`. Its public and auth APIs rooted their +answers at the dialed path, as `--auth-url` still does. #3688 (relay) and #3686 (`moq auth serve`) +replaced that with raw patterns in a `Grant` whose `root` is `None`, which +roots them at the dialed path. `anon/**` dialed at `/anon` therefore enforces +`anon/anon/**`. The `accepted` log was accurate: `root=event subscribe=event/**` +is root-relative and meant `event/event/**`. + +The same cause admits every path. Under `anon/**`, a session dialed at +`/rooms/123` gets `rooms/123/anon/**`, so an anonymous client can publish +inside a JWT-protected room. A rule that starts with a wildcard is worse: +`public_subscribe = "*"`, meant as the top-level broadcasts, lets a session +dialed at `/rooms/123` subscribe to every broadcast in that room. 0.14 +answered 401. It also breaks HTTP: +`/fetch/anon/bbb/video` scopes to `anon/bbb/anon/**` and times out with 504, +and `/announced/anon` lists nothing. The demo's meet page dials +`/anon/meet/`, which fails under `prod.toml`'s suggested +`--public-publish 'anon/**'`. + +### Decisions + +- `Decider::Public` in `rs/moq-relay/src/auth.rs` and the public and mTLS + branches of `serve::Policy::decide` in `rs/moq-auth/src/serve.rs` build + `Claims { root: "", publish, subscribe, .. }` and call + `authorize(&request.path)`. This adds no public API. `grant.root = Some("")` + would be wrong, because it would move the session root to `/`. +- A dialed path the rules don't reach is refused, as 0.14 did + (`ExpectedToken`) and as a JWT is (`RootMismatch` / `NoAccess`). It is a + refusal (401 or 403), never an outage: the relay's + `From` maps everything but `Refused` to a 502 that clients + retry forever, so map `NoAccess` explicitly. +- A public or mTLS pattern with no wildcard (`anon`, `""`) refuses to start, + naming `anon/**` for the subtree. This is a migration guard: 0.14 read + `anon` as a prefix and 0.15 reads it as exact, so either silent reading + misleads someone, and an operator fixes it before any client connects. It + also catches an empty `MOQ_AUTH_PUBLIC=` from an unset variable, which + today enables anonymous access. The cost is that no exact broadcast can be + made public; the guard can relax once 0.14 configs are gone. +- The `accepted` log stays root-relative; the fix makes its output correct. +- `/fetch` keeps dialing the whole broadcast path, as 0.14 did. The rebase + makes it work under public rules. + +### Tests + +Every existing integration test grants bare `**`, which reads the same +whether relative or absolute, so it hides the bug. Use rooted patterns such as +`anon/**` and `anon/*` throughout, which read differently at every dialed path +but the root. + +- Unit: relay `Decider::Public` and `serve::Policy` public and mTLS rules with + `anon/**`, dialed at `/`, `/anon`, `/anon/room`, and `/other`. Fix + `a_public_config_admits_anonymous_and_certificate_alike`, which asserts the + bug. +- Unit: a bare pattern refuses to start in both places. +- Unit: restore 0.14's public-subscribe-only and public-publish-only tests. +- Smoke: with `public = "anon/**"`, a client dialed at `/anon` publishes + `test.hang`, a subscriber at `/` sees `anon/test.hang`, and a session at + `/rooms/123` is refused. With `public_subscribe = "*"`, a session at + `/rooms/123` is refused. +- Smoke: `/fetch` and `/announced` serve a broadcast under `anon/**` and + refuse one outside it. +- Smoke: relay `--auth-url` to `moq auth serve --public-subscribe 'event/**'`, + with an anonymous viewer dialed at `/event` watching `cam1.hang` published + under a `root=event` JWT. + +### Docs + +- `doc/bin/relay/auth.md:13` says "patterns under the dialed path". State the + model above once, and point the JWT and public sections at it. +- `doc/bin/relay/auth.md:286` lists `--auth-public` as removed; it is current. + Check every row of that migration table against a test. +- `doc/bin/relay/index.md:41` gives `public = ""` for everything; use `"**"`. + `index.md:15` still mentions anonymous prefixes and an auth API. +- `doc/setup/upgrade.md:65-68`: bare prefixes now refuse to start. +- `doc/bin/relay/http.md`: `/announced/

` returns names relative to `p`. +- `rs/moq-auth/src/serve.rs:3-4` and `claims.rs:64-68` claim public rules + match 0.14 or share `Permissions`' frame; make them true. + +## Related + +- [Auth parity](/quest/m0/auth-parity.md) - the other auth behaviors 0.15 changed silently +- [Path patterns](/quest/m1/path-patterns.md) - owns the grammar these rules use From 03f4b3033bcdd1dd5ce66b92316446ceb5bb6ab7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 14:36:03 -0700 Subject: [PATCH 41/87] fix(auth): root public and mTLS rules at / (#4318) Co-authored-by: Claude Opus 5.5 --- bench/relay.sh | 2 +- doc/bin/relay/auth.md | 38 ++++- doc/bin/relay/config.md | 2 +- doc/bin/relay/http.md | 2 +- doc/bin/relay/index.md | 4 +- doc/setup/upgrade.md | 6 +- quest/m0/README.md | 1 - quest/m0/auth-parity.md | 4 - quest/m0/public-rules.md | 121 -------------- rs/moq-auth/src/claims.rs | 2 +- rs/moq-auth/src/serve.rs | 74 ++++++++- rs/moq-cli/src/auth.rs | 64 ++++++- rs/moq-relay/src/auth.rs | 131 +++++++++++++-- rs/moq-relay/tests/auth_lifetime.rs | 5 +- rs/moq-relay/tests/public_rules.rs | 249 ++++++++++++++++++++++++++++ rs/moq-relay/tests/smoke.rs | 6 +- 16 files changed, 542 insertions(+), 169 deletions(-) delete mode 100644 quest/m0/public-rules.md create mode 100644 rs/moq-relay/tests/public_rules.rs diff --git a/bench/relay.sh b/bench/relay.sh index 82e888bb98..0a7a3369e0 100755 --- a/bench/relay.sh +++ b/bench/relay.sh @@ -58,7 +58,7 @@ relay_config() { "listen = \"127.0.0.1:$port\"" \ '' \ '[auth]' \ - 'public = ""' \ + 'public = "**"' \ "${runtime_config[@]}" >"$path" } diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 596f32e4a6..c3c5497b4d 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -10,7 +10,7 @@ A relay admits a session in exactly one of two ways: | Flag | What admits | | --- | --- | | `--auth-url` | An auth server, asked once per session event with everything the relay knows. `moq auth serve` is the reference server; a Worker or a service of your own answers the same contract. | -| `--auth-public` | A static grant for anonymous sessions: patterns under the dialed path, no server. | +| `--auth-public` | A static grant for anonymous sessions: patterns rooted at `/`, like a token with an empty root. No server. | Setting both, or neither, fails at startup; an application that embeds the relay may leave both unset and decide [in process](#in-process) instead. @@ -178,6 +178,19 @@ at the dialed path: the path may equal the root, extend it (which narrows the grant), or be a parent of it (the grant still applies at the root). An unrelated path is rejected. The relay forwards the raw path and enforces the grant it gets. +The session's root is always the path it dialed, and what it sees is named +relative to that. For a grant of `demo/**` from the root: + +| Dialed | Session root | Granted | +| --- | --- | --- | +| `/demo/bar` | `demo/bar` | `**` | +| `/demo` | `demo` | `**` | +| `/` | \`\` | `demo/**` | +| `/other` | refused | | + +Anonymous and mTLS rules, on the relay and in `moq auth serve`, are authorized +the same way, as a token with an empty root. + | root | publish | subscribe | Publish | Subscribe | | --- | --- | --- | --- | --- | | `demo` | `my-stream/**` | `**` | `demo/my-stream/**` | `demo/**` | @@ -197,7 +210,12 @@ public_subscribe = ["anon/**", "demo/**"] public_publish = ["anon/**"] ``` -A static grant with no expiry and no re-check. `public = "**"` opens everything +A static grant with no expiry and no re-check. The patterns are rooted at `/`, +like a token with an empty root (see [Path matching](#path-matching)): `anon/**` +admits a session dialed at `/`, `/anon`, or `/anon/room`, scoped to `anon/`, +and refuses one dialed anywhere else. A pattern with no wildcard, such as +`anon`, refuses to start: 0.14 read it as a prefix and a pattern reads it as one +broadcast, so write `anon/**` for the subtree. `public = "**"` opens everything and is for development only. With `--auth-url` the anonymous rules live on the server instead (`moq auth serve --public-*`). @@ -252,10 +270,12 @@ Policy runs in this order and stops at the first that applies: through to the anonymous rules. So is a SETUP token of any other `kind`, and a session presenting both a SETUP token and a `jwt` query. 2. A verified client certificate gets `--mtls-publish` and - `--mtls-subscribe`, and nothing when they are empty. Cluster peers are + `--mtls-subscribe`, rooted at `/` and authorized at the dialed path like a + token with an empty root, and nothing when they are empty. Cluster peers are admitted this way; a mesh needs `'**'` for both. -3. Anything else gets `--public-publish` and `--public-subscribe`, and is - refused when they are empty. +3. Anything else gets `--public-publish` and `--public-subscribe`, rooted + the same way, and is refused when they are empty or reach nothing at the + dialed path. Every grant carries `--tier`, a `revalidate` cadence (`--revalidate`, default one minute), and an `expires`: the token's `exp`, the certificate's notAfter, @@ -276,10 +296,12 @@ its own. ### Migrating from the relay flags -The relay flags below were deleted; the policy moves to the server and the -relay gets `--auth-url`. +Most of the 0.14 relay flags below were deleted; their policy moves to the +server and the relay gets `--auth-url`. A relay without a server keeps +`--auth-public`, as patterns: `--auth-public 'PREFIX/**'`. With a server, the +anonymous rules move too. -| Removed relay flag | `moq auth serve` | +| 0.14 relay flag | `moq auth serve` | | --- | --- | | `--auth-key FILE` | `--key FILE` | | `--auth-key-dir DIR` | `--key-dir DIR` | diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 8f5484fcba..d5febea5b8 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -119,7 +119,7 @@ See [HTTP endpoints](/bin/relay/http). # Exactly one of these: url = "http://127.0.0.1:4440/" # An auth server asked once per session event (`moq auth serve`, # or your own). https:// presents connect.tls; unix:// is a socket. -# public = "anon/**" # Or a static anonymous grant, publish and subscribe alike. +# public = "anon/**" # Or a static anonymous grant rooted at /, publish and subscribe alike. # public_subscribe = ["anon/**", "demo/**"] # Or split them; patterns, `foo/**` for a subtree. # public_publish = ["anon/**"] ``` diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md index dcf73a4636..8046e48a26 100644 --- a/doc/bin/relay/http.md +++ b/doc/bin/relay/http.md @@ -12,7 +12,7 @@ operational ones that must stay private. | Endpoint | Returns | | --- | --- | -| `GET /announced/` | Broadcasts announced under the prefix. | +| `GET /announced/` | Broadcasts announced under the prefix, named relative to it. | | `GET /fetch//?group=N` | One group from the cache, the latest by default, or `404` if the track has no such group. Useful for catch-up and debugging. | | `GET /certificate.sha256` | The fingerprint of the first configured TLS certificate, for pinning a self-signed dev certificate. | | `GET /health` | `200 ok`, unauthenticated, for load balancers. | diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index 969ab37397..f0caabd011 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -12,7 +12,7 @@ relay serves video, audio, and data alike. ## Features - **QUIC, WebTransport, and WebSocket** listeners, so browsers and native clients connect to one process. -- **Path-scoped authentication** with JWTs, mTLS for peers, anonymous prefixes, and an optional auth API for dynamic policy. See [Authentication](/bin/relay/auth). +- **Path-scoped authentication** with JWTs, mTLS for peers, and anonymous patterns, decided by an auth server or a static grant. See [Authentication](/bin/relay/auth). - **Clustering** across hosts and regions with hop-list routing, per-link costs, gossip discovery, and dynamic peer lists. See [Clustering](/bin/relay/cluster). - **A group cache** with byte and age budgets, so late joiners and the HLS gateway can fetch recent history. - **HTTP endpoints** to list broadcasts, fetch groups, probe health, and scrape Prometheus metrics. See [HTTP](/bin/relay/http). @@ -38,7 +38,7 @@ tls.generate = ["localhost"] listen = "[::]:4443" # serves the certificate fingerprint for local browsers [auth] -public = "" # anonymous access to everything; development only +public = "**" # anonymous access to everything; development only ``` Every option is also a `--flag` or `MOQ_*` environment variable, and diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 99013372b3..e67ccb87ed 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -65,7 +65,11 @@ Other changes to a deployment: - **Grants are patterns, not prefixes.** `anon` is now exactly the broadcast `anon`; write `anon/**` for the subtree. This applies to `--auth-public`, TOML `public`, and the `[auth.public]` table, which is now - `public_subscribe` / `public_publish`. + `public_subscribe` / `public_publish`. A public or mTLS pattern with no + wildcard refuses to start, naming the subtree to write, rather than pick + one reading silently. The patterns are rooted at `/`, as in 0.14, so + `anon/**` admits a client dialed at `/anon` and refuses one dialed outside + `anon/`. Earlier 0.15 releases rooted them at the dialed path instead. - **Token grants are patterns.** JWT `publish` and `subscribe` claims are patterns, so a token granting `alice` covers only `alice`; sign `alice/**` instead. Existing `put`/`get` tokens and key scopes keep working as subtrees, diff --git a/quest/m0/README.md b/quest/m0/README.md index 8d15de06cc..865a53a06d 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -93,7 +93,6 @@ do not add another media abstraction or a renderer crate during stabilization. ## Quests -- [Public rules parity](/quest/m0/public-rules.md) - public and mTLS rules authorize like a JWT rooted at `/`, so `anon/**` works at `/anon` and an anonymous client can no longer reach into every other path - [Auth parity](/quest/m0/auth-parity.md) - 0.14 configs and tokens keep working or are refused with the fix named, and every credential a session presents is evaluated or refused - [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 - [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces diff --git a/quest/m0/auth-parity.md b/quest/m0/auth-parity.md index 98e28d9c69..264211ef79 100644 --- a/quest/m0/auth-parity.md +++ b/quest/m0/auth-parity.md @@ -134,10 +134,6 @@ Each of these was considered and kept as 0.15 behavior: `doc/setup/upgrade.md`. List the declined differences in the upgrade notes, since none of them is documented today. -## Required - -- [Public rules parity](/quest/m0/public-rules.md) - same auth code; one owner at a time - ## Related - [Release](/quest/m0/release.md) - ships this parity diff --git a/quest/m0/public-rules.md b/quest/m0/public-rules.md deleted file mode 100644 index c1bfecec9f..0000000000 --- a/quest/m0/public-rules.md +++ /dev/null @@ -1,121 +0,0 @@ -# [M] Public rules parity - -## Goal - -Public and mTLS rules (`[auth] public*`, `--auth-public*`, and -`moq auth serve --public-*`/`--mtls-*`) are authorized like a JWT with root -`/`, as static public rules were in moq-relay 0.14.18. `public = "anon/**"` -(0.14 spelled it `public = "anon"`) lets an anonymous client dialed at `/anon` -publish `test.hang` (absolute `anon/test.hang`) and refuses a session dialed -outside `anon/`. Relay tests cover it. - -This is a security fix for released 0.15: today an anonymous client can reach -into any path. It lands first and on its own, ahead of -[Auth parity](/quest/m0/auth-parity.md). - -Non-goals: the pattern grammar (owned by [Path patterns](/quest/m1/path-patterns.md)) -and JWT grant semantics, which already behave this way. - -## Plan - -### Model - -A grant names what it may access from an absolute root. The session root is -the dialed path, and the patterns are rebased onto it by -`Claims::authorize`. For access to `demo/**`: - -| Dialed | Session root | Patterns | -|---|---|---| -| `/demo/bar` | `demo/bar` | `**` | -| `/demo` | `demo` | `**` | -| `/` | `` | `demo/**` | -| `/other` | refused | | - -JWTs already work this way. Public and mTLS rules must too, in the relay and -in `moq auth serve`. The Grant contract is unchanged: `root: None` still means -the dialed path. - -### Cause - -0.14 built static public access as `Claims::default().with_root("")` and ran -it through `Claims::authorize(path)`. Its public and auth APIs rooted their -answers at the dialed path, as `--auth-url` still does. #3688 (relay) and #3686 (`moq auth serve`) -replaced that with raw patterns in a `Grant` whose `root` is `None`, which -roots them at the dialed path. `anon/**` dialed at `/anon` therefore enforces -`anon/anon/**`. The `accepted` log was accurate: `root=event subscribe=event/**` -is root-relative and meant `event/event/**`. - -The same cause admits every path. Under `anon/**`, a session dialed at -`/rooms/123` gets `rooms/123/anon/**`, so an anonymous client can publish -inside a JWT-protected room. A rule that starts with a wildcard is worse: -`public_subscribe = "*"`, meant as the top-level broadcasts, lets a session -dialed at `/rooms/123` subscribe to every broadcast in that room. 0.14 -answered 401. It also breaks HTTP: -`/fetch/anon/bbb/video` scopes to `anon/bbb/anon/**` and times out with 504, -and `/announced/anon` lists nothing. The demo's meet page dials -`/anon/meet/`, which fails under `prod.toml`'s suggested -`--public-publish 'anon/**'`. - -### Decisions - -- `Decider::Public` in `rs/moq-relay/src/auth.rs` and the public and mTLS - branches of `serve::Policy::decide` in `rs/moq-auth/src/serve.rs` build - `Claims { root: "", publish, subscribe, .. }` and call - `authorize(&request.path)`. This adds no public API. `grant.root = Some("")` - would be wrong, because it would move the session root to `/`. -- A dialed path the rules don't reach is refused, as 0.14 did - (`ExpectedToken`) and as a JWT is (`RootMismatch` / `NoAccess`). It is a - refusal (401 or 403), never an outage: the relay's - `From` maps everything but `Refused` to a 502 that clients - retry forever, so map `NoAccess` explicitly. -- A public or mTLS pattern with no wildcard (`anon`, `""`) refuses to start, - naming `anon/**` for the subtree. This is a migration guard: 0.14 read - `anon` as a prefix and 0.15 reads it as exact, so either silent reading - misleads someone, and an operator fixes it before any client connects. It - also catches an empty `MOQ_AUTH_PUBLIC=` from an unset variable, which - today enables anonymous access. The cost is that no exact broadcast can be - made public; the guard can relax once 0.14 configs are gone. -- The `accepted` log stays root-relative; the fix makes its output correct. -- `/fetch` keeps dialing the whole broadcast path, as 0.14 did. The rebase - makes it work under public rules. - -### Tests - -Every existing integration test grants bare `**`, which reads the same -whether relative or absolute, so it hides the bug. Use rooted patterns such as -`anon/**` and `anon/*` throughout, which read differently at every dialed path -but the root. - -- Unit: relay `Decider::Public` and `serve::Policy` public and mTLS rules with - `anon/**`, dialed at `/`, `/anon`, `/anon/room`, and `/other`. Fix - `a_public_config_admits_anonymous_and_certificate_alike`, which asserts the - bug. -- Unit: a bare pattern refuses to start in both places. -- Unit: restore 0.14's public-subscribe-only and public-publish-only tests. -- Smoke: with `public = "anon/**"`, a client dialed at `/anon` publishes - `test.hang`, a subscriber at `/` sees `anon/test.hang`, and a session at - `/rooms/123` is refused. With `public_subscribe = "*"`, a session at - `/rooms/123` is refused. -- Smoke: `/fetch` and `/announced` serve a broadcast under `anon/**` and - refuse one outside it. -- Smoke: relay `--auth-url` to `moq auth serve --public-subscribe 'event/**'`, - with an anonymous viewer dialed at `/event` watching `cam1.hang` published - under a `root=event` JWT. - -### Docs - -- `doc/bin/relay/auth.md:13` says "patterns under the dialed path". State the - model above once, and point the JWT and public sections at it. -- `doc/bin/relay/auth.md:286` lists `--auth-public` as removed; it is current. - Check every row of that migration table against a test. -- `doc/bin/relay/index.md:41` gives `public = ""` for everything; use `"**"`. - `index.md:15` still mentions anonymous prefixes and an auth API. -- `doc/setup/upgrade.md:65-68`: bare prefixes now refuse to start. -- `doc/bin/relay/http.md`: `/announced/

` returns names relative to `p`. -- `rs/moq-auth/src/serve.rs:3-4` and `claims.rs:64-68` claim public rules - match 0.14 or share `Permissions`' frame; make them true. - -## Related - -- [Auth parity](/quest/m0/auth-parity.md) - the other auth behaviors 0.15 changed silently -- [Path patterns](/quest/m1/path-patterns.md) - owns the grammar these rules use diff --git a/rs/moq-auth/src/claims.rs b/rs/moq-auth/src/claims.rs index d49a252f1a..e49df781e8 100644 --- a/rs/moq-auth/src/claims.rs +++ b/rs/moq-auth/src/claims.rs @@ -66,7 +66,7 @@ impl Scope { /// /// Produced by [`Claims::authorize`]. `**` grants the path itself and everything /// beneath it; the empty pattern grants exactly the path. The reference server's -/// policy uses the same pair for anonymous and mTLS grants. +/// policy holds its anonymous and mTLS rules as this pair too, authorized at `/`. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Permissions { /// Patterns the holder may subscribe to, relative to the authorized path. diff --git a/rs/moq-auth/src/serve.rs b/rs/moq-auth/src/serve.rs index 96e2de12bf..18532b1cad 100644 --- a/rs/moq-auth/src/serve.rs +++ b/rs/moq-auth/src/serve.rs @@ -2,8 +2,10 @@ //! //! [`Policy`] decides a [`Request`] the way `--auth-key`, `--auth-key-dir`, and //! `--auth-public` did on the relay, plus an explicit grant for mTLS peers and live -//! session caps. [`Server`] serves it on a TCP or unix listener as `POST /`. `moq auth -//! serve` is the CLI; the relay's tests run against it in process. +//! session caps. Its anonymous and mTLS rules are rooted at `/`, like a token with an +//! empty root, and authorized at the dialed path the same way. [`Server`] serves it +//! on a TCP or unix listener as `POST /`. `moq auth serve` is the CLI; the relay's +//! tests run against it in process. use std::collections::HashMap; use std::net::IpAddr; @@ -51,6 +53,10 @@ pub struct Limits { /// first that applies: a JWT, then a verified certificate, then the anonymous rules. /// A malformed or expired token is a refusal, never a fall through. /// +/// The anonymous and mTLS rules name what they grant from `/`, like a token with an +/// empty root: `anon/**` admits a session dialed at `/`, `/anon`, or `/anon/room`, +/// each scoped to `anon/`, and refuses one dialed at `/other`. +/// /// The JWT is the `jwt` query parameter or a moq-transport SETUP token of type 0 /// ([`Token::OUT_OF_BAND`]), verified alike. A session presenting both is refused, /// as is a SETUP token of any other type. @@ -61,9 +67,10 @@ pub struct Limits { pub struct Policy { /// The keys a `jwt` is verified against; `None` refuses every token. pub keys: Option, - /// What an anonymous session is granted; empty refuses it. + /// What an anonymous session is granted, rooted at `/`; empty refuses it. pub public: Permissions, - /// What a session presenting a verified certificate is granted; empty refuses it. + /// What a session presenting a verified certificate is granted, rooted at `/`; + /// empty refuses it. pub mtls: Permissions, /// The tier stamped on every grant. pub tier: Option, @@ -104,7 +111,7 @@ pub enum Refusal { InvalidToken(String), #[error("the token root `{root}` does not overlap the dialed path `{path}`")] RootMismatch { root: String, path: String }, - #[error("the token grants no access at `{path}`")] + #[error("nothing is granted at `{path}`")] NoAccess { path: String }, #[error("a certificate was presented but nothing is granted to certificates")] NoMtlsGrant, @@ -139,12 +146,12 @@ impl Policy { if self.mtls.is_empty() { return Err(Refusal::NoMtlsGrant); } - (self.mtls.clone(), peer.expires) + (authorize(&self.mtls, &request.path)?, peer.expires) } else { if self.public.is_empty() { return Err(Refusal::NoPublicGrant); } - (self.public.clone(), None) + (authorize(&self.public, &request.path)?, None) }; let mut grant = Grant::new(permissions.publish, permissions.subscribe); @@ -169,6 +176,20 @@ impl Policy { } } +/// Authorize rules rooted at `/` at the dialed `path`, exactly as a token with an +/// empty root would be. +fn authorize(rules: &Permissions, path: &str) -> Result { + let claims = crate::Claims { + publish: rules.publish.clone(), + subscribe: rules.subscribe.clone(), + ..Default::default() + }; + // An empty root overlaps every path, so the only refusal is reaching nothing. + claims.authorize(path).map_err(|_| Refusal::NoAccess { + path: crate::path::normalize(path), + }) +} + /// The JWT the request presents: its SETUP token, or else its `jwt` query parameter. fn jwt(request: &Request) -> Result, Refusal> { match (&request.token, query_jwt(request)) { @@ -681,6 +702,45 @@ mod tests { assert!(grant.expires.is_some()); } + /// The rules are rooted at `/`, not at the dialed path: `anon/**` scopes a session + /// dialed anywhere to `anon/`, and refuses one dialed outside it. Bare `**` reads + /// the same either way, which is how rooting them at the dialed path went unnoticed. + #[tokio::test] + async fn public_and_mtls_rules_are_rooted_at_slash() { + let policy = Policy { + public: rules(&["anon/**"], &["anon/**", "*/chat"]), + mtls: rules(&["origin/*"], &["origin/*"]), + ..Default::default() + }; + + for (path, publish, subscribe) in [ + ("/", &["anon/**"][..], &["anon/**", "*/chat"][..]), + ("/anon", &["**"], &["**", "chat"]), + ("/anon/room", &["**"], &["**"]), + ("/rooms", &[], &["chat"]), + ] { + let grant = policy.decide(&request(path)).await.unwrap(); + assert_eq!(grant.publish, patterns(publish), "{path}"); + assert_eq!(grant.subscribe, patterns(subscribe), "{path}"); + assert_eq!(grant.root, None, "{path}"); + } + + // Before, `/rooms/123` got `rooms/123/anon/**`: into any room, anonymously. + for path in ["/rooms/123", "/other/room", "/anonymous/room"] { + assert_eq!( + policy.decide(&request(path)).await.unwrap_err(), + Refusal::NoAccess { + path: path.trim_start_matches('/').into() + }, + ); + } + + let grant = policy.decide(&with_peer(request("/origin/edge0"), None)).await.unwrap(); + assert_eq!(grant.publish, patterns(&[""])); + let err = policy.decide(&with_peer(request("/anon"), None)).await.unwrap_err(); + assert!(matches!(err, Refusal::NoAccess { .. }), "{err}"); + } + #[tokio::test] async fn limits_count_connect_end_and_aging() { tokio::time::pause(); diff --git a/rs/moq-cli/src/auth.rs b/rs/moq-cli/src/auth.rs index 3061bbc1ea..2c2dfa4154 100644 --- a/rs/moq-cli/src/auth.rs +++ b/rs/moq-cli/src/auth.rs @@ -367,19 +367,19 @@ pub struct Serve { #[usage(long, conflicts = "--key", value_hint = usage::ValueHint::DirPath)] key_dir: Option, - /// Patterns an anonymous session may publish (repeatable); `foo/**` for a subtree. + /// Patterns an anonymous session may publish, rooted at `/` (repeatable); `foo/**` for a subtree. #[usage(long)] public_publish: Vec, - /// Patterns an anonymous session may subscribe to (repeatable). + /// Patterns an anonymous session may subscribe to, rooted at `/` (repeatable). #[usage(long)] public_subscribe: Vec, - /// Patterns a session with a verified certificate may publish (repeatable). Empty refuses certificates. + /// Patterns a session with a verified certificate may publish, rooted at `/` (repeatable). Empty refuses certificates. #[usage(long)] mtls_publish: Vec, - /// Patterns a session with a verified certificate may subscribe to (repeatable). + /// Patterns a session with a verified certificate may subscribe to, rooted at `/` (repeatable). #[usage(long)] mtls_subscribe: Vec, @@ -411,6 +411,25 @@ impl Serve { if revalidate.is_zero() { anyhow::bail!("--revalidate must be longer than 0s; every client would re-check in a tight loop"); } + // 0.14 read `anon` as the prefix `anon/`, and a pattern reads it as exactly the + // broadcast `anon`, so either silent reading would mislead someone upgrading. + let flags = [ + ("--public-publish", &self.public_publish), + ("--public-subscribe", &self.public_subscribe), + ("--mtls-publish", &self.mtls_publish), + ("--mtls-subscribe", &self.mtls_subscribe), + ]; + for (flag, patterns) in flags { + for pattern in patterns.iter().filter(|pattern| pattern.is_literal()) { + // A literal at the maximum depth is already its own subtree. + let subtree = Pattern::subtree(pattern.as_str())?; + if subtree != *pattern { + anyhow::bail!( + "{flag} `{pattern}` has no wildcard, so it names exactly one broadcast; write `{subtree}` for the subtree" + ); + } + } + } let rules = |publish: &[Pattern], subscribe: &[Pattern]| { Permissions::new(publish.iter().cloned().collect(), subscribe.iter().cloned().collect()) }; @@ -655,6 +674,43 @@ mod tests { assert!(err.to_string().contains("--revalidate"), "{err}"); } + /// 0.14 read `anon` as a prefix and a pattern reads it as one broadcast, so a + /// wildcard-free rule refuses to start rather than pick silently. + #[test] + fn serve_refuses_a_rule_without_a_wildcard() { + for (flag, rule, hint) in [ + ("--public-publish", "anon", "anon/**"), + ("--public-subscribe", "event/cam1.hang", "event/cam1.hang/**"), + ("--mtls-publish", "", "**"), + ("--mtls-subscribe", "origin", "origin/**"), + ] { + let err = serve(&["moq", "auth", "serve", flag, rule]).policy().unwrap_err(); + let err = err.to_string(); + assert!(err.contains(flag) && err.contains(&format!("`{hint}`")), "{err}"); + } + assert!( + serve(&[ + "moq", + "auth", + "serve", + "--public-subscribe", + "*/chat", + "--mtls-publish", + "origin/*" + ]) + .policy() + .is_ok() + ); + + // Nothing sits beneath a literal at the maximum depth, so it is its own subtree. + let deepest = vec!["a"; Pattern::MAX_SEGMENTS].join("/"); + assert!( + serve(&["moq", "auth", "serve", "--public-subscribe", &deepest]) + .policy() + .is_ok() + ); + } + #[cfg(unix)] #[test] fn unlink_socket_leaves_anything_but_a_socket_alone() { diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index bbe0261d90..66715275ec 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -35,9 +35,9 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, - /// Patterns an anonymous session may both publish and subscribe to, such as - /// `anon/**`. Repeatable or comma-separated. Sets a static grant with no expiry - /// and no server. + /// Patterns an anonymous session may both publish and subscribe to, rooted at + /// `/`, such as `anon/**`. Repeatable or comma-separated. Sets a static grant + /// with no expiry and no server. #[usage( long = "auth-public", env = "MOQ_AUTH_PUBLIC", @@ -48,7 +48,7 @@ pub struct Config { #[serde_as(as = "OneOrMany<_>")] pub public: Vec, - /// Patterns an anonymous session may subscribe to. Repeatable. + /// Patterns an anonymous session may subscribe to, rooted at `/`. Repeatable. #[usage( long = "auth-public-subscribe", env = "MOQ_AUTH_PUBLIC_SUBSCRIBE", @@ -59,7 +59,7 @@ pub struct Config { #[serde_as(as = "OneOrMany<_>")] pub public_subscribe: Vec, - /// Patterns an anonymous session may publish. Repeatable. + /// Patterns an anonymous session may publish, rooted at `/`. Repeatable. #[usage( long = "auth-public-publish", env = "MOQ_AUTH_PUBLIC_PUBLISH", @@ -73,6 +73,9 @@ pub struct Config { impl Config { /// The static grant the public patterns name, or `None` when none is set. + /// + /// Its patterns are rooted at `/`, not at a dialed path: each session is granted + /// what they reach from where it dialed, as a token with an empty root would be. pub fn public_grant(&self) -> Option { let publish: Patterns = self.public.iter().chain(&self.public_publish).cloned().collect(); let subscribe: Patterns = self.public.iter().chain(&self.public_subscribe).cloned().collect(); @@ -88,7 +91,27 @@ impl Config { /// Refuse a configuration that admits nobody, or that names both a server and /// a static grant, so the question of who decides has one answer. + /// + /// A public pattern without a wildcard is refused too. 0.14 read `anon` as the + /// prefix `anon/`, and a pattern reads it as exactly the broadcast `anon`, so + /// either silent reading would mislead someone upgrading. pub fn validate(&self) -> anyhow::Result<()> { + let flags = [ + ("--auth-public", &self.public), + ("--auth-public-publish", &self.public_publish), + ("--auth-public-subscribe", &self.public_subscribe), + ]; + for (flag, patterns) in flags { + for pattern in patterns.iter().filter(|pattern| pattern.is_literal()) { + // A literal at the maximum depth is already its own subtree. + let subtree = Pattern::subtree(pattern.as_str())?; + if subtree != *pattern { + anyhow::bail!( + "{flag} `{pattern}` has no wildcard, so it names exactly one broadcast; write `{subtree}` for the subtree" + ); + } + } + } match (&self.url, self.public_grant()) { (Some(_), Some(_)) => anyhow::bail!("--auth-url and --auth-public cannot both be set; the server decides"), (None, None) => anyhow::bail!( @@ -109,7 +132,11 @@ impl Config { let tls = tls.build()?; Decider::Server(moq_auth::Client::new(url.clone(), Some(tls))?) } - (None, Some(grant)) => Decider::Public(grant), + (None, Some(grant)) => Decider::Public( + moq_auth::Claims::default() + .with_publish(grant.publish) + .with_subscribe(grant.subscribe), + ), (None, None) => unreachable!("validated above"), }; let (auth, admissions) = Auth::embedded(node); @@ -324,7 +351,8 @@ impl Lease { enum Decider { Server(moq_auth::Client), - Public(Grant), + /// The public rules, as a token with an empty root. + Public(moq_auth::Claims), Refuse, } @@ -342,7 +370,18 @@ impl Decider { } }); } - Self::Public(grant) => admission.grant(lease::Consumer::fixed(grant.clone())), + Self::Public(rules) => match rules.authorize(&admission.request.path) { + Ok(access) => { + let grant = Grant::new(access.publish, access.subscribe); + admission.grant(lease::Consumer::fixed(grant)); + } + // A path the rules don't reach is a refusal, never an outage a + // client would retry. + Err(err) => { + tracing::debug!(path = %admission.request.path, %err, "public rules refused"); + admission.refuse(Error::Refused); + } + }, Self::Refuse => admission.refuse(Error::Refused), } } @@ -537,16 +576,82 @@ mod tests { assert!(grant.publish.is_empty()); } + /// The public rules are rooted at `/`, like a token with an empty root. Bare `**` + /// reads the same either way, which is how rooting them at the dialed path went + /// unnoticed: `anon/**` at `/rooms/123` granted `rooms/123/anon/**`. + #[tokio::test] + async fn public_rules_are_rooted_at_slash() { + let auth = Config { + public_publish: patterns(&["anon/**"]).into_iter().collect(), + public_subscribe: patterns(&["anon/**", "*/chat"]).into_iter().collect(), + ..Default::default() + } + .init("relay-1", &moq_tokio::tls::Connect::default()) + .unwrap(); + + for (path, root, publish, subscribe) in [ + ("/", "", &["anon/**"][..], &["anon/**", "*/chat"][..]), + ("/anon", "anon", &["**"], &["**", "chat"]), + ("/anon/room", "anon/room", &["**"], &["**"]), + ("/rooms", "rooms", &[], &["chat"]), + ] { + let lease = auth.admit(auth.request(moq_auth::Transport::Quic, path)).await.unwrap(); + assert_eq!(lease.token().root, Path::new(root).to_owned(), "{path}"); + assert_eq!(lease.token().publish, patterns(publish), "{path}"); + assert_eq!(lease.token().subscribe, patterns(subscribe), "{path}"); + } + + // A path the rules don't reach is refused, never an outage the client retries. + for path in ["/rooms/123", "/other/room", "/anonymous/room"] { + let Err(err) = auth.admit(auth.request(moq_auth::Transport::Quic, path)).await else { + panic!("{path} was admitted"); + }; + assert!(matches!(err, Error::Refused), "{path}: {err}"); + assert_eq!(http::StatusCode::from(&err), http::StatusCode::UNAUTHORIZED); + } + } + + /// A certificate is a fact the public rules ignore: it gets exactly what anyone does. #[tokio::test] async fn a_public_config_admits_anonymous_and_certificate_alike() { let auth = config(None, &["anon/**"]) .init("relay-1", &moq_tokio::tls::Connect::default()) .unwrap(); - let request = auth.request(moq_auth::Transport::Quic, "/anon/room"); - let lease = auth.admit(request).await.unwrap(); - assert_eq!(lease.token().root, Path::new("anon/room").to_owned()); - assert_eq!(lease.token().subscribe, patterns(&["anon/**"])); - assert_eq!(lease.token().tier, Tier::default()); + let anonymous = auth.request(moq_auth::Transport::Quic, "/anon/room"); + let mut certificate = anonymous.clone(); + certificate.tls = Some(moq_auth::Peer { + name: "edge0".into(), + fingerprint: "ab".repeat(32), + expires: None, + issuer: "CN=cluster".into(), + }); + for request in [anonymous, certificate] { + let lease = auth.admit(request).await.unwrap(); + assert_eq!(lease.token().root, Path::new("anon/room").to_owned()); + assert_eq!(lease.token().subscribe, patterns(&["**"])); + assert_eq!(lease.token().tier, Tier::default()); + } + } + + /// 0.14 read `anon` as a prefix and a pattern reads it as one broadcast, so a + /// wildcard-free public pattern refuses to start rather than pick silently. + #[test] + fn a_public_pattern_without_a_wildcard_refuses_to_start() { + for (public, hint) in [("anon", "anon/**"), ("anon/room", "anon/room/**"), ("", "**")] { + let err = config(None, &[public]).validate().unwrap_err().to_string(); + assert!(err.contains(&format!("`{hint}`")), "{public}: {err}"); + } + let split = Config { + public_subscribe: patterns(&["anon/**", "live"]).into_iter().collect(), + ..Default::default() + }; + let err = split.validate().unwrap_err().to_string(); + assert!(err.starts_with("--auth-public-subscribe `live`"), "{err}"); + assert!(config(None, &["anon/*", "*/chat", "**"]).validate().is_ok()); + + // Nothing sits beneath a literal at the maximum depth, so it is its own subtree. + let deepest = vec!["a"; Pattern::MAX_SEGMENTS].join("/"); + assert!(config(None, &[&deepest]).validate().is_ok()); } #[test] diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index d5d39c72a8..6063889f3e 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -888,7 +888,7 @@ async fn a_certificate_admits_only_what_the_server_grants() { ))) .await; let (addr, relay) = spawn_quic_relay(build_auth(narrow), Some(root.clone())).await; - let url: url::Url = format!("moql://127.0.0.1:{}/room", addr.port()).parse().unwrap(); + let url: url::Url = format!("moql://127.0.0.1:{}/mine", addr.port()).parse().unwrap(); let origin = moq_tokio::origin::spawn(); let session = tokio::time::timeout( TIMEOUT, @@ -909,6 +909,9 @@ async fn a_certificate_admits_only_what_the_server_grants() { ); // A subscribe-only client has nothing granted, so it is refused at the handshake. assert_refused_with(mtls_client(), &url).await; + // The rules are rooted at `/`, so a certificate dialed outside `mine` gets nothing. + let room: url::Url = format!("moql://127.0.0.1:{}/room", addr.port()).parse().unwrap(); + assert_refused_with(mtls_client(), &room).await; drop(session); relay.abort(); } diff --git a/rs/moq-relay/tests/public_rules.rs b/rs/moq-relay/tests/public_rules.rs new file mode 100644 index 0000000000..1928f55f22 --- /dev/null +++ b/rs/moq-relay/tests/public_rules.rs @@ -0,0 +1,249 @@ +//! Public rules are rooted at `/`, like a token with an empty root, through a real +//! relay: on the relay's own `--auth-public` and on `moq auth serve --public-*` +//! behind `--auth-url`. +//! +//! Every other test grants bare `**`, which reads the same whether rooted at `/` or +//! at the dialed path. These use `anon/**` and `event/**`, which do not: rooted at +//! the dialed path, `anon/**` at `/anon` meant `anon/anon/**`, and at `/rooms/123` +//! it let an anonymous client into `rooms/123/anon/**`. + +use std::time::Duration; + +use moq_relay::{auth, cluster, web}; +use moq_tokio::moq_net; + +const TIMEOUT: Duration = Duration::from_secs(10); + +fn client() -> moq_tokio::Client { + let mut config = moq_tokio::connect::Config::default(); + config.once = Some(true); + config.websocket.delay = Duration::ZERO; + config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); + config.init(Default::default()).expect("client init") +} + +/// The relay's web stack with WebSocket and the HTTP routes, admitting through `auth`. +async fn spawn_relay(auth: auth::Config) -> (u16, tokio::task::JoinHandle<()>) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let auth = auth + .init("test", &moq_tokio::tls::Connect::default()) + .expect("auth init"); + let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); + + // Only the certificate handle is used; stream listeners bind lazily. + let mut server_config = moq_tokio::listen::Config::default(); + server_config.bind = Some("[::]:0".parse().unwrap()); + server_config.tls.generate = vec!["localhost".into()]; + let certificates = server_config + .init(Default::default()) + .expect("server init") + .certificates(); + + let mut web_config = web::Config::default(); + web_config.ws = true; + web_config.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); + let web = web::Web::new(auth, cluster, certificates, web_config) + .bind() + .expect("bind web listener"); + let port = web.addrs().http.expect("HTTP listener is configured").port(); + let handle = tokio::spawn(async move { + let _ = web.run().await; + }); + (port, handle) +} + +fn url(port: u16, path: &str) -> url::Url { + format!("ws://127.0.0.1:{port}{path}").parse().expect("parse url") +} + +/// Publish `name` at `url` with one open group holding `hello`. Keep the returned +/// handles alive for as long as the broadcast should stay announced. +async fn publish(url: url::Url, name: &str) -> Box { + let origin = moq_tokio::origin::spawn(); + let broadcast = origin.create_broadcast(name).expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast.create_track("video", None).expect("create track"); + let mut group = track.append_group().expect("append group"); + group + .write_frame(moq_net::Timestamp::ZERO, b"hello".as_ref()) + .expect("write frame"); + let session = tokio::time::timeout( + TIMEOUT, + client() + .with_publisher(origin.consume()) + .with_reconnect(false) + .connect(url) + .established(), + ) + .await + .expect("publisher connect timeout") + .expect("publisher connect failed"); + Box::new((session, broadcast, track, group)) +} + +/// Subscribe at `url` and return the first broadcast announced, after reading one +/// frame of its `video` track. +async fn first_broadcast(url: url::Url) -> String { + let origin = moq_tokio::origin::spawn(); + let consumer = origin.consume(); + let mut announcements = consumer.announced(); + let _session = tokio::time::timeout( + TIMEOUT, + client() + .with_subscriber(origin) + .with_reconnect(false) + .connect(url) + .established(), + ) + .await + .expect("subscriber connect timeout") + .expect("subscriber connect failed"); + + let update = tokio::time::timeout(TIMEOUT, announcements.next()) + .await + .expect("announcement timeout") + .expect("origin closed"); + assert!(update.kind.is_active(), "expected announce, got retraction"); + let name = update.prefix.to_string(); + + let broadcast = consumer.request_broadcast(&name).await.expect("broadcast resolves"); + let mut track = broadcast + .track("video") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"); + let mut group = tokio::time::timeout(TIMEOUT, track.recv_group()) + .await + .expect("group timeout") + .expect("group failed") + .expect("track closed"); + let frame = tokio::time::timeout(TIMEOUT, group.read_frame()) + .await + .expect("frame timeout") + .expect("frame failed") + .expect("group closed"); + assert_eq!(&frame.payload[..], b"hello"); + name +} + +/// A session the relay refuses never carries media: the transport may finish its +/// handshake before the verdict, so an established one has to close right away. +async fn assert_refused(url: url::Url) { + let origin = moq_tokio::origin::spawn(); + let result = tokio::time::timeout( + TIMEOUT, + client() + .with_subscriber(origin) + .with_reconnect(false) + .connect(url.clone()) + .established(), + ) + .await + .expect("connect timeout"); + if let Ok(session) = result { + let closed = tokio::time::timeout(Duration::from_secs(3), session.closed()) + .await + .unwrap_or_else(|_| panic!("the relay admitted {url}")); + assert!(closed.is_err(), "a refused session at {url} closed cleanly"); + } +} + +fn anon() -> auth::Config { + let mut config = auth::Config::default(); + config.public = vec!["anon/**".parse().unwrap()]; + config +} + +#[tokio::test] +async fn anon_rules_admit_under_anon_and_refuse_elsewhere() { + let (port, relay) = spawn_relay(anon()).await; + + let _publisher = publish(url(port, "/anon"), "test.hang").await; + assert_eq!(first_broadcast(url(port, "/")).await, "anon/test.hang"); + assert_eq!(first_broadcast(url(port, "/anon")).await, "test.hang"); + + // Rooted at the dialed path, this was `rooms/123/anon/**`. + assert_refused(url(port, "/rooms/123")).await; + assert_refused(url(port, "/other")).await; + + relay.abort(); +} + +#[tokio::test] +async fn a_leading_wildcard_does_not_reach_into_rooms() { + let mut config = auth::Config::default(); + config.public_subscribe = vec!["*".parse().unwrap()]; + let (port, relay) = spawn_relay(config).await; + + // Rooted at the dialed path, `*` at `/rooms/123` was every broadcast in the room. + assert_refused(url(port, "/rooms/123")).await; + + relay.abort(); +} + +#[tokio::test] +async fn http_routes_follow_the_anon_rules() { + let (port, relay) = spawn_relay(anon()).await; + let _publisher = publish(url(port, "/anon/bbb"), "cam").await; + // The HTTP routes report only what has reached the relay. + assert_eq!(first_broadcast(url(port, "/anon")).await, "bbb/cam"); + + let http = reqwest::Client::new(); + let get = |path: &str| http.get(format!("http://127.0.0.1:{port}{path}")).send(); + + let announced = get("/announced/anon").await.expect("announced request"); + assert_eq!(announced.status(), 200); + assert_eq!(announced.text().await.expect("announced body").trim(), "bbb/cam"); + + let mut fetch = get("/fetch/anon/bbb/cam/video").await.expect("fetch request"); + assert_eq!(fetch.status(), 200); + let first = tokio::time::timeout(TIMEOUT, fetch.chunk()) + .await + .expect("fetch timeout") + .expect("fetch body") + .expect("fetch body ended early"); + assert_eq!(&first[..], b"hello"); + + for path in ["/announced/other", "/fetch/other/cam/video"] { + assert_eq!(get(path).await.expect("request").status(), 401, "{path}"); + } + + relay.abort(); +} + +/// The reported upgrade: `moq auth serve --public-subscribe 'event/**'` behind +/// `--auth-url`, with a viewer at `/event` watching a camera published under a +/// `root=event` token. Rooted at the dialed path, the viewer saw `event/event/**`. +#[tokio::test] +async fn auth_server_public_rules_are_rooted_at_slash() { + let dir = tempfile::tempdir().expect("tempdir"); + let key = moq_auth::Key::generate(moq_auth::Algorithm::HS256, None).expect("generate key"); + let key_path = dir.path().join("key.jwk"); + key.to_file(&key_path).expect("write key"); + + let mut policy = moq_auth::serve::Policy::default(); + policy.keys = Some(moq_auth::serve::Keys::File(key_path)); + policy.public = moq_auth::Permissions::new(Default::default(), ["event/**".parse().unwrap()].into_iter().collect()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let server_url: url::Url = format!("http://{}/", listener.local_addr().unwrap()).parse().unwrap(); + let server = moq_auth::serve::Server::new(policy); + tokio::spawn(async move { server.serve(listener).await }); + + let mut config = auth::Config::default(); + config.url = Some(server_url); + let (port, relay) = spawn_relay(config).await; + + let claims = moq_auth::Claims::default() + .with_root("event") + .with_publish(["**".parse().unwrap()]); + let jwt = key.sign(&claims).expect("sign"); + let mut camera = url(port, "/event"); + camera.query_pairs_mut().append_pair("jwt", &jwt); + let _publisher = publish(camera, "cam1.hang").await; + + assert_eq!(first_broadcast(url(port, "/event")).await, "cam1.hang"); + assert_refused(url(port, "/rooms/123")).await; + + relay.abort(); +} diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 50920fe0c1..6466d6c509 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -579,7 +579,7 @@ async fn two_publish_only_clients_coexist() { /// Run the relay's accept loop over the given server config, the same path /// `main.rs` uses. Authenticates through the shared [`Auth`], here with fully -/// public access (`--auth-public ""`) so no-JWT clients get the root. +/// public access (`--auth-public "**"`) so no-JWT clients get the root. /// /// Returns the QUIC and TCP sockets the server bound, when it has them, so a /// caller that asked for an ephemeral port can dial it. @@ -628,7 +628,7 @@ async fn spawn_internal_relay() -> (u16, tokio::task::JoinHandle<()>) { let mut config = moq_tokio::listen::Config::default(); config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); - // Public Simple([""]) lets any no-JWT stream client through at the root. + // Public `**` lets any no-JWT stream client through at the root. let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; @@ -730,7 +730,7 @@ async fn spawn_internal_unix_relay() -> (std::path::PathBuf, tokio::task::JoinHa let mut config = moq_tokio::listen::Config::default(); config.unix.bind = Some(path.clone()); - // Public Simple([""]) lets any no-JWT stream client through at the root. + // Public `**` lets any no-JWT stream client through at the root. let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; From b00be0ffc3734addcdd5e6ad4b348ae055e70957 Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:04:03 -0700 Subject: [PATCH 42/87] chore: release (#4256) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 54 +++++++++++++++++------------------ Cargo.toml | 38 ++++++++++++------------ rs/hang/CHANGELOG.md | 10 +++++++ rs/hang/Cargo.toml | 2 +- rs/libmoq/CHANGELOG.md | 6 ++++ rs/libmoq/Cargo.toml | 2 +- rs/moq-archive/CHANGELOG.md | 6 ++++ rs/moq-archive/Cargo.toml | 2 +- rs/moq-audio/CHANGELOG.md | 6 ++++ rs/moq-audio/Cargo.toml | 2 +- rs/moq-auth/CHANGELOG.md | 14 +++++++++ rs/moq-auth/Cargo.toml | 2 +- rs/moq-binary/CHANGELOG.md | 6 ++++ rs/moq-binary/Cargo.toml | 2 +- rs/moq-boy/CHANGELOG.md | 6 ++++ rs/moq-boy/Cargo.toml | 2 +- rs/moq-cli/CHANGELOG.md | 8 ++++++ rs/moq-cli/Cargo.toml | 2 +- rs/moq-e2ee/CHANGELOG.md | 6 ++++ rs/moq-e2ee/Cargo.toml | 2 +- rs/moq-ffi/CHANGELOG.md | 11 +++++++ rs/moq-ffi/Cargo.toml | 2 +- rs/moq-gst/CHANGELOG.md | 6 ++++ rs/moq-gst/Cargo.toml | 2 +- rs/moq-hls/CHANGELOG.md | 6 ++++ rs/moq-hls/Cargo.toml | 2 +- rs/moq-json/CHANGELOG.md | 6 ++++ rs/moq-json/Cargo.toml | 2 +- rs/moq-loc/CHANGELOG.md | 6 ++++ rs/moq-loc/Cargo.toml | 2 +- rs/moq-mux/CHANGELOG.md | 11 +++++++ rs/moq-mux/Cargo.toml | 2 +- rs/moq-net/CHANGELOG.md | 16 +++++++++++ rs/moq-net/Cargo.toml | 2 +- rs/moq-pattern/CHANGELOG.md | 14 +++++++++ rs/moq-pattern/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 10 +++++++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-room/CHANGELOG.md | 6 ++++ rs/moq-room/Cargo.toml | 2 +- rs/moq-rtc/CHANGELOG.md | 6 ++++ rs/moq-rtc/Cargo.toml | 2 +- rs/moq-rtmp/CHANGELOG.md | 6 ++++ rs/moq-rtmp/Cargo.toml | 2 +- rs/moq-srt/CHANGELOG.md | 6 ++++ rs/moq-srt/Cargo.toml | 2 +- rs/moq-stats/CHANGELOG.md | 6 ++++ rs/moq-stats/Cargo.toml | 2 +- rs/moq-tokio/CHANGELOG.md | 14 +++++++++ rs/moq-tokio/Cargo.toml | 2 +- rs/moq-transcode/CHANGELOG.md | 6 ++++ rs/moq-transcode/Cargo.toml | 2 +- rs/moq-uring/CHANGELOG.md | 6 ++++ rs/moq-uring/Cargo.toml | 2 +- rs/moq-video/CHANGELOG.md | 6 ++++ rs/moq-video/Cargo.toml | 2 +- 56 files changed, 289 insertions(+), 73 deletions(-) create mode 100644 rs/moq-pattern/CHANGELOG.md diff --git a/Cargo.lock b/Cargo.lock index 39a13627cd..033acd5d5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.7" +version = "0.21.8" dependencies = [ "anyhow", "bytes", @@ -3884,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.7" +version = "0.6.8" dependencies = [ "anyhow", "bytes", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.7" +version = "0.0.8" dependencies = [ "async-trait", "bytes", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.6" +version = "0.1.7" dependencies = [ "block2 0.6.2", "bytes", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "moq-auth" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "aws-lc-rs", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.6" +version = "0.1.7" dependencies = [ "bytes", "kio 0.6.1", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "boytacean", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.7" +version = "0.12.8" dependencies = [ "anyhow", "axum", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.7" +version = "0.0.8" dependencies = [ "aws-lc-rs", "base64 0.23.1", @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.7" +version = "0.4.8" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4374,7 +4374,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.7" +version = "0.4.8" dependencies = [ "anyhow", "bytes", @@ -4393,7 +4393,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.7" +version = "0.5.8" dependencies = [ "axum", "bytes", @@ -4417,7 +4417,7 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.5.4" +version = "0.5.5" dependencies = [ "bytes", "criterion", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.15" +version = "0.2.16" dependencies = [ "bytes", "moq-net", @@ -4452,7 +4452,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.7" +version = "0.10.8" dependencies = [ "anyhow", "base64 0.23.1", @@ -4489,7 +4489,7 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.6" +version = "0.3.7" dependencies = [ "arrayvec", "bytes", @@ -4597,7 +4597,7 @@ dependencies = [ [[package]] name = "moq-pattern" -version = "0.1.0" +version = "0.1.1" dependencies = [ "moq-pattern", "serde", @@ -4606,7 +4606,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.7" +version = "0.15.8" dependencies = [ "anyhow", "axum", @@ -4649,7 +4649,7 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.7" +version = "0.2.8" dependencies = [ "kio 0.6.1", "moq-auth", @@ -4663,7 +4663,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.7" +version = "0.3.8" dependencies = [ "aws-lc-rs", "axum", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.7" +version = "0.3.8" dependencies = [ "anyhow", "byteorder", @@ -4731,7 +4731,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.7" +version = "0.3.8" dependencies = [ "bytes", "futures", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.7" +version = "0.2.8" dependencies = [ "futures", "moq-json", @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.18" +version = "0.19.19" dependencies = [ "anyhow", "bytes", @@ -4815,7 +4815,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "bytes", @@ -4834,7 +4834,7 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", "bytes", @@ -4891,7 +4891,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index 9ceb5a4d1b..65170e3b2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,7 +119,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.7", path = "rs/hang" } +hang = { version = "0.21.8", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" @@ -137,16 +137,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.6", path = "rs/moq-audio", default-features = false } -moq-auth = { version = "0.1.4", path = "rs/moq-auth" } -moq-binary = { version = "0.1.6", path = "rs/moq-binary" } +moq-audio = { version = "0.1.7", path = "rs/moq-audio", default-features = false } +moq-auth = { version = "0.1.5", path = "rs/moq-auth" } +moq-binary = { version = "0.1.7", path = "rs/moq-binary" } moq-flate = { version = "0.2.0", path = "rs/moq-flate" } -moq-hls = { version = "0.5.7", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.5.4", path = "rs/moq-json" } -moq-loc = { version = "0.2.15", path = "rs/moq-loc" } +moq-hls = { version = "0.5.8", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.5.5", path = "rs/moq-json" } +moq-loc = { version = "0.2.16", path = "rs/moq-loc" } moq-msf = { version = "0.5.1", path = "rs/moq-msf" } -moq-mux = { version = "0.10.7", path = "rs/moq-mux" } -moq-net = { version = "0.3.6", path = "rs/moq-net" } +moq-mux = { version = "0.10.8", path = "rs/moq-mux" } +moq-net = { version = "0.3.7", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. moq-noq-proto = { version = "1.3.1", default-features = false } @@ -155,24 +155,24 @@ moq-noq-udp = "1.3.1" # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. moq-nvenc = { version = "0.1.2", path = "rs/moq-nvenc" } -moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.7", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.7", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.7", path = "rs/moq-rtmp" } +moq-pattern = { version = "0.1.1", path = "rs/moq-pattern" } +moq-relay = { version = "0.15.8", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.8", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.8", path = "rs/moq-rtmp" } moq-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.7", path = "rs/moq-srt" } -moq-stats = { version = "0.2.7", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.18", path = "rs/moq-tokio", default-features = false } +moq-srt = { version = "0.3.8", path = "rs/moq-srt" } +moq-stats = { version = "0.2.8", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.19", path = "rs/moq-tokio", default-features = false } # Default features off on moq-transcode and moq-video so each workspace consumer # chooses native codecs, OpenH264, and rendering explicitly. Both crates still # provide working native plus software defaults when depended on directly. # VAAPI is opt-in everywhere; its decoder is hardware-validated, while its # encoder is not yet. -moq-transcode = { version = "0.1.6", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.7", path = "rs/moq-transcode", default-features = false } # default-features off (the noq backend) so the consumer picks which QUIC # stack the io_uring path compiles; cargo features are additive, so a default-on # backend could not be opted out of. -moq-uring = { version = "0.0.8", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.9", path = "rs/moq-uring", default-features = false } # In-tree fork of `v4l` with the videodev2.h bindings checked in, so moq-video's # `capture` and `v4l2` need no libclang or kernel headers. Linux only; an empty # stub elsewhere. @@ -185,7 +185,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.6", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.7", path = "rs/moq-video", default-features = false } nix = { version = "0.31.3", features = ["net", "socket", "uio"] } # Upstream noq-proto, only for iroh's controller factory types. noq-proto = { version = "1.2", default-features = false } diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index cb6915e4f3..9083efe9a8 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.8](https://github.com/moq-dev/moq/compare/hang-v0.21.7...hang-v0.21.8) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) + +### Fixed + +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + ## [0.21.7](https://github.com/moq-dev/moq/compare/hang-v0.21.6...hang-v0.21.7) - 2026-09-26 ### Added diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 0f8fc27b8d..6f9733a835 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.21.7" +version = "0.21.8" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index deb91a00f9..e633377dfc 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.8](https://github.com/moq-dev/moq/compare/libmoq-v0.6.7...libmoq-v0.6.8) - 2026-09-27 + +### Other + +- video resumes on a keyframe after discontinuity() ([#4285](https://github.com/moq-dev/moq/pull/4285)) + ## [0.6.7](https://github.com/moq-dev/moq/compare/libmoq-v0.6.6...libmoq-v0.6.7) - 2026-09-26 ### Added diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index f175436a0e..72ddaffaa8 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.7" +version = "0.6.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-archive/CHANGELOG.md b/rs/moq-archive/CHANGELOG.md index 131e05651e..45bbe508b0 100644 --- a/rs/moq-archive/CHANGELOG.md +++ b/rs/moq-archive/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.8](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.7...moq-archive-v0.0.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net + ## [0.0.7](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.6...moq-archive-v0.0.7) - 2026-09-26 ### Other diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index 855a28d3b3..6aaee0878b 100644 --- a/rs/moq-archive/Cargo.toml +++ b/rs/moq-archive/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.7" +version = "0.0.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index dcc8e8dd67..6d63a2edf1 100644 --- a/rs/moq-audio/CHANGELOG.md +++ b/rs/moq-audio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.6...moq-audio-v0.1.7) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.1.6](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.5...moq-audio-v0.1.6) - 2026-09-26 ### Other diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index f6126c6414..943fcf16c2 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.6" +version = "0.1.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-auth/CHANGELOG.md b/rs/moq-auth/CHANGELOG.md index c8a00c9909..20fd5c973d 100644 --- a/rs/moq-auth/CHANGELOG.md +++ b/rs/moq-auth/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.5](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.4...moq-auth-v0.1.5) - 2026-09-27 + +### Added + +- *(net)* the SETUP AUTHORIZATION TOKEN option reaches the verifier ([#4278](https://github.com/moq-dev/moq/pull/4278)) + +### Fixed + +- *(auth)* root public and mTLS rules at / ([#4318](https://github.com/moq-dev/moq/pull/4318)) + +### Other + +- *(auth)* run the outage grant test on the real clock ([#4291](https://github.com/moq-dev/moq/pull/4291)) + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.3...moq-auth-v0.1.4) - 2026-09-26 ### Fixed diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 71db9a0d87..bc1f934171 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.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-binary/CHANGELOG.md b/rs/moq-binary/CHANGELOG.md index 0ede8f673a..23d61fa49e 100644 --- a/rs/moq-binary/CHANGELOG.md +++ b/rs/moq-binary/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.6...moq-binary-v0.1.7) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) + ## [0.1.6](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.5...moq-binary-v0.1.6) - 2026-09-26 ### Other diff --git a/rs/moq-binary/Cargo.toml b/rs/moq-binary/Cargo.toml index a57c90cf24..439d2ea6eb 100644 --- a/rs/moq-binary/Cargo.toml +++ b/rs/moq-binary/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.6" +version = "0.1.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index 141ad2a5d5..5aed50492d 100644 --- a/rs/moq-boy/CHANGELOG.md +++ b/rs/moq-boy/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.8](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.7...moq-boy-v0.5.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, moq-json, hang, moq-mux, moq-tokio, moq-video, moq-audio + ## [0.5.7](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.6...moq-boy-v0.5.7) - 2026-09-26 ### Added diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 3609644d0d..71be7defbf 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" keywords = ["moq", "gameboy", "streaming", "emulator", "live"] categories = ["multimedia::video", "emulators", "network-programming"] -version = "0.5.7" +version = "0.5.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 7dda0a76c0..30b677f494 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.8](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.7...moq-cli-v0.12.8) - 2026-09-27 + +### Fixed + +- *(auth)* root public and mTLS rules at / ([#4318](https://github.com/moq-dev/moq/pull/4318)) +- *(cli)* close the relay connection on SIGINT and SIGTERM ([#4287](https://github.com/moq-dev/moq/pull/4287)) +- *(cli)* finish the catalog at stdin EOF ([#4303](https://github.com/moq-dev/moq/pull/4303)) + ## [0.12.7](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.6...moq-cli-v0.12.7) - 2026-09-26 ### Fixed diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 020952cb52..4078f83f2e 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.7" +version = "0.12.8" edition = "2024" # Depends on moq-relay, whose sysinfo 0.39 needs 1.95, above the 1.91 workspace # floor. This binary is an application, so the bump stays here rather than diff --git a/rs/moq-e2ee/CHANGELOG.md b/rs/moq-e2ee/CHANGELOG.md index f72b7acdce..e51500a8ed 100644 --- a/rs/moq-e2ee/CHANGELOG.md +++ b/rs/moq-e2ee/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.8](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.7...moq-e2ee-v0.0.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net + ## [0.0.7](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.6...moq-e2ee-v0.0.7) - 2026-09-26 ### Other diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index faabbf35f5..7ae4e28b5e 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.7" +version = "0.0.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index ba27f82e16..982b8aa471 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.8](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.7...moq-ffi-v0.4.8) - 2026-09-27 + +### Added + +- *(kt)* end a broadcast with end() ([#4259](https://github.com/moq-dev/moq/pull/4259)) + +### Other + +- fix stale agent rules, the moq-net hop range, and the ffi unannounce doc ([#4305](https://github.com/moq-dev/moq/pull/4305)) +- video resumes on a keyframe after discontinuity() ([#4285](https://github.com/moq-dev/moq/pull/4285)) + ## [0.4.7](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.6...moq-ffi-v0.4.7) - 2026-09-26 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 8c1d26b37b..7238d5dad4 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.7" +version = "0.4.8" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 2d816fb588..f8d3f075ee 100644 --- a/rs/moq-gst/CHANGELOG.md +++ b/rs/moq-gst/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.8](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.7...moq-gst-v0.4.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux, moq-tokio + ## [0.4.7](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.6...moq-gst-v0.4.7) - 2026-09-26 ### Added diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 9aaca91417..7adbdccd52 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.7" +version = "0.4.8" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index b3af4e69f6..e8c4060921 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.8](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.7...moq-hls-v0.5.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.5.7](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.6...moq-hls-v0.5.7) - 2026-09-26 ### Added diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 4b07c4b955..47b2a92d4d 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.7" +version = "0.5.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index 3500ad22c6..3fe1b6d5a7 100644 --- a/rs/moq-json/CHANGELOG.md +++ b/rs/moq-json/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.5](https://github.com/moq-dev/moq/compare/moq-json-v0.5.4...moq-json-v0.5.5) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) + ## [0.5.4](https://github.com/moq-dev/moq/compare/moq-json-v0.5.3...moq-json-v0.5.4) - 2026-09-26 ### Other diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 6d79a3f605..62dc702b48 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.4" +version = "0.5.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-loc/CHANGELOG.md b/rs/moq-loc/CHANGELOG.md index b2f1a45f9b..851eb1a60d 100644 --- a/rs/moq-loc/CHANGELOG.md +++ b/rs/moq-loc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.16](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.15...moq-loc-v0.2.16) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net + ## [0.2.15](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.14...moq-loc-v0.2.15) - 2026-09-26 ### Other diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index b30f2a704b..ca491c7795 100644 --- a/rs/moq-loc/Cargo.toml +++ b/rs/moq-loc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.15" +version = "0.2.16" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index 096436aa1b..f4ef743a18 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.8](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.7...moq-mux-v0.10.8) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) + +### Fixed + +- *(fmp4)* carry Opus pre-skip and gain through dOps ([#4294](https://github.com/moq-dev/moq/pull/4294)) +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + ## [0.10.7](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.6...moq-mux-v0.10.7) - 2026-09-26 ### Added diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 46cad13147..315fad5b99 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.7" +version = "0.10.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 89eaa3be34..e60aef120e 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.7](https://github.com/moq-dev/moq/compare/moq-net-v0.3.6...moq-net-v0.3.7) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) +- *(net)* the SETUP AUTHORIZATION TOKEN option reaches the verifier ([#4278](https://github.com/moq-dev/moq/pull/4278)) + +### Fixed + +- *(net)* settle lite-07 tails on stream counts ([#4224](https://github.com/moq-dev/moq/pull/4224)) +- *(net)* announce a covering route to a narrower prefix instead of panicking ([#4302](https://github.com/moq-dev/moq/pull/4302)) + +### Other + +- fix stale agent rules, the moq-net hop range, and the ffi unannounce doc ([#4305](https://github.com/moq-dev/moq/pull/4305)) + ## [0.3.6](https://github.com/moq-dev/moq/compare/moq-net-v0.3.5...moq-net-v0.3.6) - 2026-09-26 ### Added diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 4c29366281..967afb259d 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.6" +version = "0.3.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-pattern/CHANGELOG.md b/rs/moq-pattern/CHANGELOG.md new file mode 100644 index 0000000000..56c0b699cd --- /dev/null +++ b/rs/moq-pattern/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.1](https://github.com/moq-dev/moq/compare/moq-pattern-v0.1.0...moq-pattern-v0.1.1) - 2026-09-27 + +### Fixed + +- *(pattern)* subtree of a max-depth path is the path itself ([#4284](https://github.com/moq-dev/moq/pull/4284)) diff --git a/rs/moq-pattern/Cargo.toml b/rs/moq-pattern/Cargo.toml index bcaf3935af..6a3d8af3ba 100644 --- a/rs/moq-pattern/Cargo.toml +++ b/rs/moq-pattern/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 191bfeb6a0..01140559d4 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.8](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.7...moq-relay-v0.15.8) - 2026-09-27 + +### Added + +- *(net)* the SETUP AUTHORIZATION TOKEN option reaches the verifier ([#4278](https://github.com/moq-dev/moq/pull/4278)) + +### Fixed + +- *(auth)* root public and mTLS rules at / ([#4318](https://github.com/moq-dev/moq/pull/4318)) + ## [0.15.7](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.6...moq-relay-v0.15.7) - 2026-09-26 ### Added diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index ea8a6ad3b2..8d7e7edece 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.15.7" +version = "0.15.8" edition = "2024" # sysinfo 0.39 (cache governor cgroup limits) needs 1.95, above the 1.91 # workspace floor. moq-relay is lib+bin, so this applies to its library target diff --git a/rs/moq-room/CHANGELOG.md b/rs/moq-room/CHANGELOG.md index 20d2c5c7b9..8f4460b081 100644 --- a/rs/moq-room/CHANGELOG.md +++ b/rs/moq-room/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.8](https://github.com/moq-dev/moq/compare/moq-room-v0.2.7...moq-room-v0.2.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, moq-json, moq-auth + ## [0.2.7](https://github.com/moq-dev/moq/compare/moq-room-v0.2.6...moq-room-v0.2.7) - 2026-09-26 ### Other diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index 244068734d..e7e0fc2be6 100644 --- a/rs/moq-room/Cargo.toml +++ b/rs/moq-room/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.7" +version = "0.2.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index 5a0eb86d6a..c5a89cf070 100644 --- a/rs/moq-rtc/CHANGELOG.md +++ b/rs/moq-rtc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.8](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.7...moq-rtc-v0.3.8) - 2026-09-27 + +### Fixed + +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + ## [0.3.7](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.6...moq-rtc-v0.3.7) - 2026-09-26 ### Added diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index 57db95df4d..6317324ae8 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.7" +version = "0.3.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtmp/CHANGELOG.md b/rs/moq-rtmp/CHANGELOG.md index 872863d765..f81ac82a2c 100644 --- a/rs/moq-rtmp/CHANGELOG.md +++ b/rs/moq-rtmp/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.8](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.7...moq-rtmp-v0.3.8) - 2026-09-27 + +### Fixed + +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + ## [0.3.7](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.6...moq-rtmp-v0.3.7) - 2026-09-26 ### Added diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index b7d4d371f0..1057d73d9d 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/moq-dev/moq" # src/rml/LICENSE and applies to that module regardless of which option you pick. license = "MIT OR Apache-2.0" -version = "0.3.7" +version = "0.3.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-srt/CHANGELOG.md b/rs/moq-srt/CHANGELOG.md index 2daf6a367d..9d9defe076 100644 --- a/rs/moq-srt/CHANGELOG.md +++ b/rs/moq-srt/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.8](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.7...moq-srt-v0.3.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, moq-mux + ## [0.3.7](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.6...moq-srt-v0.3.7) - 2026-09-26 ### Added diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index 642cd04f0c..44dd4bba92 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.7" +version = "0.3.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index 55e8abedf3..4143caec72 100644 --- a/rs/moq-stats/CHANGELOG.md +++ b/rs/moq-stats/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.8](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.7...moq-stats-v0.2.8) - 2026-09-27 + +### Fixed + +- *(stats)* keep an idle path in the frame while its counters live ([#4299](https://github.com/moq-dev/moq/pull/4299)) + ## [0.2.7](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.6...moq-stats-v0.2.7) - 2026-09-26 ### Added diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index 267ae90289..d2ba4ac428 100644 --- a/rs/moq-stats/Cargo.toml +++ b/rs/moq-stats/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.7" +version = "0.2.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index 1b43f4157c..d89727bf47 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.19](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.18...moq-tokio-v0.19.19) - 2026-09-27 + +### Added + +- *(net)* the SETUP AUTHORIZATION TOKEN option reaches the verifier ([#4278](https://github.com/moq-dev/moq/pull/4278)) + +### Fixed + +- *(cli)* close the relay connection on SIGINT and SIGTERM ([#4287](https://github.com/moq-dev/moq/pull/4287)) + +### Other + +- fix three load-only test failures at the cause ([#4286](https://github.com/moq-dev/moq/pull/4286)) + ## [0.19.18](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.17...moq-tokio-v0.19.18) - 2026-09-26 ### Added diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 34865d2707..0e3a459dc2 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.19.18" +version = "0.19.19" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index 506e11faf3..a8fe965b83 100644 --- a/rs/moq-transcode/CHANGELOG.md +++ b/rs/moq-transcode/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.6...moq-transcode-v0.1.7) - 2026-09-27 + +### Fixed + +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + ## [0.1.6](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.5...moq-transcode-v0.1.6) - 2026-09-26 ### Added diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index 521e559ce7..2c34597c75 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.6" +version = "0.1.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-uring/CHANGELOG.md b/rs/moq-uring/CHANGELOG.md index bc5972bfab..7e87c776df 100644 --- a/rs/moq-uring/CHANGELOG.md +++ b/rs/moq-uring/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.9](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.8...moq-uring-v0.0.9) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net + ## [0.0.8](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.7...moq-uring-v0.0.8) - 2026-09-26 ### Other diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index cc48a21633..af9e9fd8e0 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.8" +version = "0.0.9" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index df045937e8..70f717d661 100644 --- a/rs/moq-video/CHANGELOG.md +++ b/rs/moq-video/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7](https://github.com/moq-dev/moq/compare/moq-video-v0.1.6...moq-video-v0.1.7) - 2026-09-27 + +### Fixed + +- *(video)* keep the CUDA context alive until the NVDEC decoder is destroyed ([#4290](https://github.com/moq-dev/moq/pull/4290)) + ## [0.1.6](https://github.com/moq-dev/moq/compare/moq-video-v0.1.5...moq-video-v0.1.6) - 2026-09-26 ### Added diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 4a63a86d17..ffb07201cb 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.6" +version = "0.1.7" edition = "2024" rust-version.workspace = true From 5908d358c6e31f831457305167b7daa9eb40db22 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 16:07:07 -0700 Subject: [PATCH 43/87] fix(wasm): make free() safe while a call is pending (#4314) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + js/wasm/README.md | 3 + rs/moq-wasm/Cargo.toml | 7 +- rs/moq-wasm/src/lib.rs | 182 ++++++++++++++++++++++++++++++----------- test/wasm/README.md | 7 ++ test/wasm/src/main.ts | 67 +++++++++++++++ 6 files changed, 218 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 033acd5d5d..f2d8721dd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4944,6 +4944,7 @@ name = "moq-wasm" version = "0.0.0" dependencies = [ "console_error_panic_hook", + "futures", "getrandom 0.4.3", "js-sys", "moq-net", diff --git a/js/wasm/README.md b/js/wasm/README.md index b836032e6c..a3f2c2f294 100644 --- a/js/wasm/README.md +++ b/js/wasm/README.md @@ -24,6 +24,9 @@ for (let group = await track?.recvGroup(); group; group = await track?.recvGroup The classes (`Moq.Session`, `Moq.Broadcast`, `Moq.Track`, `Moq.Group`) drop the `Moq` prefix since they're already namespaced under the import. +`free()` is safe while a call is pending, and rejects that handle's pending +calls. Freeing a `Session` also closes it. + ## Building `dist/` is generated, not committed. Build it from the repo root: diff --git a/rs/moq-wasm/Cargo.toml b/rs/moq-wasm/Cargo.toml index 79f41629b9..8e72fc84a9 100644 --- a/rs/moq-wasm/Cargo.toml +++ b/rs/moq-wasm/Cargo.toml @@ -9,9 +9,9 @@ edition = "2024" publish = false [package.metadata.cargo-shear] -# Required indirectly (getrandom: feature enabler; wasm-bindgen-futures: async -# codegen) with no `use`, so cargo-shear can't see them. -ignored = ["getrandom", "wasm-bindgen-futures"] +# Required indirectly (getrandom: feature enabler) with no `use`, so cargo-shear +# can't see it. +ignored = ["getrandom"] [lib] crate-type = ["cdylib", "rlib"] @@ -26,6 +26,7 @@ crate-type = ["cdylib", "rlib"] # cfg in those crates is a prerequisite for browser media muxing. [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1" +futures = { workspace = true } getrandom = { workspace = true } # wasm_js backend for rand-via-moq-net; cfg flag in .cargo/config.toml js-sys = "0.3" moq-net = { workspace = true } diff --git a/rs/moq-wasm/src/lib.rs b/rs/moq-wasm/src/lib.rs index 9d2a392f05..c4cabe2fdf 100644 --- a/rs/moq-wasm/src/lib.rs +++ b/rs/moq-wasm/src/lib.rs @@ -11,15 +11,26 @@ //! //! `moq_net::time::run` drives moq-net with the browser clock and timer; this //! crate spawns it on the browser's microtask queue. +//! +//! Methods that wait return a `Promise` over cloned state rather than being an +//! `async fn(&self)`: wasm-bindgen keeps `&self` borrowed across such a method's +//! await, and a JS `free()` during it throws from inside Rust, which unwinds past +//! the shadow stack and corrupts the wasm heap. Freeing a handle rejects its +//! pending calls with a cancel instead; freeing the `Session` also closes it. // Browser-only crate. Empty on native so `cargo check --workspace` stays green. #![cfg(target_arch = "wasm32")] use std::cell::RefCell; +use std::pin::pin; use std::rc::Rc; -use js_sys::Uint8Array; +use futures::FutureExt; +use futures::channel::oneshot; +use futures::future::{Either, Shared, select}; +use js_sys::{Promise, Uint8Array}; use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::future_to_promise; pub mod transport; @@ -28,6 +39,42 @@ fn js_err(e: impl std::fmt::Display) -> JsValue { JsError::new(&e.to_string()).into() } +/// Rejects a handle's pending calls once JS frees the handle. +/// +/// The handle owns the sender; each pending call races a clone of the receiver, +/// which resolves when the sender drops with the handle. +struct Freed { + _handle: oneshot::Sender<()>, + signal: Shared>, +} + +impl Freed { + fn new() -> Self { + let (handle, signal) = oneshot::channel(); + Self { + _handle: handle, + signal: signal.shared(), + } + } + + /// Run `task`, rejecting with a cancel if the handle is freed first. + /// + /// The signal is polled first, so a freed handle rejects even when `task` could + /// finish on its first poll; dropping `task` releases whatever it had taken. + fn guard(&self, task: F) -> impl Future> + use + where + F: Future>, + { + let freed = self.signal.clone(); + async move { + match select(freed, pin!(task)).await { + Either::Left(_) => Err(js_err(moq_net::Error::Cancel)), + Either::Right((result, _)) => result, + } + } + } +} + /// Install panic + tracing hooks for readable errors. Call once after the wasm /// module's default `init()` loader resolves. (Named `setup` to avoid colliding /// with wasm-bindgen's default `init` export, which loads the module itself.) @@ -96,19 +143,44 @@ impl Session { /// Reject when the session closes, with the reason it closed. /// /// Every close carries a reason, including a clean one, so this never resolves. - pub async fn closed(&self) -> Result<(), JsValue> { - Err(js_err(self.inner.closed().await)) + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn closed(&self) -> Promise { + let session = self.inner.clone(); + future_to_promise(async move { Err(js_err(session.closed().await)) }) } /// Subscribe to a broadcast by path, waiting until a route covers it. - pub async fn consume(&self, path: String) -> Result, JsValue> { - if self.consumer.routed(path.as_str()).await.is_none() { - return Ok(None); - } - match self.consumer.request_broadcast(path.as_str()).await { - Ok(inner) => Ok(Some(Broadcast { inner })), - Err(_) => Ok(None), - } + /// + /// Rejects with the close reason if the session closes first. + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn consume(&self, path: String) -> Promise { + let session = self.inner.clone(); + let consumer = self.consumer.clone(); + future_to_promise(async move { + let request = pin!(async { + consumer.routed(path.as_str()).await?; + consumer.request_broadcast(path.as_str()).await.ok() + }); + // The origin outlives the session, so its wait alone never ends on a close. + let closed = pin!(session.closed()); + match select(request, closed).await { + Either::Left((inner, _)) => Ok(inner.map_or(JsValue::UNDEFINED, |inner| { + Broadcast { + inner, + freed: Freed::new(), + } + .into() + })), + Either::Right((err, _)) => Err(js_err(err)), + } + }) + } +} + +impl Drop for Session { + // Pending calls hold their own clones, so the close-on-last-drop would wait for them. + fn drop(&mut self) { + self.inner.abort(moq_net::Error::Cancel); } } @@ -116,48 +188,60 @@ impl Session { #[wasm_bindgen] pub struct Broadcast { inner: moq_net::broadcast::Consumer, + freed: Freed, } #[wasm_bindgen] impl Broadcast { /// Subscribe to a track by name, resolving once the publisher accepts. - pub async fn subscribe(&self, name: String) -> Result { - let track = self.inner.track(&name).map_err(js_err)?; - let subscriber = track.subscribe(None).await.map_err(js_err)?; - Ok(Track { - inner: Rc::new(RefCell::new(Some(subscriber))), - }) + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn subscribe(&self, name: String) -> Promise { + let broadcast = self.inner.clone(); + future_to_promise(self.freed.guard(async move { + let track = broadcast.track(&name).map_err(js_err)?; + let subscriber = track.subscribe(None).await.map_err(js_err)?; + Ok(Track { + inner: Rc::new(RefCell::new(Some(subscriber))), + freed: Freed::new(), + } + .into()) + })) } } /// A subscriber to a single track, yielding groups. #[wasm_bindgen] pub struct Track { - // Rc>> for interior mutability: wasm-bindgen async methods - // take `&self` and must produce 'static futures, so we move the value out of - // the cell for the duration of the await rather than holding a borrow across - // it (which would make the future self-referential). One in-flight call at a - // time; a re-entrant call while one is pending errors instead of aliasing. + // Shared with the pending read, which moves the subscriber out of the cell for + // the duration of the await instead of holding a borrow across it. One read in + // flight at a time; a concurrent call errors instead of aliasing. inner: Rc>>, + freed: Freed, } #[wasm_bindgen] impl Track { /// Receive the next group in arrival order, or `null` when the track ends. - #[wasm_bindgen(js_name = recvGroup)] - pub async fn recv_group(&self) -> Result, JsValue> { + #[wasm_bindgen(js_name = recvGroup, unchecked_return_type = "Promise")] + pub fn recv_group(&self) -> Promise { let cell = self.inner.clone(); - let mut sub = cell - .borrow_mut() - .take() - .ok_or_else(|| js_err("recvGroup already in progress"))?; - let result = sub.recv_group().await; - *cell.borrow_mut() = Some(sub); - - let group = result.map_err(js_err)?; - Ok(group.map(|g| Group { - sequence: g.sequence, - inner: Rc::new(RefCell::new(Some(g))), + future_to_promise(self.freed.guard(async move { + let mut sub = cell + .borrow_mut() + .take() + .ok_or_else(|| js_err("recvGroup already in progress"))?; + let result = sub.recv_group().await; + *cell.borrow_mut() = Some(sub); + + let group = result.map_err(js_err)?; + Ok(group.map_or(JsValue::UNDEFINED, |g| { + Group { + sequence: g.sequence, + inner: Rc::new(RefCell::new(Some(g))), + freed: Freed::new(), + } + .into() + })) })) } } @@ -166,7 +250,9 @@ impl Track { #[wasm_bindgen] pub struct Group { sequence: u64, + // Shared with the pending read; see `Track`. inner: Rc>>, + freed: Freed, } #[wasm_bindgen] @@ -177,17 +263,21 @@ impl Group { } /// Read the next frame in the group, or `null` at the end of the group. - #[wasm_bindgen(js_name = readFrame)] - pub async fn read_frame(&self) -> Result, JsValue> { + #[wasm_bindgen(js_name = readFrame, unchecked_return_type = "Promise")] + pub fn read_frame(&self) -> Promise { let cell = self.inner.clone(); - let mut group = cell - .borrow_mut() - .take() - .ok_or_else(|| js_err("readFrame already in progress"))?; - let result = group.read_frame().await; - *cell.borrow_mut() = Some(group); - - let frame = result.map_err(js_err)?; - Ok(frame.map(|frame| Uint8Array::from(frame.payload.as_ref()))) + future_to_promise(self.freed.guard(async move { + let mut group = cell + .borrow_mut() + .take() + .ok_or_else(|| js_err("readFrame already in progress"))?; + let result = group.read_frame().await; + *cell.borrow_mut() = Some(group); + + let frame = result.map_err(js_err)?; + Ok(frame.map_or(JsValue::UNDEFINED, |frame| { + Uint8Array::from(frame.payload.as_ref()).into() + })) + })) } } diff --git a/test/wasm/README.md b/test/wasm/README.md index a1eb599b49..a65cb85dd8 100644 --- a/test/wasm/README.md +++ b/test/wasm/README.md @@ -77,6 +77,13 @@ uncovered here. surface, as a rejected `subscribe` (lite-06, which looks a track's info up first) or as a track that yields no group (IETF, lite-02). A hang fails on the case timeout. +- **free() rejects a pending consume** -- freeing a session closes it, so a + `consume` still waiting on an unannounced path has to reject. A fresh session + then has to connect, which a corrupted wasm heap would not. +- **free() cancels a pending read** -- freeing a `Broadcast`, `Track`, or + `Group` while its call is pending rejects the call, even when data is already + buffered, and the page keeps working. An `async fn(&self)` binding fails both + cases, since wasm-bindgen keeps `&self` borrowed across the await. ### Known failures diff --git a/test/wasm/src/main.ts b/test/wasm/src/main.ts index 70585a9d52..85ca740bbb 100644 --- a/test/wasm/src/main.ts +++ b/test/wasm/src/main.ts @@ -354,8 +354,75 @@ const CASES: Case[] = [ }); }, }, + { + // Freeing a session closes it, so a call still waiting on it has to reject + // rather than hang or take the page's wasm down with it. + name: "free() rejects a pending consume", + run: async (wasm, relay) => { + const session = await connect(wasm, relay); + const pending = session.consume(`wasm-test/${relay.name}-never-announced`); + session.free(); + const resolved = await pending.then( + (broadcast) => { + broadcast?.free(); + return true; + }, + () => false, + ); + if (resolved) throw new Error("consume resolved after the session was freed, want a rejection"); + await expectUsable(wasm, relay); + }, + }, + { + // Freeing a handle cancels its pending call, even one that could finish + // right away, and releases what the call held; the page keeps working. + name: "free() cancels a pending read", + run: async (wasm, relay) => { + const path = `wasm-test/${relay.name}-free`; + await withPublisher(relay, path, async () => { + await withSession(wasm, relay, async (session) => { + const broadcast = await consume(session, path); + const track = await broadcast.subscribe(TRACK); + const pendingTrack = broadcast.subscribe(TRACK); + broadcast.free(); + await expectCancelled("subscribe", pendingTrack, (track) => track.free()); + + const group = await track.recvGroup(); + if (!group) throw new Error("track ended before its first group"); + const pendingGroup = track.recvGroup(); + track.free(); + await expectCancelled("recvGroup", pendingGroup, (group) => group?.free()); + + const frame = group.readFrame(); + group.free(); + await expectCancelled("readFrame", frame, () => {}); + }); + }); + await expectUsable(wasm, relay); + }, + }, ]; +/** Fail unless `pending` rejects, releasing whatever it resolved with instead. */ +async function expectCancelled(what: string, pending: Promise, release: (value: T) => void): Promise { + const resolved = await pending.then( + (value) => { + release(value); + return true; + }, + () => false, + ); + if (resolved) throw new Error(`${what} resolved after its handle was freed, want a rejection`); +} + +/** Fail unless a fresh session still works, which a corrupted wasm heap would not. */ +async function expectUsable(wasm: Wasm, relay: RelayFixture): Promise { + await withSession(wasm, relay, async (session) => { + const version = session.version(); + if (version !== relay.version) throw new Error(`version is "${version}", want "${relay.version}"`); + }); +} + /** Run every case against every relay, sequentially, and report each outcome. */ async function run(config: Config): Promise { const wasm = await load(config.module); From 735bc0e1f98c2379648dc2934faa33a064a45f1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:14:30 +0000 Subject: [PATCH 44/87] chore(deps): bump the bun group across 4 directories with 9 updates (#4312) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Luke Curley Co-authored-by: Claude Opus 5.5 --- bun.lock | 68 ++++++++++----------- demo/pub/bun.lock | 18 +++--- doc/package.json | 4 +- infra/apt/bun.lock | 18 +++--- infra/rpm/bun.lock | 18 +++--- js/auth/package.json | 2 +- js/clock/package.json | 2 +- js/hang/package.json | 4 +- js/net/package.json | 2 +- js/net/src/lite/publisher.ts | 2 +- js/publish/package.json | 2 +- package.json | 4 +- test/interop/clients/js-native/package.json | 2 +- 13 files changed, 73 insertions(+), 73 deletions(-) diff --git a/bun.lock b/bun.lock index 4005df50c8..661ebc2605 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "moq", "devDependencies": { - "@babel/parser": "^8.0.5", - "@biomejs/biome": "^2.5.13", + "@babel/parser": "^8.0.6", + "@biomejs/biome": "^2.5.14", "concurrently": "^10.0.5", "markdown-extensions": "^2.0.0", "publint": "^0.3.24", @@ -64,11 +64,11 @@ "version": "0.1.0", "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "typescript": "7.0.2", "vitepress": "^1.6.4", "vitepress-plugin-llms": "^1.14.0", - "wrangler": "^4.131.2", + "wrangler": "^4.135.0", "yaml": "^2.9.1", }, }, @@ -88,7 +88,7 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "rimraf": "^6.1.3", "typescript": "7.0.2", }, @@ -119,7 +119,7 @@ "@moq/net": "workspace:*", }, "devDependencies": { - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "typescript": "7.0.2", }, }, @@ -146,8 +146,8 @@ "@moq/loc": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "@svta/cml-iso-bmff": "^1.0.5", - "@svta/cml-utils": "1.6.0", + "@svta/cml-iso-bmff": "^1.0.6", + "@svta/cml-utils": "1.6.1", "@zod/mini": "^4.6.5", "zod": "^4.6.5", }, @@ -236,7 +236,7 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", "typescript": "7.0.2", @@ -264,7 +264,7 @@ "@moq/json": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "mediabunny": "^1.56.2", + "mediabunny": "^1.58.1", }, "devDependencies": { "@types/audioworklet": "^0.0.100", @@ -368,7 +368,7 @@ "zod": "^4.6.5", }, "devDependencies": { - "tsx": "^4.23.13", + "tsx": "^4.23.15", }, }, "test/wasm": { @@ -450,7 +450,7 @@ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@8.0.5", "", { "dependencies": { "@babel/types": "^8.0.5" }, "bin": "./bin/babel-parser.js" }, "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw=="], + "@babel/parser": ["@babel/parser@8.0.6", "", { "dependencies": { "@babel/types": "^8.0.6" }, "bin": "./bin/babel-parser.js" }, "sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], @@ -460,37 +460,37 @@ "@babel/types": ["@babel/types@8.0.6", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.6", "@babel/helper-validator-identifier": "^8.0.6" } }, "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A=="], - "@biomejs/biome": ["@biomejs/biome@2.5.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.13", "@biomejs/cli-darwin-x64": "2.5.13", "@biomejs/cli-linux-arm64": "2.5.13", "@biomejs/cli-linux-arm64-musl": "2.5.13", "@biomejs/cli-linux-x64": "2.5.13", "@biomejs/cli-linux-x64-musl": "2.5.13", "@biomejs/cli-win32-arm64": "2.5.13", "@biomejs/cli-win32-x64": "2.5.13" }, "bin": { "biome": "bin/biome" } }, "sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.14", "@biomejs/cli-darwin-x64": "2.5.14", "@biomejs/cli-linux-arm64": "2.5.14", "@biomejs/cli-linux-arm64-musl": "2.5.14", "@biomejs/cli-linux-x64": "2.5.14", "@biomejs/cli-linux-x64-musl": "2.5.14", "@biomejs/cli-win32-arm64": "2.5.14", "@biomejs/cli-win32-x64": "2.5.14" }, "bin": { "biome": "bin/biome" } }, "sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.13", "", { "os": "win32", "cpu": "x64" }, "sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260911.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260911.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260911.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260911.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260911.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -832,9 +832,9 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], - "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.5", "", { "peerDependencies": { "@svta/cml-utils": "1.6.0" } }, "sha512-9JM/i5q81/ckFb0V5z2gESX4ICyoBOCwYhLmltPgQXNo55DenORjfY8czgSdlGuMOKxyX1yvwe/OpY4twmqT2g=="], + "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.6", "", { "peerDependencies": { "@svta/cml-utils": "1.6.1" } }, "sha512-R5QvYqaQzmEexWRqN5bPXmPSwIADKg9VDFTK1cgFg946rAoFuTu7+0g3pCg1ietQvoCFqNHA5fIoCVh6daddgg=="], - "@svta/cml-utils": ["@svta/cml-utils@1.6.0", "", {}, "sha512-h8jd5Y0nrr8wHWkI7XB03Denxy6QR7dgROxchZ2S0VQ4PFINrIu6J0k9N1IGHmtJnJjG3ttszsJd4LS5V/DqcA=="], + "@svta/cml-utils": ["@svta/cml-utils@1.6.1", "", {}, "sha512-jPKb+it53Pi8rbO8fNX8qpbdE+JfDAeOXF7lcG+uVU5BCcR0wbMTT2VtFdMeVsUN57TFPAurRavE3aEUSzC+zw=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -906,7 +906,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], @@ -1402,7 +1402,7 @@ "media-captions": ["media-captions@0.0.18", "", {}, "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ=="], - "mediabunny": ["mediabunny@1.56.2", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-KrrL2Hr47q+IXS19BVWJyYMw1Wb8gz8FkeKP/+ui7q8tPt0lfaio2d9CaGkEWFghaD02wNae/HgYBZTcg5RyZg=="], + "mediabunny": ["mediabunny@1.58.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-edscMXOwBgUlMT+45wpAHdiXTGukCQb1nOJPE2D+fM4esNhAvGyB2yGGgnEer2VSwhEg+7gMliZDDQQqLaR7nA=="], "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], @@ -1458,7 +1458,7 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@5.20260911.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ=="], + "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -1796,7 +1796,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + "tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], @@ -1880,9 +1880,9 @@ "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "workerd": ["workerd@1.20260911.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260911.1", "@cloudflare/workerd-darwin-arm64": "1.20260911.1", "@cloudflare/workerd-linux-64": "1.20260911.1", "@cloudflare/workerd-linux-arm64": "1.20260911.1", "@cloudflare/workerd-windows-64": "1.20260911.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ=="], + "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "wrangler": ["wrangler@4.131.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260911.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260911.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260911.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg=="], + "wrangler": ["wrangler@4.135.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/demo/pub/bun.lock b/demo/pub/bun.lock index 8a83558a98..f88a9755d4 100644 --- a/demo/pub/bun.lock +++ b/demo/pub/bun.lock @@ -15,17 +15,17 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260911.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260911.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260911.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260911.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260911.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260915.1", "", {}, "sha512-3Lw2wkyVcEDdD0W6rNBmannaAkUsWGsO5TjbyHflWIDlhxtFRDFnxqHoDlg4YiVxinsIcBl1j9jW1kdruUhZpg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260920.1", "", {}, "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -167,7 +167,7 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "miniflare": ["miniflare@5.20260911.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ=="], + "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -185,9 +185,9 @@ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "workerd": ["workerd@1.20260911.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260911.1", "@cloudflare/workerd-darwin-arm64": "1.20260911.1", "@cloudflare/workerd-linux-64": "1.20260911.1", "@cloudflare/workerd-linux-arm64": "1.20260911.1", "@cloudflare/workerd-windows-64": "1.20260911.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ=="], + "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "wrangler": ["wrangler@4.131.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260911.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260911.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260911.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg=="], + "wrangler": ["wrangler@4.135.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], diff --git a/doc/package.json b/doc/package.json index 5bd4adf433..7900d0cada 100644 --- a/doc/package.json +++ b/doc/package.json @@ -13,11 +13,11 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "typescript": "7.0.2", "vitepress": "^1.6.4", "vitepress-plugin-llms": "^1.14.0", - "wrangler": "^4.131.2", + "wrangler": "^4.135.0", "yaml": "^2.9.1" } } diff --git a/infra/apt/bun.lock b/infra/apt/bun.lock index b7b6e1f8f6..201d5b4eeb 100644 --- a/infra/apt/bun.lock +++ b/infra/apt/bun.lock @@ -16,17 +16,17 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260911.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260911.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260911.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260911.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260911.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260915.1", "", {}, "sha512-3Lw2wkyVcEDdD0W6rNBmannaAkUsWGsO5TjbyHflWIDlhxtFRDFnxqHoDlg4YiVxinsIcBl1j9jW1kdruUhZpg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260920.1", "", {}, "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -208,7 +208,7 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "miniflare": ["miniflare@5.20260911.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ=="], + "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -228,9 +228,9 @@ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "workerd": ["workerd@1.20260911.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260911.1", "@cloudflare/workerd-darwin-arm64": "1.20260911.1", "@cloudflare/workerd-linux-64": "1.20260911.1", "@cloudflare/workerd-linux-arm64": "1.20260911.1", "@cloudflare/workerd-windows-64": "1.20260911.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ=="], + "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "wrangler": ["wrangler@4.131.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260911.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260911.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260911.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg=="], + "wrangler": ["wrangler@4.135.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], diff --git a/infra/rpm/bun.lock b/infra/rpm/bun.lock index 10c9b93f67..c1a90063a2 100644 --- a/infra/rpm/bun.lock +++ b/infra/rpm/bun.lock @@ -16,17 +16,17 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260911.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260911.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260911.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260911.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260911.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260915.1", "", {}, "sha512-3Lw2wkyVcEDdD0W6rNBmannaAkUsWGsO5TjbyHflWIDlhxtFRDFnxqHoDlg4YiVxinsIcBl1j9jW1kdruUhZpg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260920.1", "", {}, "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -208,7 +208,7 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "miniflare": ["miniflare@5.20260911.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ=="], + "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -228,9 +228,9 @@ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "workerd": ["workerd@1.20260911.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260911.1", "@cloudflare/workerd-darwin-arm64": "1.20260911.1", "@cloudflare/workerd-linux-64": "1.20260911.1", "@cloudflare/workerd-linux-arm64": "1.20260911.1", "@cloudflare/workerd-windows-64": "1.20260911.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ=="], + "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "wrangler": ["wrangler@4.131.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260911.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260911.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260911.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg=="], + "wrangler": ["wrangler@4.135.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], diff --git a/js/auth/package.json b/js/auth/package.json index 60f2a21c2d..7c930f97e4 100644 --- a/js/auth/package.json +++ b/js/auth/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "rimraf": "^6.1.3", "typescript": "7.0.2" } diff --git a/js/clock/package.json b/js/clock/package.json index 6444a36234..74e2c9e7a8 100644 --- a/js/clock/package.json +++ b/js/clock/package.json @@ -26,7 +26,7 @@ "@fails-components/webtransport-transport-http3-quiche": "^1.6.8" }, "devDependencies": { - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "typescript": "7.0.2" } } diff --git a/js/hang/package.json b/js/hang/package.json index 51bcaf28d6..0c977d72ee 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -29,8 +29,8 @@ "@moq/loc": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "@svta/cml-iso-bmff": "^1.0.5", - "@svta/cml-utils": "1.6.0", + "@svta/cml-iso-bmff": "^1.0.6", + "@svta/cml-utils": "1.6.1", "@zod/mini": "^4.6.5", "zod": "^4.6.5" }, diff --git a/js/net/package.json b/js/net/package.json index ce53b03637..7ec2b125f1 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -33,7 +33,7 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", "typescript": "7.0.2", diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 5d89e54178..2327855f70 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -928,7 +928,7 @@ export class Publisher { } const cached = tracks.get(track); - if (cached) return cached; + if (cached !== undefined) return cached; const pending = (async () => { const info = await wireOf(front).resolveTrackInfo(track); diff --git a/js/publish/package.json b/js/publish/package.json index f1b4cd30a7..104019bc2e 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -33,7 +33,7 @@ "@moq/json": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "mediabunny": "^1.56.2" + "mediabunny": "^1.58.1" }, "devDependencies": { "@types/audioworklet": "^0.0.100", diff --git a/package.json b/package.json index bffe7256c3..3a85e66642 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "moq", "version": "0.0.0", "devDependencies": { - "@babel/parser": "^8.0.5", - "@biomejs/biome": "^2.5.13", + "@babel/parser": "^8.0.6", + "@biomejs/biome": "^2.5.14", "concurrently": "^10.0.5", "markdown-extensions": "^2.0.0", "publint": "^0.3.24", diff --git a/test/interop/clients/js-native/package.json b/test/interop/clients/js-native/package.json index 3931408011..2639f17155 100644 --- a/test/interop/clients/js-native/package.json +++ b/test/interop/clients/js-native/package.json @@ -11,6 +11,6 @@ "zod": "^4.6.5" }, "devDependencies": { - "tsx": "^4.23.13" + "tsx": "^4.23.15" } } From 2a72b3ae15b78853237235da88a9d297e0600762 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 16:39:48 -0700 Subject: [PATCH 45/87] fix(auth)!: restore 0.14 auth parity (#4319) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 19 +++ demo/relay/leaf0.toml | 11 +- demo/relay/leaf1.toml | 11 +- demo/relay/prod.toml | 3 - demo/relay/root.toml | 13 +- doc/bin/relay/auth.md | 77 ++++++----- doc/bin/relay/cluster.md | 3 +- doc/setup/upgrade.md | 22 ++++ js/auth/src/claims.ts | 23 +++- js/auth/src/key.test.ts | 34 +++++ nix/modules/moq-relay.nix | 11 +- quest/m0/README.md | 1 - quest/m0/auth-parity.md | 139 ------------------- rs/moq-auth/Cargo.toml | 1 + rs/moq-auth/src/claims.rs | 9 +- rs/moq-auth/src/error.rs | 6 + rs/moq-auth/src/key.rs | 78 ++++++++++- rs/moq-auth/src/serve.rs | 177 ++++++++++++++++++------- rs/moq-auth/src/set.rs | 1 + rs/moq-auth/src/wire.rs | 16 +++ rs/moq-auth/tests/released_token.rs | 47 +++++++ rs/moq-cli/src/args.rs | 1 + rs/moq-cli/src/auth.rs | 68 ++++++---- rs/moq-relay/src/auth.rs | 198 ++++++++++++++++++++++++++++ rs/moq-relay/src/config.rs | 61 +++++++++ rs/moq-relay/src/relay.rs | 10 ++ rs/moq-relay/tests/auth_lifetime.rs | 2 +- rs/moq-relay/tests/public_rules.rs | 21 ++- rs/moq-relay/tests/runtime_uring.rs | 2 +- 29 files changed, 794 insertions(+), 271 deletions(-) delete mode 100644 quest/m0/auth-parity.md create mode 100644 rs/moq-auth/tests/released_token.rs diff --git a/Cargo.lock b/Cargo.lock index f2d8721dd0..8b0aded8e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4213,6 +4213,7 @@ dependencies = [ "kio 0.6.1", "moq-auth", "moq-pattern", + "moq-token", "p256", "p384", "rand 0.10.3", @@ -4760,6 +4761,24 @@ dependencies = [ "web-async", ] +[[package]] +name = "moq-token" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014ed664b93a4a1bde2547fc92604f3147d2087ed7577e069a33bb88662bb57b" +dependencies = [ + "aws-lc-rs", + "base64 0.23.1", + "jsonwebtoken", + "p256", + "p384", + "rsa", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.21", +] + [[package]] name = "moq-tokio" version = "0.19.19" diff --git a/demo/relay/leaf0.toml b/demo/relay/leaf0.toml index 4fc49d1b4b..40872815d0 100644 --- a/demo/relay/leaf0.toml +++ b/demo/relay/leaf0.toml @@ -13,10 +13,11 @@ bind = "[::]:4444" tls.cert = ["leaf0.crt"] tls.key = ["leaf0.key"] -# Trust client certificates signed by this CA for mTLS peer auth. The -# certificate is a fact for the auth source below; an auth server grants -# peers through `--mtls-publish` / `--mtls-subscribe`. -tls.root = ["ca.pem"] +# To authenticate peers by certificate, trust client certificates signed by +# this CA and switch `[auth]` below to the auth server, which grants them +# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore +# certificates, so the relay refuses a client CA alongside them. +# tls.root = ["ca.pem"] [connect] # Present this certificate and key on outbound cluster connections; the @@ -44,7 +45,7 @@ public = "**" # To exercise JWT + public-pattern auth instead, drop `public` above, run # `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`, -# and point the relay at it: +# point the relay at it, and uncomment `listen.tls.root` above: # url = "http://127.0.0.1:4440/" [stats] diff --git a/demo/relay/leaf1.toml b/demo/relay/leaf1.toml index 463253940f..c112e619ac 100644 --- a/demo/relay/leaf1.toml +++ b/demo/relay/leaf1.toml @@ -13,10 +13,11 @@ bind = "[::]:4445" tls.cert = ["leaf1.crt"] tls.key = ["leaf1.key"] -# Trust client certificates signed by this CA for mTLS peer auth. The -# certificate is a fact for the auth source below; an auth server grants -# peers through `--mtls-publish` / `--mtls-subscribe`. -tls.root = ["ca.pem"] +# To authenticate peers by certificate, trust client certificates signed by +# this CA and switch `[auth]` below to the auth server, which grants them +# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore +# certificates, so the relay refuses a client CA alongside them. +# tls.root = ["ca.pem"] [connect] # Present this certificate and key on outbound cluster connections; the @@ -44,7 +45,7 @@ public = "**" # To exercise JWT + public-pattern auth instead, drop `public` above, run # `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`, -# and point the relay at it: +# point the relay at it, and uncomment `listen.tls.root` above: # url = "http://127.0.0.1:4440/" [stats] diff --git a/demo/relay/prod.toml b/demo/relay/prod.toml index 069bdc0a52..287af9c74d 100644 --- a/demo/relay/prod.toml +++ b/demo/relay/prod.toml @@ -37,6 +37,3 @@ key = "key.pem" # For multiple keys with rotation, give it `--key-dir /path/to/keys/` instead: # keys are named {kid}.jwk and resolved by the kid in the JWT header. url = "http://127.0.0.1:4440/" - -# Or use a URL for remote key management: -# key_dir = "https://api.example.com/keys" diff --git a/demo/relay/root.toml b/demo/relay/root.toml index b3fbda5623..0597547604 100644 --- a/demo/relay/root.toml +++ b/demo/relay/root.toml @@ -14,11 +14,12 @@ bind = "[::]:4443" tls.cert = ["root.crt"] tls.key = ["root.key"] -# Trust client certificates signed by this CA for mTLS peer auth. The -# certificate is a fact for the auth source: `auth.public = "**"` below admits -# the leaves, and an auth server would grant them through `--mtls-publish` / -# `--mtls-subscribe`. `just ca` generates the CA. -tls.root = ["ca.pem"] +# To authenticate peers by certificate, trust client certificates signed by +# this CA and switch `[auth]` below to the auth server, which grants them +# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore +# certificates, so the relay refuses a client CA alongside them. +# `just ca` generates the CA. +# tls.root = ["ca.pem"] [web.http] # Listen for HTTP and WebSocket (TCP) connections on the given address. @@ -31,5 +32,5 @@ public = "**" # To exercise JWT + public-pattern auth instead, drop `public` above, run # `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`, -# and point the relay at it: +# point the relay at it, and uncomment `listen.tls.root` above: # url = "http://127.0.0.1:4440/" diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index c3c5497b4d..4a9e5dff78 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -158,7 +158,12 @@ after which it can never sign a broader token. | `root` | Base path. Optional. | | `publish` | Patterns the bearer may publish under `root`. `**` means everything; omitted means no publishing. | | `subscribe` | Patterns the bearer may subscribe to under `root`. Same rules. | -| `exp`, `iat` | Expiry and issue time. `exp` is enforced for the whole session, not just at connect. | +| `exp`, `iat`, `nbf` | Expiry, issue time, and not-before. `exp` is enforced for the whole session, not just at connect, and a token is refused before its `nbf`. | +| `iss`, `sub`, `jti` | Read and ignored. | + +Any other claim refuses the token with its name, `aud` included: an unknown +claim may narrow the grant, and a misspelled `root` would otherwise widen it to +everything. Put application data somewhere other than the token. Tokens and key scopes from the older `moq-token` format still work: each `put` and `get` prefix `p` reads as the subtree `p/**`, and `""` as `**`. When every @@ -210,7 +215,9 @@ public_subscribe = ["anon/**", "demo/**"] public_publish = ["anon/**"] ``` -A static grant with no expiry and no re-check. The patterns are rooted at `/`, +A static grant with no expiry and no re-check. Nothing here verifies a token, +so a session presenting one (a `jwt` query or any SETUP token) is refused +rather than admitted on the public grant. The patterns are rooted at `/`, like a token with an empty root (see [Path matching](#path-matching)): `anon/**` admits a session dialed at `/`, `/anon`, or `/anon/room`, scoped to `anon/`, and refuses one dialed anywhere else. A pattern with no wildcard, such as @@ -225,8 +232,9 @@ server instead (`moq auth serve --public-*`). bad chain still fails there. What the certificate admits is the server's decision: the relay reports its facts in the request's `tls` and enforces the grant it gets back. `moq auth serve` grants a certificate only what -`--mtls-publish` and `--mtls-subscribe` name, empty by default. A relay on -`--auth-public` grants a certificate what it grants everyone. +`--mtls-publish` and `--mtls-subscribe` name, empty by default. Public rules +ignore certificates, so a relay on `--auth-public` refuses to start with +`listen.tls.root` or `web.https.root`. Cluster peers are admitted the same way, so a mesh runs `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server @@ -254,34 +262,43 @@ moq auth serve --listen 127.0.0.1:4440 \ --key-dir /etc/moq/keys \ --public-subscribe 'anon/**' --public-publish 'anon/**' \ --mtls-publish '**' --mtls-subscribe '**' \ - --tier edge --revalidate 1m --limit-remote 64 + --tier edge --expires 1d --revalidate 1m --limit-remote 64 ``` -Policy runs in this order and stops at the first that applies: - -1. A JWT, from the `jwt` query or a SETUP `token` of `kind` 0, is verified - against `--key FILE` or `--key-dir DIR` - (by `kid`, read per request so rotation needs no restart). Its claims - are authorized at the dialed path (`Claims::authorize`): the path may - equal the root, extend it (which narrows the grant), or be a parent of - it (the grant stays anchored at the root). Residuals become the grant. - An unrelated path, or one the token grants nothing at, is refused. A - malformed, expired, or unknown-key token is refused; it never falls - through to the anonymous rules. So is a SETUP token of any other `kind`, - and a session presenting both a SETUP token and a `jwt` query. -2. A verified client certificate gets `--mtls-publish` and - `--mtls-subscribe`, rooted at `/` and authorized at the dialed path like a - token with an empty root, and nothing when they are empty. Cluster peers are - admitted this way; a mesh needs `'**'` for both. -3. Anything else gets `--public-publish` and `--public-subscribe`, rooted - the same way, and is refused when they are empty or reach nothing at the - dialed path. - -Every grant carries `--tier`, a `revalidate` cadence (`--revalidate`, default -one minute), and an `expires`: the token's `exp`, the certificate's notAfter, -or `--expires` (default one day) when neither has one. - -`--limit-token N` and `--limit-remote N` cap live sessions per token and +What a session presents decides what is checked. Every credential is either +evaluated or refused: nothing is ignored, and no two are combined. + +| Presented | `--auth-public` | `--auth-url` to `moq auth serve` | +| --- | --- | --- | +| nothing | the public rules | `--public-*`, or refused | +| a JWT | refused | verified, or refused | +| a certificate | never requested: a client CA refuses to start | `--mtls-*`, or refused | +| a JWT and a certificate | never requested | refused | + +A JWT, from the `jwt` query or a SETUP `token` of `kind` 0, is verified +against `--key FILE` or `--key-dir DIR` (by `kid`, read per request so +rotation needs no restart) and authorized at the dialed path, as in +[Path matching](#path-matching). A malformed, expired, or unknown-key token is +refused; it never falls through to the anonymous rules. So is a SETUP token of +any other `kind`, and a session presenting both a SETUP token and a `jwt` query. +A JWT presented with a certificate is refused, because neither can safely win: +the certificate would override a JWT meant to narrow it, and the JWT would +narrow or refuse a peer by accident. So a peer presents `cluster.token` or a +certificate, not both. + +`--mtls-*` and `--public-*` are rooted at `/`, like a token with an empty root, +and a session they reach nothing at is refused. Cluster peers are admitted by +certificate; a mesh needs `--mtls-publish '**' --mtls-subscribe '**'`. + +Every grant carries `--tier`. As in 0.14, nothing is re-checked or closed by +default: a session lives until its token's `exp` or its certificate's notAfter. +`--expires D` bounds a grant with no bound of its own (an anonymous session, a +token without `exp`, a certificate without notAfter). `--revalidate D` has the +relay re-check each grant on that cadence and needs `--expires`, so an outage +still has a bound. Without `--revalidate`, rotating or deleting a key does not +close live sessions. + +`--limit-token N` and `--limit-remote N` need `--revalidate`, and cap live sessions per token and per remote address (port dropped, IPv4-mapped IPv6 folded), counted from `connect` and `end` by session id. The cap is a nuisance limit, not a security boundary: it gates admission and never revokes. A relay that dies without an diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index ddeb3ae081..b1eb6ddbe7 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -177,7 +177,8 @@ accepting relay admits a peer through the same lease as any client: its certificate is reported to the auth server, which grants it, so a mesh needs `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server of your own that grants the cluster CA) behind `--auth-url`. A relay on -`--auth-public '**'` admits peers through that grant instead. LAN peers +`--auth-public '**'` admits peers through that grant instead, as long as they +send no `cluster.token`: public rules refuse a token. LAN peers authenticate with the mDNS credential on `/.cluster/`, a secret the relay minted for itself and checks locally, and never receive `cluster.token`. Dials retry forever with capped backoff, so a rejected peer diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index e67ccb87ed..12dfbcdb50 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -78,11 +78,33 @@ Other changes to a deployment: the pattern-only `moq-auth` 0.1.0/0.1.1 or `@moq/auth` 0.1.x/0.2.0 refuse that form, so upgrade them before their issuers. Grants only a pattern can express need an upgraded verifier. +- **Removed auth settings refuse to start.** 0.14's `[auth]` `key`, `key_dir`, + `auth_api`, `domains`, `mtls_tier`, and `[auth.tls]`, and their flags and + `MOQ_AUTH_*` variables, stop the relay with the replacement named rather + than being ignored. +- **Token claims.** `iss`, `sub`, and `jti` are ignored and `nbf` is enforced. + Any other claim refuses the token with its name, `aud` and `cluster` + included, where 0.14 ignored all but `aud`: an issuer adding app claims such + as `user_id` must drop them. +- **Every credential is evaluated or refused.** A relay on `--auth-public` + refuses a session presenting a token, as 0.14 did, including peers sending + `cluster.token`, and refuses to start with a client CA. `moq auth serve` + refuses a session presenting both a JWT and a certificate, so a peer + presents one or the other. +- **`moq auth serve` never re-checks or expires by default**, as 0.14 never + did. `--revalidate` needs `--expires`, and `--limit-*` needs `--revalidate`. - **mTLS admits nothing on its own.** A verified client certificate is reported to the auth server, which grants it. `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` restores the old full access for every certificate the relay's client CA verifies, so keep that CA to cluster peers. - **`moq --listen` needs auth.** A CLI listener refuses to start without `--auth-url` or `--auth-public` instead of accepting everyone. +- **Other 0.14 auth differences kept.** A 0.14 peer that dials with a + cluster JWT no longer sees `.internal/origins` gossip; peers identify by + certificate or LAN path. An auth server's `root` alias may have any depth. + A path in `--cluster-connect` is not refused, although it shifts the mesh + frame. `moq auth serve --key` takes a file, not an https or JWKS URL. + `moq auth sign --root` is the token root and JS `verify --root` the dialed + path. `/.cluster*` roots are reserved. `--auth-public a,b` splits on commas. - **noq is the only QUIC stack** (#3811). The `quinn` and `quiche` cargo features and the backend setting are gone. - **Stats counters** are `*_started` / `*_ended` (`sessions_started`, diff --git a/js/auth/src/claims.ts b/js/auth/src/claims.ts index 493690b947..d22e594d64 100644 --- a/js/auth/src/claims.ts +++ b/js/auth/src/claims.ts @@ -70,6 +70,17 @@ const ClaimsFields = { exp: z.optional(z.int()), /** Issued-at time, as a whole unix timestamp in seconds. */ iat: z.optional(z.int()), + /** Not-before time, as a whole unix timestamp in seconds. Enforced by verification. */ + nbf: z.optional(z.int()), +}; + +// Registered claims that narrow nothing: an issuer's bookkeeping, read and dropped. +// Every other claim is refused, since an unknown one may narrow the grant, and a +// misspelled `root` would otherwise widen it to everything. +const IgnoredFields = { + iss: z.optional(z.unknown()), + sub: z.optional(z.unknown()), + jti: z.optional(z.unknown()), }; const PrefixListSchema = z.union([z.string(), z.array(z.string())]); @@ -82,12 +93,18 @@ const PrefixListSchema = z.union([z.string(), z.array(z.string())]); * exactly what it says: `alice` is one broadcast, `alice/**` is a subtree, and `**` is * everything under the root. Legacy `moq-token` claims read too, each `put`/`get` * prefix `p` as the subtree `p/**`, and signing writes that form whenever it says the - * same thing. Any other field fails verification. + * same thing. The registered `iss`, `sub`, and `jti` claims are read and dropped; any + * other field fails verification. */ export const ClaimsSchema = z .pipe( - z.strictObject({ ...ClaimsFields, put: z.optional(PrefixListSchema), get: z.optional(PrefixListSchema) }), - z.transform(decodeGrants), + z.strictObject({ + ...ClaimsFields, + ...IgnoredFields, + put: z.optional(PrefixListSchema), + get: z.optional(PrefixListSchema), + }), + z.transform(({ iss: _iss, sub: _sub, jti: _jti, ...claims }, ctx) => decodeGrants(claims, ctx)), ) .check( // Emptiness, not just presence: `publish: []` grants nothing, and the Rust crate diff --git a/js/auth/src/key.test.ts b/js/auth/src/key.test.ts index e17740ddfe..7802e6539e 100644 --- a/js/auth/src/key.test.ts +++ b/js/auth/src/key.test.ts @@ -758,3 +758,37 @@ test("key scope treats absolute and rooted grants alike", async () => { // Roles are independent: a publish-only scope grants no subscribe. await expect(Key.sign(scoped, { root: "project", subscribe: ["live/**"] })).rejects.toThrow("exceed the key scope"); }); + +/** Sign an arbitrary payload with `testKey`, bypassing the claims schema, as another issuer might. */ +async function signRaw(payload: Record): Promise { + const secret = new TextEncoder().encode("test-secret-that-is-long-enough-for-hmac-sha256"); + return new SignJWT(payload).setProtectedHeader({ alg: "HS256", typ: "JWT" }).sign(secret); +} + +// An issuer's bookkeeping is read and dropped; anything else is refused by name, since it +// might narrow the grant, and a misspelled `root` would widen it to everything. +test("verify - only registered claims", async () => { + const key = Key.parse(encodeJwk(testKey)); + const now = Math.floor(Date.now() / 1000); + + const token = await signRaw({ root: "room", publish: ["**"], iss: "api", sub: "alice", jti: "1", iat: now }); + const claims = await Key.verify(key, token); + expect(claims.root).toBe("room"); + expect(claims).not.toHaveProperty("iss"); + + for (const [claim, payload] of [ + ["rooot", { rooot: "room/123", publish: ["**"] }], + ["user_id", { root: "room", publish: ["**"], user_id: 7 }], + ["cluster", { root: "room", put: [""], cluster: true }], + ["aud", { root: "room", publish: ["**"], aud: "relay" }], + ] as const) { + await expect(Key.verify(key, await signRaw(payload))).rejects.toThrow(claim); + } +}); + +test("verify - enforces not before", async () => { + const key = Key.parse(encodeJwk(testKey)); + const now = Math.floor(Date.now() / 1000); + expect((await Key.verify(key, await signRaw({ publish: ["**"], nbf: now - 60 }))).nbf).toBe(now - 60); + await expect(Key.verify(key, await signRaw({ publish: ["**"], nbf: now + 3600 }))).rejects.toThrow(); +}); diff --git a/nix/modules/moq-relay.nix b/nix/modules/moq-relay.nix index 4dfef1232e..1a520b9874 100644 --- a/nix/modules/moq-relay.nix +++ b/nix/modules/moq-relay.nix @@ -315,10 +315,13 @@ in # Cluster configuration MOQ_CLUSTER_ROOT = cfg.cluster.rootUrl; } - // lib.optionalAttrs (cfg.cluster.mode != "none") { - MOQ_CLUSTER_TOKEN = - if cfg.cluster.tokenFile != null then cfg.cluster.tokenFile else "${cfg.stateDir}/cluster.jwt"; - } + # Public rules refuse a token, so a peer only presents one an auth server will read. + // + lib.optionalAttrs (cfg.cluster.mode != "none" && (cfg.auth.enable || cfg.cluster.tokenFile != null)) + { + MOQ_CLUSTER_TOKEN = + if cfg.cluster.tokenFile != null then cfg.cluster.tokenFile else "${cfg.stateDir}/cluster.jwt"; + } // lib.optionalAttrs (cfg.cluster.nodeUrl != null) { MOQ_CLUSTER_NODE = cfg.cluster.nodeUrl; }; diff --git a/quest/m0/README.md b/quest/m0/README.md index 865a53a06d..aa41bc85a5 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -93,7 +93,6 @@ do not add another media abstraction or a renderer crate during stabilization. ## Quests -- [Auth parity](/quest/m0/auth-parity.md) - 0.14 configs and tokens keep working or are refused with the fix named, and every credential a session presents is evaluated or refused - [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 - [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess diff --git a/quest/m0/auth-parity.md b/quest/m0/auth-parity.md deleted file mode 100644 index 264211ef79..0000000000 --- a/quest/m0/auth-parity.md +++ /dev/null @@ -1,139 +0,0 @@ -# [M] Auth parity - -## Goal - -A moq-relay 0.14.18 deployment either keeps working on 0.15 or is refused -with the fix named, never silently degraded or widened. Every credential a -session presents is either evaluated or refused: nothing is ignored, and no -two credentials are combined. A token that 0.14 accepted is still accepted, -unless it carries a claim outside the registered JWT set; that token is -refused with the claim named. - -Non-goals: the redesign that #3688 documented stays. That covers -`--auth-key` moving to `moq auth serve`, the removal of `--auth-api`, -`--auth-domain`, and key URLs, and refusing an mTLS-only relay. - -## Plan - -Each fix lands with a regression test that fails without it. - -### Startup - -- **Refuse removed auth settings.** `[auth] key`, `key_dir`, `auth_api`, - `domains`, `mtls_tier`, and `tls`, and the `MOQ_AUTH_KEY`, `_KEY_DIR`, - `_API`, `_DOMAIN`, `_MTLS_TIER`, `_PUBLIC_API`, and `_TLS_*` env vars are - ignored today, because `auth::Config` has no `deny_unknown_fields` and - `Config::deprecated()` (`rs/moq-relay/src/config.rs`) skips `[auth]`. A 0.14 - config with `key` and `public` boots with JWTs ignored. Refuse each one at - startup and name its replacement, as `doc/setup/upgrade.md` already - promises. Also fix `demo/relay/prod.toml`, which still suggests `key_dir`. -- **No re-checks or expiry by default.** `moq auth serve` defaults - `--expires` to 1 day and `--revalidate` to 1 minute, so anonymous sessions - and tokens without `exp` drop daily. 0.14's static `key` and `public` never - closed those; it did close at a token's `exp` and a certificate's - notAfter, which stays. Both flags default to off. Refuse to start on: - - `--revalidate` without `--expires`, since the contract refuses a cadence - without a bound; - - `--limit-*` without `--revalidate`, since the session table ages out - slots by the cadence. Without it, limits either undercount or leak the - slots of a relay that died. - - Document that without `--revalidate`, rotating or deleting a key does not - close live sessions. - -### Tokens - -- **Registered claims only.** Tokens are wire. 0.14 ignored every unknown - claim; today both languages refuse them. Ignoring them fails open, because - `root` defaults to `""`: an issuer's typo such as - `{"rooot":"room/123","publish":["**"]}` would grant the whole relay, and so - would a future narrowing claim read by an older verifier. So: - - `iss`, `sub`, and `jti` are accepted and ignored. `iat` is read and not - enforced, as today. - - `nbf` is enforced, like `exp`. JS (`jose`) enforces it today; Rust - ignores it. - - `aud` is refused. 0.14 refused it (jsonwebtoken's default - `validate_aud`), and so does Rust today, but JS accepts it. No audience - is configured, so there is nothing to check it against. - - Any other claim is refused with its name in the error, including - `cluster`, which 0.14 accepted and ignored. List this in the upgrade notes: - an issuer adding app claims such as `user_id` must drop them. - - Both languages must agree on every case above; add them to the shared - vectors. -- **0.14 verifies what we sign.** Signing already writes subtree-only grants - as legacy `put`/`get` prefixes (`foo/**` as `foo`, `**` as `""`), and - anything else as `publish`/`subscribe`. 0.14 reads the latter as granting - nothing and refuses the token. The field names are the version, so nothing - new is needed on the wire. Pin it with a test that verifies tokens from - `moq auth sign` and `@moq/auth` against the published `moq-token` 0.7. - Subtree grants must be accepted with the same scope, and exact grants - refused. - -### Credentials - -Today a session can present a JWT, a verified certificate, or both, and the -answer depends on which mode the relay runs in. Make it one rule: a relay -evaluates the credentials it is configured for, and refuses anything else. - -| Presented | `--auth-public` | `--auth-url` to `moq auth serve` | -|---|---|---| -| nothing | public rules | `--public-*`, or refused | -| JWT | refused | verified, or refused | -| certificate | never requested (see below) | `--mtls-*`, or refused | -| JWT and certificate | never requested | refused | - -- **Refuse a JWT on a public-only relay.** Under `--auth-public`, a client that - sends `?jwt=` is admitted on the public grant. 0.14 answered 401 - `UnexpectedToken`. Refuse every form: the query, a SETUP token of any - type, and the HTTP endpoints. Peers that send `cluster.token` to a - public-only relay are refused too, so say so in the upgrade notes. -- **Refuse a client CA on a public-only relay.** Under `--auth-public`, a - certificate grants the public grant, so `listen.tls.root` and - `web.https.root` only make browsers offer certificates for nothing. Refuse - them at startup, pointing at `moq auth serve --mtls-*`. Watch for the - rolling-upgrade exemption in `connection::authorize`, which shows hidden - routes to any verified certificate. It widens nothing beyond the grant, but - check that no documented mesh depends on it before refusing. -- **Refuse a JWT and a certificate together** in `moq auth serve`, like - `TwoTokens`. Today the JWT wins, so a mesh that follows the migration docs - (`--mtls-*` without `--key`) refuses peers that also send `cluster.token` - with `NoKeys`. 0.14 checked the certificate first. Neither order is safe: - certificate-first lets any certificate from the client CA override a JWT - meant to narrow it, and JWT-first refuses or narrows a peer by accident. A - refusal that names both makes the operator drop one. The migration docs - must say that `cluster.token` and a peer certificate are alternatives. - -Declined: failing a client whose configured certificate was never requested. -One `connect.tls` identity serves both the auth server and peers, and a -dialer cannot know which credential the server means to check. - -### Declined - -Each of these was considered and kept as 0.15 behavior: - -- A 0.14 peer that dials with a cluster JWT no longer sees - `.internal/origins` gossip (#4060). Peers identify by certificate or LAN - path. -- An alias `grant.root` of any depth is accepted. 0.14 required the dialed - path's depth, which hard-codes one aliasing scheme; a server that moves the - root owns the scope it hands out. -- A path in `--cluster-connect` is not refused, although it shifts the mesh - frame. -- `moq auth serve --key` takes a file path only, not 0.14's https or JWKS URL. -- `moq auth sign --root` (token root) and JS `verify --root` (dialed path) - keep their names. -- `/.cluster*` roots are reserved. -- `--auth-public a,b` splits on commas. - -### Docs - -- Put the credentials table in `doc/bin/relay/auth.md`, and replace the - "Policy runs in this order" list with it. -- Record each restored or refused behavior in `doc/bin/relay/auth.md` and - `doc/setup/upgrade.md`. List the declined differences in the upgrade notes, - since none of them is documented today. - -## Related - -- [Release](/quest/m0/release.md) - ships this parity diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index bc1f934171..d0c3196e10 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -41,6 +41,7 @@ url = { workspace = true } [dev-dependencies] anyhow = { workspace = true } moq-auth = { path = ".", features = ["client", "serve"] } +moq-token = "0.7" tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } wiremock = "0.6" diff --git a/rs/moq-auth/src/claims.rs b/rs/moq-auth/src/claims.rs index e49df781e8..2a64714a0b 100644 --- a/rs/moq-auth/src/claims.rs +++ b/rs/moq-auth/src/claims.rs @@ -105,7 +105,9 @@ impl Permissions { /// Legacy `moq-token` claims are read too: each `put`/`get` prefix `p` is the subtree /// `p/**`. Claims that only grant subtrees are written that way, so every published /// verifier accepts them; anything else is written as `publish`/`subscribe`, which an -/// older verifier refuses rather than misreads. Any other field fails verification. +/// older verifier refuses rather than misreads. The registered `iss`, `sub`, and `jti` +/// claims are read and ignored; any other field fails verification, since it might +/// narrow the grant, and a misspelled `root` would otherwise widen it. #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(try_from = "crate::wire::Claims", into = "crate::wire::Claims")] #[non_exhaustive] @@ -127,6 +129,10 @@ pub struct Claims { /// The issued time of the token as a unix timestamp (`iat`). pub issued: Option, + + /// The time before which the token is refused, as a unix timestamp (`nbf`). + /// Enforced by [`Key::verify`](crate::Key::verify). + pub not_before: Option, } impl Claims { @@ -246,6 +252,7 @@ mod tests { subscribe: patterns(&["test-sub/**"]), expires: Some(SystemTime::now() + Duration::from_secs(3600)), issued: Some(SystemTime::now()), + not_before: None, } } diff --git a/rs/moq-auth/src/error.rs b/rs/moq-auth/src/error.rs index 9ea9e5234e..fcd36d3496 100644 --- a/rs/moq-auth/src/error.rs +++ b/rs/moq-auth/src/error.rs @@ -90,6 +90,9 @@ pub enum Error { #[error("token has expired")] TokenExpired, + #[error("token is not valid yet")] + TokenNotYetValid, + #[error(transparent)] Pattern(#[from] moq_pattern::InvalidPattern), @@ -99,6 +102,9 @@ pub enum Error { #[error("grant asks to be revalidated but never expires")] UnboundedRevalidate, + #[error("session limits need a revalidate cadence, which ages out the slots of a relay that died")] + LimitsWithoutRevalidate, + #[error("grant asks to be revalidated at no interval")] ZeroRevalidate, diff --git a/rs/moq-auth/src/key.rs b/rs/moq-auth/src/key.rs index fc584803de..903e32e427 100644 --- a/rs/moq-auth/src/key.rs +++ b/rs/moq-auth/src/key.rs @@ -527,7 +527,8 @@ impl Key { /// Verify a token's signature with this key and return its claims. /// - /// Rejects an expired token (the `exp` claim) and one that grants nothing. + /// Rejects an expired token (the `exp` claim), one not yet valid (`nbf`), and one + /// that grants nothing. /// Scoping the claims to a connection path is a separate step; see /// [`Claims::authorize`]. pub fn verify(&self, token: &str) -> crate::Result { @@ -543,11 +544,17 @@ impl Key { let token = jsonwebtoken::decode::(token, decode, &validation)?; + let now = std::time::SystemTime::now(); if let Some(exp) = token.claims.expires - && exp < std::time::SystemTime::now() + && exp < now { return Err(crate::Error::TokenExpired); } + if let Some(nbf) = token.claims.not_before + && nbf > now + { + return Err(crate::Error::TokenNotYetValid); + } token.claims.validate()?; self.validate_scope(&token.claims)?; @@ -684,6 +691,7 @@ mod tests { subscribe: patterns(&["test-sub/**"]), expires: Some(SystemTime::now() + Duration::from_secs(3600)), issued: Some(SystemTime::now()), + not_before: None, } } @@ -906,6 +914,7 @@ mod tests { subscribe: patterns(&[]), expires: None, issued: None, + not_before: None, }; let result = key.sign(&invalid_claims); @@ -962,6 +971,69 @@ mod tests { assert!(result.is_ok()); } + /// Sign an arbitrary payload with `key`, bypassing [`Claims`], as another issuer might. + fn sign_raw(key: &Key, payload: serde_json::Value) -> String { + let header = Header::new(key.algorithm.into()); + jsonwebtoken::encode(&header, &payload, key.to_encoding_key().unwrap()).unwrap() + } + + /// An issuer's bookkeeping is read and dropped; anything else is refused by name, + /// since it might narrow the grant, and a misspelled `root` would widen it to + /// everything. + #[test] + fn test_key_verify_only_registered_claims() { + let key = create_test_key(); + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let token = sign_raw( + &key, + serde_json::json!({"root": "room", "publish": ["**"], "iss": "api", "sub": "alice", "jti": "1", "iat": now}), + ); + assert_eq!(key.verify(&token).unwrap().root, "room"); + + for (claim, payload) in [ + ("rooot", serde_json::json!({"rooot": "room/123", "publish": ["**"]})), + ( + "user_id", + serde_json::json!({"root": "room", "publish": ["**"], "user_id": 7}), + ), + ( + "cluster", + serde_json::json!({"root": "room", "put": [""], "cluster": true}), + ), + ] { + let err = key.verify(&sign_raw(&key, payload)).unwrap_err().to_string(); + assert!(err.contains(&format!("`{claim}`")), "{claim}: {err}"); + } + + // No audience is configured, so there is nothing to check one against. + let token = sign_raw( + &key, + serde_json::json!({"root": "room", "publish": ["**"], "aud": "relay"}), + ); + assert!(key.verify(&token).is_err()); + } + + #[test] + fn test_key_verify_enforces_not_before() { + let key = create_test_key(); + let at = |offset: i64| { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + sign_raw( + &key, + serde_json::json!({"root": "room", "publish": ["**"], "nbf": now + offset}), + ) + }; + assert!(key.verify(&at(-60)).is_ok()); + assert!(matches!(key.verify(&at(3600)), Err(crate::Error::TokenNotYetValid))); + } + #[test] fn test_key_verify_expired_token() { let key = create_test_key(); @@ -982,6 +1054,7 @@ mod tests { subscribe: patterns(&["**"]), expires: None, issued: None, + not_before: None, }; let token = key.sign(&claims).unwrap(); @@ -1001,6 +1074,7 @@ mod tests { subscribe: patterns(&["test-sub/**"]), expires: Some(SystemTime::now() + Duration::from_secs(3600)), issued: Some(SystemTime::now()), + not_before: None, }; let token = key.sign(&original_claims).unwrap(); diff --git a/rs/moq-auth/src/serve.rs b/rs/moq-auth/src/serve.rs index 18532b1cad..2a55c60ba1 100644 --- a/rs/moq-auth/src/serve.rs +++ b/rs/moq-auth/src/serve.rs @@ -38,6 +38,7 @@ pub enum Keys { /// a relay that dies without sending `end` holds its slots until they age out after /// two cadences, a restart empties the table until the fleet's next cadence refills /// it, and a session admitted while a live one's slot was missing stays over the cap. +/// The cadence is [`Policy::revalidate`]; without one, nothing ages out. /// /// `#[non_exhaustive]`, so start from [`Limits::default`] and set the fields. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -59,11 +60,14 @@ pub struct Limits { /// /// The JWT is the `jwt` query parameter or a moq-transport SETUP token of type 0 /// ([`Token::OUT_OF_BAND`]), verified alike. A session presenting both is refused, -/// as is a SETUP token of any other type. +/// as is a SETUP token of any other type, and one presenting a JWT alongside a +/// certificate: neither can safely win, since a certificate would override a JWT +/// meant to narrow it, and a JWT would narrow or refuse a peer by accident. /// /// `#[non_exhaustive]`, so start from [`Policy::default`] and set the fields. #[derive(Clone, Debug)] #[non_exhaustive] +#[derive(Default)] pub struct Policy { /// The keys a `jwt` is verified against; `None` refuses every token. pub keys: Option, @@ -74,29 +78,16 @@ pub struct Policy { pub mtls: Permissions, /// The tier stamped on every grant. pub tier: Option, - /// How often the relay re-checks each grant. - pub revalidate: Duration, + /// How often the relay re-checks each grant; `None` never re-checks. The contract + /// refuses a cadence without a bound, so set [`expires`](Self::expires) with it. + pub revalidate: Option, /// How long a grant with no bound of its own lasts: an anonymous session, a token - /// without `exp`, a certificate without one. - pub expires: Duration, + /// without `exp`, a certificate without one. `None` leaves those unbounded. + pub expires: Option, /// Live session caps. pub limits: Limits, } -impl Default for Policy { - fn default() -> Self { - Self { - keys: None, - public: Permissions::default(), - mtls: Permissions::default(), - tier: None, - revalidate: Duration::from_secs(60), - expires: Duration::from_secs(24 * 60 * 60), - limits: Limits::default(), - } - } -} - /// Why a session was refused; the body of the 403. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] @@ -125,12 +116,18 @@ pub enum Refusal { TwoTokens, #[error("SETUP token type {0:#x} is not supported; only type 0 (a JWT) is")] UnsupportedToken(u64), + #[error("both a JWT and a client certificate were presented; present one")] + TokenAndCertificate, } impl Policy { /// Decide `request` by the policy alone, ignoring session limits. pub async fn decide(&self, request: &Request) -> Result { - let (permissions, expires) = if let Some(jwt) = jwt(request)? { + let jwt = jwt(request)?; + if jwt.is_some() && request.tls.is_some() { + return Err(Refusal::TokenAndCertificate); + } + let (permissions, expires) = if let Some(jwt) = jwt { let key = self.key(jwt).await?; let claims = key.verify(jwt).map_err(|err| Refusal::InvalidToken(err.to_string()))?; let permissions = claims.authorize(&request.path).map_err(|err| match err { @@ -155,9 +152,8 @@ impl Policy { }; let mut grant = Grant::new(permissions.publish, permissions.subscribe); - // The contract refuses a cadence without a bound, so every grant carries one. - grant.expires = Some(expires.unwrap_or_else(|| SystemTime::now() + self.expires)); - grant.revalidate = Some(self.revalidate); + grant.expires = expires.or_else(|| self.expires.map(|bound| SystemTime::now() + bound)); + grant.revalidate = self.revalidate; grant.tier = self.tier.clone(); Ok(grant) } @@ -302,12 +298,28 @@ pub struct Server { } impl Server { - /// A server answering with `policy`. - pub fn new(policy: Policy) -> Self { - Self { + /// A server answering with `policy`, refusing one that asks for a re-check without + /// a bound, or caps sessions without the re-check that ages out a dead relay's. + pub fn new(policy: Policy) -> crate::Result { + if policy.revalidate.is_some() && policy.expires.is_none() { + return Err(crate::Error::UnboundedRevalidate); + } + let limited = policy.limits.token.is_some() || policy.limits.remote.is_some(); + if limited && policy.revalidate.is_none() { + return Err(crate::Error::LimitsWithoutRevalidate); + } + Ok(Self { policy: Arc::new(policy), sessions: Default::default(), - } + }) + } + + /// The cadence the session table ages out by, or `None` when no limit needs a table. + fn cadence(&self) -> Option { + let limits = self.policy.limits; + (limits.token.is_some() || limits.remote.is_some()) + .then_some(self.policy.revalidate) + .flatten() } /// Answer one event: the grant, or why the session is refused. @@ -315,16 +327,20 @@ impl Server { match request.event { Event::Connect => { let grant = self.policy.decide(request).await?; - let mut sessions = self.sessions.lock().unwrap(); - sessions.sweep(self.policy.revalidate); - sessions.connect(request, self.policy.limits)?; + if let Some(cadence) = self.cadence() { + let mut sessions = self.sessions.lock().unwrap(); + sessions.sweep(cadence); + sessions.connect(request, self.policy.limits)?; + } Ok(Some(grant)) } Event::Revalidate => { let grant = self.policy.decide(request).await?; - let mut sessions = self.sessions.lock().unwrap(); - sessions.sweep(self.policy.revalidate); - sessions.revalidate(request); + if let Some(cadence) = self.cadence() { + let mut sessions = self.sessions.lock().unwrap(); + sessions.sweep(cadence); + sessions.revalidate(request); + } Ok(Some(grant)) } Event::End { .. } => { @@ -418,7 +434,7 @@ mod tests { /// The server behind a client, with a signal for each `end` it has handled. async fn serve(policy: Policy) -> (Client, Arc) { - let server = Server::new(policy); + let server = Server::new(policy).unwrap(); let ended = Arc::new(tokio::sync::Notify::new()); let router = Router::new() .route( @@ -463,7 +479,7 @@ mod tests { assert_eq!(grant.publish, patterns(&["alice/**"])); assert_eq!(grant.subscribe, patterns(&["**"])); assert_eq!(grant.expires, Some(exp)); - assert_eq!(grant.revalidate, Some(Duration::from_secs(60))); + assert_eq!(grant.revalidate, None); assert_eq!(grant.tier.as_deref(), Some("gold")); assert_eq!(grant.root, None); } @@ -473,7 +489,7 @@ mod tests { let (dir, key) = key_dir(); let policy = Policy { keys: Some(Keys::Dir(dir.path().into())), - expires: Duration::from_secs(3600), + expires: Some(Duration::from_secs(3600)), ..Default::default() }; let jwt = sign(&key, "demo", &["**"], &[], None); @@ -682,14 +698,36 @@ mod tests { assert_eq!(grant.publish, patterns(&["**"])); assert_eq!(grant.expires, Some(not_after)); - // A certificate without a bound gets the default one. + // A certificate without a bound gets none by default, like 0.14. let grant = policy.decide(&with_peer(request("/"), None)).await.unwrap(); - assert!(grant.expires.unwrap() <= SystemTime::now() + policy.expires); + assert_eq!(grant.expires, None); // The certificate does not stand in for a public grant. assert_eq!(policy.decide(&request("/")).await.unwrap_err(), Refusal::NoPublicGrant); } + /// A certificate would override a JWT meant to narrow it, and a JWT would narrow or + /// refuse a peer by accident, so presenting both is refused, even with a bad JWT. + #[tokio::test] + async fn a_jwt_and_a_certificate_together_are_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + mtls: rules(&["**"], &["**"]), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + for request in [ + with_peer(with_token(request("/demo"), &jwt), None), + with_peer(with_token(request("/demo"), "garbage"), None), + with_peer(with_setup_token(request("/demo"), Token::OUT_OF_BAND, &jwt), None), + ] { + assert_eq!(policy.decide(&request).await.unwrap_err(), Refusal::TokenAndCertificate); + } + assert!(policy.decide(&with_peer(request("/demo"), None)).await.is_ok()); + assert!(policy.decide(&with_token(request("/demo"), &jwt)).await.is_ok()); + } + #[tokio::test] async fn anonymous_gets_the_public_rules() { let policy = Policy { @@ -699,7 +737,9 @@ mod tests { let grant = policy.decide(&request("/")).await.unwrap(); assert_eq!(grant.subscribe, patterns(&["anon/**"])); assert!(grant.publish.is_empty()); - assert!(grant.expires.is_some()); + // Never re-checked or closed by default, like 0.14. + assert_eq!(grant.expires, None); + assert_eq!(grant.revalidate, None); } /// The rules are rooted at `/`, not at the dialed path: `anon/**` scopes a session @@ -750,9 +790,11 @@ mod tests { token: None, remote: Some(2), }, - revalidate: Duration::from_secs(60), + revalidate: Some(Duration::from_secs(60)), + expires: Some(Duration::from_secs(24 * 60 * 60)), ..Default::default() - }); + }) + .unwrap(); let first = request("/"); let second = request("/"); @@ -792,6 +834,49 @@ mod tests { assert_eq!(server.answer(&request("/")).await.unwrap_err(), Refusal::RemoteLimit); } + /// The re-check a session table ages out by, which every limit needs. + fn limited() -> Policy { + Policy { + revalidate: Some(Duration::from_secs(60)), + expires: Some(Duration::from_secs(3600)), + ..Default::default() + } + } + + /// Without a re-check, a relay that died would hold its slots forever, and a + /// re-check without a bound would outlive an outage. + #[test] + fn a_server_refuses_limits_without_a_cadence() { + let capped = Policy { + limits: Limits { + token: Some(1), + remote: None, + }, + ..Default::default() + }; + assert!(matches!(Server::new(capped), Err(Error::LimitsWithoutRevalidate))); + let unbounded = Policy { + revalidate: Some(Duration::from_secs(60)), + ..Default::default() + }; + assert!(matches!(Server::new(unbounded), Err(Error::UnboundedRevalidate))); + } + + /// With no limit to count against, nothing is kept per session, so a relay that + /// dies without sending `end` leaks nothing here. + #[tokio::test] + async fn no_limits_keep_no_sessions() { + let server = Server::new(Policy { + public: rules(&["**"], &["**"]), + ..Default::default() + }) + .unwrap(); + for _ in 0..3 { + server.answer(&request("/")).await.unwrap(); + } + assert!(server.sessions.lock().unwrap().slots.is_empty()); + } + #[tokio::test] async fn limits_count_sessions_per_token_and_fold_mapped_addresses() { let (dir, key) = key_dir(); @@ -801,8 +886,9 @@ mod tests { token: Some(1), remote: Some(1), }, - ..Default::default() - }); + ..limited() + }) + .unwrap(); let jwt = sign(&key, "demo", &["**"], &[], None); server.answer(&with_token(request("/demo"), &jwt)).await.unwrap(); @@ -830,7 +916,7 @@ mod tests { token: None, remote: Some(1), }, - ..Default::default() + ..limited() }) .await; @@ -855,7 +941,8 @@ mod tests { let server = Server::new(Policy { public: rules(&["**"], &["**"]), ..Default::default() - }); + }) + .unwrap(); tokio::spawn(async move { server.serve_unix(listener).await }); let url = url::Url::from_file_path(&path).unwrap(); diff --git a/rs/moq-auth/src/set.rs b/rs/moq-auth/src/set.rs index c1c88241b6..4dd03b4130 100644 --- a/rs/moq-auth/src/set.rs +++ b/rs/moq-auth/src/set.rs @@ -147,6 +147,7 @@ mod tests { subscribe: patterns(&["test-sub/**"]), expires: Some(SystemTime::now() + Duration::from_secs(3600)), issued: Some(SystemTime::now()), + not_before: None, } } diff --git a/rs/moq-auth/src/wire.rs b/rs/moq-auth/src/wire.rs index c7fedc14ef..09d56739d5 100644 --- a/rs/moq-auth/src/wire.rs +++ b/rs/moq-auth/src/wire.rs @@ -31,6 +31,17 @@ pub(crate) struct Claims { exp: Option, #[serde_as(as = "Option>")] iat: Option, + #[serde_as(as = "Option>")] + nbf: Option, + // Registered claims that narrow nothing: an issuer's bookkeeping, read and dropped. + // Every other claim is refused, since an unknown one may narrow the grant, and a + // misspelled `root` would otherwise widen it to everything. + #[serde(skip_serializing)] + iss: Option, + #[serde(skip_serializing)] + sub: Option, + #[serde(skip_serializing)] + jti: Option, } impl From for Claims { @@ -44,6 +55,10 @@ impl From for Claims { subscribe: grants.subscribe, exp: claims.expires, iat: claims.issued, + nbf: claims.not_before, + iss: None, + sub: None, + jti: None, } } } @@ -65,6 +80,7 @@ impl TryFrom for crate::Claims { subscribe, expires: wire.exp, issued: wire.iat, + not_before: wire.nbf, }) } } diff --git a/rs/moq-auth/tests/released_token.rs b/rs/moq-auth/tests/released_token.rs new file mode 100644 index 0000000000..088976a2b8 --- /dev/null +++ b/rs/moq-auth/tests/released_token.rs @@ -0,0 +1,47 @@ +//! Tokens we sign against the verifier moq-relay 0.14 shipped (`moq-token` 0.7). +//! +//! A grant of subtrees is written as legacy `put`/`get` prefixes, which 0.14 reads +//! with the same scope. Anything a prefix can't say is written as +//! `publish`/`subscribe`, which 0.14 reads as granting nothing and refuses, rather +//! than misreading an exact grant as a prefix. + +use moq_auth::{Algorithm, Claims, Key}; + +fn patterns(texts: &[&str]) -> Vec { + texts.iter().map(|text| text.parse().unwrap()).collect() +} + +/// A key pair both crates hold: ours signs, the released one verifies. +fn keys() -> (Key, moq_token::Key) { + let key = Key::generate(Algorithm::HS256, None).unwrap(); + let released = moq_token::Key::from_str(&key.to_str().unwrap()).unwrap(); + (key, released) +} + +#[test] +fn subtree_grants_verify_on_0_14_with_the_same_scope() { + let (key, released) = keys(); + let claims = Claims::default() + .with_root("room") + .with_publish(patterns(&["alice/**"])) + .with_subscribe(patterns(&["**"])); + let verified = released.verify(&key.sign(&claims).unwrap()).unwrap(); + assert_eq!(verified.root, "room"); + assert_eq!(verified.publish, ["alice"]); + assert_eq!(verified.subscribe, [""]); +} + +#[test] +fn exact_grants_are_refused_by_0_14() { + let (key, released) = keys(); + for claims in [ + Claims::default().with_root("room").with_publish(patterns(&["alice"])), + Claims::default().with_subscribe(patterns(&["*/chat"])), + // One exact grant moves the whole document to patterns. + Claims::default() + .with_publish(patterns(&["alice/**"])) + .with_subscribe(patterns(&["bob"])), + ] { + assert!(released.verify(&key.sign(&claims).unwrap()).is_err(), "{claims:?}"); + } +} diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index 2e5cff514d..7a8f9f3780 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -378,6 +378,7 @@ impl MoqSide { found.extend(self.quic.deprecated()); found.extend(self.server.deprecated()); found.extend(self.cluster.deprecated()); + found.extend(self.auth.deprecated()); if self.origin.is_some() { found.flag("--origin", Some("MOQ_ORIGIN"), "--hop / MOQ_HOP"); } diff --git a/rs/moq-cli/src/auth.rs b/rs/moq-cli/src/auth.rs index 2c2dfa4154..43f900c1e3 100644 --- a/rs/moq-cli/src/auth.rs +++ b/rs/moq-cli/src/auth.rs @@ -387,19 +387,19 @@ pub struct Serve { #[usage(long)] tier: Option, - /// How often the relay re-checks each grant. - #[usage(long, default = "1m")] - revalidate: crate::duration::Duration, + /// How often the relay re-checks each grant. Off by default; needs `--expires`. + #[usage(long)] + revalidate: Option, - /// How long a grant with no bound of its own lasts: anonymous sessions, tokens without `exp`, certificates without one. - #[usage(long, default = "1d")] - expires: crate::duration::Duration, + /// How long a grant with no bound of its own lasts: anonymous sessions, tokens without `exp`, certificates without one. Off by default. + #[usage(long)] + expires: Option, - /// The most live sessions presenting one token. + /// The most live sessions presenting one token. Needs `--revalidate`. #[usage(long)] limit_token: Option, - /// The most live sessions from one remote address. + /// The most live sessions from one remote address. Needs `--revalidate`. #[usage(long)] limit_remote: Option, } @@ -407,10 +407,21 @@ pub struct Serve { impl Serve { /// The policy these flags describe, refusing a cadence the relay would spin on. fn policy(&self) -> anyhow::Result { - let revalidate = self.revalidate.into_std(); - if revalidate.is_zero() { + let revalidate = self.revalidate.map(|cadence| cadence.into_std()); + let expires = self.expires.map(|bound| bound.into_std()); + if revalidate.is_some_and(|cadence| cadence.is_zero()) { anyhow::bail!("--revalidate must be longer than 0s; every client would re-check in a tight loop"); } + if revalidate.is_some() && expires.is_none() { + anyhow::bail!("--revalidate needs --expires, so a grant re-checked through an outage still has a bound"); + } + // Only a re-check tells the session table a slot is still live; without one, + // a relay that died without an `end` would hold its slots forever. + if (self.limit_token.is_some() || self.limit_remote.is_some()) && revalidate.is_none() { + anyhow::bail!( + "--limit-token and --limit-remote need --revalidate, which ages out the slots of dead relays" + ); + } // 0.14 read `anon` as the prefix `anon/`, and a pattern reads it as exactly the // broadcast `anon`, so either silent reading would mislead someone upgrading. let flags = [ @@ -443,7 +454,7 @@ impl Serve { policy.mtls = rules(&self.mtls_publish, &self.mtls_subscribe); policy.tier = self.tier.clone(); policy.revalidate = revalidate; - policy.expires = self.expires.into_std(); + policy.expires = expires; policy.limits.token = self.limit_token; policy.limits.remote = self.limit_remote; Ok(policy) @@ -468,7 +479,7 @@ impl Serve { async fn run(self) -> anyhow::Result<()> { let listen = self.listener()?; - let server = moq_auth::serve::Server::new(self.policy()?); + let server = moq_auth::serve::Server::new(self.policy()?)?; match listen { Listen::Tcp(addr) => { let listener = tokio::net::TcpListener::bind(addr) @@ -655,23 +666,34 @@ mod tests { assert!(policy.public.publish.is_empty()); assert_eq!(policy.mtls.publish, ["**".parse().unwrap()].into_iter().collect()); assert_eq!(policy.tier.as_deref(), Some("internal")); - assert_eq!(policy.revalidate, std::time::Duration::from_secs(30)); - assert_eq!(policy.expires, std::time::Duration::from_secs(7200)); + assert_eq!(policy.revalidate, Some(std::time::Duration::from_secs(30))); + assert_eq!(policy.expires, Some(std::time::Duration::from_secs(7200))); assert_eq!(policy.limits.token, Some(3)); assert_eq!(policy.limits.remote, Some(8)); - // Nothing configured is a server that refuses everyone, on the defaults. + // Nothing configured is a server that refuses everyone, and like 0.14, never + // re-checks or closes a session on its own. let bare = serve(&["moq", "auth", "serve"]).policy().unwrap(); assert!(bare.keys.is_none()); assert!(bare.public.is_empty() && bare.mtls.is_empty()); - assert_eq!(bare.revalidate, std::time::Duration::from_secs(60)); - assert_eq!(bare.expires, std::time::Duration::from_secs(86400)); - - // A zero cadence would have every client re-check in a tight loop. - let err = serve(&["moq", "auth", "serve", "--revalidate", "0s"]) - .policy() - .unwrap_err(); - assert!(err.to_string().contains("--revalidate"), "{err}"); + assert_eq!(bare.revalidate, None); + assert_eq!(bare.expires, None); + + for (args, needs) in [ + // A zero cadence would have every client re-check in a tight loop. + (&["--revalidate", "0s", "--expires", "1h"][..], "longer than 0s"), + // The contract refuses a cadence without a bound. + (&["--revalidate", "1m"], "--revalidate needs --expires"), + // Only a re-check keeps a slot alive. + (&["--limit-token", "3"], "need --revalidate"), + (&["--limit-remote", "3", "--expires", "1h"], "need --revalidate"), + ] { + let argv: Vec<&str> = ["moq", "auth", "serve"].iter().chain(args).copied().collect(); + let err = serve(&argv).policy().unwrap_err().to_string(); + assert!(err.contains(needs), "{args:?}: {err}"); + } + let expiring = serve(&["moq", "auth", "serve", "--expires", "1h"]).policy().unwrap(); + assert_eq!(expiring.revalidate, None); } /// 0.14 read `anon` as a prefix and a pattern reads it as one broadcast, so a diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 66715275ec..799074012d 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -69,6 +69,88 @@ pub struct Config { #[serde(skip_serializing_if = "Vec::is_empty")] #[serde_as(as = "OneOrMany<_>")] pub public_publish: Vec, + + /// The 0.14 flags and env vars #3688 removed, kept parsing but hidden. Never + /// read as settings: [`deprecated`](Self::deprecated) names what replaced each. + #[usage(flatten)] + #[serde(skip)] + pub(crate) legacy: Legacy, + + // The 0.14 `[auth]` keys #3688 removed, kept only so they refuse with their + // replacement named rather than being ignored. + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) key: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) key_dir: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) auth_api: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) domains: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) mtls_tier: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) tls: Option, +} + +/// The `--auth-*` flags 0.14 had and #3688 removed, with their env vars: a relay +/// deployed through the environment would otherwise ignore them without a word. +#[derive(Clone, Debug, Default, usage::Args)] +#[usage(unknown_flags = "error", args_override_self = false)] +pub(crate) struct Legacy { + #[usage(name = "auth-key", long = "auth-key", env = "MOQ_AUTH_KEY", hide = true)] + key: Option, + #[usage(name = "auth-key-dir", long = "auth-key-dir", env = "MOQ_AUTH_KEY_DIR", hide = true)] + key_dir: Option, + #[usage(name = "auth-api", long = "auth-api", env = "MOQ_AUTH_API", hide = true)] + api: Option, + #[usage( + name = "auth-public-api", + long = "auth-public-api", + env = "MOQ_AUTH_PUBLIC_API", + hide = true + )] + public_api: Option, + #[usage(name = "auth-domain", long = "auth-domain", env = "MOQ_AUTH_DOMAIN", hide = true)] + domain: Vec, + #[usage( + name = "auth-mtls-tier", + long = "auth-mtls-tier", + env = "MOQ_AUTH_MTLS_TIER", + hide = true + )] + mtls_tier: Option, + #[usage( + name = "auth-tls-root", + long = "auth-tls-root", + env = "MOQ_AUTH_TLS_ROOT", + hide = true + )] + tls_root: Vec, + #[usage( + name = "auth-tls-cert", + long = "auth-tls-cert", + env = "MOQ_AUTH_TLS_CERT", + hide = true + )] + tls_cert: Option, + #[usage(name = "auth-tls-key", long = "auth-tls-key", env = "MOQ_AUTH_TLS_KEY", hide = true)] + tls_key: Option, + #[usage( + name = "auth-tls-disable-verify", + long = "auth-tls-disable-verify", + env = "MOQ_AUTH_TLS_DISABLE_VERIFY", + hide = true, + default_missing = "true", + num_args = 0..=1, + require_equals = true + )] + tls_disable_verify: Option, } impl Config { @@ -82,6 +164,76 @@ impl Config { (!publish.is_empty() || !subscribe.is_empty()).then(|| Grant::new(publish, subscribe)) } + /// The 0.14 settings in use, each paired with what replaced it. A relay refuses + /// to start on any: `key` with `public` would otherwise boot with every JWT + /// ignored. + pub fn deprecated(&self) -> moq_tokio::cli::Deprecated { + let mut found = moq_tokio::cli::Deprecated::default(); + let legacy = &self.legacy; + for (set, flag, env, toml, new) in [ + ( + legacy.key.is_some(), + "--auth-key", + "MOQ_AUTH_KEY", + self.key.is_some().then_some("[auth] key"), + "--auth-url to `moq auth serve --key`", + ), + ( + legacy.key_dir.is_some(), + "--auth-key-dir", + "MOQ_AUTH_KEY_DIR", + self.key_dir.is_some().then_some("[auth] key_dir"), + "--auth-url to `moq auth serve --key-dir`", + ), + ( + legacy.api.is_some(), + "--auth-api", + "MOQ_AUTH_API", + self.auth_api.is_some().then_some("[auth] auth_api"), + "--auth-url to a server answering the contract", + ), + ( + legacy.public_api.is_some(), + "--auth-public-api", + "MOQ_AUTH_PUBLIC_API", + None, + "--auth-url to a server answering the contract", + ), + ( + !legacy.domain.is_empty(), + "--auth-domain", + "MOQ_AUTH_DOMAIN", + self.domains.is_some().then_some("[auth] domains"), + "an auth server that reads `server_name`", + ), + ( + legacy.mtls_tier.is_some(), + "--auth-mtls-tier", + "MOQ_AUTH_MTLS_TIER", + self.mtls_tier.is_some().then_some("[auth] mtls_tier"), + "--auth-url to `moq auth serve --tier`", + ), + ( + !legacy.tls_root.is_empty() + || legacy.tls_cert.is_some() + || legacy.tls_key.is_some() + || legacy.tls_disable_verify.is_some(), + "--auth-tls-*", + "MOQ_AUTH_TLS_*", + self.tls.is_some().then_some("[auth.tls]"), + "--connect-tls-*, which an https:// --auth-url presents", + ), + ] { + if set { + found.flag(flag, Some(env), new); + } + if let Some(toml) = toml { + found.toml(toml, new, None); + } + } + found + } + /// Whether no source is named at all. Such a relay admits nothing on its own: /// [`Relay::load`](crate::Relay::load) hands its sessions to the embedder as /// [`Admissions`], and [`validate`](Self::validate) refuses it for a binary. @@ -370,6 +522,12 @@ impl Decider { } }); } + // Nothing here can verify a token, so one is refused rather than ignored: + // its holder expects it to count, and the public grant is not what it says. + Self::Public(_) if presents_token(&admission.request) => { + tracing::debug!(path = %admission.request.path, "a token was presented to public rules"); + admission.refuse(Error::Refused); + } Self::Public(rules) => match rules.authorize(&admission.request.path) { Ok(access) => { let grant = Grant::new(access.publish, access.subscribe); @@ -389,6 +547,17 @@ impl Decider { } } +/// Whether `request` carries a token: a SETUP token of any type, or a non-empty +/// `jwt` query parameter, the convention `moq auth serve` reads. +fn presents_token(request: &Request) -> bool { + request.token.is_some() + || request.query.as_deref().is_some_and(|query| { + query + .split('&') + .any(|pair| pair.strip_prefix("jwt=").is_some_and(|jwt| !jwt.is_empty())) + }) +} + /// Admits sessions by queueing every request for one admission decider. #[derive(Clone)] pub struct Auth { @@ -611,6 +780,35 @@ mod tests { } } + /// Public rules verify nothing, so a token is refused rather than ignored, in + /// whichever form it arrives. + #[tokio::test] + async fn public_rules_refuse_a_token() { + let auth = config(None, &["**"]) + .init("relay-1", &moq_tokio::tls::Connect::default()) + .unwrap(); + let mut query = auth.request(moq_auth::Transport::WebSocket, "/"); + query.query = Some("a=1&jwt=eyJ".into()); + let mut setup = auth.request(moq_auth::Transport::Quic, "/"); + setup.token = Some(moq_auth::Token { + kind: moq_auth::Token::CAT, + value: b"anything".to_vec(), + }); + let mut http = auth.request(moq_auth::Transport::Http, "/"); + http.query = Some("jwt=eyJ".into()); + for request in [query, setup, http] { + let Err(err) = auth.admit(request).await else { + panic!("a token was admitted on the public grant"); + }; + assert!(matches!(err, Error::Refused), "{err}"); + } + + // An empty `jwt=` is no token. + let mut empty = auth.request(moq_auth::Transport::WebSocket, "/"); + empty.query = Some("jwt=&b=2".into()); + assert!(auth.admit(empty).await.is_ok()); + } + /// A certificate is a fact the public rules ignore: it gets exactly what anyone does. #[tokio::test] async fn a_public_config_admits_anonymous_and_certificate_alike() { diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index 3fca841c59..658a3e0035 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -310,6 +310,7 @@ impl Config { deprecated.extend(self.listen.deprecated()); deprecated.extend(self.connect.deprecated()); deprecated.extend(self.cluster.deprecated()); + deprecated.extend(self.auth.deprecated()); if let Some(server) = &self.server { deprecated.toml("[server]", "[listen]", None); deprecated.extend(server.deprecated()); @@ -423,6 +424,66 @@ max_streams = 64 assert!(err.contains("both directions"), "{err}"); } + /// A 0.14 `[auth]` config with `key` and `public` would otherwise boot with + /// every JWT ignored; each removed key refuses with its replacement named. + #[test] + fn released_auth_keys_refuse_to_boot() { + let toml = r#" +[auth] +key = "root.jwk" +key_dir = "keys/" +auth_api = "https://api.example.com/auth" +domains = ["example.com"] +mtls_tier = "internal" +public = "anon/**" + +[auth.tls] +root = ["ca.pem"] +"#; + let mut config: Config = toml::from_str(toml).expect("released config must still parse"); + let err = config.resolve().expect_err("must refuse").to_string(); + for old in [ + "[auth] key -> --auth-url to `moq auth serve --key`", + "[auth] key_dir -> --auth-url to `moq auth serve --key-dir`", + "[auth] auth_api -> ", + "[auth] domains -> ", + "[auth] mtls_tier -> --auth-url to `moq auth serve --tier`", + "[auth.tls] -> --connect-tls-*", + ] { + assert!(err.contains(old), "{old}: {err}"); + } + } + + /// The environment is the half a removed flag silently misses: a relay deployed + /// through it never typed the flag. + #[test] + fn released_auth_env_refuses_to_boot() { + let vars = [ + ("MOQ_AUTH_KEY", "--auth-key / MOQ_AUTH_KEY"), + ("MOQ_AUTH_KEY_DIR", "--auth-key-dir / MOQ_AUTH_KEY_DIR"), + ("MOQ_AUTH_API", "--auth-api / MOQ_AUTH_API"), + ("MOQ_AUTH_PUBLIC_API", "--auth-public-api / MOQ_AUTH_PUBLIC_API"), + ("MOQ_AUTH_DOMAIN", "--auth-domain / MOQ_AUTH_DOMAIN"), + ("MOQ_AUTH_MTLS_TIER", "--auth-mtls-tier / MOQ_AUTH_MTLS_TIER"), + ("MOQ_AUTH_TLS_ROOT", "--auth-tls-* / MOQ_AUTH_TLS_*"), + ]; + let _env = EnvGuard::clear(&vars.map(|(var, _)| var)); + for (var, spelling) in vars { + unsafe { std::env::set_var(var, "x") }; + let err = Config::parse_and_merge(["moq-relay", "--auth-public", "**"]) + .expect_err("must refuse") + .to_string(); + unsafe { std::env::remove_var(var) }; + assert!(err.contains(spelling), "{var}: {err}"); + } + + // 0.14 took the bare flag, so it has to parse to be refused by name. + let err = Config::parse_and_merge(["moq-relay", "--auth-public", "**", "--auth-tls-disable-verify"]) + .expect_err("must refuse") + .to_string(); + assert!(err.contains("--auth-tls-* / MOQ_AUTH_TLS_*"), "{err}"); + } + /// A released flag and a released table are refused together, in one message. /// /// The flag lands on a hidden field the merge's TOML round-trip drops, so a diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index 082ab27978..5670a7a821 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -123,6 +123,16 @@ impl Relay { .clone() .or_else(|| config.cluster.node.clone()) .unwrap_or_default(); + // Public rules grant a certificate what they grant anyone, so a client CA + // would only have browsers offer certificates for nothing. + if config.auth.url.is_none() + && config.auth.public_grant().is_some() + && !(config.listen.tls.root.is_empty() && config.web.https.root.is_empty()) + { + anyhow::bail!( + "--listen-tls-root and --web-https-root verify client certificates, which --auth-public ignores; remove them, or grant certificates with --auth-url to `moq auth serve --mtls-*`" + ); + } // No `[auth]` source means the embedder decides: it takes the admissions // before `run`, which refuses to start if nobody did. let (auth, admissions) = match config.auth.is_empty() { diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 6063889f3e..a61e794a67 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -856,7 +856,7 @@ async fn a_certificate_admits_only_what_the_server_grants() { let serve = |policy: moq_auth::serve::Policy| async move { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url: url::Url = format!("http://{}/", listener.local_addr().unwrap()).parse().unwrap(); - let server = moq_auth::serve::Server::new(policy); + let server = moq_auth::serve::Server::new(policy).unwrap(); tokio::spawn(async move { server.serve(listener).await }); url }; diff --git a/rs/moq-relay/tests/public_rules.rs b/rs/moq-relay/tests/public_rules.rs index 1928f55f22..51445d5c3b 100644 --- a/rs/moq-relay/tests/public_rules.rs +++ b/rs/moq-relay/tests/public_rules.rs @@ -227,7 +227,7 @@ async fn auth_server_public_rules_are_rooted_at_slash() { policy.public = moq_auth::Permissions::new(Default::default(), ["event/**".parse().unwrap()].into_iter().collect()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let server_url: url::Url = format!("http://{}/", listener.local_addr().unwrap()).parse().unwrap(); - let server = moq_auth::serve::Server::new(policy); + let server = moq_auth::serve::Server::new(policy).unwrap(); tokio::spawn(async move { server.serve(listener).await }); let mut config = auth::Config::default(); @@ -247,3 +247,22 @@ async fn auth_server_public_rules_are_rooted_at_slash() { relay.abort(); } + +/// Public rules grant a certificate what they grant anyone, so a client CA on a +/// public-only relay is refused rather than left to verify certificates for nothing. +#[tokio::test] +async fn a_client_ca_needs_an_auth_server() { + for web in [false, true] { + let mut config = moq_relay::Config::default(); + config.auth = anon(); + match web { + false => config.listen.tls.root = vec!["ca.pem".into()], + true => config.web.https.root = vec!["ca.pem".into()], + } + let error = match moq_relay::Relay::load(config).await { + Ok(_) => panic!("a client CA was accepted under --auth-public"), + Err(error) => error.to_string(), + }; + assert!(error.contains("--auth-public ignores"), "{error}"); + } +} diff --git a/rs/moq-relay/tests/runtime_uring.rs b/rs/moq-relay/tests/runtime_uring.rs index 0d8be349f9..0fa3e6e63b 100644 --- a/rs/moq-relay/tests/runtime_uring.rs +++ b/rs/moq-relay/tests/runtime_uring.rs @@ -489,7 +489,7 @@ async fn spawn_auth_server(policy: moq_auth::serve::Policy) -> url::Url { let url = format!("http://{}/", listener.local_addr().expect("auth addr")) .parse() .expect("auth url"); - let server = moq_auth::serve::Server::new(policy); + let server = moq_auth::serve::Server::new(policy).unwrap(); tokio::spawn(async move { server.serve(listener).await }); url } From 6a7262c4eaf30901464ce078322530ea14a507cd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 16:40:36 -0700 Subject: [PATCH 46/87] quest: plan cluster routing, update dedupe, and the local origin (#4213) Co-authored-by: Claude Opus 5.5 --- quest/m0/README.md | 3 + quest/m0/announce-update-dedupe.md | 32 ++++++++ quest/m0/local-origin.md | 36 +++++++++ quest/{m1 => m0}/wildcard/README.md | 8 +- quest/{m1 => m0}/wildcard/demand.md | 4 +- quest/{m1 => m0}/wildcard/resolve.md | 0 quest/m1/README.md | 2 +- quest/m1/archive/README.md | 2 +- quest/m1/broadcast-epoch/README.md | 2 +- quest/m1/cluster-routing.md | 116 +++++++++++++++++++++++++++ quest/m1/path-patterns.md | 2 +- quest/m1/pop-skipping/README.md | 2 +- quest/m1/processor/README.md | 2 +- quest/m2/README.md | 1 - quest/m2/plan-routing-origin.md | 41 ---------- 15 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 quest/m0/announce-update-dedupe.md create mode 100644 quest/m0/local-origin.md rename quest/{m1 => m0}/wildcard/README.md (98%) rename quest/{m1 => m0}/wildcard/demand.md (94%) rename quest/{m1 => m0}/wildcard/resolve.md (100%) create mode 100644 quest/m1/cluster-routing.md delete mode 100644 quest/m2/plan-routing-origin.md diff --git a/quest/m0/README.md b/quest/m0/README.md index aa41bc85a5..9794e7ecae 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -94,6 +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 +- [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - a publisher sends an announce update only when the wire route changed +- [Wildcard](/quest/m0/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability +- [Local origin](/quest/m0/local-origin.md) - localhost workers read only the broadcasts their relay ingested, from the internal listener - [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess - [A/V clock](/quest/m0/plan-av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles diff --git a/quest/m0/announce-update-dedupe.md b/quest/m0/announce-update-dedupe.md new file mode 100644 index 0000000000..c428466a89 --- /dev/null +++ b/quest/m0/announce-update-dedupe.md @@ -0,0 +1,32 @@ +# [S] Skip unchanged announce updates + +## Goal + +A publisher sends an announce update only when what the peer would decode +differs from what it last sent for that announcement. A local change the wire +cannot express (the route's source session, `served`, captures) sends nothing. +This applies on every version, lite and IETF, in Rust and JS, and it is wire +compatible. + +## Plan + +Each announce cursor dedupes its best route on `(hops, cost, source)` plus +`served` and captures (`rs/moq-net/src/model/origin.rs`, the `Updated` kind), +but the lite publisher only remembers each suffix's Announce ID +(`rs/moq-net/src/lite/publisher.rs`, the `self.live` branch) and re-sends +`Restart` for every `Updated` it sees. Only the hops and cost reach the wire, +so a flip of any other field sends an identical update, to every peer, for +every covered broadcast. This comes from reading the code, not from a test. + +- Reproduce first: a test that flips a route's source session with the same + hops and cost, and asserts the peer receives no update. +- Keep the last-sent `(hops, cost)` beside the Announce ID and skip equal + updates. Check the IETF publisher's re-pricing path and the JS publisher for + the same pattern. +- Fix the stale comments on the way: `lite/announce.rs` says restarts are only + ever received, and the `restart_announce` doc in `lite/subscriber.rs` says it + compares the first hop. + +## Related + +- [Cluster routing](/quest/m1/cluster-routing.md) - removes the other big source of updates, reroutes that change only the hop chain diff --git a/quest/m0/local-origin.md b/quest/m0/local-origin.md new file mode 100644 index 0000000000..60937e64ac --- /dev/null +++ b/quest/m0/local-origin.md @@ -0,0 +1,36 @@ +# [M] Local origin + +## Goal + +A localhost worker (a Voice agent, a recorder) connects to the relay's +internal listener and gets a moq session whose origin holds only the +broadcasts this relay ingested from its own customer sessions, never those +learned from cluster peers. Workers stop electing on hop chains ("the first +internal hop is me"), which [Cluster routing](/quest/m1/cluster-routing.md) +removes inside a cluster. + +## Plan + +- The view exists: `origin::Consumer::local()` (`rs/moq-net/src/model/origin.rs`) + hides every route a `Producer::peer` handle announced, which is how cluster + peers attach (`rs/moq-relay/src/cluster.rs`), and + `local_view_hides_peer_routes` covers it. The work is serving that view. +- The internal listener (`rs/moq-relay/src/internal.rs`) is plain HTTP + today (`/metrics`, `/health`, `/nodes`, `/sessions`). Serve a moq session on + it over the WebSocket transport the relay already accepts. It is + unauthenticated, so serve it only when the internal listener binds a + loopback address. The listener may also bind a private-overlay address, + which would expose customer media to the overlay; config load fails if the + local origin is enabled there. +- The session is read-only. Open question: Voice publishes responses. Decide + whether this session may publish into the relay origin, and under which + grant, before it accepts a publish. +- Document it in `doc/bin/relay/`. + +Tests: a two-relay cluster where each relay's local session lists only its own +publishers, a publisher moving relays moves between the two views, and a +peer-learned route never appears. + +## Related + +- [Voice on the local origin](https://github.com/moq-dev/moq.pro/blob/main/quest/m0/voice-local-origin.md) - moq.pro's Voice and recorder switch to this diff --git a/quest/m1/wildcard/README.md b/quest/m0/wildcard/README.md similarity index 98% rename from quest/m1/wildcard/README.md rename to quest/m0/wildcard/README.md index e7be970d69..13795b6eff 100644 --- a/quest/m1/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -64,7 +64,7 @@ the requester's excluded hop, ordered by `route_order` (`rs/moq-net/src/model/origin.rs:633`), served on demand by the session that announced it and cached per prefix in `ServeState.served` (`:764`). That is the split-horizon-safe lookup the old `origin::Dynamic` could not provide, and it is what -[Resolve](/quest/m1/wildcard/resolve.md) now extends rather than replaces. +[Resolve](/quest/m0/wildcard/resolve.md) now extends rather than replaces. Request resolution, by contrast, is still prefix-only (`best_server` in `rs/moq-net/src/model/origin.rs`). The pattern matcher itself exists: @@ -154,7 +154,7 @@ field. composer waiting for an announcement that only demand would produce. The browser player currently enforces the opposite (`js/watch`'s `#isPathAnnounced` hides a catalog rendition with no exact-path - announcement); [Demand](/quest/m1/wildcard/demand.md) makes a covering wildcard count as + announcement); [Demand](/quest/m0/wildcard/demand.md) makes a covering wildcard count as availability there. - **Refusal is a typed stream reset, with no negative cache.** An advertiser resets a subscribe it will not serve, and the reset carries which KIND of @@ -258,9 +258,9 @@ than announce state. ## Quests -- [Resolve](/quest/m1/wildcard/resolve.md) - a relay resolves a subscribe or +- [Resolve](/quest/m0/wildcard/resolve.md) - a relay resolves a subscribe or FETCH for an unannounced path against the best matching wildcard -- [Demand](/quest/m1/wildcard/demand.md) - the browser player subscribes to a +- [Demand](/quest/m0/wildcard/demand.md) - the browser player subscribes to a catalog-referenced broadcast a wildcard covers, breaking the lazy-rendition deadlock diff --git a/quest/m1/wildcard/demand.md b/quest/m0/wildcard/demand.md similarity index 94% rename from quest/m1/wildcard/demand.md rename to quest/m0/wildcard/demand.md index 9cd10f88fd..80be0b35ed 100644 --- a/quest/m1/wildcard/demand.md +++ b/quest/m0/wildcard/demand.md @@ -28,7 +28,7 @@ Do not simply delete the gate. It exists so the player does not subscribe to absent broadcasts and so renditions appear and disappear reactively with announcements. The Rust side needs nothing here: `moq-mux::Source` resolves references through `request_broadcast`, which -[resolve](/quest/m1/wildcard/resolve.md) teaches to consult patterns. +[resolve](/quest/m0/wildcard/resolve.md) teaches to consult patterns. Two existing soft spots to not reintroduce: the first evaluation runs before the announcement stream has populated, briefly hiding cross-broadcast @@ -45,5 +45,5 @@ nothing visibly. ## Required -- [Resolve](/quest/m1/wildcard/resolve.md) - recognizing the wildcard is useless +- [Resolve](/quest/m0/wildcard/resolve.md) - recognizing the wildcard is useless until the relay routes the resulting subscribe through it diff --git a/quest/m1/wildcard/resolve.md b/quest/m0/wildcard/resolve.md similarity index 100% rename from quest/m1/wildcard/resolve.md rename to quest/m0/wildcard/resolve.md diff --git a/quest/m1/README.md b/quest/m1/README.md index e78483fdba..343826bb77 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -19,6 +19,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Drill sensitivity](/quest/m1/drill-sensitivity.md) - the nightly drill-sensitivity job passes: the subscriber-leaks-broadcasts mutation applies to the current lite subscriber again - [BBR classic ECN](/quest/m1/bbr-classic-ecn.md) - Startup and bandwidth probing respond to CE marks before the bottleneck drops packets +- [Cluster routing](/quest/m1/cluster-routing.md) - an announcement says where a broadcast originates, not how to reach it, and a relay hears only the prefixes its clients asked for - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` - [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` @@ -75,7 +76,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships -- [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability - [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 diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index b772e4d8f2..2e8b5508de 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -117,5 +117,5 @@ owned by that prerequisite, not duplicated in archive storage. - [Catalog track identity](/quest/m2/catalog-tracks.md) - explore immutable definitions or explicit version binding independently of archives -- [wildcard](/quest/m1/wildcard/README.md) - catch-all routing exposes an archive at its stable replay path +- [wildcard](/quest/m0/wildcard/README.md) - catch-all routing exposes an archive at its stable replay path - [e2ee](/quest/m1/e2ee/README.md) - protected broadcasts are excluded initially diff --git a/quest/m1/broadcast-epoch/README.md b/quest/m1/broadcast-epoch/README.md index a0a9551ecc..0c40773fe3 100644 --- a/quest/m1/broadcast-epoch/README.md +++ b/quest/m1/broadcast-epoch/README.md @@ -42,7 +42,7 @@ Decided: prefix route. Document this rather than promise it works. - Derived output lives under the epoch it came from (`pid/foo.hang/@e/transcode.pro`), so nested epochs must parse. This moves - the [wildcard](/quest/m1/wildcard/README.md) line's derived-output example + the [wildcard](/quest/m0/wildcard/README.md) line's derived-output example down one segment, and its suffix patterns still match. This README owns: diff --git a/quest/m1/cluster-routing.md b/quest/m1/cluster-routing.md new file mode 100644 index 0000000000..13d66d28a8 --- /dev/null +++ b/quest/m1/cluster-routing.md @@ -0,0 +1,116 @@ +# [XL] Cluster routing + +## Goal + +A broadcast event reaches each relay at most once, and a relay learns only the +prefixes its own clients asked for. An announcement says a path exists at an +origin relay, at a cost; how to reach that origin comes from a shared relay +topology, so no announcement inside a cluster carries a hop list. This quest +records the design; its wire and implementation quests are planned from the +simulator's report. + +Non-goals: warm re-origination (a warm relay would be one more origin with a +cost, so leave room for it), and a permanently mixed-version cluster. + +## Plan + +### Why not path vector or Babel + +Today every relay advertises its best route to every peer not already in the +hop chain. One publish costs about R·(d-1) announces for R relays of mesh +degree d, every relay learns every broadcast (`.stats` and `.internal` +included), and a link change rewrites every route crossing it. On moq.pro's +live fleet (26 PoPs, average degree about 5) each relay receives every event +about five times. Babel ([RFC 8966](https://www.rfc-editor.org/rfc/rfc8966)) +shrinks the message and skips equal-cost reroutes, but it is still distance +vector: the same fan-out, the same global knowledge, and no loop freedom +when several sources claim a prefix (section 2.7), which pools and wildcards +make MoQ's common case. + +### Decisions + +- Existence is split from reachability. An announcement carries the path, its + origin relay, and the origin's cost, and nothing about the path to it. +- An existence event carries the origin's seqno, scoped to its incarnation. A + relay applies an event only when it is newer than the last it applied for + that path and origin, and keeps an ended path's seqno, so a start delayed on + a stale tree or a failed-over registry cannot revive it. Babel keeps + feasibility past withdrawal for the same reason + ([RFC 8966 section 3.7.3](https://www.rfc-editor.org/rfc/rfc8966#section-3.7.3)). +- The topology is configured: `--cluster-connect` or the connect API gives the + relay graph and link costs. Relays flood per-link liveness among themselves + with a per-link seqno. The seqno is scoped to the relay's incarnation, so a + restarted relay's links supersede its stale ones instead of looking older. + Gossip discovery (`cluster.mesh`) stays for zero-config self-hosting and + derives the topology from what it discovers; it need not scale. +- A relay picks the origin with the lowest shortest-path distance plus origin + cost, ties broken by rendezvous hashing (HRW) of the requested path and the + origin id, and forwards along its shortest path. Distance compares cost, + then hop count, so every hop strictly shortens it even across `?cost=0` + links. That is a shortest path to a virtual node linked to every origin, so + it is loop-free whenever relays agree on the topology. Specificity still + ranks first, per [Wildcard](/quest/m0/wildcard/README.md). +- The first relay's choice rides the SUBSCRIBE, and transit relays forward + toward that origin by topology alone, never re-selecting. Re-selection + against another existence view loops: a relay that lost a specific claim + falls back to a broader one through a relay still routing to the specific + one ([RFC 8966 section 3.5.4](https://www.rfc-editor.org/rfc/rfc8966#section-3.5.4)). + If the origin no longer serves the path, it refuses, and the first relay + selects again. +- SUBSCRIBE and FETCH carry a visited-relay list end to end. It catches loops + while liveness views disagree and names the path for stats. Narrowing it to + cluster hops is later work. The serving origin's identity rides the reply, + per Wildcard's Spread quest. +- Announcements are on demand. A relay forwards only the union of its clients' + ANNOUNCE_REQUEST prefixes, never the empty prefix. A wide prefix that many + edges' viewers request is that customer's cost. `.stats` becomes ordinary + demand. +- Registries are an optional, configured tier: moq-relay in a registry mode, + one or more per region. + - An ingest relay registers its broadcasts with its nearest registry, and an + edge sends its ANNOUNCE_REQUEST there. + - Registries form a small full mesh and flood existence among themselves, so + an event crosses an ocean once per remote registry, not once per relay. + Announce latency is about one round trip to the nearest registry, + whatever the path length. + - A relay fails over to the next-nearest registry and reconciles its view + instead of treating the lost session as ends, so a registry failure never + reports a live broadcast offline. + - With no registry reachable, a relay freezes: it keeps its view, learns + nothing new, and alerts. Falling back to flooding would cascade the + failure. +- Without registries (self-hosting), existence floods along the shortest-path + tree, one copy per relay. A relay forwards an event only when it changes its + view, so a duplicate copy, from trees built on disagreeing liveness, stops + there. +- Between clusters, announcements stay path vector with cluster ids as the + hops, like BGP between autonomous systems. A customer's on-prem cluster is + one hop, and an announcement naming the receiving cluster is dropped. +- The cluster switches versions as a whole; older lite and IETF sessions stay + at its edges. + +### Open questions + +- Sharding registries by HRW over a prefix key once one registry cannot hold + everything, and what that key is. +- A mixed-version bridge, if a fleet cannot switch at once. +- How long a relay keeps an ended path's seqno. A new origin incarnation + clears it; within one, it must outlive every delayed copy of the start. +- How an edge routes a SUBSCRIBE for a path none of its clients asked to + announce. It holds no route for it, and asking a registry first adds a round + trip before the first byte. +- What a cold ANNOUNCE_REQUEST reports as live. Answering from the local view + keeps a relay from waiting on peers but reports an empty set until the + registry's replay lands, on every new prefix rather than in a rare race. +- What remains of announce compression's hop-tail half (`Hop Base` and + `Hop Keep` in the lite draft) once only cluster boundaries carry hops. + +## Required + +- moq.pro's routing simulator reports ([quest](https://github.com/moq-dev/moq.pro/blob/main/quest/m1/routing-simulator.md)) + +## Related + +- [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - cuts duplicate updates on today's routing +- [Local origin](/quest/m0/local-origin.md) - workers stop reading hop chains before they go +- [Wildcard](/quest/m0/wildcard/README.md) - the specificity, pool spread, and reply identity this selection builds on diff --git a/quest/m1/path-patterns.md b/quest/m1/path-patterns.md index fff74556a5..90b43fb304 100644 --- a/quest/m1/path-patterns.md +++ b/quest/m1/path-patterns.md @@ -98,5 +98,5 @@ matches, containment refusal, and old-version behavior. ## Related -- [Wildcard advertisements](/quest/m1/wildcard/README.md) - routing adopts the +- [Wildcard advertisements](/quest/m0/wildcard/README.md) - routing adopts the matcher while retaining its own cost, pool, refusal, and resolution work diff --git a/quest/m1/pop-skipping/README.md b/quest/m1/pop-skipping/README.md index c1399c0b19..89a3652246 100644 --- a/quest/m1/pop-skipping/README.md +++ b/quest/m1/pop-skipping/README.md @@ -154,5 +154,5 @@ costs of one bidirectional session, which one `?cost=` cannot split. ## Related - [drain](/quest/m1/drain/README.md) - a second relay per PoP makes the same-PoP link price and its connection cardinality operationally important -- [wildcard](/quest/m1/wildcard/README.md) - it reuses this questline's route cost, and needs a cluster on Lite06 +- [wildcard](/quest/m0/wildcard/README.md) - it reuses this questline's route cost, and needs a cluster on Lite06 - [relay-memory](/quest/m1/relay-memory.md) - a denser mesh multiplies whatever a non-selected route costs diff --git a/quest/m1/processor/README.md b/quest/m1/processor/README.md index 93041435b6..dcc625f468 100644 --- a/quest/m1/processor/README.md +++ b/quest/m1/processor/README.md @@ -30,5 +30,5 @@ and custom transforms use the same worker lifecycle. - [Reference vision worker](/quest/m3/processor-vision.md) - a runnable worker publishes frame-correlated detections and proves demand, reconnect, failover, and teardown end to end -- [Wildcard advertisements](/quest/m1/wildcard/README.md) - lets a dormant +- [Wildcard advertisements](/quest/m0/wildcard/README.md) - lets a dormant processor advertise what it could serve without enumerating live sources diff --git a/quest/m2/README.md b/quest/m2/README.md index 5f1a80717d..33a1ac3dd6 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -55,7 +55,6 @@ upstream release waits in [m4](/quest/m4/README.md). - [Compressed tracks](/quest/m2/flate/README.md) - any track compresses per group from every language, not only the JSON modes - [Binary delta stats](/quest/m2/stats-delta.md) - an on-demand varint delta flavor of every stats track, if relay encode CPU still matters after the JSON fixes - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink: a flushing restart after EOS opens a new publication generation -- [Plan: routing without a hop list](/quest/m2/plan-routing-origin.md) - whether announcements can drop the hop list and stay loop-free once stitching keys on the subscribe reply - [Redundant ingest](/quest/m2/redundant-ingest.md) - decide whether two publishers sharing one epoch may splice, and who declares the incumbent dead before the keep-alive does - [Multipath spike](/quest/m2/multipath-spike.md) - whether bonded contribution over multipath QUIC is worth building, given it needs noq on both ends - [Receive timestamps](/quest/m2/quic-receive-ts.md) - per-packet arrival times in ACKs, the feedback GCC and deadlines need diff --git a/quest/m2/plan-routing-origin.md b/quest/m2/plan-routing-origin.md deleted file mode 100644 index 5a0c7f74f3..0000000000 --- a/quest/m2/plan-routing-origin.md +++ /dev/null @@ -1,41 +0,0 @@ -# [M] Plan: routing without a hop list - -## Goal - -Decide whether moq-lite announcements can drop the full hop list and still -route loop-free, then write the decision into quests. Today every -announcement carries the Hop IDs it traversed. That path does three jobs: -loop prevention (a relay discards a path containing itself), request -exclusion (never serve a request back through the peer that made it), and -route identity (the first hop decides whether a failover may stitch -seamlessly). The hop list was designed before prefix claims, and once the Spread quest in -[Wildcard advertisements](/quest/m1/wildcard/README.md) moves stitching -identity onto the subscribe reply, only the first two jobs remain. - -## Plan - -Open questions, to settle with the maintainer: - -- Loop freedom without a path. Rising cost alone ("cost + 1, never - advertise lower") counts to infinity after a withdrawal, as two relays - offer each other the stale route at ever higher cost. Remembering each - route's immediate neighbor and never echoing a route back to it (split - horizon) stops two-relay loops, but not three-relay loops. It also hides - backups: a relay never learns an alternative that passes back through the - neighbor it came from. Babel's feasibility condition (RFC 8966: a - per-origin sequence number; a route is feasible when its sequence number is - newer, at any cost, or equal and cheaper than the best seen for it) is - loop-free with only the origin on the wire. Weigh it and - any simpler scheme against what the relay cluster actually needs. -- What replaces request exclusion, and whether it still matters once - announcements are loop-free. -- What the stats and sidecar consumers lose without a path, and whether - anything besides the origin must stay on the wire. -- The version it lands in (lite-07 is `moq-lite-07-wip` today) and the - bridge to versions that still carry a hop list. lite-07 already compresses - each announcement's hop chain against a live base (#4196, - `rs/moq-net/src/lite/compress.rs`); dropping the hop list removes that too. - -## Related - -- [Wildcard advertisements](/quest/m1/wildcard/README.md) - its Spread quest moves stitching identity to the subscribe reply, which this builds on From 040d68c7aba9e50da26fd059a458bf8d3528b5b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:25:52 +0000 Subject: [PATCH 47/87] chore(deps): bump the nix group across 1 directory with 3 updates (#4309) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Luke Curley Co-authored-by: Claude Opus 5.5 --- flake.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flake.lock b/flake.lock index 05b62f209b..fca20bdac3 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1788465171, - "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=", + "lastModified": 1789760033, + "narHash": "sha256-jtT4yxZpR8seYnlCMMWSSPlFN92zO6ICuZ8pJrmi86k=", "owner": "ipetkov", "repo": "crane", - "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54", + "rev": "73b980519cefc727a5f6cc8e5c0947a2f9be6edd", "type": "github" }, "original": { @@ -35,11 +35,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1788549839, - "narHash": "sha256-kOrCcSIA6w9J1hX5DqHy2k9pDTJymExTsbV74U9UtCA=", + "lastModified": 1790510107, + "narHash": "sha256-EVMNYv7hYDDD9TGVT/hIyTYgpiXA8y3m5xIEIxuGNU0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "17de0b976395537756f30a3e78f2f06e5cec89ed", + "rev": "3181085bfd08663b6b9e60bc7a8395c2aaa741bd", "type": "github" }, "original": { @@ -95,11 +95,11 @@ ] }, "locked": { - "lastModified": 1788678114, - "narHash": "sha256-pcqbpV4ZI79al6KAlr+WJjaEo/PYfgctRlG43D5BSJM=", + "lastModified": 1789889921, + "narHash": "sha256-aL/ogiN7qZCGeNovS1f9hX73jwYIKb4Pcq6AZm57WtY=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "4748ec2f5ed4a881474ed4c98aa71a5308cdac8d", + "rev": "1fb104a12a8667045559b2575d6d448ae2fbd99b", "type": "github" }, "original": { From 088cc2b6b6f737dbf349b8957b0e9bb53ee79739 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 17:41:54 -0700 Subject: [PATCH 48/87] feat(net): read a subtree through an origin mount (#4271) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/auth.md | 8 +- doc/concept/moq-lite.md | 6 +- doc/lib/rs/moq-auth.md | 2 +- js/auth/src/contract.ts | 2 + rs/moq-auth/src/grant.rs | 18 + rs/moq-net/benches/origin.rs | 50 +++ rs/moq-net/src/model/origin.rs | 612 ++++++++++++++++++++++++++++++--- rs/moq-relay/src/auth.rs | 30 +- rs/moq-relay/src/cluster.rs | 53 ++- 9 files changed, 717 insertions(+), 64 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 4a9e5dff78..26dcc47f0c 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -42,7 +42,11 @@ received. **Grant.** `publish` and `subscribe` as pattern unions (`foo/**` is a subtree, `**` is everything, an empty list is nothing), `root` (optional; replaces the -dialed path, which is how a slug aliases to a canonical id), `expires` +dialed path, which is how a slug aliases to a canonical id), `mounts` +(optional object; each key, a path relative to the root, reads from the +absolute path it maps to: `{".svc": ".svc/pid"}` resolves `.svc/foo` at +`.svc/pid/foo` and presents its announcements under `.svc`, the patterns still +authorize `.svc/foo`, and nothing may be published beneath a key), `expires` (optional unix seconds; the session closes then), `revalidate` (optional seconds until the relay asks again), `tier` (optional label handed to [stats](/bin/relay/config#stats)), and `peer` (optional; `true` marks another @@ -57,7 +61,7 @@ The client and relay snapshot each accepted grant's deadline on a monotonic clock, so later polls, outages, and wall-clock adjustments do not restart it. **Revalidate and outage.** On the cadence the relay POSTs `revalidate` with the -same request. A grant applies: a changed `root` or one that no longer covers +same request. A grant applies: a changed `root` or `mounts`, or one that no longer covers what the session holds closes it with `Unauthorized` (the live session is not resized in place), and so does a flipped `peer`; a changed `tier` keeps the session and moves its stats: its presence counts under the new tier from then diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 2752bc1f0d..6f2985e87c 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -163,7 +163,11 @@ request rather than narrowing the claim, and no message narrows a route. Token scope is any pattern union; the session asks for each member's literal head on the prefix-only wire and filters locally. In Rust and TypeScript, `origin.scope(root, patterns)` narrows the handle's permissions and presents paths -relative to `root`. Nested scopes intersect with their parent. A session receiving +relative to `root`. Nested scopes intersect with their parent. In Rust, +`origin.mount(at, target)` reads the subtree at `at` from `target` instead: a +request for `at/rest` joins the one front at `target/rest`, announcements under +`target` present under `at`, the handle's patterns still authorize `at/rest`, +and nothing is published beneath `at`. A session receiving into that scoped origin asks for the literal heads of its allowed patterns, coalescing duplicate or nested heads. An unscoped origin still asks for the empty prefix, covering every namespace. These subscriptions include hidden routes; diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index b406e0adf7..5f0de2a969 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -13,7 +13,7 @@ Everything a party needs to ask for or answer an authorization on answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. -- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. +- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `mounts` that read a subtree from elsewhere on the origin, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. - **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline: future expiries are unchanged, and one already less than five seconds late gets the remainder of that skew window. - **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`, 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 from the `jwt` query or a type-0 SETUP token, verified against a key file or a `{kid}.jwk` directory (a token of another type, or both at once, is refused), an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index c3371ba6e7..4768f9776c 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -127,6 +127,8 @@ export const GrantSchema = z subscribe: z.optional(PatternListSchema), /** The path the patterns are relative to, replacing the dialed one. Absent means the dialed path. */ root: z.optional(z.string()), + /** Subtrees read from elsewhere: each path, relative to the root, resolves at the absolute path it maps to. Read-only. */ + mounts: z.optional(z.record(z.string(), z.string())), /** When the session closes, as whole unix seconds. */ expires: z.optional(z.int()), /** How long until the relay asks again, in whole seconds. Zero would be a tight loop. */ diff --git a/rs/moq-auth/src/grant.rs b/rs/moq-auth/src/grant.rs index 954b60aae1..0558b4e6dd 100644 --- a/rs/moq-auth/src/grant.rs +++ b/rs/moq-auth/src/grant.rs @@ -1,6 +1,7 @@ use moq_pattern::Patterns; use serde::{Deserialize, Serialize}; use serde_with::{DurationSeconds, TimestampSeconds, serde_as}; +use std::collections::BTreeMap; use std::time::{Duration, SystemTime}; /// A grant that expired this recently still stands: the auth server's clock may run behind. @@ -40,6 +41,12 @@ pub struct Grant { /// server aliases a slug to a canonical id. Absent means the dialed path. pub root: Option, + /// Subtrees the session reads from elsewhere: each path, relative to the root, + /// resolves at the absolute path it maps to. Read-only: nothing is published + /// beneath one. The patterns still name the path relative to the root. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub mounts: BTreeMap, + /// When the session closes, as unix seconds. #[serde_as(as = "Option>")] pub expires: Option, @@ -108,6 +115,7 @@ mod tests { publish: patterns(&["alice/**"]), subscribe: patterns(&["**"]), root: Some("pid/room".into()), + mounts: BTreeMap::new(), expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)), revalidate: Some(Duration::from_secs(60)), tier: Some("websocket".into()), @@ -129,6 +137,7 @@ mod tests { publish: patterns(&["alice/**"]), subscribe: patterns(&["**"]), root: Some("pid/room".into()), + mounts: BTreeMap::new(), expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)), revalidate: Some(Duration::from_secs(60)), tier: Some("websocket".into()), @@ -140,6 +149,15 @@ mod tests { ); } + #[test] + fn mounts_round_trip_as_an_object() { + let mut grant = Grant::new(Patterns::new(), patterns(&["**"])); + grant.mounts.insert(".svc".into(), ".svc/pid".into()); + let json = serde_json::to_string(&grant).unwrap(); + assert_eq!(json, r#"{"subscribe":["**"],"mounts":{".svc":".svc/pid"}}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), grant); + } + #[test] fn empty_fields_are_omitted_and_defaulted() { let grant = Grant::new(patterns(&["**"]), Patterns::new()); diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index ffea126018..62692ebd9d 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -81,6 +81,55 @@ fn bench_announce(c: &mut Criterion) { group.finish(); } +/// `bench_announce` read through mounts: `publishers` routes under the fleet-wide +/// `.svc/p0`, watched by `subscribers` project sessions that each mount it at +/// their own `/.svc`. Every mount aliases the one target, the worst +/// case, so each announcement reaches every mounted cursor. Compare against +/// `origin/announce` for what a mount adds per cursor. +fn bench_announce_mounted(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/announce_mounted"); + for (publishers, subscribers) in SHAPES { + let id = BenchmarkId::from_parameter(format!("{publishers}p_{subscribers}s")); + group.bench_function(id, |b| { + let (producer, _driver) = origin::Producer::new(origin::Config::default()); + let _publishers: Vec<_> = (0..publishers) + .map(|i| { + producer + .publish(format!(".svc/p0/{i}"), origin::Route::default()) + .unwrap() + }) + .collect(); + let mut cursors: Vec = (0..subscribers) + .map(|project| { + producer + .mount(format!("p{project}/.svc"), ".svc/p0") + .unwrap() + .scope(format!("p{project}"), &Patterns::from(Pattern::all())) + .unwrap() + .consume() + .with_hidden(true) + .announced() + }) + .collect(); + for cursor in &mut cursors { + while cursor.next().now_or_never().flatten().is_some() {} + } + + b.iter(|| { + let handle = producer.publish(".svc/p0/incoming", origin::Route::default()).unwrap(); + for cursor in &mut cursors { + cursor.next().now_or_never().flatten().expect("announce delivered"); + } + drop(handle); + for cursor in &mut cursors { + cursor.next().now_or_never().flatten().expect("retract delivered"); + } + }); + }); + } + group.finish(); +} + /// A relay's own stats fan-out: `.stats//node/` for every project /// on every node, watched by one cursor per peer, each scoped to one project. /// Cursors differ in scope, so none can be collapsed, and an announcement under @@ -377,6 +426,7 @@ fn bench_handoff(c: &mut Criterion) { criterion_group!( benches, bench_announce, + bench_announce_mounted, bench_announce_fleet, bench_announce_duplicate, bench_announce_fronts, diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index dfa1089b61..8643499979 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -19,6 +19,7 @@ use super::{ use crate::{ AsPath, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, coding::{BoundsExceeded, Decode, DecodeError, Encode, EncodeError}, + path::Segment, runtime::{Instant, Timers}, time::Clock, util::{Keepalive, TaskSet, Tasks, TasksWeak}, @@ -894,8 +895,18 @@ struct TableCursor { horizon: Horizon, /// Which routes beneath a hidden segment are reported. hidden: Hidden, - /// The delivery buffer, drained by the cursor's `poll_next`. + /// The delivery buffer, drained by the cursor's `poll_next`. A mounted + /// consumer's cursors share one. state: kio::Producer, + /// Where this cursor's presented prefixes land in the delivery buffer: empty, + /// or the mount the cursor reads for, relative to the consumer's root. + under: PathOwned, + /// The absolute prefixes a mount shadows: routes at or beneath them are not + /// this cursor's to present. + holes: Vec, + /// For a cursor reading through a mount: the mount and the handle's own + /// patterns, so a wildcard captures what the handle names rather than the target. + named: Option<(Mount, Patterns)>, /// The last delivered best route per presented (relative) prefix, for change /// detection: `(entry id, hops, cost)`. // entry id, metadata, and whether the entry could serve requests: the last @@ -923,8 +934,11 @@ impl TableCursor { /// What the cursor's most specific matching scope member captures from an /// exact announced prefix. An overlap-only route does not pin every wildcard. fn captures(&self, prefix: &Path) -> Option> { - let literal = Pattern::literal(prefix.as_str()).ok()?; - self.allowed + let (literal, allowed) = match &self.named { + Some((mount, allowed)) => (Pattern::literal(mount.name(prefix)?.as_str()).ok()?, allowed), + None => (Pattern::literal(prefix.as_str()).ok()?, &self.allowed), + }; + allowed .iter() .filter_map(|allowed| { allowed @@ -936,23 +950,98 @@ impl TableCursor { } /// Whether this cursor may observe `entry` at all: advertised, not behind - /// the excluded peer (split horizon), and within the cursor's patterns. + /// the excluded peer (split horizon), within the cursor's patterns, and + /// nameable through its mount. fn visible(&self, entry: &RouteEntry) -> bool { - entry.advertised && self.horizon.admits(entry) && entry.overlaps(&self.allowed) && self.discovers(&entry.prefix) + entry.advertised + && self.horizon.admits(entry) + && entry.overlaps(&self.allowed) + && self.discovers(&entry.prefix) + && !self.holes.iter().any(|hole| entry.prefix.has_prefix(hole)) + && self.named.as_ref().is_none_or(|(mount, _)| mount.names(&entry.prefix)) } /// Whether the hidden rule lets this cursor discover a route at `prefix`. fn discovers(&self, prefix: &Path) -> bool { - (self.hidden.include || !hides(&self.heads, prefix)) - && self.hidden.beyond.as_ref().is_none_or(|outer| hides(outer, prefix)) + self.hidden.discovers(&self.heads, prefix) } } -/// A handle's view of an origin: the absolute patterns it may reach. +/// A handle's view of an origin: the absolute patterns it may reach, and the +/// subtrees it reads from elsewhere on the origin. #[derive(Clone)] struct OriginScope { - // The paths this handle may reach, absolute. + // The paths this handle may reach, absolute, named as the handle sees them: + // a path under a mount is authorized here, before it is resolved. allowed: Patterns, + // The subtrees that resolve elsewhere; see [`Producer::mount`]. Disjoint. + mounts: Arc<[Mount]>, +} + +/// A subtree a handle reads from elsewhere on the origin: `at/rest` resolves at +/// `target/rest`. Both absolute. +#[derive(Clone, Debug)] +struct Mount { + at: PathOwned, + target: PathOwned, +} + +impl Mount { + /// Where the handle-side absolute `path` resolves, when it is under this mount + /// and the result fits [`Path::MAX_PARTS`]. + fn resolve(&self, path: &Path) -> Option { + let resolved = self.target.join(path.strip_prefix(&self.at)?); + (resolved.parts().count() <= Path::MAX_PARTS).then_some(resolved) + } + + /// Where the origin-side absolute `path` shows on the handle, when it is at or + /// beneath the target. A route covering the target has no handle-side name. + fn name(&self, path: &Path) -> Option { + Some(self.at.join(path.strip_prefix(&self.target)?)) + } + + /// Whether the origin-side absolute `path` has a handle-side name within + /// [`Path::MAX_PARTS`], so a reader could ask for it. A route covering the + /// target always does. + fn names(&self, path: &Path) -> bool { + path.strip_prefix(&self.target) + .is_none_or(|rest| self.at.parts().count() + rest.parts().count() <= Path::MAX_PARTS) + } + + /// The handle-side absolute `patterns` beneath this mount, as origin-side + /// patterns beneath its target. + /// + /// A member too deep to root matches no valid path and drops, except that a + /// `**` one segment past the limit can only match nothing and is dropped instead, + /// so a deep target keeps its exact path. + fn translate(&self, patterns: &Patterns) -> Patterns { + let target = self.target.as_str(); + patterns + .rebase(self.at.as_str()) + .iter() + .filter_map(|member| { + member + .rooted(target) + .or_else(|_| { + let segments = member + .segments() + .iter() + .filter(|segment| **segment != Segment::Globstar); + Pattern::new(segments.cloned())?.rooted(target) + }) + .ok() + }) + .collect() + } + + /// A handle-side interest head as an origin-side one: a head beneath the mount + /// moves under the target, and one above it hangs at the target itself. + fn translate_head(&self, head: &Path) -> Option { + match head.strip_prefix(&self.at) { + Some(rest) => Some(self.target.join(rest)), + None => self.at.has_prefix(head).then(|| self.target.clone()), + } + } } impl OriginScope { @@ -960,6 +1049,7 @@ impl OriginScope { fn empty() -> Self { Self { allowed: Patterns::new(), + mounts: Arc::from([]), } } @@ -969,10 +1059,33 @@ impl OriginScope { if allowed.is_empty() { None } else { - Some(Self { allowed }) + Some(Self { + allowed, + mounts: self.mounts.clone(), + }) } } + /// The mount the absolute `path` is under, if any. + fn mount(&self, path: &Path) -> Option<&Mount> { + self.mounts.iter().find(|mount| path.has_prefix(&mount.at)) + } + + /// Where the absolute `path` resolves on the origin: itself, or its mount + /// target. `None` when the mount would resolve it past [`Path::MAX_PARTS`]. + fn resolve<'a>(&self, path: &'a Path<'a>) -> Option> { + match self.mount(path) { + Some(mount) => mount.resolve(path), + None => Some(path.borrow()), + } + } + + /// Whether this view may publish at the absolute `prefix`: a mount is read-only, + /// so nothing is published at or beneath one. + fn publishes(&self, prefix: &Path) -> bool { + self.mount(prefix).is_none() + } + /// Whether this view reaches the absolute `path`. fn permits(&self, path: &Path) -> bool { self.allowed.matches(path.as_str()) @@ -988,6 +1101,7 @@ impl Default for OriginScope { fn default() -> Self { Self { allowed: Patterns::from(Pattern::all()), + mounts: Arc::from([]), } } } @@ -1021,6 +1135,26 @@ pub(crate) struct Hidden { beyond: Option>, } +impl Hidden { + /// Whether a cursor hanging at `heads` reports a route at `prefix`. + fn discovers(&self, heads: &[PathOwned], prefix: &Path) -> bool { + (self.include || !hides(heads, prefix)) && self.beyond.as_ref().is_none_or(|outer| hides(outer, prefix)) + } + + /// This rule for a cursor reading through `mount`, whose heads sit on the + /// target: a feed it tops up hid everything under the mount, or hid what it + /// hides under the target. + fn translate(&self, mount: &Mount) -> Self { + let beyond = self.beyond.as_ref().and_then(|outer| { + (!hides(outer, &mount.at)).then(|| outer.iter().filter_map(|head| mount.translate_head(head)).collect()) + }); + Self { + include: self.include, + beyond, + } + } +} + /// Whether a segment of `prefix` below the head it sits under starts with `.`. /// A route at or above a head has nothing below it, so it never hides. fn hides(heads: &[PathOwned], prefix: &Path) -> bool { @@ -1241,7 +1375,8 @@ impl Producer { /// either way the path closes once it was the last source. /// /// Fails with [`Error::Unauthorized`] if `path` is outside the prefixes this - /// producer may publish under (after [`scope`](Self::scope)), + /// producer may publish under (after [`scope`](Self::scope)) or beneath a + /// [`mount`](Self::mount), /// [`Error::BoundsExceeded`] if the full rooted path exceeds /// [`Path::MAX_PARTS`], [`Error::InvalidPath`] if it holds a segment no /// pattern can spell (`*` or `**`), or [`Error::Closed`] once the origin's @@ -1250,7 +1385,7 @@ impl Producer { let path = path.as_path(); let full = self.root.join(&path).to_owned(); - if !self.scope.permits(&full) { + if !self.scope.permits(&full) || !self.scope.publishes(&full) { return Err(Error::Unauthorized); } // A decoded prefix and suffix are each within the wire limit, but their @@ -1410,6 +1545,61 @@ impl Producer { }) } + /// Returns a producer that reads the subtree at `at` from `target` instead. + /// + /// Both are relative to this producer's root. A consumer derived from the + /// result resolves `at/rest` at `target/rest`, through the one front serving + /// that path, and presents the routes under `target` (or covering it) under + /// `at`. Permissions stay named from the handle's side: a later + /// [`scope`](Self::scope) authorizes `at/rest` as written. The mount is + /// read-only: nothing is published at or beneath `at`, so what a reader finds + /// there is only ever `target`'s. Whatever the origin holds at `at` itself is + /// hidden from the mounted handle. + /// + /// Returns [`Error::Unauthorized`] unless this producer reaches all of + /// `target`, so a mount never widens a scope, and [`Error::Duplicate`] when + /// `at` or `target` overlaps a mount this producer already has: mounts never + /// chain, so a target is always read as the origin holds it. + /// [`Error::BoundsExceeded`] if either rooted path exceeds [`Path::MAX_PARTS`]. + pub fn mount(&self, at: impl AsPath, target: impl AsPath) -> Result { + let at = self.root.join(at).to_owned(); + let target = self.root.join(target).to_owned(); + if [&at, &target] + .into_iter() + .any(|path| path.parts().count() > Path::MAX_PARTS) + { + return Err(BoundsExceeded.into()); + } + if !self + .scope + .allowed + .covers(&Patterns::from(Pattern::subtree(target.as_str())?)) + { + return Err(Error::Unauthorized); + } + if self.scope.mounts.iter().any(|mount| { + [&at, &target] + .into_iter() + .any(|path| mount.at.has_prefix(path) || path.has_prefix(&mount.at)) + }) { + return Err(Error::Duplicate); + } + let mounts = self + .scope + .mounts + .iter() + .cloned() + .chain([Mount { at, target }]) + .collect(); + Ok(Producer { + scope: OriginScope { + allowed: self.scope.allowed.clone(), + mounts, + }, + ..self.clone() + }) + } + /// Cheap read handle over this origin's route table. /// /// Use [`Consumer::announced`] to register interest and start receiving @@ -1464,7 +1654,7 @@ impl Announcing { return Err(BoundsExceeded.into()); } let claim = prefix_claim(&requested)?; - if !producer.scope.allowed.overlaps(&claim) { + if !producer.scope.allowed.overlaps(&claim) || !producer.scope.publishes(&requested) { return Err(Error::Unauthorized); } Ok(Self { @@ -2883,13 +3073,13 @@ impl OriginState { // old identity explicitly so capture-keyed consumers can remove it. Some((_, prev, _, prev_captures)) if prev_captures != captures => { if let Ok(mut state) = cursor.state.write() { - state.apply_unannounce(presented.clone(), prev, prev_captures); - state.apply_announce(presented.clone(), meta, captures); + state.apply_unannounce(cursor.under.join(presented), prev, prev_captures); + state.apply_announce(cursor.under.join(presented), meta, captures); } } _ => { if let Ok(mut state) = cursor.state.write() { - state.apply_announce(presented.clone(), meta, captures); + state.apply_announce(cursor.under.join(presented), meta, captures); } } } @@ -2898,7 +3088,7 @@ impl OriginState { if let Some((_, last, _, captures)) = cursor.current.remove(presented) && let Ok(mut state) = cursor.state.write() { - state.apply_unannounce(presented.clone(), last, captures); + state.apply_unannounce(cursor.under.join(presented), last, captures); } } } @@ -3457,14 +3647,80 @@ impl Consumer { /// patterns are hidden unless [`with_hidden`](Self::with_hidden) opted in. /// Drop the returned [`AnnounceConsumer`] to unregister. pub fn announced(&self) -> AnnounceConsumer { - AnnounceConsumer::new( - self.root.clone(), - self.scope.allowed.clone(), - self.stats.clone(), - self.horizon, - self.hidden.clone(), - &self.shared, - ) + let state = kio::Producer::::default(); + let cursor = |root: PathOwned, + allowed: Patterns, + hidden: Hidden, + mount: Option<&Mount>, + under: PathOwned, + holes: Vec| TableCursor { + root, + heads: interest_prefixes(&allowed), + allowed, + horizon: self.horizon, + hidden, + state: state.clone(), + under, + holes, + named: mount.map(|mount| (mount.clone(), self.scope.allowed.clone())), + current: HashMap::new(), + }; + + // A root at or beneath a mount reads only the mount: one cursor, re-rooted + // onto the target. + if let Some(mount) = self.scope.mount(&self.root) { + // A root the mount resolves past the depth limit has nothing to announce. + let Some(root) = mount.resolve(&self.root) else { + return AnnounceConsumer::new(self.root.clone(), Vec::new(), state, self.stats.clone(), &self.shared); + }; + let cursors = vec![cursor( + root, + mount.translate(&self.scope.allowed), + self.hidden.translate(mount), + Some(mount), + PathOwned::default(), + Vec::new(), + )]; + return AnnounceConsumer::new(self.root.clone(), cursors, state, self.stats.clone(), &self.shared); + } + + // Otherwise the consumer's own cursor, minus the mounted subtrees, plus one + // cursor per mount beneath the root it may discover, presenting under it. + let heads = interest_prefixes(&self.scope.allowed); + let mut holes = Vec::new(); + let mut cursors = Vec::new(); + for mount in self.scope.mounts.iter() { + let Some(under) = mount.at.strip_prefix(&self.root) else { + continue; + }; + holes.push(mount.at.clone()); + let allowed = mount.translate(&self.scope.allowed); + // Hidden at the mount point means hidden throughout: the dot segment is above + // everything the mount holds. A top-up feed's rule moves onto the target. + if allowed.is_empty() || !(self.hidden.include || !hides(&heads, &mount.at)) { + continue; + } + cursors.push(cursor( + mount.target.clone(), + allowed, + self.hidden.translate(mount), + Some(mount), + under.to_owned(), + Vec::new(), + )); + } + cursors.insert( + 0, + cursor( + self.root.clone(), + self.scope.allowed.clone(), + self.hidden.clone(), + None, + PathOwned::default(), + holes, + ), + ); + AnnounceConsumer::new(self.root.clone(), cursors, state, self.stats.clone(), &self.shared) } /// Returns a cheap duplicate of this read handle. @@ -3480,6 +3736,7 @@ impl Consumer { if !self.scope.permits(&full) { return None; } + let full = self.scope.resolve(&full)?; let table = self.shared.lock(); table .routes @@ -3565,7 +3822,9 @@ impl Consumer { if table.closed { return Err(Error::Closed); } - let watch = table.watch(&self.shared, &self.root.join(&path)); + let named = self.root.join(&path); + let resolved = self.scope.resolve(&named).ok_or(BoundsExceeded)?; + let watch = table.watch(&self.shared, &resolved); let seen = watch.seen(); (watch, seen) }; @@ -3624,21 +3883,27 @@ impl Consumer { pub fn request_broadcast(&self, path: impl AsPath) -> kio::Pending { let path = path.as_path(); - // Key requests by absolute path so scoped/rooted consumers and handlers - // (which may have a different root) agree on the same entry, and so the egress - // counters resolve against the same broadcast the ingress side wrote. - let absolute = self.root.join(&path).to_owned(); - let scope = self.stats.egress(&absolute); + // The path as this handle names it, absolute: what its scope authorizes and + // what its egress is counted under, so a read through a mount bills where + // the reader asked. + let named = self.root.join(&path).to_owned(); + let scope = self.stats.egress(&named); // The resolved handle is named by what *this* cursor asked for, not by the absolute // path: a rooted cursor cannot name anything above its own root, so that is what a // catalog it reads may reference. let requested = path.to_owned(); // Routes only cover paths within this consumer's scope. - if !self.scope.permits(&absolute) { + if !self.scope.permits(&named) { return kio::Pending::new(Requesting::failed(Error::Unauthorized)); } + // Key requests by the absolute path on the origin, past any mount, so scoped, + // rooted, and mounted consumers and handlers agree on the same entry and front. + let Some(absolute) = self.scope.resolve(&named).map(|path| path.to_owned()) else { + return kio::Pending::new(Requesting::failed(BoundsExceeded.into())); + }; + let mut state = self.shared.lock(); // The origin's driver dropped: nothing will ever serve this. @@ -3736,7 +4001,9 @@ impl Consumer { /// Created by [`Consumer::announced`]. /// Drop to unregister. pub struct AnnounceConsumer { - id: ConsumerId, + // One registration per table cursor feeding `state`: more than one when the + // consumer reads through a mount. + ids: Vec, shared: kio::Shared, root: PathOwned, @@ -3761,15 +4028,12 @@ pub struct AnnounceConsumer { impl AnnounceConsumer { fn new( root: PathOwned, - allowed: Patterns, + cursors: Vec, + state: kio::Producer, stats: stats::Session, - horizon: Horizon, - hidden: Hidden, shared: &kio::Shared, ) -> Self { - let state = kio::Producer::::default(); - let id = ConsumerId::new(); - + let mut ids = Vec::with_capacity(cursors.len()); { let mut table = shared.lock(); if table.closed { @@ -3778,23 +4042,16 @@ impl AnnounceConsumer { state.ended = true; } } else { - table.register_cursor( - id, - TableCursor { - root: root.clone(), - heads: interest_prefixes(&allowed), - allowed, - horizon, - hidden, - state: state.clone(), - current: HashMap::new(), - }, - ); + for cursor in cursors { + let id = ConsumerId::new(); + table.register_cursor(id, cursor); + ids.push(id); + } } } Self { - id, + ids, shared: shared.clone(), root, state, @@ -3899,9 +4156,11 @@ impl futures::Stream for AnnounceConsumer { impl Drop for AnnounceConsumer { fn drop(&mut self) { let mut shared = self.shared.lock(); - if let Some(cursor) = shared.cursors.remove(&self.id) { - for head in &cursor.heads { - shared.routes.remove_cursor(head, self.id); + for id in &self.ids { + if let Some(cursor) = shared.cursors.remove(id) { + for head in &cursor.heads { + shared.routes.remove_cursor(head, *id); + } } } } @@ -4138,6 +4397,249 @@ mod tests { assert_eq!(resolved.info().path.as_str(), ".stats/node"); } + /// A project-rooted handle reading `.svc` from the fleet-wide `.svc/`. + fn mounted(producer: &Producer, pid: &str, patterns: &[&str]) -> Consumer { + let patterns: Patterns = patterns.iter().map(|pattern| pattern.parse().unwrap()).collect(); + producer + .mount(format!("{pid}/.svc"), format!(".svc/{pid}")) + .unwrap() + .scope(pid, &patterns) + .unwrap() + .consume() + } + + /// A request through a mount is a request for the target path: it joins the + /// one front there, so the fleet-wide claim is asked once for both readers. + #[tokio::test] + async fn mount_resolves_on_the_target_front() { + let producer = origin(1).produce(); + let server = producer.dynamic(".svc", Route::default()).unwrap(); + let project = mounted(&producer, "p1", &["**"]); + + let through = project.request_broadcast(".svc/foo"); + let direct = producer.consume().request_broadcast(".svc/p1/foo"); + + let request = queued(&server).await; + assert_eq!(request.path().as_str(), ".svc/p1/foo"); + assert!(server.poll_requested_broadcast(&kio::Waiter::noop()).is_pending()); + let source = broadcast::Info::new().produce(); + request.accept(&source); + + let through = through.await.expect("resolves"); + let direct = direct.await.expect("resolves"); + assert!(through.is_clone(&direct)); + // Named by what the reader asked for. + assert_eq!(through.info().path.as_str(), ".svc/foo"); + } + + /// Routes under the target, and the claim covering it, present under the + /// mount; what the origin holds at the mounted path itself does not. + #[tokio::test] + async fn mount_presents_target_routes_under_the_mount() { + let producer = origin(1).produce(); + let _claim = producer.announce(".svc", Route::default()).unwrap(); + let foo = producer.publish(".svc/p1/foo", Route::default()).unwrap(); + let _other = producer.publish(".svc/p2/bar", Route::default()).unwrap(); + let _cam = producer.publish("p1/cam", Route::default()).unwrap(); + let _shadowed = producer.publish("p1/.svc/forged", Route::default()).unwrap(); + let project = mounted(&producer, "p1", &["**"]); + + // A dot segment hides the mount like any other. + let mut announced = project.announced(); + announced.assert_next_active("cam"); + announced.assert_next_wait(); + + let mut announced = project.clone().with_hidden(true).announced(); + announced.assert_next_active(".svc"); + announced.assert_next_active(".svc/foo"); + announced.assert_next_active("cam"); + announced.assert_next_wait(); + + // Rooted inside the mount, the claim covers the root itself. + let mut inside = project + .scope(".svc", &Patterns::from(Pattern::all())) + .unwrap() + .announced(); + inside.assert_next_active(""); + inside.assert_next_active("foo"); + inside.assert_next_wait(); + + drop(foo); + announced.assert_next_ended(".svc/foo"); + inside.assert_next_ended("foo"); + announced.assert_next_wait(); + + // The shadowed path never resolves: the mount answers for it. + let err = project.request_broadcast(".svc/forged").await.err().unwrap(); + assert!(matches!(err, Error::NotFound | Error::Unroutable), "{err:?}"); + } + + /// The handle's own patterns authorize a path through the mount, named as the + /// handle names it. + #[tokio::test] + async fn mount_authorizes_the_named_path() { + let producer = origin(1).produce(); + let _foo = producer.publish(".svc/p1/foo", Route::default()).unwrap(); + let _bar = producer.publish(".svc/p1/bar", Route::default()).unwrap(); + + let granted = mounted(&producer, "p1", &["foo", ".svc/foo"]); + granted.request_broadcast(".svc/foo").await.expect("granted"); + let refused = granted + .request_broadcast(".svc/bar") + .now_or_never() + .expect("refused at once"); + assert!(matches!(refused, Err(Error::Unauthorized))); + let mut announced = granted.clone().with_hidden(true).announced(); + announced.assert_next_active(".svc/foo"); + announced.assert_next_wait(); + + let narrower = mounted(&producer, "p1", &["foo"]); + let refused = narrower + .request_broadcast(".svc/foo") + .now_or_never() + .expect("refused at once"); + assert!(matches!(refused, Err(Error::Unauthorized))); + narrower.clone().with_hidden(true).announced().assert_next_wait(); + + // Another project's mount reaches its own target, never this one's. + let other = mounted(&producer, "p2", &["**"]); + other.clone().with_hidden(true).announced().assert_next_wait(); + let err = other.request_broadcast(".svc/foo").await.err().unwrap(); + assert!(matches!(err, Error::Unroutable)); + } + + /// A wildcard spanning the mount point captures the path as the handle names + /// it, so capture-keyed consumers key a mounted route like any other. + #[tokio::test] + async fn mount_captures_the_named_path() { + let producer = origin(1).produce(); + let _foo = producer.publish(".svc/p1/foo", Route::default()).unwrap(); + let project = mounted(&producer, "p1", &["**"]).with_hidden(true); + + let update = project.announced().try_next().expect("foo"); + assert_eq!(update.prefix.as_str(), ".svc/foo"); + assert_eq!(update.captures, Some(vec![".svc/foo".parse::().unwrap()])); + + let inside = project.scope(".svc", &Patterns::from(Pattern::all())).unwrap(); + let update = inside.announced().try_next().expect("foo"); + assert_eq!(update.prefix.as_str(), "foo"); + assert_eq!(update.captures, Some(vec!["foo".parse::().unwrap()])); + } + + /// A target at the maximum depth still presents its exact path: the `**` a + /// wildcard scope carries past it can only match nothing there. + #[tokio::test] + async fn mount_keeps_a_max_depth_target() { + let producer = origin(1).produce(); + let deep = vec!["d"; Path::MAX_PARTS].join("/"); + let _leaf = producer.publish(deep.as_str(), Route::default()).unwrap(); + let project = producer + .mount("p1/.svc", deep.as_str()) + .unwrap() + .scope("p1", &Patterns::from(Pattern::all())) + .unwrap() + .consume() + .with_hidden(true); + + let mut announced = project.announced(); + announced.assert_next_active(".svc"); + announced.assert_next_wait(); + + // Beneath the mount the target has no room: refused, never handed to a route. + let err = project.request_broadcast(".svc/x").await.err().unwrap(); + assert!(matches!(err, Error::BoundsExceeded(_)), "{err:?}"); + let inside = project.scope(".svc/x", &Patterns::from(Pattern::all())).unwrap(); + inside.announced().assert_next_wait(); + } + + /// A mount point deeper than its target never presents a route whose name + /// through the mount is past the depth limit, and a mount point past it is refused. + #[tokio::test] + async fn mount_bounds_the_named_path() { + let producer = origin(1).produce(); + let _near = producer.publish("t/x", Route::default()).unwrap(); + let deep = format!("t/{}", vec!["d"; Path::MAX_PARTS - 1].join("/")); + let _deep = producer.publish(deep.as_str(), Route::default()).unwrap(); + let project = producer + .mount("p1/a/b", "t") + .unwrap() + .scope("p1", &Patterns::from(Pattern::all())) + .unwrap() + .consume(); + + let mut announced = project.announced(); + announced.assert_next_active("a/b/x"); + announced.assert_next_wait(); + + let over = vec!["d"; Path::MAX_PARTS + 1].join("/"); + assert!(matches!( + producer.mount(over.as_str(), "t"), + Err(Error::BoundsExceeded(_)) + )); + } + + /// Nothing is published at or beneath a mount. + #[tokio::test] + async fn mount_is_read_only() { + let producer = origin(1).produce(); + let project = producer + .mount("p1/.svc", ".svc/p1") + .unwrap() + .scope("p1", &Patterns::from(Pattern::all())) + .unwrap(); + assert!(matches!(project.create_broadcast(".svc/foo"), Err(Error::Unauthorized))); + assert!(matches!( + project.dynamic(".svc", Route::default()), + Err(Error::Unauthorized) + )); + assert!(matches!( + project.dynamic(".svc/foo", Route::default()), + Err(Error::Unauthorized) + )); + project.publish("cam", Route::default()).unwrap(); + } + + /// A mount reaches only what the handle already reaches, and mounts never nest. + #[test] + fn mount_never_widens_a_scope() { + let (producer, _driver) = Producer::new(Config::new(origin(1))); + let project = producer.scope("", &scopes(&["p1"])).unwrap(); + assert!(matches!(project.mount("p1/.svc", ".svc/p1"), Err(Error::Unauthorized))); + + let mounted = producer.mount("p1/.svc", ".svc/p1").unwrap(); + assert!(matches!(mounted.mount("p1/.svc/x", ".other"), Err(Error::Duplicate))); + assert!(matches!(mounted.mount("p1", ".other"), Err(Error::Duplicate))); + // A target through a mount would read the subtree the mount shadows. + assert!(matches!(mounted.mount("p2", "p1/.svc/x"), Err(Error::Duplicate))); + assert!(matches!(mounted.mount("p2", "p1"), Err(Error::Duplicate))); + mounted.mount("p1/.other", ".other/p1").unwrap(); + } + + /// Egress through a mount counts under the path the reader named, so it + /// attributes to the reader's root rather than the fleet-wide target. + #[tokio::test] + async fn mount_egress_counts_under_the_named_path() { + let registry = stats::Registry::new(stats::Config::new()); + let producer = origin(1).produce(); + let _foo = producer.publish(".svc/p1/foo", Route::default()).unwrap(); + let project = mounted(&producer, "p1", &["**"]) + .with_stats(registry.tier(stats::Tier::default()).session("p1")) + .with_hidden(true); + + let mut announced = project.announced(); + announced.assert_next_active(".svc/foo"); + project.request_broadcast(".svc/foo").await.expect("resolves"); + + let mut report = stats::Report::default(); + registry.report(&mut report); + let paths: Vec<_> = report + .traffic + .iter() + .map(|entry| entry.path.as_str().to_string()) + .collect(); + assert_eq!(paths, ["p1/.svc/foo"]); + } + /// A route that turns up later is filtered the same way as the replay. #[tokio::test] async fn hidden_route_announced_later_stays_hidden() { diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 799074012d..bd610e90b6 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -358,6 +358,9 @@ pub struct Token { pub(crate) path: String, /// The root the session is scoped to: the grant's `root` alias, else the dialed path. pub root: PathOwned, + /// Subtrees read from elsewhere on the origin: each path, relative to `root`, + /// resolves at the absolute path it maps to. + pub mounts: Vec<(PathOwned, PathOwned)>, /// The patterns the holder may subscribe to, relative to `root`. pub subscribe: Patterns, /// The patterns the holder may publish to, relative to `root`. @@ -379,6 +382,11 @@ impl Token { Self { path: path.to_string(), root: Path::new(root).to_owned(), + mounts: grant + .mounts + .iter() + .map(|(at, target)| (Path::new(at).to_owned(), Path::new(target).to_owned())) + .collect(), subscribe: grant.subscribe.clone(), publish: grant.publish.clone(), tier: crate::configured_tier(grant.tier.clone()), @@ -393,10 +401,13 @@ impl Token { } /// Whether `other` still covers everything this token scopes: the same root and - /// every grant still held. A narrower re-check closes the session until + /// mounts, and every grant still held. A narrower re-check closes the session until /// pattern scopes can resize it in place. pub(crate) fn covered_by(&self, other: &Self) -> bool { - self.root == other.root && other.subscribe.covers(&self.subscribe) && other.publish.covers(&self.publish) + self.root == other.root + && self.mounts == other.mounts + && other.subscribe.covers(&self.subscribe) + && other.publish.covers(&self.publish) } } @@ -451,7 +462,7 @@ impl Lease { /// Wait for the lease to stop covering the session: the grant expired, was /// revoked, or was re-checked into one that no longer covers the token. /// - /// A changed root or a narrower grant ends it: origin handles cannot yet narrow + /// A changed root or mounts, or a narrower grant, ends it: origin handles cannot yet narrow /// a live scope in place (tracked by `quest/m1/auth/narrowing.md`). A flipped /// `peer` ends it too, since the routes it already announced would be /// misreported as entering here or from a peer. A changed tier keeps the @@ -471,6 +482,9 @@ impl Lease { if fresh.root != self.token.root { return "root changed".into(); } + if fresh.mounts != self.token.mounts { + return "mounts changed".into(); + } // Routes the session already announced were recorded as // entering here or from a peer; a flip would misreport them. if fresh.peer != self.token.peer { @@ -988,10 +1002,20 @@ mod tests { let wide = Token::new("/room", &Grant::new(patterns(&["**"]), patterns(&["**"]))); let narrow = Token::new("/room", &Grant::new(patterns(&["alice/**"]), patterns(&["**"]))); let moved = Token::new("/other", &Grant::new(patterns(&["**"]), patterns(&["**"]))); + let mut mounted = Grant::new(patterns(&["**"]), patterns(&["**"])); + mounted.mounts.insert(".svc".into(), ".svc/room".into()); + let mounted = Token::new("/room", &mounted); + assert_eq!( + mounted.mounts, + [(Path::new(".svc").to_owned(), Path::new(".svc/room").to_owned())] + ); assert!(wide.covered_by(&wide)); assert!(narrow.covered_by(&wide)); assert!(!wide.covered_by(&narrow)); assert!(!wide.covered_by(&moved)); + // A mount moves what a path resolves to, so either way it is a new scope. + assert!(!wide.covered_by(&mounted)); + assert!(!mounted.covered_by(&wide)); } /// Routes a session announced were recorded as a peer's or not; a re-check that diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index a70de866bf..cfb8304d2f 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1204,19 +1204,36 @@ impl Cluster { /// Passed by reference to [`moq_net::Server::with_publisher`] (or the /// equivalent per-request setter), which derives the read handle. pub fn subscriber(&self, token: &auth::Token) -> Option { - self.origin.scope(&token.root, &token.subscribe).ok() + self.mounted(token)?.scope(&token.root, &token.subscribe).ok() } /// Returns an [`origin::Producer`] scoped to this session's publish permissions, /// marked [`origin::Producer::peer`] when the grant names a cluster peer. + /// Nothing is published beneath the grant's mounts. pub fn publisher(&self, token: &auth::Token) -> Option { - let publisher = self.origin.scope(&token.root, &token.publish).ok()?; + let publisher = self.mounted(token)?.scope(&token.root, &token.publish).ok()?; Some(match token.peer { true => publisher.peer(), false => publisher, }) } + /// The origin with the grant's mounts applied, before it is scoped to the + /// session. A mount the origin refuses (overlapping another) admits nothing. + fn mounted(&self, token: &auth::Token) -> Option { + let mut origin = self.origin.clone(); + for (at, target) in &token.mounts { + origin = match origin.mount(token.root.join(at), target) { + Ok(origin) => origin, + Err(err) => { + tracing::warn!(root = %token.root, %at, %target, %err, "grant mount refused"); + return None; + } + }; + } + Some(origin) + } + /// Resolve whether gossip is on and which URL this relay advertises, from /// `cluster.node` and the (string-typed) `cluster.mesh` toggle. /// @@ -2231,6 +2248,38 @@ mod tests { Cluster::new(Options::new(config)) } + /// A grant's mount reaches the target for subscribe and refuses publish, both + /// named from the session's root. + #[tokio::test] + async fn session_handles_apply_grant_mounts() { + let cluster = new_cluster(Config::default()).expect("cluster"); + let everything = || moq_net::Patterns::from(moq_net::Pattern::all()); + let mut grant = moq_auth::Grant::new(everything(), everything()); + grant.mounts.insert(".svc".into(), ".svc/pid".into()); + let token = auth::Token::new("/pid", &grant); + + let _worker = cluster + .origin + .publish(".svc/pid/foo", origin::Route::default()) + .expect("publish at the fleet path"); + let subscriber = cluster.subscriber(&token).expect("subscribe grant").consume(); + let broadcast = subscriber + .request_broadcast(".svc/foo") + .await + .expect("resolves through the mount"); + assert_eq!(broadcast.info().path.as_str(), ".svc/foo"); + + let publisher = cluster.publisher(&token).expect("publish grant"); + assert!(publisher.create_broadcast(".svc/foo").is_err()); + publisher.create_broadcast("cam").expect("publish outside the mount"); + + // A mount the origin refuses admits nothing rather than half a grant. + grant.mounts.insert(".svc/x".into(), ".other".into()); + let token = auth::Token::new("/pid", &grant); + assert!(cluster.subscriber(&token).is_none()); + assert!(cluster.publisher(&token).is_none()); + } + /// The publish task holds only a `Weak` to its producer, so it stops when the /// last `moq_stats::Producer` clone drops. Attaching one must therefore hand /// its lifetime to the cluster: an embedder clones handles off a `Relay` and From 163fcdaa3812fee9f49e37863bd26744aaf5994a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 17:46:15 -0700 Subject: [PATCH 49/87] perf(net): search the track timeline instead of scanning it (#4321) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 6 ++ js/net/bench/track.ts | 108 ++++++++++++++++++++++++ js/net/src/track.test.ts | 53 ++++++++++++ js/net/src/track.ts | 153 ++++++++++++++++++++++------------ quest/m1/README.md | 1 - quest/m1/js-timeline-scans.md | 32 ------- 6 files changed, 267 insertions(+), 86 deletions(-) create mode 100644 js/net/bench/track.ts delete mode 100644 quest/m1/js-timeline-scans.md diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 91aadf86e0..546458adae 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -89,6 +89,12 @@ jobs: if: ${{ !cancelled() }} run: nix develop --command bun js/net/bench/frames.ts + # Fails if publishing a group costs more as the track retains more groups, + # which means the latency guard or the cache went back to scanning them all. + - name: JS track retention benchmark + if: ${{ !cancelled() }} + run: nix develop --command bun js/net/bench/track.ts + # Runs each moq-net Criterion routine once, so a bench that panics (an # unparsable version, a shape that deadlocks) fails here instead of on the # next manual run. Timing is not compared; nextest never runs a diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts new file mode 100644 index 0000000000..a9d910c8b6 --- /dev/null +++ b/js/net/bench/track.ts @@ -0,0 +1,108 @@ +/** Sweep retained groups and subscribers for one group published through a track and received. */ +import { type Consumer as GroupConsumer, Producer as GroupProducer } from "../src/group.ts"; +import { Milli, Timestamp } from "../src/time.ts"; +import { Producer, type Subscriber } from "../src/track.ts"; + +const retainedCounts = [25, 100, 400, 1500]; +const subscriberCounts = [1, 4, 16]; +// Open groups each subscriber is still reading, so their latency guards re-evaluate on every arrival. +const heldCounts = [0, 16]; +const groups = 100; +const reps = 7; +// Publishing a group must not cost more as the retained window grows, or a guard or cache pass +// has gone back to scanning every retained group. The sweep spans 60x, so a scan lands well past +// this; the margin is loose because the machine is noisy. +const maxSlope = 5; +const payload = new Uint8Array(80); +let checksum = 0; + +// A clock that advances a millisecond per group, so a window of N ms retains N groups and every +// publish ages the oldest one out. Timing reads the real clock. +const now = performance.now.bind(performance); +let clock = 0; +performance.now = () => clock; + +// Let every woken reader re-evaluate its guard before the next group. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function receive(subscribers: Subscriber[]): GroupConsumer[] { + const received: GroupConsumer[] = []; + for (const subscriber of subscribers) { + const group = subscriber.tryRecvGroup(); + if (!group) throw new Error("group was not delivered"); + const frame = group.tryReadFrame(); + if (!frame) throw new Error("frame was not delivered"); + checksum += frame.payload.byteLength; + received.push(group); + } + return received; +} + +async function measure(retained: number, subscriberCount: number, held: number): Promise { + // Held groups would age out too, so those rows keep everything instead. + const window = Milli(held > 0 ? 3_600_000 : retained); + const producer = new Producer("bench").accept({ maxAge: window }); + const subscribers = Array.from({ length: subscriberCount }, () => producer.subscribe({ maxAge: window })); + let sequence = 0; + // Inserted by sequence, the way the wire hands a subscribed track its groups. + const publish = (close: boolean) => { + clock++; + const group = new GroupProducer(sequence); + producer.writeGroup(group); + group.writeFrame({ payload, timestamp: Timestamp.fromMillis(sequence++) }); + if (close) group.close(); + return group; + }; + + // The held groups stay open, each with a reader parked on its next frame. + const open: GroupProducer[] = []; + const reads: Promise[] = []; + for (let index = 0; index < retained; index++) { + const isHeld = index < held; + const group = publish(!isHeld); + const received = receive(subscribers); + if (isHeld) { + open.push(group); + for (const consumer of received) reads.push(consumer.readFrame()); + } + } + await flush(); + + const start = now(); + for (let index = 0; index < groups; index++) { + publish(true); + receive(subscribers); + await flush(); + } + const elapsed = now() - start; + + for (const group of open) group.close(); + await Promise.all(reads); + producer.close(); + return (elapsed * 1000) / groups; +} + +// Warm the JIT so the first row isn't the slowest. +await measure(100, 1, 0); + +console.log("retained,subscribers,held,publish_us"); +for (const held of heldCounts) { + for (const subscriberCount of subscriberCounts) { + let baseline: number | undefined; + for (const retained of retainedCounts) { + const samples: number[] = []; + for (let rep = 0; rep < reps; rep++) samples.push(await measure(retained, subscriberCount, held)); + // The fastest run is the one least disturbed by the rest of the machine. + const us = Math.min(...samples); + console.log(`${retained},${subscriberCount},${held},${us.toFixed(1)}`); + + baseline ??= us; + if (us > baseline * maxSlope) { + throw new Error( + `${retained} retained groups cost ${us.toFixed(1)} us/group, over ${maxSlope}x ${baseline.toFixed(1)}`, + ); + } + } + } +} +if (checksum === 0) throw new Error("benchmark did no work"); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index b2efb8eb99..e3ca4517ff 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -453,6 +453,29 @@ test("an unstamped immediate successor leaves reach unbounded", async () => { expect((await track.recvGroup())?.sequence).toBe(2); }); +// Groups can arrive out of sequence order; the immediate successor bounds reach even when it +// arrived last. +test("a late-arriving successor bounds a group's reach", async () => { + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); + const track = producer.subscribe({ maxAge: Milli(500) }); + + for (const [sequence, ms] of [ + [0, 0], + [2, 3000], + [1, 200], + ]) { + const group = new GroupProducer(sequence); + group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); + group.close(); + producer.writeGroup(group); + } + + // Group 0 reaches at most 200ms, where group 1 starts, so it is past the budget. + // Group 1 reaches 3000ms, the edge itself. + expect((await track.recvGroup())?.sequence).toBe(1); + expect((await track.recvGroup())?.sequence).toBe(2); +}); + // The ordered frame helpers ride the same cursor, so they see the same budget: a // backlog inside it is drained in full, and what is past it is skipped. test("ordered frame reads follow the budget", async () => { @@ -963,6 +986,36 @@ test("retention reclaims a group the publisher abandoned open", async () => { } }); +test("an idle live edge ages out once a newer group arrives", async () => { + const clock = mockMonotonicTime(10_000); + try { + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); + const edge = producer.appendGroup(); + edge.writeString("first"); + + const group = await track.recvGroup(); + if (!group) throw new Error("missing group"); + expect(await group.readString()).toBe("first"); + + // A prune while it is still the live edge keeps it, and finds nothing else to age out. + clock.set(10_200); + producer.subscribe({ maxAge: Milli(100) }); + + // A successor ends the exemption, and the group is long past the window, so the + // write evicts it rather than a later wakeup. + clock.set(10_300); + producer.appendGroup(); + const read = group.readFrame().then( + () => "clean end", + (err: unknown) => err, + ); + expect(await Promise.race([read, settle().then(() => "still parked")])).toBeInstanceOf(TooFarBehind); + } finally { + clock.restore(); + } +}); + test("an abandoned open group ages out with no further write", async () => { // Real time, since this is about the wakeup: nothing writes to the track again, so // without a timer the read below parks forever. diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 913d1ce70f..dc6e8747ec 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -24,6 +24,9 @@ export type { Datagram } from "./datagram.ts"; // and fires right away. const MAX_TIMEOUT_MS = 2 ** 31 - 1; +// The cache scans at most this many times per retention window. +const PRUNE_SLICES = 8; + /** Default {@link Info.maxAge} window (milliseconds) when the publisher does not set one. */ export const DEFAULT_MAX_AGE_MS = Milli(5000); @@ -285,6 +288,31 @@ export class Consumer { } } +// The index of the first timeline group at or after `sequence`. +function timelineIndex(timeline: GroupConsumer[], sequence: number): number { + let lo = 0; + let hi = timeline.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (timeline[mid].sequence < sequence) lo = mid + 1; + else hi = mid; + } + return lo; +} + +// Add a group to a sorted timeline, replacing any group with the same sequence. Groups +// usually arrive in order, so the append is checked first. +function timelineInsert(timeline: GroupConsumer[], group: GroupConsumer): void { + const last = timeline.at(-1); + if (!last || last.sequence < group.sequence) { + timeline.push(group); + return; + } + const index = timelineIndex(timeline, group.sequence); + if (timeline[index]?.sequence === group.sequence) timeline[index] = group; + else timeline.splice(index, 0, group); +} + // The shared state behind a Producer / Subscriber pair. Package-internal // wiring, unexported so it never appears in the published type declarations. class TrackState { @@ -292,9 +320,10 @@ class TrackState { producer?: Producer; groups = new Signal([]); // Every group still in the producer's replay cache, including groups this - // subscriber already consumed, paired with its source queue time. Drift anchors - // have the same lifetime as content. - timeline = new Map(); + // subscriber already consumed, sorted by sequence. Drift anchors have the same + // lifetime as content. Sorted so the latency guard, evaluated per group and per + // arrival, searches it instead of scanning the whole retained window. + timeline: GroupConsumer[] = []; // First timestamps mutate group state rather than track state, so held groups // watch this revision as well as arrivals when enforcing latency after handoff. timelineChanged = new Signal(0); @@ -346,7 +375,7 @@ async function resolveInfo(state: TrackState): Promise { // A source group retained in the producer cache, with the mirror handed to each sink // so eviction can drop them together. -type CachedGroup = { group: GroupProducer; time: number; mirrors: Map }; +type CachedGroup = { group: GroupProducer; mirrors: Map }; function bindProducer(name: string, producer: Producer, sequences: TrackSequences): void { let shared = sequences.get(name); @@ -403,11 +432,16 @@ export class Producer { // it handed to every sink so eviction can drop them too: otherwise a slow consumer // that never reads would pin old groups (and their frame bytes) forever. #cache: CachedGroup[] = []; + // The same entries by sequence, so a write finds a duplicate without a scan. + #cached = new Map(); + // When the cache was last scanned. See #prune. + #pruned = Number.NEGATIVE_INFINITY; // Wakeup for the next entry due to age out. Writes settle retention inline, but a // publisher that stalls stops writing, so without this an abandoned group (and any // reader parked in it) would wait for a write that never comes. #pruneTimer?: ReturnType; + #pruneTimerAt = 0; // One independent downstream state per live subscriber. #sinks = new Set(); @@ -569,7 +603,7 @@ export class Producer { #mirror(entry: CachedGroup, sink: TrackState): void { const dst = entry.group.mirror(); entry.mirrors.set(sink, dst); - sink.timeline.set(dst.sequence, { group: dst, time: entry.time }); + timelineInsert(sink.timeline, dst); void dst.readable().then(() => sink.timelineChanged.update((revision) => revision + 1)); sink.latest = Math.max(sink.latest ?? 0, dst.sequence); sink.groups.mutate((groups) => { @@ -591,7 +625,8 @@ export class Producer { const i = groups.indexOf(mirror); if (i >= 0) groups.splice(i, 1); }); - if (sink.timeline.get(mirror.sequence)?.group === mirror) sink.timeline.delete(mirror.sequence); + const index = timelineIndex(sink.timeline, mirror.sequence); + if (sink.timeline[index] === mirror) sink.timeline.splice(index, 1); mirror.close(); } entry.mirrors.clear(); @@ -609,46 +644,57 @@ export class Producer { // Evict cached groups idle for longer than the cache window. Idle means nothing // written, so an abandoned open group ages out instead of pinning its buffer (and // any reader parked in it) forever. + // + // Scans at most once per slice of the window, so a track publishing faster than that + // evicts a run of groups per scan instead of scanning everything to evict one per + // write. A group can outlive the window by up to one slice. #prune(): void { const maxAgeMs = this.#state.info.peek()?.maxAge ?? DEFAULT_MAX_AGE_MS; - const cutoff = performance.now() - maxAgeMs; + const now = performance.now(); + const slice = maxAgeMs / PRUNE_SLICES; + if (now < this.#pruned + slice) { + // Something may have come due since the last scan, so make sure another follows. + this.#wake(this.#pruned + slice); + return; + } + this.#pruned = now; + + const cutoff = now - maxAgeMs; const live = this.#liveEdge(); + let oldest: number | undefined; const retained: CachedGroup[] = []; for (const entry of this.#cache) { - if (entry.group === live || entry.group.activity >= cutoff) { + if (entry.group === live) { retained.push(entry); - continue; + } else if (entry.group.activity >= cutoff) { + retained.push(entry); + if (oldest === undefined || entry.group.activity < oldest) oldest = entry.group.activity; + } else { + this.#cached.delete(entry.group.sequence); + this.#evict(entry); } - this.#evict(entry); } this.#cache = retained; - this.#schedulePrune(); - } - // Arm the wakeup for the next entry due to age out, replacing any pending one. - // Writes settle retention inline, so this only has to cover the case no write - // follows. Cheap to over-arm: an entry written since is retained and re-armed. - #schedulePrune(): void { + // Replace the wakeup with one for the next entry due to age out. Writes settle + // retention inline, so this only has to cover the case no write follows. Cheap to + // over-arm: an entry written since is retained and re-armed. clearTimeout(this.#pruneTimer); this.#pruneTimer = undefined; - if (this.#state.closed.peek() !== undefined) return; + if (oldest !== undefined) this.#wake(Math.max(oldest + maxAgeMs, now + slice)); + } - // One pass, no intermediate arrays: this runs on every publish, and a spread - // over the cache would also cap how many groups a track can hold. - const live = this.#liveEdge(); - let oldest: number | undefined; - for (const entry of this.#cache) { - if (entry.group === live) continue; - if (oldest === undefined || entry.group.activity < oldest) oldest = entry.group.activity; - } - if (oldest === undefined) return; + // Arm the prune wakeup for `at`, unless one is already armed sooner. + #wake(at: number): void { + if (this.#state.closed.peek() !== undefined) return; + if (this.#pruneTimer !== undefined && this.#pruneTimerAt <= at) return; + clearTimeout(this.#pruneTimer); - const maxAgeMs = this.#state.info.peek()?.maxAge ?? DEFAULT_MAX_AGE_MS; // setTimeout truncates its delay to a signed 32-bit int, so a longer window // would fire immediately and spin. Wake at the cap instead and re-arm: #prune // retains anything still fresh, so the extra wakeups are the only cost. - const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, oldest + maxAgeMs - performance.now())); + const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, at - performance.now())); const timer = setTimeout(() => { this.#pruneTimer = undefined; this.#prune(); @@ -656,12 +702,14 @@ export class Producer { // A cache prune is never a reason to hold a Node/Bun process open. (timer as unknown as { unref?: () => void }).unref?.(); this.#pruneTimer = timer; + this.#pruneTimerAt = at; } // Retain a source group and fan it out to every live sink. #publish(group: GroupProducer): void { - const entry: CachedGroup = { group, time: performance.now(), mirrors: new Map() }; + const entry: CachedGroup = { group, mirrors: new Map() }; this.#cache.push(entry); + this.#cached.set(group.sequence, entry); for (const sink of this.#sinks) this.#mirror(entry, sink); // Give held mirrors the new live edge before pruning their timeline entry, // so their latency guard can preserve a terminal expiry verdict. @@ -699,14 +747,14 @@ export class Producer { writeGroup(group: GroupProducer) { this.#writable(group.sequence); - const existing = this.#cache.findIndex((entry) => entry.group.sequence === group.sequence); - if (existing >= 0) { - const entry = this.#cache[existing]; - if (!(entry.group.closed.peek() instanceof Error)) { + const existing = this.#cached.get(group.sequence); + if (existing) { + if (!(existing.group.closed.peek() instanceof Error)) { throw new Error(`duplicate group: sequence=${group.sequence}`); } - this.#evict(entry); - this.#cache.splice(existing, 1); + this.#evict(existing); + this.#cache.splice(this.#cache.indexOf(existing), 1); + this.#cached.delete(group.sequence); } // Only advance the shared counter upward (for appendGroup auto-increment). @@ -876,16 +924,17 @@ export class Subscriber { end?: number; } { const { end } = this.#cursor.peek(); + const timeline = this.#state.timeline; let presentation: { sequence: number; timestamp: Timestamp } | undefined; - for (const { group } of this.#state.timeline.values()) { - if (end !== undefined && group.sequence >= end) continue; + // The edge wants the newest content that exists, so it takes the newest + // stamped group's latest frame: walk back from the cap to the first one. + for (let i = (end === undefined ? timeline.length : timelineIndex(timeline, end)) - 1; i >= 0; i--) { + const group = timeline[i]; if (group.closed.peek() instanceof Error) continue; - // The edge wants the newest content that exists, so it takes the newest - // stamped group's latest frame. const timestamp = hooks.groupTimestamp(group); - if (timestamp !== undefined && (!presentation || group.sequence > presentation.sequence)) { - presentation = { sequence: group.sequence, timestamp: hooks.groupLatest(group) ?? timestamp }; - } + if (timestamp === undefined) continue; + presentation = { sequence: group.sequence, timestamp: hooks.groupLatest(group) ?? timestamp }; + break; } const requested = this.#state.update.peek()?.maxAge ?? 0; @@ -910,15 +959,14 @@ export class Subscriber { // unstamped successor will begin, and shrinking the bound is the unsafe direction. // An unstamped successor leaves the reach unbounded until it presents a frame. #reach(sequence: number, end?: number): number | undefined { - let successor: GroupConsumer | undefined; - for (const { group } of this.#state.timeline.values()) { - if (group.sequence <= sequence) continue; - if (end !== undefined && group.sequence >= end) continue; - if (group.closed.peek() instanceof Error) continue; - if (!successor || group.sequence < successor.sequence) successor = group; + const timeline = this.#state.timeline; + for (let i = timelineIndex(timeline, sequence + 1); i < timeline.length; i++) { + const successor = timeline[i]; + if (end !== undefined && successor.sequence >= end) break; + if (successor.closed.peek() instanceof Error) continue; + return hooks.groupTimestamp(successor)?.asMillis(); } - if (!successor) return undefined; - return hooks.groupTimestamp(successor)?.asMillis(); + return undefined; } // Whether the drift budget says to give up on `group`. @@ -945,8 +993,7 @@ export class Subscriber { end?: number; }, ): boolean { - const candidate = this.#state.timeline.get(group.sequence); - if (candidate?.group !== group) return false; + if (this.#state.timeline[timelineIndex(this.#state.timeline, group.sequence)] !== group) return false; const reach = this.#reach(group.sequence, drift.end); return ( @@ -1145,7 +1192,7 @@ export class Subscriber { }); this.#frameGroup?.close(abort); this.#frameGroup = undefined; - this.#state.timeline.clear(); + this.#state.timeline.length = 0; } /** diff --git a/quest/m1/README.md b/quest/m1/README.md index 343826bb77..8d9e5cc9cf 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -114,7 +114,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Scope track priority](/quest/m1/track-priority-scope.md) - priority orders one owner's streams, and a shared cluster session is fair across tenants - [Stream sessions](/quest/m1/uring-tcp/README.md) - serve WebSocket and HTTP from the io_uring workers, where io_uring pays off most - [IETF on the ring](/quest/m1/uring-ietf.md) - the io_uring workers serve moq-transport sessions too, so a uring relay drops no client protocol -- [JS timeline scans](/quest/m1/js-timeline-scans.md) - a `@moq/net` publisher's per-group cost stays flat as the retained window grows - [BBR ACK cleanup](/quest/m1/bbr-ack-cleanup.md) - packet bookkeeping scales with completed entries instead of scanning the flight on every ACK - [Perf](/quest/m1/perf/README.md) - eliminate measured hot-path costs across moq-uring, kio, and the moq-net model - [#2924](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every listener on both runtimes shares one reloadable served identity, so rotation is atomic and generate works with workers diff --git a/quest/m1/js-timeline-scans.md b/quest/m1/js-timeline-scans.md deleted file mode 100644 index 9c0d856afa..0000000000 --- a/quest/m1/js-timeline-scans.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] JS timeline scans - -## Goal - -A `@moq/net` publisher's per-group cost stays flat as the retained window -grows. `Subscriber.#drift()`, `#reach()`, and the prune pass stop scanning -every cached group. - -## Plan - -- `js/net/src/track.ts` keeps the timeline in a `Map` of every cached group, - consumed ones included. `#drift` and `#reach` scan it per popped group and on - every guard re-evaluation, and `#prune`/`#schedulePrune` scan it per publish. -- Mirror Rust's `rs/moq-net/src/model/track.rs`: keep sequences sorted (append - fast path, binary insert otherwise). Drift walks backward to the first - stamped, non-errored group in range; reach binary-searches the successor. - Fold the prune scans in if the benchmark shows them. Memoizing per revision - was rejected: a new group still costs O(n) per subscriber. An incremental - edge was rejected: invalidation on aborts and cursor ends gets fiddly. -- Add `js/net/bench/track.ts`: microseconds per published group through - Producer, Subscriber, `tryRecvGroup`, and the guard, with no transport. Sweep - retained groups (about 25 to 1500) against subscribers (1 to 16), plus a case - with guards in flight. Wire it into `nightly.yml` beside `broadcasts.ts`. The - fix passes when the retained-groups axis is flat. - -## Closes - -- [#4246](https://github.com/moq-dev/moq/issues/4246) - per-group publishing cost grows with the retained window - -## Related - -- [Browser benchmarks](/quest/m1/browser-benchmarks.md) - the browser media path; this bench covers the track model From 2b689c2423f6800855ad2f9994c73ad922aedc90 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 20:11:40 -0700 Subject: [PATCH 50/87] fix: make BBR respond to classic ECN (#4265) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 16 +++++----- Cargo.toml | 6 ++-- quest/m1/README.md | 1 - quest/m1/bbr-ack-cleanup.md | 1 - quest/m1/bbr-classic-ecn.md | 61 ------------------------------------ quest/m1/close-codes.md | 5 ++- quest/m1/quic/ecn-measure.md | 7 ++--- quest/m2/quic-ecn.md | 7 ++--- 8 files changed, 18 insertions(+), 86 deletions(-) delete mode 100644 quest/m1/bbr-classic-ecn.md diff --git a/Cargo.lock b/Cargo.lock index 8b0aded8e9..db0d69c052 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4523,9 +4523,9 @@ dependencies = [ [[package]] name = "moq-noq" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e37523750e5f1313c3fd1798c4ffdb00218571054f601c3193ceb4e100e5ca" +checksum = "8f73dd3abdf1efa3b89f903494d93836968f4eb4040a29cde5a03a4c6451535b" dependencies = [ "bytes", "cfg_aliases", @@ -4545,9 +4545,9 @@ dependencies = [ [[package]] name = "moq-noq-proto" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7448c4a6a6713b276b346931f3def60e0a89415cd8172d12f6465fed6501a261" +checksum = "c8ede2ffc5008e6c53798802c257689475f01b12960f37a5f78e91cf4580159b" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -4576,9 +4576,9 @@ dependencies = [ [[package]] name = "moq-noq-udp" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98e7d85788b7ec2437ac3dd83a231c4e403b96a5e53e39a8701ba5d1d4fad4a4" +checksum = "b1e67d81510e43c0a0ce67a310e35938aa1dbd4a34ccef32d10f752719269cf2" dependencies = [ "cfg_aliases", "libc", @@ -9836,9 +9836,9 @@ dependencies = [ [[package]] name = "web-transport-moq" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3bdb2400eb49f82b778165df8da318f94be5a667a840a94fe6fc32f3798746" +checksum = "c2d0167f1b04c31eefb41262fd459b90d72d6f41eea0cbc35d7e2c6279190290" dependencies = [ "bytes", "futures", diff --git a/Cargo.toml b/Cargo.toml index 65170e3b2e..789ecbbfd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,8 +149,8 @@ moq-mux = { version = "0.10.8", path = "rs/moq-mux" } moq-net = { version = "0.3.7", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. -moq-noq-proto = { version = "1.3.1", default-features = false } -moq-noq-udp = "1.3.1" +moq-noq-proto = { version = "1.3.2", default-features = false } +moq-noq-udp = "1.3.2" # 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. @@ -234,7 +234,7 @@ web-transport-iroh = "0.7" # default-features off so the QUIC crypto provider is chosen by moq-tokio's # aws-lc-rs / ring features. # The WebTransport adapter released with moq-noq, from the same repository. -web-transport-moq = { version = "1.3.1", default-features = false } +web-transport-moq = { version = "1.3.2", default-features = false } web-transport-proto = "0.6" web-transport-trait = "0.4" web-transport-wasm = "0.6" diff --git a/quest/m1/README.md b/quest/m1/README.md index 8d9e5cc9cf..50e3d328e9 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -18,7 +18,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests - [Drill sensitivity](/quest/m1/drill-sensitivity.md) - the nightly drill-sensitivity job passes: the subscriber-leaks-broadcasts mutation applies to the current lite subscriber again -- [BBR classic ECN](/quest/m1/bbr-classic-ecn.md) - Startup and bandwidth probing respond to CE marks before the bottleneck drops packets - [Cluster routing](/quest/m1/cluster-routing.md) - an announcement says where a broadcast originates, not how to reach it, and a relay hears only the prefixes its clients asked for - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` diff --git a/quest/m1/bbr-ack-cleanup.md b/quest/m1/bbr-ack-cleanup.md index 02ad06e0f1..702b7b2124 100644 --- a/quest/m1/bbr-ack-cleanup.md +++ b/quest/m1/bbr-ack-cleanup.md @@ -56,6 +56,5 @@ is needed. ## Related -- [Classic ECN](/quest/m1/bbr-classic-ecn.md) - an independent correctness fix in the same controller; coordinate ownership of the shared file - [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - preserve packet metadata needed by the separate loss-sample repair - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - reusable measurement guidance, not a prerequisite for this fix diff --git a/quest/m1/bbr-classic-ecn.md b/quest/m1/bbr-classic-ecn.md deleted file mode 100644 index d085ba2d8c..0000000000 --- a/quest/m1/bbr-classic-ecn.md +++ /dev/null @@ -1,61 +0,0 @@ -# [M] Make BBR respond to classic ECN - -## Goal - -BBRv3 responds to validated CE marks before a marking bottleneck has to drop -packets, including during Startup and ProbeUp. Keep ECT(0) enabled and ship -the corrected controller through MoQ's published dependency chain. L4S, -new configuration flags, and changing the default controller are out of scope. - -## Plan - -The fix lives in moq-dev/noq. In released 1.3.1 (`ff9d2ab5`), -[`on_congestion_event`](https://github.com/moq-dev/noq/blob/ff9d2ab518cfb155f9ebb9925f1c784665eac92a/noq-proto/src/congestion/bbr3/mod.rs#L1828) -passes CE to the lost-packet path with zero lost bytes. Startup and ProbeUp -skip the short-term loss response, while their high-loss checks see no lost -bytes. A controller reproduction sends eight rounds of 1, 2, 4, ..., 128 -1200-byte packets, 20 ms apart, acknowledging each round after 10 ms and -reporting CE after `on_end_acks`, as the transport does. Both marked and -unmarked controls remain in Startup with a 318,000-byte window and -42,167,347.2 bytes/s pacing. This is a controller result, not an AQM network -measurement. - -[Draft-06 section 3.7](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-3.7) -requires an ECN-capable sender to treat CE as congestion, without prescribing -one BBR response. [Google Linux BBRv3](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L1048) -has a separate Startup ECN response when its ECN mode is eligible. Choose a -classic response consistent with QUIC recovery: stop acceleration and reduce -the permitted sending load on new CE feedback, at most once per recovery -period. Do not fabricate lost bytes or apply repeated reductions for old CE -counts. Preserve the distinction between loss and CE, including undo of -spurious loss. Prefer the existing controller boundary; this fix does not -need a CE-fraction API or Google's L4S policy. - -Extend the existing shared BBR `Sim` with the failing reproduction, then test -through actual QUIC ECN validation and controller callbacks. Cover Startup, -ProbeUp, Cruise, ProbeRTT, repeated ACKs with no new CE, several CE-bearing -ACKs in one recovery period, a later period with new CE, simultaneous loss, -and invalid or missing ECN feedback. Acceptance requires a bounded decrease -in sending load under sustained CE, recovery after marking stops, and no -response on the unmarked control; merely changing an internal state is not -enough. Keep the default CUBIC path working. - -Retain a reproducible rate-limited marking-versus-dropping network case, -recording queue delay, goodput, loss, and response timing. Run deterministic -regressions in fork CI and the network case at least nightly. Provider -availability does not block this lab validation. Record the response rule -and its recovery-period boundary with the results. - -Land the fix in the fork, offer it upstream or record why not, publish an -immutable fork release, and pin the corrected dependency chain here before -completing this quest. Do not wait for the broader QUIC stack release or the -ACK cleanup fix. No wire change or new public API is intended; document any -necessary API change and apply the repository's branch rules. Update stale -controller and relay ECN documentation inline; no separate guide is needed. - -## Related - -- [ACK cleanup](/quest/m1/bbr-ack-cleanup.md) - an independent fix in the same controller; coordinate ownership of the shared file -- [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - the separate lost-packet sample repair -- [ECN measurement](/quest/m1/quic/ecn-measure.md) - measures the released response on the backbone -- [L4S](/quest/m2/quic-ecn.md) - a separate scalable ECN policy and opt-in configuration diff --git a/quest/m1/close-codes.md b/quest/m1/close-codes.md index f1e3e9a34f..4e52f19d62 100644 --- a/quest/m1/close-codes.md +++ b/quest/m1/close-codes.md @@ -18,9 +18,8 @@ Both bugs are upstream; fix them at the source, release, and bump the pins. `accept_bi` also return a bare `Closed`. Make the first close win, make `close()` a no-op once closed, and have `accept_*` return the recorded reason. Add a qmux test where APPLICATION_CLOSE and EOF arrive together. -- `web-transport-moq` 1.3.1 (`moq-dev/noq`) maps `ApplicationClosed` only - through the HTTP/3 code space in `error.rs`, so a raw `moqt://` code yields - no `session_error()`. Map raw QUIC codes directly. +- `web-transport-moq` 1.3.2 (`moq-dev/noq#11`) maps a raw `moqt://` peer's + `ApplicationClosed` code directly. Done; only the regression below remains. - moq-net's `close(Internal)` after a transport error is correct: closing a closed connection does nothing. Do not work around it here. - One moq-tokio regression runs the issue's three cases (abort after accept, diff --git a/quest/m1/quic/ecn-measure.md b/quest/m1/quic/ecn-measure.md index 56b6658477..2745829780 100644 --- a/quest/m1/quic/ecn-measure.md +++ b/quest/m1/quic/ecn-measure.md @@ -14,7 +14,8 @@ A manual procedure on Linux, run as root, with the commands and what to record written here so the dualpi2 run and any later provider re-check repeat it. -Use the released [classic BBR ECN fix](/quest/m1/bbr-classic-ecn.md) for the +Use moq-noq 1.3.2 or later, which carries the classic BBR CE response +([moq-dev/noq#12](https://github.com/moq-dev/noq/pull/12)), for the controller-response verdict and record the exact dependency version. Provider mark-survival captures alone do not establish a controller response; a result from 1.3.1 is a defective baseline, not evidence that classic ECN cannot help. @@ -35,7 +36,3 @@ from 1.3.1 is a defective baseline, not evidence that classic ECN cannot help. and the tcpdump summaries beside the numbers in the L4S quest's Plan. If neither provider preserves the marks, say so there: L4S stays off and the marking response is only a lab result. - -## Required - -- [Classic BBR ECN](/quest/m1/bbr-classic-ecn.md) - the final response verdict needs the corrected released controller diff --git a/quest/m2/quic-ecn.md b/quest/m2/quic-ecn.md index e4b0e9d24e..3d8e415b83 100644 --- a/quest/m2/quic-ecn.md +++ b/quest/m2/quic-ecn.md @@ -9,9 +9,9 @@ marks fall back to no ECN, and a viewer's session is unaffected. ## Plan -ECT(0) marking and ACK ECN counts are carried end to end, but BBR's -[classic CE response](/quest/m1/bbr-classic-ecn.md) must be corrected before -it is used as the baseline. This quest adds the scalable policy separately. +ECT(0) marking and ACK ECN counts are carried end to end, and BBR's classic +CE response ([moq-dev/noq#12](https://github.com/moq-dev/noq/pull/12), in +moq-noq 1.3.2) is the baseline. This quest adds the scalable policy separately. noq-proto has no ECN knob: `sending_ecn` starts on per path and validation failure or an ACK without counts turns it off, so both `off` and `ect1` need the fork. @@ -35,6 +35,5 @@ need the fork. ## Required -- [Classic BBR ECN](/quest/m1/bbr-classic-ecn.md) - establish a corrected released classic response before comparing L4S - [Measure ECN on the backbone](/quest/m1/quic/ecn-measure.md) - the provider verdict this quest acts on From 971a1a8f65aa183d665f5fe7f3f4ac08825703d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Migda=C5=82?= <68378289+kidq330@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:10:38 +0000 Subject: [PATCH 51/87] fix(net): keep a settled track's groups when it is aborted (#4351) Co-authored-by: Claude Fable 5.1 --- rs/moq-net/src/model/track.rs | 127 ++++++++++++++++-- .../tests/settled_end_survives_abort.rs | 89 ++++++++++++ 2 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 rs/moq-net/tests/settled_end_survives_abort.rs diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index d4f3216a6d..9ad9737a22 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -411,6 +411,20 @@ impl TrackState { next_sequence: u64, end_sequence: Option, ) -> Poll>> { + // An abort before the end settled cut the track off. One after it is a clean + // end (see `is_complete`): the cached groups were everything the end promised, + // so the cursor ends as `poll_recv_group` does, not with the abort. + if let Some(err) = &self.abort + && !self.settled + { + return Poll::Ready(Err(err.clone())); + } + // Nothing more can arrive once the last producer is gone: the track was sealed + // (dropped with its end declared) or aborted with that end settled. Either closed + // the channel, so parking is not an option: the consumer would be handed the + // abort, or `Dropped`, instead of the end it reached. + let closed = self.sealed || self.abort.is_some(); + // If the exclusive end is already at or below where we'd resume, no // group can ever satisfy this call until the cap rises. Pending (not // None) so the consumer is parked rather than told the stream is over. @@ -418,8 +432,8 @@ impl TrackState { if let Some(end) = end_sequence && end <= next_sequence { - if let Some(err) = &self.abort { - return Poll::Ready(Err(err.clone())); + if closed { + return Poll::Ready(Ok(None)); } return Poll::Pending; } @@ -440,16 +454,12 @@ impl TrackState { } // No in-range group is cached. Decide whether more could ever arrive. - if let Some(err) = &self.abort { - return Poll::Ready(Err(err.clone())); - } // `final_sequence` is one past the last possible sequence. If our // floor is already at/past it, nothing else can land in range. - // A sealed track produces nothing more either: the last producer - // dropped with the boundary already declared, so a gap below it is - // the end, not a wait. Cached in-range groups were returned above. - // This is the cursor the ordered and spliced readers use. - if self.sealed || self.final_sequence.is_some_and(|fin| next_sequence >= fin) { + // A closed track produces nothing more either, so a gap below its + // boundary is the end, not a wait. Cached in-range groups were + // returned above. This is the cursor the ordered and spliced readers use. + if closed || self.final_sequence.is_some_and(|fin| next_sequence >= fin) { return Poll::Ready(Ok(None)); } Poll::Pending @@ -1195,8 +1205,14 @@ fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) { // Decided before the cache goes: the groups below the end are the evidence. state.settled = state.is_settled(); state.abort = Some(err); - state.clear_cache(); - state.datagrams.clear(); + // A settled end is a clean end: the track holds every group it promised, so keep + // them for consumers still draining, as `finish()` does. Clearing here would abort + // the finished groups a slower reader has not pulled yet, and it would then see the + // track end short of them with no error. + if !state.settled { + state.clear_cache(); + state.datagrams.clear(); + } state.close(); } @@ -1506,6 +1522,11 @@ impl Producer { /// of the leftover cache. Child groups are independent: a consumer that already pulled /// a [`group::Consumer`] keeps its own handle and can finish reading it. /// + /// Unless the declared end had settled: the final sequence from + /// [`finish_at`](Self::finish_at) was reached and every group below it finished. The + /// track then already holds everything it promised, so it ends cleanly and keeps the + /// cached groups for consumers still draining, as [`finish`](Self::finish) does. + /// /// [`finish`](Self::finish) is deliberately not terminal: it declares the final /// sequence, and lower-numbered groups may still be written afterwards. pub fn abort(self, err: Error) -> Result<()> { @@ -4116,8 +4137,9 @@ impl Ordered { /// the cap rises or is removed. /// /// Returns `Poll::Ready(Ok(Some(group)))` when a group is available, - /// `Poll::Ready(Ok(None))` when the track is finished, - /// `Poll::Ready(Err(e))` when the track has been aborted, or + /// `Poll::Ready(Ok(None))` when the track is finished (including an abort after its + /// declared end settled, see [`Producer::abort`]), + /// `Poll::Ready(Err(e))` when the track has been aborted short of its end, or /// `Poll::Pending` when no group is available yet. pub fn poll_next_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.inner.poll_next_group(waiter) @@ -6953,6 +6975,83 @@ mod test { assert!(matches!(res, Ok(None))); } + /// The settled end stands on the ordered cursor too: a reader that starts after the + /// abort gets every group below the end, then the clean end, not the abort. + #[tokio::test] + async fn abort_after_the_end_settles_ends_clean_for_an_ordered_reader() { + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None).ordered(); + + for sequence in 0..2 { + let group = producer.create_group(group::Info { sequence }).unwrap(); + group.finish().unwrap(); + } + producer.finish_at(2).unwrap(); + producer.abort(Error::Timeout).unwrap(); + + assert_eq!(drain_ordered(&mut consumer), [0, 1]); + let end = consumer + .next_group() + .now_or_never() + .expect("should not block") + .map(|group| group.map(|group| group.sequence)); + assert!(matches!(end, Ok(None)), "expected the clean end, got {end:?}"); + } + + /// The settled end stands on the ordered cursor even where it has nothing to read: + /// capped below the end, and with a gap below the cap. Parking is not an option on a + /// closed track (the channel would turn it into the abort), so these end clean. + #[tokio::test] + async fn abort_after_the_end_settles_ends_clean_below_the_cap() { + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None).ordered(); + for sequence in 0..2 { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + producer.finish_at(2).unwrap(); + producer.abort(Error::Timeout).unwrap(); + + consumer.set_groups(..1); + assert_eq!(drain_ordered(&mut consumer), [0]); + let end = consumer + .next_group() + .now_or_never() + .expect("should not block") + .map(|group| group.map(|group| group.sequence)); + assert!( + matches!(end, Ok(None)), + "capped at the end: expected the clean end, got {end:?}" + ); + + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None).ordered(); + for sequence in [0, 2] { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + producer.finish_at(3).unwrap(); + producer.abort(Error::Timeout).unwrap(); + + consumer.set_groups(..2); + assert_eq!(drain_ordered(&mut consumer), [0]); + let end = consumer + .next_group() + .now_or_never() + .expect("should not block") + .map(|group| group.map(|group| group.sequence)); + assert!( + matches!(end, Ok(None)), + "gap below the cap: expected the clean end, got {end:?}" + ); + } + #[tokio::test] async fn recv_group_finishes_without_waiting_for_gaps() { let producer = track_producer("test", None); diff --git a/rs/moq-net/tests/settled_end_survives_abort.rs b/rs/moq-net/tests/settled_end_survives_abort.rs new file mode 100644 index 0000000000..2ef3826438 --- /dev/null +++ b/rs/moq-net/tests/settled_end_survives_abort.rs @@ -0,0 +1,89 @@ +//! A track whose declared end has settled holds every group it promised: the end was +//! reached and each group below it finished. An abort that lands afterwards, such as +//! the session dying, ends the track cleanly but must not throw those groups away: a +//! consumer that has not read them yet still gets them, then the clean end. + +mod support; + +use moq_net::{Error, Hop, Timestamp}; + +fn produce_origin(hop: u64) -> moq_net::origin::Producer { + let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer +} + +const GROUPS: u64 = 2; + +/// Publish `GROUPS` finished groups, declare the end at `GROUPS`, then abort the track. +/// Returns the sequences a consumer that starts reading only now receives, in arrival +/// or in sequence order, and how the read ended. +async fn round(abort: bool, ordered: bool) -> (Vec, Option) { + let origin = produce_origin(1); + let broadcast = origin.create_broadcast("bcast").unwrap(); + let mut track = broadcast.create_track("video", None).unwrap(); + // From the first group with a replay window: a late reader is owed the whole track, + // not the live edge. + let subscription = moq_net::track::Subscription::default() + .with_start(moq_net::track::Position::group(0)) + .with_max_age(std::time::Duration::from_secs(30)); + let mut consumer = track.subscribe(subscription); + + for _ in 0..GROUPS { + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + } + track.finish_at(GROUPS).unwrap(); + // Every group below the end is finished: the end has settled and the track is + // complete. Nothing has been read yet. + if abort { + track.abort(Error::Session(moq_net::SessionError::Cancel)).unwrap(); + } + + let mut got = Vec::new(); + if ordered { + let mut consumer = consumer.ordered(); + loop { + match consumer.next_group().await { + Ok(Some(group)) => got.push(group.sequence), + Ok(None) => return (got, None), + Err(err) => return (got, Some(err)), + } + } + } + loop { + match consumer.recv_group().await { + Ok(Some(group)) => got.push(group.sequence), + Ok(None) => return (got, None), + Err(err) => return (got, Some(err)), + } + } +} + +fn assert_whole((got, err): (Vec, Option), what: &str) { + assert!( + err.is_none() && got == (0..GROUPS).collect::>(), + "{what}: got {got:?} of {GROUPS} groups, err={err:?} (every group had finished before \ + the end, so a late reader gets all of them and then the clean end)" + ); +} + +/// The groups a settled track holds outlive an abort: a late reader gets all of them. +#[tokio::test] +async fn an_abort_after_the_end_settled_keeps_the_groups_for_a_late_reader() { + assert_whole(round(true, false).await, "arrival order, aborted after the end settled"); +} + +/// The same in sequence order: the ordered cursor ends clean too, not with the abort. +#[tokio::test] +async fn an_abort_after_the_end_settled_keeps_the_groups_for_a_late_ordered_reader() { + assert_whole(round(true, true).await, "sequence order, aborted after the end settled"); +} + +/// Control: with no abort the same late reader gets every group, then the clean end. +#[tokio::test] +async fn a_settled_track_delivers_every_group_to_a_late_reader() { + assert_whole(round(false, false).await, "arrival order, no abort"); + assert_whole(round(false, true).await, "sequence order, no abort"); +} From ca47661f734fe4e0ae769595b2e1dd0a47021e1c Mon Sep 17 00:00:00 2001 From: pwrwpw Date: Mon, 28 Sep 2026 22:10:42 +0900 Subject: [PATCH 52/87] fix(libmoq): open extern "C" in the generated moq.h (#4350) --- cpp/obs/src/moq-output.cpp | 2 -- cpp/obs/src/moq-settings.cpp | 2 -- cpp/obs/src/moq-settings.h | 2 -- cpp/obs/src/moq-source.cpp | 2 +- cpp/obs/src/obs-moq.cpp | 2 -- cpp/obs/test/moq-output-test.cpp | 2 -- cpp/obs/test/moq-source-test.cpp | 2 +- rs/libmoq/build.rs | 4 ++++ rs/libmoq/cbindgen.toml | 9 +++++---- 9 files changed, 11 insertions(+), 16 deletions(-) diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 7a5e071951..49de54d3ff 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -10,9 +10,7 @@ #include #include -extern "C" { #include "moq.h" -} namespace { diff --git a/cpp/obs/src/moq-settings.cpp b/cpp/obs/src/moq-settings.cpp index 8b3fc4b5a2..a1dcfec192 100644 --- a/cpp/obs/src/moq-settings.cpp +++ b/cpp/obs/src/moq-settings.cpp @@ -7,9 +7,7 @@ #include #include -extern "C" { #include "moq.h" -} namespace MoQSettings { diff --git a/cpp/obs/src/moq-settings.h b/cpp/obs/src/moq-settings.h index 85915adc60..644dfc1f91 100644 --- a/cpp/obs/src/moq-settings.h +++ b/cpp/obs/src/moq-settings.h @@ -5,9 +5,7 @@ #include #include -extern "C" { #include "moq.h" -} // Advanced MoQ connection settings. // diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index 43b38fa1db..96dfae45fd 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -22,8 +22,8 @@ extern "C" { #include #include #include -#include "moq.h" } +#include "moq.h" #include "moq-source.h" #include "moq-url.h" diff --git a/cpp/obs/src/obs-moq.cpp b/cpp/obs/src/obs-moq.cpp index 052b977705..1287a8abff 100644 --- a/cpp/obs/src/obs-moq.cpp +++ b/cpp/obs/src/obs-moq.cpp @@ -27,9 +27,7 @@ with this program. If not, see #include "moq-dock.h" #endif -extern "C" { #include "moq.h" -} #ifdef _WIN64 #include diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index c20c9d30c8..2a56d7e07a 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -22,9 +22,7 @@ #include #include -extern "C" { #include "moq.h" -} #include "moq-settings.h" diff --git a/cpp/obs/test/moq-source-test.cpp b/cpp/obs/test/moq-source-test.cpp index ec9e7e4799..f9d0bb5820 100644 --- a/cpp/obs/test/moq-source-test.cpp +++ b/cpp/obs/test/moq-source-test.cpp @@ -41,8 +41,8 @@ extern "C" { #include #include #include -#include "moq.h" } +#include "moq.h" #include "moq-source.h" diff --git a/rs/libmoq/build.rs b/rs/libmoq/build.rs index 3ba1a40b7f..60bb29956c 100644 --- a/rs/libmoq/build.rs +++ b/rs/libmoq/build.rs @@ -42,6 +42,10 @@ fn main() { // header has no include guard unless we ask for one here. Without it a // project reaching moq.h down two include paths gets redefinition errors. pragma_once: true, + // C++ has to see these declarations with C linkage. Emitting the `extern "C"` + // block here saves every C++ consumer from wrapping the include by hand, which + // also wraps the system headers moq.h pulls in. + cpp_compat: true, export: cbindgen::ExportConfig { // These enums cross the ABI as plain `uint32_t`, so that an unknown // discriminant from C is an error rather than UB. That leaves no signature diff --git a/rs/libmoq/cbindgen.toml b/rs/libmoq/cbindgen.toml index d23798a319..594533807c 100644 --- a/rs/libmoq/cbindgen.toml +++ b/rs/libmoq/cbindgen.toml @@ -1,10 +1,11 @@ # cbindgen configuration for libmoq. # # NOT loaded: build.rs drives cbindgen through `Builder::new()`, which never -# discovers this file, and passes its own config. The generated moq.h therefore -# has none of the settings below (no include guard, no `#pragma once`, no -# renaming). Loading it now would rename every struct field and type in the -# published C API, so the options a build actually uses live in build.rs. +# discovers this file, and passes its own config. Of the settings below, only +# `pragma_once` and `cpp_compat` reach the generated moq.h, because build.rs sets +# them itself; the rest (the MOQ_H include guard, the renaming) do not. Loading +# it now would rename every struct field and type in the published C API, so +# the options a build actually uses live in build.rs. language = "C" include_guard = "MOQ_H" From 9adde589b90390bcb02a177c68c1269396e30303 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 10:54:13 -0700 Subject: [PATCH 53/87] chore: bump quest to 46d7fe8 (#4371) Co-authored-by: Claude Opus 5.5 --- .claude/skills/quest-audit/SKILL.md | 7 +++++++ .claude/skills/quest-convert/SKILL.md | 7 +++++++ .claude/skills/{quest-close => quest-delete}/SKILL.md | 6 +++--- .claude/skills/quest-finish/SKILL.md | 7 +++++++ .claude/skills/quest-issues/SKILL.md | 7 ------- .claude/skills/quest-merge/SKILL.md | 4 ++-- .claude/skills/quest-plan/SKILL.md | 4 ++-- .claude/skills/quest-spawn-merge/SKILL.md | 7 ------- .claude/skills/quest-spawn/SKILL.md | 4 ++-- .claude/skills/quest-start/SKILL.md | 2 +- .claude/skills/quest-takeover/SKILL.md | 7 ------- flake.lock | 8 ++++---- flake.nix | 2 +- quest/README.md | 2 +- quest/m0/README.md | 2 +- quest/m0/audio-jitter-target/README.md | 2 +- quest/m0/audio-quality-harness/README.md | 2 +- quest/m0/wildcard/README.md | 2 +- quest/m1/README.md | 2 +- quest/m1/archive/README.md | 2 +- quest/m1/audio-codecs/README.md | 2 +- quest/m1/auth/README.md | 2 +- quest/m1/broadcast-epoch/README.md | 7 ++----- quest/m1/c/README.md | 2 +- quest/m1/cli-inspect/README.md | 2 +- quest/m1/cpp/README.md | 2 +- quest/m1/drain/README.md | 2 +- quest/m1/e2ee/README.md | 2 +- quest/m1/ffi-shape/README.md | 7 ++----- quest/m1/ladder/README.md | 2 +- quest/m1/moxygen/README.md | 2 +- quest/m1/obs-moq-video/README.md | 2 +- quest/m1/obs-moq-video/source.md | 4 ++-- quest/m1/one-port/README.md | 2 +- quest/m1/p2p/README.md | 2 +- quest/m1/perf/README.md | 2 +- quest/m1/pop-skipping/README.md | 2 +- quest/m1/processor/README.md | 2 +- quest/m1/qos/README.md | 2 +- quest/m1/qos/stats/README.md | 2 +- quest/m1/quic/README.md | 2 +- quest/m1/rs2ts/README.md | 2 +- quest/m1/rs2ts/sans-io/README.md | 2 +- quest/m1/tooling/README.md | 2 +- quest/m1/transport-upgrade/README.md | 2 +- quest/m1/uring-tcp/README.md | 2 +- quest/m2/README.md | 2 +- quest/m2/carrier-voice/README.md | 2 +- quest/m2/cat/README.md | 2 +- quest/m2/cs/README.md | 2 +- quest/m2/flate/README.md | 2 +- quest/m2/intra-refresh/README.md | 2 +- quest/m2/sei/README.md | 2 +- quest/m2/teleop/README.md | 2 +- quest/m3/README.md | 2 +- quest/m4/README.md | 2 +- 56 files changed, 82 insertions(+), 88 deletions(-) create mode 100644 .claude/skills/quest-audit/SKILL.md create mode 100644 .claude/skills/quest-convert/SKILL.md rename .claude/skills/{quest-close => quest-delete}/SKILL.md (50%) create mode 100644 .claude/skills/quest-finish/SKILL.md delete mode 100644 .claude/skills/quest-issues/SKILL.md delete mode 100644 .claude/skills/quest-spawn-merge/SKILL.md delete mode 100644 .claude/skills/quest-takeover/SKILL.md diff --git a/.claude/skills/quest-audit/SKILL.md b/.claude/skills/quest-audit/SKILL.md new file mode 100644 index 0000000000..8ae2c71b74 --- /dev/null +++ b/.claude/skills/quest-audit/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-audit +description: Audit outstanding quests for disagreements, conflicts, misaligned priorities, and stale plans, then resolve them with /quest-plan. Use when the user invokes /quest-audit or asks to check the quest tree for consistency. +--- + +Run `quest skill audit` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-convert/SKILL.md b/.claude/skills/quest-convert/SKILL.md new file mode 100644 index 0000000000..bb2e8cbeab --- /dev/null +++ b/.claude/skills/quest-convert/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-convert +description: Convert Github issues to quests. +--- + +Run `quest skill convert` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-close/SKILL.md b/.claude/skills/quest-delete/SKILL.md similarity index 50% rename from .claude/skills/quest-close/SKILL.md rename to .claude/skills/quest-delete/SKILL.md index db3d093ee4..5d12f0cf09 100644 --- a/.claude/skills/quest-close/SKILL.md +++ b/.claude/skills/quest-delete/SKILL.md @@ -1,7 +1,7 @@ --- -name: quest-close -description: Close a GitHub PR. +name: quest-delete +description: Abandon a quest. --- -Run `quest skill quest-close` and follow its output. +Run `quest skill delete` and follow its output. If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-finish/SKILL.md b/.claude/skills/quest-finish/SKILL.md new file mode 100644 index 0000000000..4583b29d66 --- /dev/null +++ b/.claude/skills/quest-finish/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-finish +description: Decide which open PRs to merge, then merge them in parallel. +--- + +Run `quest skill finish` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-issues/SKILL.md b/.claude/skills/quest-issues/SKILL.md deleted file mode 100644 index 74e65c239a..0000000000 --- a/.claude/skills/quest-issues/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: quest-issues -description: Plan quests from open GitHub issues without the quest label. ---- - -Run `quest skill quest-issues` and follow its output. -If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-merge/SKILL.md b/.claude/skills/quest-merge/SKILL.md index 8196069889..b665c4f904 100644 --- a/.claude/skills/quest-merge/SKILL.md +++ b/.claude/skills/quest-merge/SKILL.md @@ -1,7 +1,7 @@ --- name: quest-merge -description: Merge a GitHub PR once reviews and CI pass. +description: Merge a quest once reviews and CI pass. --- -Run `quest skill quest-merge` and follow its output. +Run `quest skill merge` and follow its output. If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-plan/SKILL.md b/.claude/skills/quest-plan/SKILL.md index 45e6f68fea..7b0307359f 100644 --- a/.claude/skills/quest-plan/SKILL.md +++ b/.claude/skills/quest-plan/SKILL.md @@ -1,7 +1,7 @@ --- name: quest-plan -description: Scope, create, and publish a quest through an interactive grilling interview. Use when the user invokes /quest-plan, asks to plan a quest, or wants unsettled work split into quests. +description: Scope and create quests through an interactive interview. Use when the user invokes /quest-plan, asks to plan a quest, or wants unsettled work split into quests. --- -Run `quest skill quest-plan` and follow its output. +Run `quest skill plan` and follow its output. If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-spawn-merge/SKILL.md b/.claude/skills/quest-spawn-merge/SKILL.md deleted file mode 100644 index 8cedf12224..0000000000 --- a/.claude/skills/quest-spawn-merge/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: quest-spawn-merge -description: Decide and merge GitHub PRs in parallel. ---- - -Run `quest skill quest-spawn-merge` and follow its output. -If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-spawn/SKILL.md b/.claude/skills/quest-spawn/SKILL.md index da9567055c..21a9b33488 100644 --- a/.claude/skills/quest-spawn/SKILL.md +++ b/.claude/skills/quest-spawn/SKILL.md @@ -1,7 +1,7 @@ --- name: quest-spawn -description: Spawn background agents to work on quests in parallel. +description: Start multiple quests in parallel. --- -Run `quest skill quest-spawn` and follow its output. +Run `quest skill spawn` and follow its output. If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-start/SKILL.md b/.claude/skills/quest-start/SKILL.md index 8ab6bf60bf..5164b6753d 100644 --- a/.claude/skills/quest-start/SKILL.md +++ b/.claude/skills/quest-start/SKILL.md @@ -3,5 +3,5 @@ name: quest-start description: Start work on a quest. --- -Run `quest skill quest-start` and follow its output. +Run `quest skill start` and follow its output. If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-takeover/SKILL.md b/.claude/skills/quest-takeover/SKILL.md deleted file mode 100644 index 004e163189..0000000000 --- a/.claude/skills/quest-takeover/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: quest-takeover -description: Adopt someone else's open PR and drive it to landable. ---- - -Run `quest skill quest-takeover` and follow its output. -If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/flake.lock b/flake.lock index fca20bdac3..73deb19a16 100644 --- a/flake.lock +++ b/flake.lock @@ -65,17 +65,17 @@ ] }, "locked": { - "lastModified": 1790533850, - "narHash": "sha256-6cBgVpfIBVQ9tTXyV7Xj8AAYH+Qtp6aOmFx/dwEUNLw=", + "lastModified": 1790617890, + "narHash": "sha256-k8uvr4/Pt5Hf+Adjv3sp84l6HLkpR5BSKHoeMj4cxRA=", "owner": "kixelated", "repo": "quest", - "rev": "a4c3debebeacc142dc31b170f3255d1ccf6e4c9b", + "rev": "46d7fe89247919583632e4963aee1c9a68dfe059", "type": "github" }, "original": { "owner": "kixelated", "repo": "quest", - "rev": "a4c3debebeacc142dc31b170f3255d1ccf6e4c9b", + "rev": "46d7fe89247919583632e4963aee1c9a68dfe059", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 24c1685036..b25ce4cdc5 100644 --- a/flake.nix +++ b/flake.nix @@ -27,7 +27,7 @@ # The quest CLI, which also serves the quest guide and skills the stubs in # .claude/skills call. Bump the rev to upgrade them. quest = { - url = "github:kixelated/quest/a4c3debebeacc142dc31b170f3255d1ccf6e4c9b"; + url = "github:kixelated/quest/46d7fe89247919583632e4963aee1c9a68dfe059"; inputs.nixpkgs.follows = "nixpkgs"; inputs.flake-utils.follows = "flake-utils"; inputs.crane.follows = "crane"; diff --git a/quest/README.md b/quest/README.md index c4c5af6425..3f2ded513b 100644 --- a/quest/README.md +++ b/quest/README.md @@ -15,7 +15,7 @@ m4 waits on an upstream release or external dependency to ship. Priority is separate from branch targeting: published API and wire breaks still land on dev under the repository rules. -## Quests +## Required - [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: the release API gates, the release, and the Pronto GPU path - [m1: next wave](/quest/m1/README.md) - reliability, capabilities, performance, and the planning that settles their contracts diff --git a/quest/m0/README.md b/quest/m0/README.md index 9794e7ecae..e621b682fb 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -91,7 +91,7 @@ Producer and transcode's validated Ladder and coalescing active cursor. Keep one video Frame/Surface hierarchy and its deliberate native/wgpu type interop; do not add another media abstraction or a renderer crate during stabilization. -## Quests +## Required - [Release](/quest/m0/release.md) - the release moq.pro adopts: binding docs, an upgrade page, and a staging soak gate it rather than the merge - [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - a publisher sends an announce update only when the wire route changed diff --git a/quest/m0/audio-jitter-target/README.md b/quest/m0/audio-jitter-target/README.md index 6ce2effd6e..9ccd989e69 100644 --- a/quest/m0/audio-jitter-target/README.md +++ b/quest/m0/audio-jitter-target/README.md @@ -74,7 +74,7 @@ playback may drift from the live edge before skipping a stalled group, and `start`, where to begin on a track that already holds groups. Nothing pads the buffer against uneven arrivals. -## Quests +## Required - [Watch](/quest/m0/audio-jitter-target/watch.md) - js/watch and js/hang bring the #3517 branch's estimator into conformance - [Native](/quest/m0/audio-jitter-target/native.md) - rs/moq-audio grows a measured jitter buffer from the same algorithm diff --git a/quest/m0/audio-quality-harness/README.md b/quest/m0/audio-quality-harness/README.md index 5861c81352..0daecf0d58 100644 --- a/quest/m0/audio-quality-harness/README.md +++ b/quest/m0/audio-quality-harness/README.md @@ -44,7 +44,7 @@ sample rate, which is more than a merge gate should carry, and `nightly.yml` already exists for exactly this trade. Budgets are keyed by the full row, since each of those dimensions moves the expected floor. -## Quests +## Required - [Browser](/quest/m0/audio-quality-harness/browser.md) - upstream the fork's harness, grade it against a budget, run it nightly - [Native](/quest/m0/audio-quality-harness/native.md) - the same profiles and budgets through `moq play` on a dummy device diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 13795b6eff..0feb062b21 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -256,7 +256,7 @@ no generation, so a client that must distinguish recording generations reads the catalog's archive entry ([archive](/quest/m1/archive/README.md)) rather than announce state. -## Quests +## Required - [Resolve](/quest/m0/wildcard/resolve.md) - a relay resolves a subscribe or FETCH for an unannounced path against the best matching wildcard diff --git a/quest/m1/README.md b/quest/m1/README.md index 50e3d328e9..674dbee9ae 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -15,7 +15,7 @@ Give one agent ownership of each shared code area at a time (origin/auth, the JS Reader, audio playback, media containers and archive, bindings, worker transport, benchmark tooling); worktrees isolate commits, not semantics. -## Quests +## Required - [Drill sensitivity](/quest/m1/drill-sensitivity.md) - the nightly drill-sensitivity job passes: the subscriber-leaks-broadcasts mutation applies to the current lite subscriber again - [Cluster routing](/quest/m1/cluster-routing.md) - an announcement says where a broadcast originates, not how to reach it, and a relay hears only the prefixes its clients asked for diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 2e8b5508de..cf79f69604 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -101,7 +101,7 @@ surface; [JavaScript FETCH](/quest/m1/js-fetch.md) supplies the missing browser IETF support before browser archive implementation. Transport codecs are owned by that prerequisite, not duplicated in archive storage. -## Quests +## Required - [Recording writer](/quest/m1/archive/writer.md) - feed the segmenter from a `broadcast::Consumer`, store each segment, then commit its record - [Recording reader](/quest/m1/archive/reader.md) - serve archived FETCH through a supplied `broadcast::Producer` diff --git a/quest/m1/audio-codecs/README.md b/quest/m1/audio-codecs/README.md index 2a23be20c5..c91e1854fd 100644 --- a/quest/m1/audio-codecs/README.md +++ b/quest/m1/audio-codecs/README.md @@ -41,7 +41,7 @@ its own decode and encode quest so verification stays per host. The HE-AAC refusal and the PCE parse are defects in what ships today and are ready now. -## Quests +## Required - [Named codecs](/quest/m1/audio-codecs/named-codecs.md) - `decode::Kind::Named` keeps the published codec names and picks backends internally; must land before the line merges - [HE-AAC refusal](/quest/m1/audio-codecs/he-aac-refusal.md) - implicit-SBR HE-AAC over TS is refused instead of half-decoded as the LC core diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 2dc399d5df..dcba80f86f 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -87,7 +87,7 @@ Everything here is additive: `Session::auth()` is new, the relay derives the grant from the origin handles it already scopes, and AUTH is added to the existing lite-06 ALPN. -## Quests +## Required - [Lite stream](/quest/m1/auth/lite.md) - both sides of a lite-06 session exchange grants over AUTH streams, exposed as `Session::auth()`, and an diff --git a/quest/m1/broadcast-epoch/README.md b/quest/m1/broadcast-epoch/README.md index 0c40773fe3..9e0ac40d40 100644 --- a/quest/m1/broadcast-epoch/README.md +++ b/quest/m1/broadcast-epoch/README.md @@ -55,14 +55,11 @@ This README owns: consume behavior, takeover and fallback, bare-path resolution, and the prefix-route opt-out. -## Quests +## Required +- [Epoch primitive](/quest/m1/epoch.md) - the shared `Epoch` type and path split - [Origin](/quest/m1/broadcast-epoch/origin.md) - moq-net publish mints an epoch, consumers follow the newest live one, and bare requests resolve to it on every version - [Apps](/quest/m1/broadcast-epoch/apps.md) - moq-cli, the browser publish and watch components, and demo/web publish under epochs and play bare names - [Gateways](/quest/m1/broadcast-epoch/gateways.md) - RTMP, SRT, and WHIP ingest mint an epoch per incoming connection, so an encoder reconnect is a clean takeover - [Bindings](/quest/m1/broadcast-epoch/bindings.md) - moq-ffi, libmoq, and every wrapper expose the epoch and inherit the default - [GStreamer and OBS](/quest/m1/broadcast-epoch/gst-obs.md) - moqsink and the OBS plugin publish each run under a fresh epoch - -## Required - -- [Epoch primitive](/quest/m1/epoch.md) - the shared `Epoch` type and path split diff --git a/quest/m1/c/README.md b/quest/m1/c/README.md index 16dc144787..8ed159fb22 100644 --- a/quest/m1/c/README.md +++ b/quest/m1/c/README.md @@ -42,7 +42,7 @@ retires it: [fetch](/quest/m3/libmoq-fetch.md), [CMake library](/quest/m3/libmoq-cmake-lib.md), and [shutdown](/quest/m3/libmoq-shutdown.md). -## Quests +## Required - [C backend](/quest/m1/c/backend.md) - the fork emits an ergonomic C header and implementation from moq-ffi, with its own tests - [moq-c package](/quest/m1/c/package.md) - the generated header ships as `moq-c` 0.8.0 with `moq::c`, pkg-config, and a release workflow diff --git a/quest/m1/cli-inspect/README.md b/quest/m1/cli-inspect/README.md index 0ec60b61ae..718aa7fb50 100644 --- a/quest/m1/cli-inspect/README.md +++ b/quest/m1/cli-inspect/README.md @@ -18,7 +18,7 @@ This README owns the guide, written once both verbs land: a new `curl` equivalents, and reading the relay's stats track, linked from `doc/bin/cli.md`, `doc/bin/relay/http.md`, and the site sidebar. -## Quests +## Required - [Caught up](/quest/m1/cli-inspect/caught-up.md) - moq-net's announce consumer yields a `Live` marker once the initial set has landed, and shell completion drops its settle timer - [ls](/quest/m1/cli-inspect/ls.md) - `moq ls` prints the live set and exits, or follows changes, as paths or JSON lines diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index ea7c38b463..75a1040092 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -63,7 +63,7 @@ Confirmed in [#4100](https://github.com/moq-dev/moq/pull/4100): - MSVC is covered by the post-merge nightly, not a branch dispatch: branches never dispatch the nightly. -## Quests +## Required - [Generator](/quest/m1/cpp/generator.md) - the uniffi 0.32 C++ generator with futures and expected-style errors, pinned and generating `cpp/ffi` in CI - [Package](/quest/m1/cpp/package.md) - the `cpp/moq` wrapper, CMake package, release tarball, interop client, and docs diff --git a/quest/m1/drain/README.md b/quest/m1/drain/README.md index d849afd904..284fd4aa71 100644 --- a/quest/m1/drain/README.md +++ b/quest/m1/drain/README.md @@ -46,7 +46,7 @@ scale-down prerequisite, not merely a deploy improvement. RTMP/SRT/WHIP/WHEP cannot receive MoQ GOAWAY, so their contract remains DNS withdrawal followed by the stop deadline and encoder reconnect. -## Quests +## Required - [Relay drain api](/quest/m1/drain/relay-drain-api.md) - a drain hook that GOAWAYs every session, including new arrivals, triggered by the embedding diff --git a/quest/m1/e2ee/README.md b/quest/m1/e2ee/README.md index 824379b5c5..07e41ce3c9 100644 --- a/quest/m1/e2ee/README.md +++ b/quest/m1/e2ee/README.md @@ -42,7 +42,7 @@ The Rust and TypeScript cores expose the same surface, and nothing else: - A platform that forwards and meters protected bytes must never preview, record, archive, transmux, transcode, transcribe, compose, or inspect them, rejecting those paths before opening a processing session or writing product state. Applications needing those operations terminate E2EE outside the platform. A platform classifies protected broadcasts by its own credential or product state, never by name; the moq.pro (downstream) exclusion classifier and dashboard work stay downstream. - The first proof covers browser TypeScript and native Rust publication and playback in both directions, with grouped audio and video over both moq-lite and MoQ Transport. Shared vectors cover groups and moq-lite datagrams; MoQ Transport has no datagram delivery. -## Quests +## Required - [Receive failure](/quest/m1/e2ee/receiver-failure.md) - a bad grouped frame wakes and terminates every pending read - [TypeScript E2EE core](/quest/m1/e2ee/typescript.md) - the `@moq/e2ee` package diff --git a/quest/m1/ffi-shape/README.md b/quest/m1/ffi-shape/README.md index 6c0fabddbd..6e20982499 100644 --- a/quest/m1/ffi-shape/README.md +++ b/quest/m1/ffi-shape/README.md @@ -45,17 +45,14 @@ work no child does: new call per language. - `just test interop --all` green on the finished line. -## Quests +## Required +- [Release](/quest/m0/release.md) - the restructure follows the release rather than riding it - [JSON](/quest/m1/ffi-shape/json.md) - the pilot: json becomes its own namespace wrapping a track in every binding and sets the per-language pattern - [Net](/quest/m1/ffi-shape/net.md) - client and server take config records, snapshots are records, and the verbs match moq-net - [Media](/quest/m1/ffi-shape/media.md) - catalog, import, and container consume move under `media` - [Codecs](/quest/m1/ffi-shape/codec.md) - audio and video encoders and decoders move under their own namespaces with one constructor shape -## Required - -- [Release](/quest/m0/release.md) - the restructure follows the release rather than riding it - ## Related - [Track demand](/quest/m1/track-demand.md) - the same `demand()` cleanup in Rust and JS diff --git a/quest/m1/ladder/README.md b/quest/m1/ladder/README.md index 20dae78a47..79943bff1e 100644 --- a/quest/m1/ladder/README.md +++ b/quest/m1/ladder/README.md @@ -67,7 +67,7 @@ bitrate; closing or removing stalled tracks; an application-level [#2734](https://github.com/moq-dev/moq/issues/2734)); rebuilding unsupported encoders on every target change. -## Quests +## Required - [Controller](/quest/m1/ladder/controller.md) - one controller owns every rung's share, target, stalled state, and send order diff --git a/quest/m1/moxygen/README.md b/quest/m1/moxygen/README.md index d7d7e72434..e478e9d030 100644 --- a/quest/m1/moxygen/README.md +++ b/quest/m1/moxygen/README.md @@ -28,7 +28,7 @@ Out of scope, so they are not reopened as bugs: Docs stay inline in the change that makes them stale. No new guide. -## Quests +## Required - [Default track priority](/quest/m1/moxygen/priority.md) - an unset track priority is the midpoint on moq-lite and on IETF, not the least urgent value - [Group FETCH](/quest/m1/moxygen/fetch.md) - an IETF FETCH of whole groups is served from cache or fetched upstream, one group at a time diff --git a/quest/m1/obs-moq-video/README.md b/quest/m1/obs-moq-video/README.md index 3362f55a46..cdc4cdcbe4 100644 --- a/quest/m1/obs-moq-video/README.md +++ b/quest/m1/obs-moq-video/README.md @@ -16,7 +16,7 @@ Publishing remains opt-in, with one **Use MoQ encoders** choice for video and au The quests separate portable decoding, platform GPU delivery, audio, and publishing so each can land and be validated independently. The existing CPU decode path is a fallback primitive, not a GPU implementation: it explicitly converts every surface to I420. Native frame ownership must cross the FFI boundary without that conversion. -## Quests +## Required - [Video source replacement](/quest/m1/obs-moq-video/source.md) - remove FFmpeg and attempt macOS GPU delivery immediately, with a working CPU fallback on other platforms - [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - add synchronized subscribed audio through moq-audio diff --git a/quest/m1/obs-moq-video/source.md b/quest/m1/obs-moq-video/source.md index bda7405331..15d9450789 100644 --- a/quest/m1/obs-moq-video/source.md +++ b/quest/m1/obs-moq-video/source.md @@ -17,8 +17,8 @@ The MoQ source loads and plays supported video without directly linking FFmpeg l ## Required - [OBS migration](/quest/m1/cpp/obs.md) - the plugin is on the generated C++ before decode changes -- [Decoded frame ownership](/quest/m1/decoded-frames.md) - retains the existing frame and defines native-view lifetime before OBS imports it -- [Decoded frame ownership](/quest/m1/decoded-frames.md) - the decoded-frame consumer moq-ffi does not have yet +- [Decoded frame ownership](/quest/m1/decoded-frames.md) - the decoded-frame consumer moq-ffi lacks, retaining the existing frame and defining native-view lifetime before OBS imports it + ## Related - [VP8/VP9 decoding](/quest/m1/obs-moq-video/vpx.md) - restores deferred codec coverage independently diff --git a/quest/m1/one-port/README.md b/quest/m1/one-port/README.md index a11067b4cb..8e9fbe8e00 100644 --- a/quest/m1/one-port/README.md +++ b/quest/m1/one-port/README.md @@ -71,7 +71,7 @@ this. The acceptor yields classified connections; `moq-rtmp`'s `axum_server::Server::from_listener` takes a listener that a channel of pre-accepted streams can stand behind. -## Quests +## Required - [UDP demux](/quest/m1/one-port/udp-demux.md) - one socket carries QUIC, STUN answers, and the WebRTC media path, with greasing off - [TCP acceptor](/quest/m1/one-port/tcp-demux.md) - one listener carries TLS-terminated HTTP, RTMP, and RTMPS diff --git a/quest/m1/p2p/README.md b/quest/m1/p2p/README.md index ab6d3029f7..bb0550f52c 100644 --- a/quest/m1/p2p/README.md +++ b/quest/m1/p2p/README.md @@ -133,7 +133,7 @@ re-derived. - Symmetric NATs on both ends fail ICE without TURN. By design the relay keeps serving. -## Quests +## Required - [Data channel transport](/quest/m1/p2p/transport.md) - `@moq/p2p` speaks qmux over one ordered RTCDataChannel behind the WebTransport shape `@moq/net` consumes - [Signaling and policy](/quest/m1/p2p/signal.md) - opted-in peers find each other under the prefix, the application picks who to dial, and the roster-size gate decides whether STUN is used diff --git a/quest/m1/perf/README.md b/quest/m1/perf/README.md index b0b17f4d6f..025eda859d 100644 --- a/quest/m1/perf/README.md +++ b/quest/m1/perf/README.md @@ -39,7 +39,7 @@ The relay's `/metrics` endpoint already carries the ring-level counters (enters, park/wake, batch effectiveness) several quests want as evidence, one row per io_uring worker. -## Quests +## Required - [Open contract](/quest/m1/perf/uring-open-contract.md) - plan concurrent WebTransport opening and cancellation diff --git a/quest/m1/pop-skipping/README.md b/quest/m1/pop-skipping/README.md index 89a3652246..e3c1185615 100644 --- a/quest/m1/pop-skipping/README.md +++ b/quest/m1/pop-skipping/README.md @@ -140,7 +140,7 @@ gossip paths so an old unannounce cannot stale a replacement. The remaining boundary is structured policy that does not live in the URL: the two directional costs of one bidirectional session, which one `?cost=` cannot split. -## Quests +## Required - [Warm advertise](/quest/m1/pop-skipping/warm-advertise.md) - a carrying relay advertises the exact broadcast path as a warm route, and retracts it when diff --git a/quest/m1/processor/README.md b/quest/m1/processor/README.md index dcc625f468..102b88eef7 100644 --- a/quest/m1/processor/README.md +++ b/quest/m1/processor/README.md @@ -14,7 +14,7 @@ service contract, and credential minting) stay downstream in moq.pro. The contract is not vision-specific: captioning, moderation, telemetry extraction, and custom transforms use the same worker lifecycle. -## Quests +## Required - [Processor media contract](/quest/m1/processor/media-contract.md) - define contribution references, source relations, and correlation in the Hang diff --git a/quest/m1/qos/README.md b/quest/m1/qos/README.md index a5fe3fa32b..a5f0720f65 100644 --- a/quest/m1/qos/README.md +++ b/quest/m1/qos/README.md @@ -31,7 +31,7 @@ The counters and channels land here. The moq.pro (downstream) dashboard work, including the health badge, connection-health drill-down, and stream preflight, consumes them downstream. -## Quests +## Required - [Starvation](/quest/m1/qos/starvation.md) - per broadcast, how far behind the acknowledged frontier of its subscriptions is, in media time, plus the diff --git a/quest/m1/qos/stats/README.md b/quest/m1/qos/stats/README.md index c888d094e0..929e56f1c3 100644 --- a/quest/m1/qos/stats/README.md +++ b/quest/m1/qos/stats/README.md @@ -48,7 +48,7 @@ Decisions settled while planning: producer is a breaking change, and the moq-json rework there is what the producers build on. -## Quests +## Required - [Schema and library](/quest/m1/qos/stats/schema.md) - moq-stats takes an extension, serves per-broadcast tracks, and hang defines the media stats diff --git a/quest/m1/quic/README.md b/quest/m1/quic/README.md index f3234590a4..7280d2ff8f 100644 --- a/quest/m1/quic/README.md +++ b/quest/m1/quic/README.md @@ -49,7 +49,7 @@ session with fairness enabled, the send group is the broadcast. The default MoQ order is newest group first; an ordered subscription keeps oldest first. This is a transport API change, not a MoQ wire change. -## Quests +## Required - [Preserve QUIC packet identity in BBR](/quest/m1/quic/bbr-packet-identity.md) - ACKs and losses identify the right packet across QUIC spaces - [Finish each BBR ACK sample before using it](/quest/m1/quic/bbr-ack-sampling.md) - current delivery samples reach the model once with consistent metadata diff --git a/quest/m1/rs2ts/README.md b/quest/m1/rs2ts/README.md index 726f33ff0f..e24e930580 100644 --- a/quest/m1/rs2ts/README.md +++ b/quest/m1/rs2ts/README.md @@ -52,7 +52,7 @@ This README's own work is the no-downgrade report once generated lite ships: bundle size, per-frame CPU, and first-frame latency against the hand-written js/net it replaces, measured with the [browser benchmarks](/quest/m1/browser-benchmarks.md). -## Quests +## Required - [VarInt codec](/quest/m1/rs2ts/varint-codec.md) - moq-net encodes through a `VarInt` newtype and a concrete slice-based codec, not generic traits on primitives - [JS VarInt](/quest/m1/rs2ts/js-varint.md) - js/net has a 62-bit `VarInt` type with checked `number` conversion and no BigInt on the hot path diff --git a/quest/m1/rs2ts/sans-io/README.md b/quest/m1/rs2ts/sans-io/README.md index 223b68d8af..de6cbe90c8 100644 --- a/quest/m1/rs2ts/sans-io/README.md +++ b/quest/m1/rs2ts/sans-io/README.md @@ -16,7 +16,7 @@ reads the crate without the `async` feature. Split by layer so each lands on This README's own work is the `async` feature and the CI lane that builds moq-net without it. -## Quests +## Required - [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the lite session is driven by bytes, stream events, and `tick(now)` - [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - origin, broadcast, track, and group handles run without a runtime, with time supplied by the caller diff --git a/quest/m1/tooling/README.md b/quest/m1/tooling/README.md index a96ed7e11c..ee3067b9d8 100644 --- a/quest/m1/tooling/README.md +++ b/quest/m1/tooling/README.md @@ -17,7 +17,7 @@ fix`) is in every doc, skill, and workflow. Its cost was self-inflicted: logic inside recipes. The quests below are ordered and each requires the one before it, so they land as one line of pull requests. -## Quests +## Required - [Thin justfiles](/quest/m1/tooling/justfiles.md) - recipe bodies move to `sh/`, one impact map scopes check/fix/test, self-tests and guards are deleted - [Windows cross-check](/quest/m1/tooling/windows-cross-check.md) - a Linux cross-check of moq-video for Windows runs per PR when moq-video is selected diff --git a/quest/m1/transport-upgrade/README.md b/quest/m1/transport-upgrade/README.md index 7e88b12e6f..b933806797 100644 --- a/quest/m1/transport-upgrade/README.md +++ b/quest/m1/transport-upgrade/README.md @@ -55,7 +55,7 @@ Shared decisions: per-session origin makes that a replacement rather than a join, which is immediate either way. -## Quests +## Required - [Rust](/quest/m1/transport-upgrade/rust.md) - moq-tokio keeps the QUIC dial after WebSocket wins and migrates through the existing Draining path - [JavaScript](/quest/m1/transport-upgrade/js.md) - js/net keeps the WebTransport dial after WebSocket wins and migrates through the client-goaway handover diff --git a/quest/m1/uring-tcp/README.md b/quest/m1/uring-tcp/README.md index bfcd62906c..f18fc50537 100644 --- a/quest/m1/uring-tcp/README.md +++ b/quest/m1/uring-tcp/README.md @@ -33,7 +33,7 @@ Measure before porting, the same way the echo bench (rs/moq-uring/benches/session_lite.rs) gated the UDP path. The ablation's number is what justifies the rest of the line. -## Quests +## Required - [Ablation](/quest/m1/uring-tcp/ablation.md) - measure ring TCP against tokio TCP under the qmux workload before committing to the port diff --git a/quest/m2/README.md b/quest/m2/README.md index 33a1ac3dd6..f042fcf0b9 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -13,7 +13,7 @@ feature. A study may end with a measured no-go. Work gated on hardware, a partner, or a provider waits in [m3](/quest/m3/README.md); work waiting on an upstream release waits in [m4](/quest/m4/README.md). -## Quests +## Required - [AV1 metadata separation](/quest/m2/av1-metadata.md) - retain metadata OBUs inline while evaluating separate delivery - [SEI separation](/quest/m2/sei/README.md) - retain inline SEI until measured savings or a metadata-only consumer justify a split diff --git a/quest/m2/carrier-voice/README.md b/quest/m2/carrier-voice/README.md index 92799763de..4be5cad6d1 100644 --- a/quest/m2/carrier-voice/README.md +++ b/quest/m2/carrier-voice/README.md @@ -36,7 +36,7 @@ bespoke media fork for each service. compliance outside the experiment. The SIP adapter is the boundary to that world. -## Quests +## Required - [Call fabric protocol](/quest/m2/carrier-voice/protocol.md) - versioned namespaces, roles, state transitions, authorization, and both topologies diff --git a/quest/m2/cat/README.md b/quest/m2/cat/README.md index 4c29c9c4c5..689f7da37f 100644 --- a/quest/m2/cat/README.md +++ b/quest/m2/cat/README.md @@ -48,7 +48,7 @@ one. Everything rides `moq_auth::Request` and the verify quest moves them under `moq_auth::jwt` so `cat` is a sibling module rather than a set of prefixed names. -## Quests +## Required - [Verify](/quest/m2/cat/verify.md) - `moq_auth::cat` turns a CAT into a grant and `moq auth serve` admits one; `moq auth sign|verify` mint and diff --git a/quest/m2/cs/README.md b/quest/m2/cs/README.md index 9c3e9822a0..69c17b0a8d 100644 --- a/quest/m2/cs/README.md +++ b/quest/m2/cs/README.md @@ -17,7 +17,7 @@ IL2CPP constraints Unity adds (static `MonoPInvokeCallback` trampolines, no dynamic loading) are measured in the next prototype rather than designed around up front. -## Quests +## Required - [Generator](/quest/m2/cs/generator.md) - uniffi-bindgen-cs on uniffi 0.32, pinned and generating `cs/ffi` in CI - [Package](/quest/m2/cs/package.md) - the `cs/moq` wrapper, NuGet package with native runtimes, interop client, and docs diff --git a/quest/m2/flate/README.md b/quest/m2/flate/README.md index 6bbb52e968..2438bf27d7 100644 --- a/quest/m2/flate/README.md +++ b/quest/m2/flate/README.md @@ -25,7 +25,7 @@ producer interoperates with a hand-composed consumer and with `moq-json`. No wire, catalog, or relay impact. Compression stays invisible to `moq-net`; a compressed track is announced, routed, and cached like any other. -## Quests +## Required - [Track wrapper](/quest/m2/flate/track.md) - `moq-flate` and `@moq/flate` wrap a track so each group is one compression window without caller bookkeeping - [Bindings](/quest/m2/flate/bindings.md) - moq-ffi and libmoq publish and subscribe compressed tracks, mirrored through every wrapper diff --git a/quest/m2/intra-refresh/README.md b/quest/m2/intra-refresh/README.md index 8a87f4724a..54744d74f0 100644 --- a/quest/m2/intra-refresh/README.md +++ b/quest/m2/intra-refresh/README.md @@ -36,7 +36,7 @@ Decisions the quests share: Backends without the knob refuse refresh mode; NVENC and V4L2 get it now, Media Foundation and MediaCodec are follow-ups. -## Quests +## Required - [Consumer warmup](/quest/m2/intra-refresh/consumer-warmup.md) - JS and Rust viewers join `warmup` earlier and withhold display until recovery, except at a true IDR - [H.264 import](/quest/m2/intra-refresh/h264-import.md) - the splitter keeps `recovery_frame_cnt` and import publishes `warmup` from it diff --git a/quest/m2/sei/README.md b/quest/m2/sei/README.md index 4c0a7ea2e7..da44b7e4f8 100644 --- a/quest/m2/sei/README.md +++ b/quest/m2/sei/README.md @@ -19,7 +19,7 @@ Total storage and bytes delivered to a video-only subscriber are different measurements. Any future split must state which payloads move and how missing metadata is handled; do not promise byte-faithful export after a deadline miss. -## Quests +## Required - [SEI evidence](/quest/m2/sei/evidence.md) - measure savings and identify a consumer before deciding whether to split - [SEI section](/quest/m2/sei/sei.md) - define a format only after a positive verdict and settled association policy diff --git a/quest/m2/teleop/README.md b/quest/m2/teleop/README.md index 0593ccfdc3..1b4b44e856 100644 --- a/quest/m2/teleop/README.md +++ b/quest/m2/teleop/README.md @@ -78,7 +78,7 @@ VPN, the competing UDP flows, and the signalling server at once, and survives cell handover through connection migration. That is the pitch, and it is worth stating plainly because it is what a builder is comparing against. -## Quests +## Required - [Robot teleoperation primitive](/quest/m2/teleop/robot.md) - a `moq-robot` crate carrying the track shapes and discovery every teleoperated machine diff --git a/quest/m3/README.md b/quest/m3/README.md index 2f76365b3d..c2e15747b7 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -12,7 +12,7 @@ A quest lands here when its gate is the outside world, not its priority. Each states the condition in prose or as a plain-text `Required` bullet. When the condition clears, move the quest to the milestone its work belongs in. -## Quests +## Required - [DPDK](/quest/m3/dpdk.md) - a kernel-bypass UDP path for the relay, once a provider offers SR-IOV or bare metal - [Video hardware validation](/quest/m3/video-hardware.md) - run the encode, capture, and zero-copy paths that were written but never run on real machines diff --git a/quest/m4/README.md b/quest/m4/README.md index 7b975c7340..2dbf68dde3 100644 --- a/quest/m4/README.md +++ b/quest/m4/README.md @@ -11,7 +11,7 @@ Each quest states its gate as a plain-text `Required` bullet. Re-check the gates periodically; when one clears, remove the bullet and promote the quest to the milestone its priority belongs in. -## Quests +## Required - [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 encode and decode, and pre-generated bindings that remove the libclang build dependency, gated on a moq-dev/vaapi release - [Pool VAAPI resize surfaces](/quest/m4/vaapi-resize-pool.md) - a resize reuses one output surface per size once moq-vaapi ships that pool From 8d2eb83d7290064049ea3c49d0e18bdffd461334 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:26:45 -0700 Subject: [PATCH 54/87] docs(contributing): wait for Codex on the final head before merging (#4375) Co-authored-by: Claude Opus 5.5 --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28673e0f47..c70b6ad1cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,9 @@ For each finding: - If you don't agree with it, reply to the finding and move on. - If it's a relatively easy improvement, fix it and push. Update the summary if needed. +Wait for Codex to review the final head before merging. +Merge only on its thumbs up, or once every Codex finding on the PR is fixed or replied to. + # Follow-ups If you encounter issues, or findings that are out of scope, create follow-up quests. From 3f3b061a992f3c7ee531f60c98466bd8880078e0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:28:17 -0700 Subject: [PATCH 55/87] fix(net): keep an aborted track's finished groups, expire ended tracks (#4378) Co-authored-by: Claude Opus 5.5 --- rs/kio/src/weak.rs | 24 +++ rs/moq-json/src/snapshot/mod.rs | 7 +- rs/moq-net/src/model/cache.rs | 24 ++- rs/moq-net/src/model/track.rs | 274 +++++++++++++++++++++++--------- 4 files changed, 247 insertions(+), 82 deletions(-) diff --git a/rs/kio/src/weak.rs b/rs/kio/src/weak.rs index 13d1beda51..f24ae59d65 100644 --- a/rs/kio/src/weak.rs +++ b/rs/kio/src/weak.rs @@ -48,6 +48,14 @@ impl Weak { } .produce() } + + /// Read the state while another handle keeps it allocated, even once the channel + /// closed. Counts as neither a producer nor a consumer. `None` once it was dropped. + pub fn read(&self, f: impl FnOnce(&T) -> R) -> Option { + let state = self.state.upgrade()?; + let state = Ref { state: state.lock() }; + Some(f(&state)) + } } impl Default for Weak { @@ -405,6 +413,22 @@ mod test { assert!(weak.upgrade().is_none()); } + /// A closed channel stays readable through the weak handle while any handle keeps + /// it allocated, without that read reopening it or counting as demand. + #[test] + fn weak_reads_a_closed_channel() { + let producer = Producer::new(7u32); + let weak = producer.downgrade(); + let consumer = producer.consume(); + + drop(producer); + assert_eq!(weak.read(|value| *value), Some(7)); + assert!(weak.upgrade().is_none(), "reading does not reopen it"); + + drop(consumer); + assert_eq!(weak.read(|value| *value), None); + } + /// An upgrade racing the last producer's drop either loses (no handle) or wins /// (a handle that is genuinely open). It must never hand back a producer for a /// channel that the drop is about to close. diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 8e2b8032db..9b86d81e8d 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -599,11 +599,16 @@ mod test { *producer.modify().unwrap() = json!({ "big": "x".repeat(moq_net::group::MAX_CACHE_BYTES as usize + 1) }); - // The publisher learns the cause at its next edit, the consumer from the aborted track. + // The publisher learns the cause at its next edit. The consumer drains the snapshot + // that finished, then learns it from the aborted track. assert!(matches!( producer.modify(), Err(crate::Error::Net(moq_net::Error::FrameTooLarge)) )); + assert!(matches!( + subscriber.poll_next_group(&kio::Waiter::noop()), + Poll::Ready(Ok(Some(_))) + )); assert!(matches!( subscriber.poll_next_group(&kio::Waiter::noop()), Poll::Ready(Err(moq_net::Error::FrameTooLarge)) diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index 6bdb7cfe23..b4f85cea3c 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -10,7 +10,7 @@ //! global eviction task. //! //! Cross-track ordering comes from one statistic: the mean last-access time of the -//! evictable population (every cached group except each track's protected latest). +//! evictable population (every cached group except each live track's protected latest). //! A group accessed more recently than that mean is never evicted, so freshly read //! or fetched content in one track can't die while another track holds staler //! content, and a track @@ -18,9 +18,10 @@ //! old entries and inserting new ones both advance the mean, so the eviction //! frontier moves with cache turnover on its own. //! -//! The pool also owns the wall-clock LRU window ([`Pool::expiry`]): a non-latest -//! group that nobody has read or written for that long is reclaimed, no matter what -//! retention its track advertises. Track retention +//! The pool also owns the wall-clock LRU window ([`Pool::expiry`]): a group that +//! nobody has read or written for that long is reclaimed, no matter what retention +//! its track advertises. Only a live track's latest group is exempt: once a track +//! ends, a stale consumer can't pin any of it. Track retention //! ([`max_age`](crate::track::Info::max_age)) is measured in media timestamps, so a //! congestion stall can't age content out; the pool's expiry is the orthogonal //! wall-clock bound that keeps unwatched content from pinning RAM. @@ -101,8 +102,8 @@ impl Config { /// Set the wall-clock LRU window. `None` disables idle reclamation. /// - /// A non-latest cached group that nobody reads or writes for this long is - /// reclaimed, surfacing to any remaining reader as + /// A cached group (other than a live track's latest) that nobody reads or writes + /// for this long is reclaimed, surfacing to any remaining reader as /// [`Error::Old`](crate::Error::Old). This is independent of track retention: /// [`max_age`](crate::track::Info::max_age) uses media timestamps, while this /// window keeps idle content from pinning memory. Reclaiming without a write behind @@ -269,7 +270,7 @@ impl Pool { /// Call after polling and at the returned deadline, including when idle. /// Calls before that deadline only advance the pool's sampled clock. A due /// pass visits every cached group, dating accesses since the last pass and - /// reclaiming idle groups except each track's latest. Delayed calls extend + /// reclaiming idle groups except each live track's latest. Delayed calls extend /// retention. Shared pools use the latest supplied instant. /// /// `None` means both cache policies are disabled. After enabling a capacity @@ -564,7 +565,14 @@ impl Track { } // Counts as a producer while it lives, which is why `track::Producer` gates // its teardown on its own clone count rather than the state's. - let Some(state) = self.state.upgrade() else { return }; + let Some(state) = self.state.upgrade() else { + // An ended track's channel is closed, but a stale consumer can still hold its + // groups: the sweep expires them in place, or they would never go. + if full && scan_expiry { + self.state.read(|state| state.expire_closed(state.expiry_scan_drain())); + } + return; + }; let expiry = if scan_expiry { let state = state.read(); let scan = if full { diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 9ad9737a22..e15fd8786a 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -207,9 +207,10 @@ pub(crate) struct TrackState { max_sequence: Option, // The sequence of the newest cached group: the live edge, protected from - // eviction by never entering the eviction order. Tracked separately from - // `max_sequence` because datagrams advance that shared counter, and the live - // edge must still demote correctly when the next group lands past one. + // eviction by never entering the eviction order until the track is `closed`. + // Tracked separately from `max_sequence` because datagrams advance that shared + // counter, and the live edge must still demote correctly when the next group + // lands past one. latest_group: Option, // Incarnation counter for `Slot::stamp`. @@ -222,6 +223,11 @@ pub(crate) struct TrackState { // below it will never be produced. sealed: bool, + // No producer remains (aborted, sealed, or dropped), so nothing protects the live + // edge any longer: the pool's idle expiry reclaims it like every other group, and a + // stale consumer cannot pin the cache. + closed: bool, + // The first sequence the live feed serves, once the publisher declared one // (the wire's SUBSCRIBE_START). Lower groups never arrive on their own; a // fetch can still create them. @@ -233,7 +239,7 @@ pub(crate) struct TrackState { // [`Consumer::poll_start`]) wait on it; everything else treats the floor as usual. start_pending: bool, - // Where production stopped, snapshotted when the cached groups are released (an + // Where production stopped, snapshotted when the open groups are released (an // abort, or the last producer dropping). Computed live from the cache otherwise; // see [`Self::resume_position`]. resume: Option, @@ -411,29 +417,26 @@ impl TrackState { next_sequence: u64, end_sequence: Option, ) -> Poll>> { - // An abort before the end settled cut the track off. One after it is a clean - // end (see `is_complete`): the cached groups were everything the end promised, - // so the cursor ends as `poll_recv_group` does, not with the abort. - if let Some(err) = &self.abort - && !self.settled - { - return Poll::Ready(Err(err.clone())); - } - // Nothing more can arrive once the last producer is gone: the track was sealed - // (dropped with its end declared) or aborted with that end settled. Either closed - // the channel, so parking is not an option: the consumer would be handed the - // abort, or `Dropped`, instead of the end it reached. + // Nothing more can arrive once the track was sealed (dropped with its end + // declared) or aborted. Either closed the channel, so parking is not an option: + // the consumer would be handed the abort, or `Dropped`, instead of how it ended. let closed = self.sealed || self.abort.is_some(); + // Once nothing more is in range: an abort before the end settled cut the track + // off. One after it is a clean end (see `is_complete`), as is reaching the end. + let end = || match &self.abort { + Some(err) if !self.settled => Err(err.clone()), + _ => Ok(None), + }; // If the exclusive end is already at or below where we'd resume, no // group can ever satisfy this call until the cap rises. Pending (not // None) so the consumer is parked rather than told the stream is over. // An empty range (`end == 0`) parks even at the first sequence. - if let Some(end) = end_sequence - && end <= next_sequence + if let Some(cap) = end_sequence + && cap <= next_sequence { if closed { - return Poll::Ready(Ok(None)); + return Poll::Ready(end()); } return Poll::Pending; } @@ -458,9 +461,10 @@ impl TrackState { // floor is already at/past it, nothing else can land in range. // A closed track produces nothing more either, so a gap below its // boundary is the end, not a wait. Cached in-range groups were - // returned above. This is the cursor the ordered and spliced readers use. + // returned above, so an aborted track drains what finished first. + // This is the cursor the ordered and spliced readers use. if closed || self.final_sequence.is_some_and(|fin| next_sequence >= fin) { - return Poll::Ready(Ok(None)); + return Poll::Ready(end()); } Poll::Pending } @@ -732,7 +736,7 @@ impl TrackState { continue; } if slot.group.is_aborted() - || (Some(sequence) != self.latest_group + || (!self.protects(sequence) && slot .group .cache_accessed_tick(scan.gc.then_some(scan.now)) @@ -757,6 +761,26 @@ impl TrackState { || self.evict.len() > 2 * self.lookup.len() + EVICT_SLACK } + /// Expire an ended track's idle groups, whose closed channel refuses the write + /// [`Self::evict_expired_scan`] takes: abort them in place, releasing their frames, + /// and leave the slots, which every read path already skips. + pub(super) fn expire_closed(&self, scan: ExpiryScan) { + for (sequence, stamp) in &self.evict { + let Some(slot) = self.lookup.get(sequence) else { + continue; + }; + if slot.stamp == *stamp + && !slot.group.is_aborted() + && slot + .group + .cache_accessed_tick(scan.gc.then_some(scan.now)) + .is_some_and(|tick| scan.now.saturating_sub(tick) > scan.max_ticks) + { + let _ = slot.group.clone().abort(Error::Old); + } + } + } + /// Apply a scan previously selected by [`Self::expiry_scan`] or /// [`Self::expiry_scan_drain`]. pub(super) fn evict_expired_scan(&mut self, scan: ExpiryScan) { @@ -779,7 +803,7 @@ impl TrackState { self.lookup.remove(&sequence); continue; } - if Some(sequence) == self.latest_group + if self.protects(sequence) || slot .group .cache_accessed_tick(scan.gc.then_some(scan.now)) @@ -833,14 +857,32 @@ impl TrackState { self.lookup.get(&sequence).is_some_and(|slot| slot.stamp == stamp) } - /// Drop every cached group and reset the eviction bookkeeping. Each group's - /// access sample lives in its own charge, released when the group itself dies. - fn clear_cache(&mut self) { - self.lookup.clear(); - self.arrival.clear(); - self.evict.clear(); - self.latest_group = None; - self.debt = 0; + /// Whether `sequence` is the live edge, which eviction and expiry never take + /// while a producer remains. + fn protects(&self, sequence: u64) -> bool { + !self.closed && Some(sequence) == self.latest_group + } + + /// No producer remains: stop protecting the live edge, so the pool's idle expiry + /// reclaims every group once readers stop touching it, and a stale consumer can't + /// pin the cache (and its frame buffers) forever. + fn close_cache(&mut self) { + if std::mem::replace(&mut self.closed, true) { + return; + } + if let Some(latest) = self.latest_group + && let Some(slot) = self.lookup.get(&latest) + { + slot.group.cache_demote(); + self.evict.push_back((latest, slot.stamp)); + } + } + + /// Drop the open groups nobody will finish now that the track ended abruptly, and + /// keep the finished ones for readers still draining. A consumer that already + /// pulled an open group keeps its own handle and ends with it. + fn drop_open_groups(&mut self) { + self.lookup.retain(|_, slot| slot.group.is_finished()); } /// Attach `info` to this track, clamping the publisher's window down to the @@ -919,7 +961,8 @@ impl TrackState { self.evict.push_back((latest, prev.stamp)); } self.latest_group = Some(sequence); - } else { + } + if !self.protects(sequence) { group.cache_demote(); self.evict.push_back((sequence, stamp)); } @@ -1032,7 +1075,7 @@ impl TrackState { self.lookup.remove(&sequence); continue; } - if Some(sequence) == self.latest_group { + if self.protects(sequence) { // The live edge is never enqueued, but tolerate finding it anyway. self.evict.push_back((sequence, stamp)); continue; @@ -1118,8 +1161,8 @@ impl TrackState { /// /// `None` while the track has produced nothing, which is an unbounded takeover. fn resume_position(&self) -> Option { - // A snapshot taken when the cache was released wins; the groups it was derived - // from are gone. + // A snapshot taken when the open groups were released wins; the group it was + // derived from may be gone. if self.resume.is_some() { return self.resume; } @@ -1199,20 +1242,17 @@ impl TrackState { /// Record `err` and close the track: the shared tail of [`Producer::abort`] and /// [`Producer::abort_unused`]. fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) { - // Snapshot the frame boundary before the cache it's derived from goes away: an + // Snapshot the frame boundary before the open group it may sit in goes away: an // abort is exactly when a replacement route asks where to resume. state.resume = state.resume_position(); - // Decided before the cache goes: the groups below the end are the evidence. + // Decided before the open groups go: the groups below the end are the evidence. state.settled = state.is_settled(); state.abort = Some(err); - // A settled end is a clean end: the track holds every group it promised, so keep - // them for consumers still draining, as `finish()` does. Clearing here would abort - // the finished groups a slower reader has not pulled yet, and it would then see the - // track end short of them with no error. - if !state.settled { - state.clear_cache(); - state.datagrams.clear(); - } + // Keep what finished for consumers still draining: they get it, then the abort (or + // the clean end, when the end settled). Clearing here would abort finished groups a + // slower reader has not pulled yet. The pool's idle expiry bounds how long they stay. + state.drop_open_groups(); + state.close_cache(); state.close(); } @@ -1516,16 +1556,15 @@ impl Producer { /// Abort the track with the given error. /// - /// Consumes the handle, since nothing can be written to an aborted track. Drops the - /// cached groups so a stale [`Consumer`] can't pin them (and their frame buffers) in - /// memory forever. Consumers that haven't drained yet surface the abort error instead - /// of the leftover cache. Child groups are independent: a consumer that already pulled - /// a [`group::Consumer`] keeps its own handle and can finish reading it. + /// Consumes the handle, since nothing can be written to an aborted track. Consumers + /// still draining get the finished groups, then the abort error. Open groups leave the + /// cache; a consumer that already pulled one keeps its own handle and ends with it. + /// The pool's idle expiry (see [`cache::Config::with_expiry`]) reclaims the rest, the + /// latest group included, so a stale [`Consumer`] can't pin them in memory forever. /// - /// Unless the declared end had settled: the final sequence from - /// [`finish_at`](Self::finish_at) was reached and every group below it finished. The - /// track then already holds everything it promised, so it ends cleanly and keeps the - /// cached groups for consumers still draining, as [`finish`](Self::finish) does. + /// If the declared end had settled (the final sequence from + /// [`finish_at`](Self::finish_at) was reached and every group below it finished), + /// consumers get a clean end instead of the error, as after [`finish`](Self::finish). /// /// [`finish`](Self::finish) is deliberately not terminal: it declares the final /// sequence, and lower-numbered groups may still be written afterwards. @@ -1944,10 +1983,10 @@ impl Drop for Alive { if !self.published.load(Ordering::Relaxed) { return; } - // The last producer going away without finishing is an abrupt teardown: - // release the cached groups so a stale consumer can't pin them (and their - // frame buffers) forever, the same as an explicit abort. A cleanly - // finished track keeps its cache so consumers can still drain it. + // The last producer going away ends the track: nothing protects its live edge + // anymore, so the pool's idle expiry reclaims what a stale consumer would pin. + // Without a finish it is an abrupt teardown, the same as an explicit abort: + // the open groups go and the finished ones stay for readers still draining. // `abort()` closes the channel, so `write()` returns `Err(Ref)`. `finish()` // leaves it open with `final_sequence` set, so inspect both outcomes. match self.state.write() { @@ -1956,6 +1995,7 @@ impl Drop for Alive { // Groups still missing below the boundary can no longer arrive, so a // reader waiting on one ends cleanly instead of with `Dropped`. state.sealed = true; + state.close_cache(); return; } if state.abort.is_some() { @@ -1965,10 +2005,10 @@ impl Drop for Alive { track = %self.name, "track::Producer dropped without finish() or abort()" ); - // See `abort`: keep the frame boundary once its groups go away. + // See `abort`: keep the frame boundary once its open group goes away. state.resume = state.resume_position(); - state.clear_cache(); - state.datagrams.clear(); + state.drop_open_groups(); + state.close_cache(); } Err(state) => { if state.final_sequence.is_some() || state.abort.is_some() { @@ -6693,25 +6733,21 @@ mod test { } #[tokio::test] - async fn abort_clears_cached_groups() { + async fn abort_drops_open_groups() { let producer = track_producer("test", None); producer.append_group().unwrap(); producer.append_group().unwrap(); - // A stale consumer that never drains must not pin the cached groups. let mut consumer = producer.subscribe(None); assert_eq!(live_groups(&producer.state.read()), 2); producer.clone().abort(Error::Cancel).unwrap(); - { - let state = producer.state.read(); - assert!(state.lookup.is_empty(), "cached groups should be dropped on abort"); - assert!(state.arrival.is_empty()); - assert!(state.evict.is_empty()); - } - - // The consumer now surfaces the abort error rather than the leftover cache. + // Nobody will finish them, so they leave the cache rather than park a reader. + assert!( + producer.state.read().lookup.is_empty(), + "open groups are dropped on abort" + ); let result = consumer.recv_group().now_or_never().expect("should not block"); assert!(matches!(result, Err(Error::Cancel))); } @@ -6956,6 +6992,93 @@ mod test { assert!(matches!(res, Err(Error::Timeout))); } + /// An abort short of the end keeps the groups that finished for a reader that has not + /// pulled them yet: it gets them, then the abort. The open group nobody will finish + /// is gone, on both cursors. + #[tokio::test] + async fn abort_keeps_finished_groups_for_a_slow_reader() { + let producer = track_producer("test", None); + let mut arrival = producer.subscribe(None); + let mut ordered = producer.subscribe(None).ordered(); + + for sequence in 0..2 { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + let _open = producer.create_group(group::Info { sequence: 2 }).unwrap(); + producer.abort(Error::Timeout).unwrap(); + + assert_eq!(arrival.assert_group().sequence, 0); + assert_eq!(arrival.assert_group().sequence, 1); + let res = arrival.recv_group().now_or_never().expect("should not block"); + assert!(matches!(res, Err(Error::Timeout)), "expected the abort"); + + assert_eq!(drain_ordered(&mut ordered), [0, 1]); + let res = ordered + .next_group() + .now_or_never() + .expect("should not block") + .map(|group| group.map(|group| group.sequence)); + assert!(matches!(res, Err(Error::Timeout)), "expected the abort, got {res:?}"); + } + + /// The last producer dropping without a finish keeps the finished groups the same way. + #[tokio::test] + async fn dropped_producer_keeps_finished_groups_for_a_slow_reader() { + let producer = track_producer("test", None); + let mut consumer = producer.subscribe(None); + producer + .create_group(group::Info { sequence: 0 }) + .unwrap() + .finish() + .unwrap(); + drop(producer); + + assert_eq!(consumer.assert_group().sequence, 0); + let res = consumer.recv_group().now_or_never().expect("should not block"); + assert!(matches!(res, Err(Error::Dropped)), "expected the drop"); + } + + /// A closed track's groups, its latest included, expire once idle, so a stale + /// consumer cannot pin them. A live track's latest stays protected. + #[tokio::test] + async fn closed_track_expires_its_latest_group() { + let pool = cache::Pool::new(cache::Config::default().with_expiry(Duration::from_secs(1))); + + let live = track_producer_pooled("live", pool.clone()); + live.append_group().unwrap().finish().unwrap(); + + let aborted = track_producer_pooled("aborted", pool.clone()); + aborted.append_group().unwrap().finish().unwrap(); + let stale_aborted = aborted.consume(); + aborted.abort(Error::Timeout).unwrap(); + + let finished = track_producer_pooled("finished", pool.clone()); + finished.append_group().unwrap().finish().unwrap(); + finished.finish().unwrap(); + let stale_finished = finished.consume(); + drop(finished); + + // The first pass dates the activity it has not seen yet; the next one expires it. + for _ in 0..2 { + crate::model::clock::advance(Duration::from_secs(2)); + pool.sweep(); + } + + assert!(live.consume().peek_group(0).is_some(), "a live track keeps its latest"); + assert!( + stale_aborted.peek_group(0).is_none(), + "an aborted track's latest expired" + ); + assert!( + stale_finished.peek_group(0).is_none(), + "a sealed track's latest expired" + ); + } + /// An abort after every group below the declared end finished leaves the end standing. #[tokio::test] async fn abort_after_the_end_settles_ends_clean() { @@ -8310,8 +8433,13 @@ mod test { /// surviving publisher, or an abrupt drop silently behaves like a clean finish. #[tokio::test] async fn teardown_ignores_a_settling_group() { - let (mut producer, pool) = pooled_producer(1 << 40); - finished_group(&mut producer, 100); + let (producer, pool) = pooled_producer(1 << 40); + // Open, so only the abrupt teardown releases it. + let mut group = producer.append_group().unwrap(); + group + .write_frame(Timestamp::ZERO, bytes::Bytes::from(vec![0u8; 100])) + .unwrap(); + drop(group); // Stand in for a concurrent `cache::Track::settle`, mid-upgrade. let settling = producer.state.downgrade().upgrade().expect("open"); From 1a7a8ae7d817d04f0358eb670f581c0bf8e74f2e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:28:48 -0700 Subject: [PATCH 56/87] fix(pattern): JS subtree of a max-depth path is the path itself (#4360) Co-authored-by: Claude Opus 5.5 --- js/pattern/src/index.test.ts | 11 +++++++++-- js/pattern/src/index.ts | 11 +++++++++-- quest/m1/README.md | 1 - quest/m1/js-pattern-depth.md | 21 --------------------- rs/moq-pattern/src/pattern.rs | 4 ---- rs/moq-pattern/tests/pattern.json | 10 +++++++++- rs/moq-pattern/tests/pattern.rs | 13 ++++++++----- 7 files changed, 35 insertions(+), 36 deletions(-) delete mode 100644 quest/m1/js-pattern-depth.md diff --git a/js/pattern/src/index.test.ts b/js/pattern/src/index.test.ts index 0378be70ba..d05b94ccad 100644 --- a/js/pattern/src/index.test.ts +++ b/js/pattern/src/index.test.ts @@ -6,7 +6,7 @@ import { compareSpecificity, IntersectionError, InvalidPattern, Pattern, Pattern interface Vectors { parse: { text: string; canonical?: string; segments?: Segment[] | null; error?: InvalidPattern.Code }[]; literal: ({ path: string; pattern: string } | { path: string; error: InvalidPattern.Code })[]; - subtree: { path: string; pattern: string }[]; + subtree: ({ path: string; pattern: string } | { path: string; error: InvalidPattern.Code })[]; head: { pattern: string; head: string; literal: boolean; globstar: boolean }[]; matches: { pattern: string; path: string; expect: boolean }[]; contains: { outer: string; inner: string; expect: boolean }[]; @@ -68,7 +68,14 @@ describe("vectors", () => { ).toBe(c.error); else expect(Pattern.literal(c.path).text, c.path).toBe(c.pattern); } - for (const c of vectors.subtree) expect(Pattern.subtree(c.path).text, c.path).toBe(c.pattern); + for (const c of vectors.subtree) { + if ("error" in c) + expect( + errorCode(() => Pattern.subtree(c.path)), + c.path, + ).toBe(c.error); + else expect(Pattern.subtree(c.path).text, c.path).toBe(c.pattern); + } }); test("head", () => { diff --git a/js/pattern/src/index.ts b/js/pattern/src/index.ts index c95676ea42..f5e367902c 100644 --- a/js/pattern/src/index.ts +++ b/js/pattern/src/index.ts @@ -421,9 +421,16 @@ export class Pattern { return new Pattern(splitPath(path).map((value) => ({ kind: "literal", value }))); } - /** The pattern matching `path` and everything beneath it: `path/**`. The empty path yields `**`. */ + /** + * The pattern matching `path` and everything beneath it: `path/**`. + * + * The empty path yields `**`, and a path of {@link Pattern.MAX_SEGMENTS} yields the + * literal, since nothing can sit beneath it. + */ static subtree(path: string): Pattern { - return new Pattern([...splitPath(path).map((value): Segment => ({ kind: "literal", value })), GLOBSTAR]); + const segments = splitPath(path).map((value): Segment => ({ kind: "literal", value })); + if (segments.length < MAX_PATTERN_SEGMENTS) segments.push(GLOBSTAR); + return new Pattern(segments); } /** The pattern matching every path: `**`. */ diff --git a/quest/m1/README.md b/quest/m1/README.md index 674dbee9ae..b03b18875f 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -46,7 +46,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Track tail hardening](/quest/m1/track-tail-hardening.md) - Rust and JS wait out a track's tail by the same rules, with the known hang, count, truncation, grace, and memory holes closed - [Session death parity](/quest/m1/session-death.md) - a local close ends tracks cleanly in both languages, and JS group readers see the session's error on session death - [JS scoped routes](/quest/m1/js-scoped-routes.md) - a scoped JS origin reader announces the preferred route among those in its scope, like Rust, with the fan-out benchmarked -- [JS subtree at max depth](/quest/m1/js-pattern-depth.md) - `Pattern.subtree` returns the literal path at 32 segments like Rust, pinned by a shared pattern.json vector - [moq play decode schedule](/quest/m1/play-decode-schedule.md) - `moq play` video keeps valid pictures across rewinds, reordering deeper than 100 ms, and decoder batches larger than three - [moqsrc stop](/quest/m1/moqsrc-stop.md) - moqsrc's stop blocks until its session ends, without deadlocking on a blocked pad push - [More tests under load](/quest/m1/test-flakes-2.md) - the second round of load-only failures, fixed at the cause diff --git a/quest/m1/js-pattern-depth.md b/quest/m1/js-pattern-depth.md deleted file mode 100644 index 3921c8eec1..0000000000 --- a/quest/m1/js-pattern-depth.md +++ /dev/null @@ -1,21 +0,0 @@ -# [XS] JS subtree at max depth - -## Goal - -`Pattern.subtree` in `@moq/pattern` accepts a 32-segment path (the wire path -limit) and returns the literal path, as Rust's `Pattern::subtree` does since -https://github.com/moq-dev/moq/pull/4284. A 33-segment path still throws -`TooManySegments`. - -## Plan - -`js/pattern/src/index.ts` always appends `**`, so a max-depth path becomes 33 -segments and throws, which empties an announce subscription under that prefix -just as it did in Rust. Nothing can sit beneath a max-depth path, so the path -itself is the whole subtree. - -The Rust regression is a unit test in `rs/moq-pattern/src/pattern.rs`, which -JS never runs. Move the max-depth case (and the 33-segment refusal, if the -vector shape grows an error field the way `literal` has one) into the shared -`rs/moq-pattern/tests/pattern.json`, so both languages run the same vector -and the next divergence fails in whichever side lags. diff --git a/rs/moq-pattern/src/pattern.rs b/rs/moq-pattern/src/pattern.rs index fb9004a7a8..49dca7f8c2 100644 --- a/rs/moq-pattern/src/pattern.rs +++ b/rs/moq-pattern/src/pattern.rs @@ -998,10 +998,6 @@ mod tests { assert_eq!(Pattern::literal("").unwrap(), Pattern::default()); assert_eq!(Pattern::subtree("foo").unwrap(), pattern("foo/**")); assert_eq!(Pattern::subtree("/").unwrap(), Pattern::all()); - let max = ["a"; Pattern::MAX_SEGMENTS].join("/"); - assert_eq!(Pattern::subtree(&max).unwrap(), pattern(&max)); - let deep = ["a"; Pattern::MAX_SEGMENTS + 1].join("/"); - assert_eq!(Pattern::subtree(&deep), Err(InvalidPattern::TooManySegments)); assert_eq!(Pattern::literal("a/*"), Err(InvalidPattern::InvalidSegment("*".into()))); assert_eq!(Pattern::literal("**"), Err(InvalidPattern::InvalidSegment("**".into()))); } diff --git a/rs/moq-pattern/tests/pattern.json b/rs/moq-pattern/tests/pattern.json index bf479d8c36..ae8b85bdab 100644 --- a/rs/moq-pattern/tests/pattern.json +++ b/rs/moq-pattern/tests/pattern.json @@ -1,5 +1,5 @@ { - "$comment": "Golden vectors shared by rs/moq-net (tests/pattern.rs) and js/net (src/pattern.test.ts). Both test suites read this file, so the two implementations cannot drift apart silently.", + "$comment": "Golden vectors shared by rs/moq-pattern (tests/pattern.rs) and js/pattern (src/index.test.ts). Both test suites read this file, so the two implementations cannot drift apart silently.", "parse": [ { "text": "", @@ -197,6 +197,14 @@ { "path": "a/b/", "pattern": "a/b/**" + }, + { + "path": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f", + "pattern": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f" + }, + { + "path": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f/g", + "error": "too-many-segments" } ], "head": [ diff --git a/rs/moq-pattern/tests/pattern.rs b/rs/moq-pattern/tests/pattern.rs index 1c25242811..2d64bb3911 100644 --- a/rs/moq-pattern/tests/pattern.rs +++ b/rs/moq-pattern/tests/pattern.rs @@ -77,11 +77,14 @@ fn literal_and_subtree() { } for case in vectors["subtree"].as_array().unwrap() { let path = case["path"].as_str().unwrap(); - assert_eq!( - Pattern::subtree(path).unwrap(), - pattern(case["pattern"].as_str().unwrap()), - "subtree {path:?}" - ); + match Pattern::subtree(path) { + Ok(got) => assert_eq!(got, pattern(case["pattern"].as_str().unwrap()), "subtree {path:?}"), + Err(err) => assert_eq!( + error_code(&err), + case["error"].as_str().unwrap_or("ok"), + "subtree {path:?}" + ), + } } } From fa3aff6f87fa015a6fd7d28539c131bdc0f9a226 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:28:56 -0700 Subject: [PATCH 57/87] fix(auth): admit a SETUP token equal to the jwt query (#4359) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/auth.md | 3 ++- quest/m1/auth/token-in-band.md | 8 +++----- rs/moq-auth/src/serve.rs | 37 +++++++++++++++++++++++++++------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 26dcc47f0c..ec3dbe0f18 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -284,7 +284,8 @@ against `--key FILE` or `--key-dir DIR` (by `kid`, read per request so rotation needs no restart) and authorized at the dialed path, as in [Path matching](#path-matching). A malformed, expired, or unknown-key token is refused; it never falls through to the anonymous rules. So is a SETUP token of -any other `kind`, and a session presenting both a SETUP token and a `jwt` query. +any other `kind`. A SETUP token and a `jwt` query with the same value are one +JWT, verified once; different values are refused. A JWT presented with a certificate is refused, because neither can safely win: the certificate would override a JWT meant to narrow it, and the JWT would narrow or refuse a peer by accident. So a peer presents `cluster.token` or a diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 51aa256231..59bf43eb58 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -45,11 +45,9 @@ AUTH can carry the full grant once the pattern-interest prerequisite lands. the first token also rides the AUTHORIZATION TOKEN setup option (`ietf::token::into_setup`, `USE_VALUE`, token type 0), which scopes at accept the way the URL does. While the URL also carries it, the - auth server sees the same credential twice. Decided by the maintainer (on - #4211): `moq auth serve` admits a SETUP token equal to the `?jwt=` value - and refuses only two different credentials. #4278 shipped refusing both - (`serve::Refusal::TwoTokens`, pinned by a test), so this quest changes that - and its test. Never send different values in the two places. + auth server sees the same credential twice: `moq auth serve` admits a SETUP + token equal to the `?jwt=` value and refuses two different ones + (`serve::Refusal::TwoTokens`). Never send different values in the two places. - The relay admits on the URL, then widens. An anonymous connection today is admitted with the public grant when one is configured and refused otherwise; with this quest a connection with no URL credential and no diff --git a/rs/moq-auth/src/serve.rs b/rs/moq-auth/src/serve.rs index 2a55c60ba1..35bd8aeaa8 100644 --- a/rs/moq-auth/src/serve.rs +++ b/rs/moq-auth/src/serve.rs @@ -59,8 +59,9 @@ pub struct Limits { /// each scoped to `anon/`, and refuses one dialed at `/other`. /// /// The JWT is the `jwt` query parameter or a moq-transport SETUP token of type 0 -/// ([`Token::OUT_OF_BAND`]), verified alike. A session presenting both is refused, -/// as is a SETUP token of any other type, and one presenting a JWT alongside a +/// ([`Token::OUT_OF_BAND`]), verified alike. A session presenting both is verified +/// once when they are the same JWT and refused when they differ, as is a SETUP token +/// of any other type, and one presenting a JWT alongside a /// certificate: neither can safely win, since a certificate would override a JWT /// meant to narrow it, and a JWT would narrow or refuse a peer by accident. /// @@ -112,7 +113,7 @@ pub enum Refusal { TokenLimit, #[error("too many live sessions from this address")] RemoteLimit, - #[error("both a SETUP token and a `jwt` query were presented; present one")] + #[error("a SETUP token and a different `jwt` query were presented; present one")] TwoTokens, #[error("SETUP token type {0:#x} is not supported; only type 0 (a JWT) is")] UnsupportedToken(u64), @@ -187,8 +188,12 @@ fn authorize(rules: &Permissions, path: &str) -> Result { } /// The JWT the request presents: its SETUP token, or else its `jwt` query parameter. +/// Both at once count as one only when they are the same JWT. fn jwt(request: &Request) -> Result, Refusal> { match (&request.token, query_jwt(request)) { + // A client that offers a version without in-band auth copies its SETUP token + // into the URL too, so the same JWT arrives twice. + (Some(token), Some(jwt)) if token.kind == Token::OUT_OF_BAND && token.value == jwt.as_bytes() => Ok(Some(jwt)), (Some(_), Some(_)) => Err(Refusal::TwoTokens), (Some(token), None) if token.kind == Token::OUT_OF_BAND => std::str::from_utf8(&token.value) .map(Some) @@ -643,9 +648,9 @@ mod tests { ); } - /// Two credentials would leave the server guessing which one the client meant. + /// The same JWT in the SETUP token and the query is one credential, evaluated once. #[tokio::test] - async fn a_setup_token_and_a_query_jwt_are_refused() { + async fn a_setup_token_equal_to_the_query_jwt_is_admitted() { let (dir, key) = key_dir(); let policy = Policy { keys: Some(Keys::Dir(dir.path().into())), @@ -653,8 +658,26 @@ mod tests { }; let jwt = sign(&key, "demo", &["**"], &[], None); let request = with_setup_token(with_token(request("/demo"), &jwt), Token::OUT_OF_BAND, &jwt); - let err = policy.decide(&request).await.unwrap_err(); - assert_eq!(err, Refusal::TwoTokens); + let grant = policy.decide(&request).await.unwrap(); + assert_eq!(grant.publish, patterns(&["**"])); + } + + /// Two different credentials would leave the server guessing which one the client meant. + #[tokio::test] + async fn a_setup_token_and_a_different_query_jwt_are_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let setup = sign(&key, "demo", &["**"], &[], None); + let query = sign(&key, "demo", &[], &["**"], None); + let different = with_setup_token(with_token(request("/demo"), &query), Token::OUT_OF_BAND, &setup); + assert_eq!(policy.decide(&different).await.unwrap_err(), Refusal::TwoTokens); + + // The same bytes under another token type are still a second credential. + let other_type = with_setup_token(with_token(request("/demo"), &setup), Token::CAT, &setup); + assert_eq!(policy.decide(&other_type).await.unwrap_err(), Refusal::TwoTokens); } #[tokio::test] From 6ecde63a5227acae85f6cfeab2747eba17e42493 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:29:03 -0700 Subject: [PATCH 58/87] fix(gst): keep waiting for a keyframe after a header-only buffer (#4356) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/moqsink-keyframe-latch.md | 23 ---------------------- rs/moq-gst/src/sink/pad.rs | 31 ++++++++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 26 deletions(-) delete mode 100644 quest/m1/moqsink-keyframe-latch.md diff --git a/quest/m1/README.md b/quest/m1/README.md index b03b18875f..1798728a6d 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -40,7 +40,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 - [kio waiter overflow](/quest/m1/kio-waiter-lost.md) - a retained `Waiter` past 8 lists stops adding a duplicate entry to lists it already recorded -- [moqsink keyframe latch](/quest/m1/moqsink-keyframe-latch.md) - a header-only buffer after a break no longer permanently invalidates a moqsink video pad - [Capture re-anchor](/quest/m1/capture-reanchor.md) - a repeating or restarting device clock never rewinds native capture during a fast backlog drain - [Splice edge cases](/quest/m1/splice-edges.md) - an unstamped successor, a pruned segment's boundary group, and a warm head during a takeover are each handled correctly - [Track tail hardening](/quest/m1/track-tail-hardening.md) - Rust and JS wait out a track's tail by the same rules, with the known hang, count, truncation, grace, and memory holes closed diff --git a/quest/m1/moqsink-keyframe-latch.md b/quest/m1/moqsink-keyframe-latch.md deleted file mode 100644 index d86c1e638a..0000000000 --- a/quest/m1/moqsink-keyframe-latch.md +++ /dev/null @@ -1,23 +0,0 @@ -# [XS] moqsink waits for a published keyframe after a break - -## Goal - -A video pad in `moqsink` survives a pause or segment break whose first buffer -carries only codec headers. Today `Media::write` in -`rs/moq-gst/src/sink/pad.rs` clears its `keyframe` latch after any successful -decode. A header-only buffer decodes without emitting a frame, clears the -latch, and the next delta frame hits `MissingKeyframe` outside the guarded -arm, which permanently invalidates the pad -([#4239](https://github.com/moq-dev/moq/pull/4239)). - -## Plan - -Keep the latch set until the importer actually publishes a keyframe, not -until the first buffer parses. That needs `import::Track::decode` to say -whether it emitted a frame, or a comparable signal; prefer reusing what the -importer already knows over inferring it in the pad. - -Regression test beside `video_pause_drops_deltas_until_the_next_keyframe`: a -break followed by a header-only buffer (SPS/PPS alone for H.264), then a -delta, then a keyframe. The delta drops and the keyframe publishes; the pad -stays valid. diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index dcbc84bec9..f9b68dd37b 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -52,7 +52,9 @@ struct Media { /// Apply a timeline break before the next valid frame, using the normal write error path. discontinuity: bool, /// A video break closed the group, and a pause resumes mid-GOP, so deltas drop until a keyframe - /// opens the next one. + /// opens the next one. Stays set once armed: a successful decode may publish nothing (a + /// header-only buffer), and a delta only misses its keyframe while no group is open, which for + /// video means after a break. keyframe: bool, } @@ -69,7 +71,6 @@ impl Media { Err(moq_mux::Error::MissingKeyframe(_)) if self.keyframe => return Ok(false), result => result?, } - self.keyframe = false; // One group (one QUIC stream) per audio packet, so the relay forwards it without waiting for // the next. if self.audio { @@ -1758,6 +1759,32 @@ mod tests { assert_eq!(push(&mut pad, delta, 166), PushOutcome::Published); } + // A header-only buffer after a break publishes no frame, so no group opens and the deltas that + // follow must still drop rather than invalidate the pad. + #[test] + fn video_header_only_buffer_keeps_waiting_for_the_keyframe() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + let mut pad = Pad::new(); + pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), Some("video"))); + pad.observe_segment(time_segment()); + // The SPS and PPS of the keyframe AU, without its IDR slice. + let keyframe = h264_keyframe_au(); + let headers = keyframe.slice(..keyframe.len() - 9); + let delta = Bytes::from_static(&[0, 0, 0, 1, 0x61, 0xe0, 0x12, 0x34]); + let now = Instant::now(); + let push = |pad: &mut Pad, data: Bytes, pts: u64| { + pad.push_buffer(data, Some(gst::ClockTime::from_mseconds(pts)), None, None, now) + .unwrap() + }; + assert_eq!(push(&mut pad, keyframe.clone(), 0), PushOutcome::Published); + pad.discontinuity(); + assert_eq!(push(&mut pad, headers, 33), PushOutcome::Published); + assert_eq!(push(&mut pad, delta.clone(), 66), PushOutcome::Dropped); + assert_eq!(push(&mut pad, keyframe, 100), PushOutcome::Published); + assert_eq!(push(&mut pad, delta, 133), PushOutcome::Published); + } + // Text and opaque tracks carry no codec jitter, so asking them to measure one is a mistake to report // rather than a setting to ignore. #[test] From b4cc44bd5275e7394c0cc8eaa016cd86e52f8e55 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:30:27 -0700 Subject: [PATCH 59/87] fix(net): refuse chained and wildcard origin mounts in any order (#4362) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/auth.md | 5 +++- doc/concept/moq-lite.md | 3 +- rs/moq-net/src/model/origin.rs | 50 ++++++++++++++++++++++++++++------ rs/moq-relay/src/cluster.rs | 19 +++++++++++++ 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index ec3dbe0f18..a8125ce6eb 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -46,7 +46,10 @@ dialed path, which is how a slug aliases to a canonical id), `mounts` (optional object; each key, a path relative to the root, reads from the absolute path it maps to: `{".svc": ".svc/pid"}` resolves `.svc/foo` at `.svc/pid/foo` and presents its announcements under `.svc`, the patterns still -authorize `.svc/foo`, and nothing may be published beneath a key), `expires` +authorize `.svc/foo`, and nothing may be published beneath a key; a key +that holds a wildcard or overlaps another key or any value, its own included, +refuses the grant), +`expires` (optional unix seconds; the session closes then), `revalidate` (optional seconds until the relay asks again), `tier` (optional label handed to [stats](/bin/relay/config#stats)), and `peer` (optional; `true` marks another diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 6f2985e87c..f1fc3ff021 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -167,7 +167,8 @@ relative to `root`. Nested scopes intersect with their parent. In Rust, `origin.mount(at, target)` reads the subtree at `at` from `target` instead: a request for `at/rest` joins the one front at `target/rest`, announcements under `target` present under `at`, the handle's patterns still authorize `at/rest`, -and nothing is published beneath `at`. A session receiving +and nothing is published beneath `at`. Mounts never chain: a mount point that +overlaps another mount's point or any target, its own included, is refused. A session receiving into that scoped origin asks for the literal heads of its allowed patterns, coalescing duplicate or nested heads. An unscoped origin still asks for the empty prefix, covering every namespace. These subscriptions include hidden routes; diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 8643499979..bfb20e19b5 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1558,9 +1558,11 @@ impl Producer { /// /// Returns [`Error::Unauthorized`] unless this producer reaches all of /// `target`, so a mount never widens a scope, and [`Error::Duplicate`] when - /// `at` or `target` overlaps a mount this producer already has: mounts never - /// chain, so a target is always read as the origin holds it. - /// [`Error::BoundsExceeded`] if either rooted path exceeds [`Path::MAX_PARTS`]. + /// `at` or `target` overlaps a mount point this producer already has, or `at` + /// overlaps an existing target or its own: mounts never chain, in whatever order they are added, + /// so a target is always read as the origin holds it. Mounts may share a target. + /// [`Error::BoundsExceeded`] if either rooted path exceeds [`Path::MAX_PARTS`], + /// and [`Error::InvalidPath`] if `at` holds a segment no pattern can spell. pub fn mount(&self, at: impl AsPath, target: impl AsPath) -> Result { let at = self.root.join(at).to_owned(); let target = self.root.join(target).to_owned(); @@ -1570,6 +1572,8 @@ impl Producer { { return Err(BoundsExceeded.into()); } + // A mount point no pattern can spell could never be announced or authorized. + Pattern::literal(at.as_str())?; if !self .scope .allowed @@ -1577,11 +1581,17 @@ impl Producer { { return Err(Error::Unauthorized); } - if self.scope.mounts.iter().any(|mount| { - [&at, &target] - .into_iter() - .any(|path| mount.at.has_prefix(path) || path.has_prefix(&mount.at)) - }) { + // Symmetric, so the order mounts are added never changes which sets are accepted: + // no mount point overlaps any mount's point or target, its own included. + // Targets may overlap. + let overlaps = |a: &Path, b: &Path| a.has_prefix(b) || b.has_prefix(a); + if overlaps(&at, &target) + || self + .scope + .mounts + .iter() + .any(|mount| overlaps(&at, &mount.at) || overlaps(&target, &mount.at) || overlaps(&at, &mount.target)) + { return Err(Error::Duplicate); } let mounts = self @@ -4612,7 +4622,31 @@ mod tests { // A target through a mount would read the subtree the mount shadows. assert!(matches!(mounted.mount("p2", "p1/.svc/x"), Err(Error::Duplicate))); assert!(matches!(mounted.mount("p2", "p1"), Err(Error::Duplicate))); + // A mount point on a target would chain through it, in either order. + assert!(matches!(mounted.mount(".svc/p1/x", ".other"), Err(Error::Duplicate))); + assert!(matches!(mounted.mount(".svc", ".other"), Err(Error::Duplicate))); mounted.mount("p1/.other", ".other/p1").unwrap(); + // Mount points may share a target. + mounted.mount("p2/.svc", ".svc/p1").unwrap(); + // A mount point overlapping its own target is refused like any other overlap. + for (at, target) in [("a", "a/b"), ("a/b", "a"), ("a", "a")] { + assert!( + matches!(producer.mount(at, target), Err(Error::Duplicate)), + "{at} -> {target}" + ); + } + } + + /// A mount point no pattern can spell could never be announced or authorized. + #[test] + fn mount_refuses_a_wildcard_mount_point() { + let (producer, _driver) = Producer::new(Config::new(origin(1))); + for at in ["*", "p1/*", "p1/**", "p1/a*"] { + assert!( + matches!(producer.mount(at, ".svc/p1"), Err(Error::InvalidPath(_))), + "{at}" + ); + } } /// Egress through a mount counts under the path the reader named, so it diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index cfb8304d2f..545b76b3bf 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -2280,6 +2280,25 @@ mod tests { assert!(cluster.publisher(&token).is_none()); } + /// A grant whose mounts chain, or whose mount point is a wildcard, admits + /// nothing. Mounts apply in key order, so the chain is written with the + /// mount point on the target sorting last. + #[tokio::test] + async fn session_handles_refuse_invalid_grant_mounts() { + let cluster = new_cluster(Config::default()).expect("cluster"); + let everything = || moq_net::Patterns::from(moq_net::Pattern::all()); + let invalid: [&[(&str, &str)]; 2] = [&[(".a", "pid/.z"), (".z", "secret")], &[("*", ".svc/pid")]]; + for mounts in invalid { + let mut grant = moq_auth::Grant::new(everything(), everything()); + for (at, target) in mounts { + grant.mounts.insert((*at).into(), (*target).into()); + } + let token = auth::Token::new("/pid", &grant); + assert!(cluster.subscriber(&token).is_none(), "{mounts:?}"); + assert!(cluster.publisher(&token).is_none(), "{mounts:?}"); + } + } + /// The publish task holds only a `Weak` to its producer, so it stops when the /// last `moq_stats::Producer` clone drops. Attaching one must therefore hand /// its lifetime to the cluster: an embedder clones handles off a `Relay` and From d9dc35737e63530961d89739bcbf8bd2fb39cc7e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:49:40 -0700 Subject: [PATCH 60/87] docs: say who requests Codex on fork PRs, add IPv6 TLS name quest (#4389) Co-authored-by: Claude Opus 5.5 --- CONTRIBUTING.md | 1 + quest/m1/README.md | 1 + quest/m1/ipv6-tls-name.md | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 quest/m1/ipv6-tls-name.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c70b6ad1cf..ea8dd63be9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,7 @@ For each finding: Wait for Codex to review the final head before merging. Merge only on its thumbs up, or once every Codex finding on the PR is fixed or replied to. +Codex skips fork PRs; ask the maintainer to request one. # Follow-ups diff --git a/quest/m1/README.md b/quest/m1/README.md index 1798728a6d..41f9daf50a 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -58,6 +58,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [TS import shared shift](/quest/m1/ts-import-shared-shift.md) - unflagged loop wraps move audio and video by one shift, so A/V sync holds across wraps - [PipeWire duplicate cameras](/quest/m1/pipewire-dup-cameras.md) - a webcam lists once with PipeWire enabled - [Catalog wall clock](/quest/m1/catalog-wall-clock.md) - `Clock::wall_clock` keeps the catalog's full precision instead of truncating to milliseconds +- [IPv6 TLS names](/quest/m1/ipv6-tls-name.md) - dialing an IPv6 literal completes TLS on WebSocket as on noq, including a bare `::1` host override - [Capture control](/quest/m1/capture-control.md) - on dev, `encode::Capture` replaces `CaptureOptions`, an unsupported `cut()` errors, and dropping the last `Control` cancels in-flight opens - [Video surface](/quest/m1/video-surface.md) - on dev, moq-ffi's `native` becomes `surface`, refused on platforms with no surface - [HLS discontinuity sequence](/quest/m1/hls-discontinuity-sequence.md) - on dev, `Segment::discontinuity` is the absolute sequence, so every cursor agrees diff --git a/quest/m1/ipv6-tls-name.md b/quest/m1/ipv6-tls-name.md new file mode 100644 index 0000000000..a150e39699 --- /dev/null +++ b/quest/m1/ipv6-tls-name.md @@ -0,0 +1,28 @@ +# [XS] IPv6 literal TLS names on every TLS dialer + +## Goal + +Dialing an IPv6 literal (`wss://[::1]:4443`, `moqt://[::1]`) completes the TLS +handshake on both moq-tokio TLS dialers, noq and WebSocket, with or without a +pinned `Addr` and with or without a `tls_host_name` override. +[#4322](https://github.com/moq-dev/moq/pull/4322) fixes noq, where +`url::Host::to_string()` kept the URL brackets and rustls refused `[::1]` as a +server name. + +WebSocket likely already works without an override: every path, including the +pinned `connect_tls_override` one, hands tokio-tungstenite a bracketed URL, and +it strips the brackets before building the rustls name. The known gap is a bare +IPv6 override (`tls_host_name = "::1"`): `connect_tls_override` passes it to +`Url::set_host`, which rejects it, while noq accepts it. + +## Plan + +- Pin WebSocket with loopback `[::1]` tests like #4322's: unpinned and pinned + `Addr` with no override, and a bare `::1` override. Fix only what fails. +- Derive the TLS server name the same way on both dialers. A shared helper is + reasonable if both need it. +- Keep the bracketed form wherever it is correct: the HTTP `Host` header and + URL rebuilding. +- TCP is plaintext and iroh dials by endpoint id, so neither is in scope. + +Public API and wire: none. From bec5b42cf6b45f5897d6f9687284654837f4733a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:53:26 -0700 Subject: [PATCH 61/87] fix(libmoq): render moq.pc only when packaging (#4372) Co-authored-by: Claude Opus 5.5 --- doc/lib/c/index.md | 8 ++-- flake.nix | 8 ---- nix/overlay.nix | 68 +++++++++++++++++++-------------- rs/justfile | 2 +- rs/libmoq/CMakeLists.txt | 4 +- rs/libmoq/README.md | 5 +-- rs/libmoq/build.rs | 55 +++----------------------- rs/libmoq/moq.pc.in | 3 +- rs/libmoq/native-libs/apple.txt | 2 +- test/interop/interop.sh | 2 +- 10 files changed, 58 insertions(+), 99 deletions(-) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 979ce89600..7093bd5a4f 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -27,10 +27,10 @@ cc app.c $(pkg-config --cflags --libs --static moq) -o app From source, `add_subdirectory(rs/libmoq)` in CMake gives a `moq` target to link. A bare `cargo build --release -p libmoq` writes `target/release/libmoq.a`, -and `moq.h` plus `moq.pc` land in the build script's `OUT_DIR` under `include/` -and `lib/pkgconfig/`, a hashed path that `--message-format=json` reports as -`out_dir`. That `moq.pc` expects the install layout, with `libmoq.a` in `lib/` -beside `pkgconfig/`. +and `moq.h` lands in the build script's `OUT_DIR` under `include/`, a hashed +path that `--message-format=json` reports as `out_dir`. It writes no `moq.pc`; +`nix build .#libmoq` produces the same install layout as the release tarball, +pkg-config file included. ## Shape of the API diff --git a/flake.nix b/flake.nix index b25ce4cdc5..d9d90e1b5d 100644 --- a/flake.nix +++ b/flake.nix @@ -547,14 +547,6 @@ # (`.github/actions/rust-cache`); nothing here configures it. checks = { package-source-assets = pkgs.runCommand "package-source-assets" { } '' - for asset in \ - rs/libmoq/moq.pc.in \ - rs/libmoq/native-libs/apple.txt \ - rs/libmoq/native-libs/linux.txt \ - rs/libmoq/native-libs/windows.txt - do - test -f "${overlayPkgs.libmoq.src}/$asset" - done test -f "${overlayPkgs.moq-boy.src}/rs/moq-video/src/frame/nv12_resize.ptx" touch "$out" ''; diff --git a/nix/overlay.nix b/nix/overlay.nix index 961f1e72d6..6c5a7f1ce3 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -101,10 +101,11 @@ let libmoqInfo = crateInfo ../rs/libmoq/Cargo.toml; # The native libraries an external linker must pass alongside libmoq.a. - # rs/libmoq/native-libs/ is the single source: build.rs bakes it into moq.pc - # and rs/libmoq/CMakeLists.txt reads it for in-tree consumers. This installPhase - # substitutes the find_package template directly rather than running CMake, so - # format the same list here, matching CMakeLists.txt's MOQ_NATIVE_LIBS_QUOTED. + # rs/libmoq/native-libs/ is the single source: rs/libmoq/CMakeLists.txt reads + # it for in-tree consumers, and this installPhase substitutes it into the + # moq.pc and find_package templates directly rather than running CMake, so + # format it the way each expects: `Libs.private` flags, and CMakeLists.txt's + # MOQ_NATIVE_LIBS_QUOTED. libmoqNativeLibs = let platform = @@ -116,30 +117,20 @@ let "linux"; lines = final.lib.splitString "\n" (builtins.readFile ../rs/libmoq/native-libs/${platform}.txt); entries = builtins.filter (line: line != "" && !(final.lib.hasPrefix "#" line)) lines; - quote = - entry: - if final.lib.hasPrefix "framework:" entry then - ''"-framework ${final.lib.removePrefix "framework:" entry}"'' - else - ''"${entry}"''; + framework = entry: "-framework ${final.lib.removePrefix "framework:" entry}"; + isFramework = final.lib.hasPrefix "framework:"; in - final.lib.concatMapStringsSep " " quote entries; + { + pc = final.lib.concatMapStringsSep " " ( + entry: if isFramework entry then framework entry else "-l${entry}" + ) entries; + cmake = final.lib.concatMapStringsSep " " ( + entry: if isFramework entry then ''"${framework entry}"'' else ''"${entry}"'' + ) entries; + }; libmoqArgs = libmoqInfo // { - # libmoq's build.rs reads moq.pc.in and native-libs/*.txt at compile time to - # generate the pkgconfig file. craneLib.cleanCargoSource's default filter - # drops both, which makes build.rs skip pkgconfig generation (see the - # `if let Ok(template)` in rs/libmoq/build.rs) or fail reading the lib list, - # and the installPhase's `cp .../moq.pc` then fails. - src = final.lib.cleanSourceWith { - src = ../.; - name = "source"; - filter = - path: type: - (final.lib.hasSuffix ".pc.in" path) - || (final.lib.hasInfix "/rs/libmoq/native-libs/" path) - || (filterCargoSources path type); - }; + src = cleanCargoSource; cargoExtraArgs = "-p libmoq"; doCheck = false; nativeBuildInputs = with final; [ @@ -168,21 +159,40 @@ let mkdir -p $out/lib/pkgconfig $out/include $out/lib/cmake/moq # Ask cargo's build log where it put things instead of reconstructing the - # paths, which a cross --target build moves. build.rs lays out its - # OUT_DIR like this prefix, minus the staticlib. + # paths, which a cross --target build moves. build.rs writes the header + # to include/ under its OUT_DIR. jq=${final.lib.getExe final.jq} lib=$($jq -r 'select(.reason == "compiler-artifact") | .filenames[] | select(endswith("/libmoq.a"))' "$cargoBuildLog") gen=$($jq -r 'select(.reason == "build-script-executed") | select(.package_id | test("libmoq")) | .out_dir' "$cargoBuildLog") cp "$lib" $out/lib/ cp "$gen/include/moq.h" $out/include/ - cp "$gen/lib/pkgconfig/moq.pc" $out/lib/pkgconfig/ + + # Rendered here rather than by build.rs: the template's paths are relative + # to the .pc, which only holds once libmoq.a sits beside pkgconfig/. + pc=$out/lib/pkgconfig/moq.pc + substitute ${../rs/libmoq/moq.pc.in} "$pc" \ + --subst-var-by VERSION "${libmoqInfo.version}" \ + --subst-var-by LIBS_PRIVATE ${final.lib.escapeShellArg libmoqNativeLibs.pc} + if grep -nE '@[A-Z_]+@' "$pc"; then + echo "unsubstituted placeholder in moq.pc (see above)" >&2 + exit 1 + fi + # Resolve the paths the way a consumer's pkg-config will, so a template + # whose libdir or includedir misses what this prefix ships fails here. + for check in libdir:libmoq.a includedir:moq.h; do + dir=$(PKG_CONFIG_PATH=$out/lib/pkgconfig pkg-config --variable="''${check%%:*}" moq) + if [ ! -f "$dir/''${check#*:}" ]; then + echo "moq.pc ''${check%%:*} $dir has no ''${check#*:}" >&2 + exit 1 + fi + done major_version="$(echo "${libmoqInfo.version}" | cut -d. -f1)" substitute ${../rs/libmoq/cmake/moq-config.cmake.in} \ $out/lib/cmake/moq/moq-config.cmake \ --subst-var-by LIB_FILE libmoq.a \ --subst-var-by VERSION "${libmoqInfo.version}" \ - --subst-var-by MOQ_NATIVE_LIBS_QUOTED ${final.lib.escapeShellArg libmoqNativeLibs} + --subst-var-by MOQ_NATIVE_LIBS_QUOTED ${final.lib.escapeShellArg libmoqNativeLibs.cmake} substitute ${../rs/libmoq/cmake/moq-config-version.cmake.in} \ $out/lib/cmake/moq/moq-config-version.cmake \ --subst-var-by VERSION "${libmoqInfo.version}" \ diff --git a/rs/justfile b/rs/justfile index 93d4114d2e..9b7a35e0f3 100644 --- a/rs/justfile +++ b/rs/justfile @@ -770,7 +770,7 @@ c-tests: exit 1 fi - # Same list build.rs (moq.pc), CMakeLists.txt, and test/interop/interop.sh read: + # Same list nix/overlay.nix (moq.pc), CMakeLists.txt, and test/interop/interop.sh read: # `framework:Foo` is a linker framework flag, anything else a plain library. case "$(uname -s)" in Darwin) native_libs=rs/libmoq/native-libs/apple.txt ;; diff --git a/rs/libmoq/CMakeLists.txt b/rs/libmoq/CMakeLists.txt index 9c14c080ad..a6daa7d94f 100644 --- a/rs/libmoq/CMakeLists.txt +++ b/rs/libmoq/CMakeLists.txt @@ -69,8 +69,8 @@ target_include_directories(moq INTERFACE ${RUST_INCLUDE_DIR}) target_link_libraries(moq INTERFACE "${RUST_LIB}") # System libraries Rust std/deps pull in. When the staticlib is linked into a -# C/C++ target by an external linker, cargo can't inject these itself. build.rs -# reads the same files for moq.pc, so the two never drift. +# C/C++ target by an external linker, cargo can't inject these itself. +# nix/overlay.nix reads the same files for moq.pc, so the two never drift. # # Reads native-libs/.txt into MOQ_NATIVE_LIBS: `framework:Foo` becomes # a linker framework flag, anything else a plain library name. diff --git a/rs/libmoq/README.md b/rs/libmoq/README.md index 5642aef7c4..cbb10f5c7f 100644 --- a/rs/libmoq/README.md +++ b/rs/libmoq/README.md @@ -12,13 +12,12 @@ This will: - Build the static library (`libmoq.a` on Unix-like systems, `moq.lib` on Windows) - Generate the C header file at `$OUT_DIR/include/moq.h` -- Generate the pkg-config file at `$OUT_DIR/lib/pkgconfig/moq.pc` `OUT_DIR` is the build script's hashed output directory, which `cargo build --message-format=json` reports as `out_dir` on the `build-script-executed` message for libmoq. -`moq.pc` assumes the install layout (`lib/libmoq.a` beside `lib/pkgconfig/`), so -copy the staticlib into `$OUT_DIR/lib/` or a prefix before using it. +The pkg-config file (`moq.pc.in`) is rendered only when packaging (`nix build .#libmoq` +and the release tarballs), since its paths assume `lib/libmoq.a` beside `lib/pkgconfig/`. There's also a [CMakeLists.txt](CMakeLists.txt) file that can be used to import/build the library. diff --git a/rs/libmoq/build.rs b/rs/libmoq/build.rs index 60bb29956c..75ec536e44 100644 --- a/rs/libmoq/build.rs +++ b/rs/libmoq/build.rs @@ -21,11 +21,12 @@ const ENUMS: &[&str] = &[ fn main() { let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); - let version = env::var("CARGO_PKG_VERSION").unwrap(); - // Everything lands in OUT_DIR, laid out like the install prefix minus the - // staticlib. Cargo forbids writing anywhere else, and a build cache that - // replays this script (mbx) restores OUT_DIR and nothing more. Consumers ask - // cargo for the path (`build-script-executed` in --message-format=json). + // The header lands in OUT_DIR: cargo forbids writing anywhere else, and a + // build cache that replays this script (mbx) restores OUT_DIR and nothing + // more. Consumers ask cargo for the path (`build-script-executed` in + // --message-format=json). moq.pc is not written here: its libdir has to name + // the directory holding libmoq.a, which only exists once packaged (see + // nix/overlay.nix), since cargo puts the staticlib outside OUT_DIR. let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // The `rerun-if-changed` below opts out of cargo's default "rerun when any @@ -63,48 +64,4 @@ fn main() { .generate() .expect("Unable to generate bindings") .write_to_file(&header); - - let pc_in = PathBuf::from(&crate_dir).join(format!("{}.pc.in", LIB_NAME)); - let pkgconfig_dir = out_dir.join("lib").join("pkgconfig"); - fs::create_dir_all(&pkgconfig_dir).expect("Failed to create pkgconfig directory"); - let pc_out = pkgconfig_dir.join(format!("{}.pc", LIB_NAME)); - if let Ok(template) = fs::read_to_string(&pc_in) { - let target = env::var("TARGET").unwrap(); - let libs_private = native_libs(&crate_dir, &target); - - let content = template - .replace("@VERSION@", &version) - .replace("@LIBS_PRIVATE@", &libs_private); - fs::write(&pc_out, content).expect("Failed to write pkg-config file"); - } -} - -/// Read the platform's `native-libs/` list and format it for pkg-config `Libs.private`. -/// -/// CMakeLists.txt reads the same files, so the two stay in sync by construction. -fn native_libs(crate_dir: &str, target: &str) -> String { - let platform = if target.contains("apple") { - "apple" - } else if target.contains("windows") { - "windows" - } else { - "linux" - }; - - let path = PathBuf::from(crate_dir) - .join("native-libs") - .join(format!("{}.txt", platform)); - println!("cargo:rerun-if-changed={}", path.display()); - - let list = fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e)); - - list.lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(|entry| match entry.strip_prefix("framework:") { - Some(framework) => format!("-framework {}", framework), - None => format!("-l{}", entry), - }) - .collect::>() - .join(" ") } diff --git a/rs/libmoq/moq.pc.in b/rs/libmoq/moq.pc.in index fc21229333..bfa80e8c0b 100644 --- a/rs/libmoq/moq.pc.in +++ b/rs/libmoq/moq.pc.in @@ -1,6 +1,7 @@ # Paths are relative to this .pc file, in the install layout the release # tarballs and nix/overlay.nix ship: lib/{libmoq.a,pkgconfig/moq.pc} and -# include/moq.h under one prefix. +# include/moq.h under one prefix. nix/overlay.nix renders it there, and checks +# both paths resolve. libdir=${pcfiledir}/.. includedir=${pcfiledir}/../../include diff --git a/rs/libmoq/native-libs/apple.txt b/rs/libmoq/native-libs/apple.txt index a4315b8748..59751bdaf4 100644 --- a/rs/libmoq/native-libs/apple.txt +++ b/rs/libmoq/native-libs/apple.txt @@ -2,7 +2,7 @@ # targets. Cargo injects these itself for Rust consumers, but a C/C++ toolchain # linking the staticlib has to spell them out. # -# Canonical list. build.rs bakes it into moq.pc, CMakeLists.txt reads it for the +# Canonical list. nix/overlay.nix bakes it into moq.pc, CMakeLists.txt reads it for the # moq::moq target and the installed find_package config, and test/interop/interop.sh # links its C client with it. Keep the derived consumers reading this file rather # than repeating the list. diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 66688c1922..e2c8ecd884 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -306,7 +306,7 @@ prepare_c() { return } # cargo can't inject libmoq.a's native deps into an external link, so read - # them from the same list build.rs and CMake use. + # them from the same list moq.pc and CMake use. local native_libs case "$(uname -s)" in Darwin) native_libs="$WORKSPACE/rs/libmoq/native-libs/apple.txt" ;; From 61faa226d8deff14ca9adb84f99d8a96bba66054 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:11:43 -0700 Subject: [PATCH 62/87] fix(cli): schedule play decode by the earliest owed picture (#4374) Co-authored-by: Claude Opus 5.5 --- doc/bin/cli.md | 11 +- quest/m1/README.md | 1 - quest/m1/play-decode-schedule.md | 36 ---- rs/moq-cli/src/play/buffer.rs | 6 + rs/moq-cli/src/play/video.rs | 274 +++++++++++++++++++++++-------- 5 files changed, 216 insertions(+), 112 deletions(-) delete mode 100644 quest/m1/play-decode-schedule.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 811b19fb4a..217af1dbc1 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -109,10 +109,13 @@ behind it. Once the speaker owns the clock, video follows the speaker instead. Video receives encoded frames independently of decoding, so a tune-in burst can update that clock even while the window is waiting for its first picture. Encoded video is retained within the delay budget with byte accounting; a skip -resumes at a keyframe. Decoding starts at most 100 ms before presentation, and -the window holds at most three decoded pictures. A stalled window loses its -oldest picture instead of blocking reception. The configured delay therefore -does not turn into seconds of raw video surfaces. +resumes at a keyframe. Decoding starts 100 ms before the earliest picture still +owed is due, so B-frame reordering and pictures the decoder holds back are +covered however deep they go. The window holds at most three decoded pictures; +a larger decoder batch waits for room rather than pushing out pictures not yet +shown. A stalled window loses its oldest picture once a newer one is due, +instead of blocking reception. The configured delay therefore does not turn +into seconds of raw video surfaces. Each role follows the catalog for as long as it lasts. Each decoder starts at the newest cached group, including when a rendition is reopened, so playback diff --git a/quest/m1/README.md b/quest/m1/README.md index 41f9daf50a..173dbbb937 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -45,7 +45,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Track tail hardening](/quest/m1/track-tail-hardening.md) - Rust and JS wait out a track's tail by the same rules, with the known hang, count, truncation, grace, and memory holes closed - [Session death parity](/quest/m1/session-death.md) - a local close ends tracks cleanly in both languages, and JS group readers see the session's error on session death - [JS scoped routes](/quest/m1/js-scoped-routes.md) - a scoped JS origin reader announces the preferred route among those in its scope, like Rust, with the fan-out benchmarked -- [moq play decode schedule](/quest/m1/play-decode-schedule.md) - `moq play` video keeps valid pictures across rewinds, reordering deeper than 100 ms, and decoder batches larger than three - [moqsrc stop](/quest/m1/moqsrc-stop.md) - moqsrc's stop blocks until its session ends, without deadlocking on a blocked pad push - [More tests under load](/quest/m1/test-flakes-2.md) - the second round of load-only failures, fixed at the cause - [UnknownSession log flood](/quest/m1/unknown-session-logs.md) - streams reset before their WebTransport header stop being reported as UnknownSession at WARN diff --git a/quest/m1/play-decode-schedule.md b/quest/m1/play-decode-schedule.md deleted file mode 100644 index 0bc9b79b54..0000000000 --- a/quest/m1/play-decode-schedule.md +++ /dev/null @@ -1,36 +0,0 @@ -# [S] moq play decodes on a schedule that survives rewinds and deep reordering - -## Goal - -`moq play`'s video path keeps every valid picture and drops only stale ones, -across a timeline rewind, a B-frame reorder deeper than 100 ms, and a decoder -batch larger than the queue. Three Codex findings on -[#4241](https://github.com/moq-dev/moq/pull/4241) went unanswered and all -still hold in `rs/moq-cli/src/play/video.rs`: - -- After a discontinuity rewinds timestamps, pictures the decoder still holds - from the old generation pass the `< floor` check (an old 10 s picture is - above a new 0 s floor), get scheduled far ahead, and can make the cap evict - valid new pictures. -- Decode waits until `DECODE_AHEAD` (100 ms) before a frame's presentation. A - reference frame whose PTS sits far past the B-frames encoded after it - delays them past their own presentation when reordering is deeper than - that; H.264 allows a 16-frame DPB. -- `decoded()` inserts a whole batch under one lock and trims to - `MAX_FRAMES` (3), so a decode or final flush that returns more than three - pictures loses all but the newest three. - -## Plan - -- Rewind: identify stale output by generation, not timestamp order. Flush or - reset the decoder at the break and discard what it returns, or tag frames - with the generation they were submitted in. -- Decode ahead: schedule by the earliest presentation the pending access - units can still produce, or size the lead from the rendition's catalog - `jitter` (the reorder depth), rather than a fixed 100 ms. -- Batches: let the presenter drain a large batch rather than trimming it in - one step, or apply the cap only when the window is actually stalled. The - cap exists to bound memory while presentation lags, not to cut a flush tail. -- Regressions on the fake presenter/decoder rig from #4241: a rewind with - pictures held across it, a stream with reordering deeper than 100 ms, and a - flush returning more than three pictures. diff --git a/rs/moq-cli/src/play/buffer.rs b/rs/moq-cli/src/play/buffer.rs index 3c9e9599c0..9055bb2eb5 100644 --- a/rs/moq-cli/src/play/buffer.rs +++ b/rs/moq-cli/src/play/buffer.rs @@ -29,6 +29,12 @@ impl Buffer { self.waiting_keyframe = true; } + /// The earliest presentation time still buffered, wherever it sits in + /// decode order. + pub fn oldest(&self) -> Option { + self.oldest.front().copied() + } + pub fn pop(&mut self) -> Option { let frame = self.frames.pop_front()?; self.bytes -= frame.payload.len(); diff --git a/rs/moq-cli/src/play/video.rs b/rs/moq-cli/src/play/video.rs index 51a6efb441..9469c73fe5 100644 --- a/rs/moq-cli/src/play/video.rs +++ b/rs/moq-cli/src/play/video.rs @@ -1,10 +1,9 @@ //! Receive encoded video independently of the paced decoder and window. -use std::collections::VecDeque; +use std::collections::{BTreeSet, VecDeque}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use hang::moq_net; use moq_mux::container::Frame; use tokio::time::Instant; @@ -13,10 +12,36 @@ use super::output::Output; use super::timeline::Presentation; use super::window::Event; -/// A few surfaces, independent of the requested playout delay. +/// A few surfaces, independent of the requested playout delay. A codec batch +/// that overflows it waits beside the queue rather than pushing out pictures +/// the window has yet to show. pub(super) const MAX_FRAMES: usize = 3; +/// How long before the earliest owed picture is due to feed the codec. const DECODE_AHEAD: Duration = Duration::from_millis(100); +/// What the decode loop does next. +enum Step { + /// Sleep until the instant, or until the buffer or the window changes. + Wait(Option), + Decode, + /// The track ended: drain what the codec still holds. + Flush, +} + +/// When the window's queue has room for another picture, or `None` if it does +/// now. +/// +/// The window presents the newest due picture, so a full queue's oldest one +/// is skipped anyway once the next falls due. Making room then keeps a +/// stalled window from stalling decode without dropping a picture a live one +/// would show. +fn vacancy(queue: &VecDeque, presentation: &Presentation) -> Option { + if queue.len() < MAX_FRAMES { + return None; + } + queue.get(1).and_then(|frame| presentation.due(frame.timestamp)) +} + type Track = moq_mux::container::Consumer; /// The window's shared state and the subscription's encoded retention budget. @@ -89,106 +114,160 @@ impl Video { Ok::<_, anyhow::Error>(()) }; let decode = async { + // The generation the decoder's output belongs to, while it holds any. let mut generation = None; + // Presentation times fed to the decoder that it has not returned yet. + let mut held = BTreeSet::new(); + // Decoded pictures waiting for room in the window's queue. + let mut pending = VecDeque::new(); loop { - let next = { + let (current, oldest, ended) = { let buffer = buffer.lock().unwrap(); - buffer.frames.front().map(|frame| frame.timestamp) + (buffer.generation, buffer.oldest(), buffer.ended) }; - let Some(timestamp) = next else { - if buffer.lock().unwrap().ended { + if generation.is_some_and(|generation| generation != current) { + // Whatever the codec still holds sits below the new floor, so it is + // filtered on the way out and owes the window nothing. + generation = None; + held.clear(); + pending.clear(); + } + let step = { + let mut queue = self.frames.lock().unwrap(); + let presentation = self.presentation.lock().unwrap(); + let now = Instant::now().into_std(); + let mut placed = false; + while !pending.is_empty() && vacancy(&queue, &presentation).is_none_or(|at| at <= now) { + if queue.len() >= MAX_FRAMES { + queue.pop_front(); + } + let frame: moq_video::Frame = pending.pop_front().expect("checked by the loop"); + let index = queue.partition_point(|queued| queued.timestamp <= frame.timestamp); + queue.insert(index, frame); + placed = true; + } + if placed { + self.output.send(Event::Wake); + } + if !pending.is_empty() { + Step::Wait(vacancy(&queue, &presentation)) + } else if let Some(oldest) = oldest { + // Every access unit up to the earliest picture still owed must be + // decoded before that picture is due, however deep the stream + // reorders or the codec holds pictures back. + let owed = held.first().map_or(oldest, |held| oldest.min(*held)); + let at = presentation + .due(owed) + .and_then(|at| at.checked_sub(DECODE_AHEAD)) + .max(vacancy(&queue, &presentation)); + match at.filter(|at| *at > now) { + Some(at) => Step::Wait(Some(at)), + None => Step::Decode, + } + } else if !ended { + Step::Wait(None) + } else if generation.is_some() { + Step::Flush + } else { break; } - self.changed.notified().await; - continue; }; - let at = { - let frames = self.frames.lock().unwrap(); - let presentation = self.presentation.lock().unwrap(); - let mut at = presentation.due(timestamp).and_then(|at| at.checked_sub(DECODE_AHEAD)); - // A full window may lag, but a future picture still deserves its - // slot. Once due, evict it rather than blocking the live reader. - if frames.len() >= MAX_FRAMES { - at = at.max(frames.front().and_then(|frame| presentation.due(frame.timestamp))); + let frames = match step { + Step::Wait(Some(at)) => { + tokio::select! { + _ = tokio::time::sleep_until(at.into()) => {}, + _ = self.changed.notified() => {}, + } + continue; } - at - }; - if let Some(at) = at.filter(|at| *at > Instant::now().into_std()) { - tokio::select! { - _ = tokio::time::sleep_until(at.into()) => {}, - _ = self.changed.notified() => {}, + Step::Wait(None) => { + self.changed.notified().await; + continue; + } + Step::Decode => { + let frame = buffer + .lock() + .unwrap() + .pop() + .expect("no await since inspecting the buffer"); + generation = Some(current); + held.insert(frame.timestamp); + // Never race a codec call: cancelling Sink::decode poisons it. The + // joined receiver continues observing arrivals during this await. + decoder.decode(frame).await? + } + Step::Flush => { + generation = None; + held.clear(); + decoder.flush().await? } - continue; - } - let (frame, current) = { - let mut buffer = buffer.lock().unwrap(); - ( - buffer.pop().expect("no await since inspecting the front"), - buffer.generation, - ) }; - // Never race a codec call: cancelling Sink::decode poisons it. The - // joined receiver continues observing arrivals during this await. - let frames = decoder.decode(frame).await?; - generation = Some(current); - if buffer.lock().unwrap().generation == current { - self.decoded(frames, buffer.lock().unwrap().floor); + // A codec returns display order, so a picture held before the newest + // one it returned was dropped rather than delayed. + if let Some(last) = frames.iter().map(|frame| frame.timestamp).max() { + held.retain(|held| *held > last); } - } - let tail = decoder.flush().await?; - if generation == Some(buffer.lock().unwrap().generation) { - self.decoded(tail, buffer.lock().unwrap().floor); + // A codec may return pictures it held across the keyframe that + // resumed a skipped timeline. Those pictures no longer have a slot. + let floor = buffer.lock().unwrap().floor; + pending.extend( + frames + .into_iter() + .filter(|frame| floor.is_none_or(|floor| frame.timestamp.as_micros() >= floor.as_micros())), + ); } Ok::<_, anyhow::Error>(()) }; tokio::try_join!(receive, decode)?; Ok(()) } - - fn decoded(&self, frames: Vec, floor: Option) { - let mut queue = self.frames.lock().unwrap(); - for frame in frames { - // A codec may return pictures it held across the keyframe that - // resumed a skipped timeline. Those pictures no longer have a slot. - if floor.is_some_and(|floor| frame.timestamp.as_micros() < floor.as_micros()) { - continue; - } - let index = queue.partition_point(|queued| queued.timestamp <= frame.timestamp); - queue.insert(index, frame); - while queue.len() > MAX_FRAMES { - queue.pop_front(); - } - } - self.output.send(Event::Wake); - } } #[cfg(test)] mod tests { use super::super::{args::Args, fake::Recorder, media::Media}; use super::*; + use hang::moq_net; - #[derive(Default)] + /// A codec whose reorder buffer holds `depth` pictures and bumps the + /// earliest one out, the way a real decoder returns display order. struct Buffered { - pending: Option, + depth: usize, + held: Vec, decoded: Arc>>, flushed: Arc>, } + impl Default for Buffered { + fn default() -> Self { + Self::new(1) + } + } + + impl Buffered { + fn new(depth: usize) -> Self { + Self { + depth, + held: Vec::new(), + decoded: Default::default(), + flushed: Default::default(), + } + } + } + impl Decoder for Buffered { async fn decode(&mut self, frame: Frame) -> anyhow::Result> { self.decoded.lock().unwrap().push((frame.timestamp, Instant::now())); let surface = moq_video::Surface::rgba(&[128; 16 * 16 * 4], moq_video::Size::new(16, 16))?; - Ok(self - .pending - .replace(moq_video::Frame::new(surface, frame.timestamp)) - .into_iter() - .collect()) + self.held.push(moq_video::Frame::new(surface, frame.timestamp)); + self.held.sort_by_key(|frame| frame.timestamp); + let bumped = self.held.len().saturating_sub(self.depth); + Ok(self.held.drain(..bumped).collect()) } async fn flush(&mut self) -> anyhow::Result> { *self.flushed.lock().unwrap() += 1; - Ok(self.pending.take().into_iter().collect()) + Ok(std::mem::take(&mut self.held)) } } @@ -340,11 +419,7 @@ mod tests { tokio::time::advance(Duration::from_millis(1)).await; } task.await.unwrap().unwrap(); - assert_eq!( - shown, - [33, 66, 99], - "the bounded queue evicts the oldest, then presents reordered output" - ); + assert_eq!(shown, [0, 33, 66, 99], "reordered output was not presented in order"); } #[tokio::test] async fn a_discontinuity_discards_old_pictures_and_restarts_the_delay() { @@ -402,4 +477,61 @@ mod tests { assert_eq!(queued, [500], "old decoder output crossed the discontinuity"); assert_eq!(media.output.present(&media).unwrap().as_millis(), 500); } + + /// Play one burst of 33 ms pictures, given by slot in decode order, through a + /// codec holding `depth` pictures, and check the window shows every one on + /// time. + async fn schedule(order: &[u64], depth: usize) { + tokio::time::pause(); + let delay = Duration::from_secs(2); + let media = media(delay); + let track = track(order.iter().map(|&slot| frame(slot * 33, slot == 0)), delay).await; + let task = tokio::spawn(playback(&media, delay).run(track, Buffered::new(depth))); + tokio::task::yield_now().await; + let last = moq_net::Timestamp::from_millis(order.iter().max().unwrap() * 33).unwrap(); + let end = media.presentation.lock().unwrap().due(last).unwrap(); + let mut shown = Vec::new(); + while Instant::now().into_std() <= end + Duration::from_millis(10) { + if let Some(timestamp) = media.output.present(&media) { + shown.push((timestamp, Instant::now().into_std())); + } + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + } + task.await.unwrap().unwrap(); + let mut expected = order.iter().map(|slot| (slot * 33) as u128).collect::>(); + expected.sort(); + assert_eq!( + shown + .iter() + .map(|(timestamp, _)| timestamp.as_millis()) + .collect::>(), + expected, + "a picture was dropped" + ); + let presentation = media.presentation.lock().unwrap(); + for (timestamp, at) in shown { + let due = presentation.due(timestamp).unwrap(); + assert!( + at.saturating_duration_since(due) <= Duration::from_millis(1), + "{timestamp:?} was shown {:?} late", + at - due + ); + } + } + + /// A hierarchical GOP: the reference coded second is presented 264 ms after + /// the first picture, so the B-pictures coded after it need it decoded far + /// more than 100 ms before its own deadline. + #[tokio::test] + async fn reordering_deeper_than_the_decode_lead_stays_on_time() { + schedule(&[0, 8, 4, 2, 1, 3, 6, 5, 7], 3).await; + } + + /// A codec holding more pictures than the window's queue returns a tail + /// larger than the queue when it is flushed. + #[tokio::test] + async fn a_flush_larger_than_the_queue_keeps_its_tail() { + schedule(&(0..10).collect::>(), 5).await; + } } From 3d2a7bdd4dea0e4410b4c4fc67665246d6b7150d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:11:55 -0700 Subject: [PATCH 63/87] chore: audit cleanup for PRs merged 09-26..09-28 (#4373) Co-authored-by: Claude Opus 5.5 --- CONTRIBUTING.md | 4 +- doc/lib/py/index.md | 2 +- doc/lib/swift/index.md | 2 +- js/net/src/stream.test.ts | 14 ++++++ js/net/src/stream.ts | 3 ++ quest/m1/README.md | 6 ++- quest/m1/bbr-idle-burst.md | 31 ------------- quest/m1/data-capture-bindings.md | 13 ++++-- quest/m1/drain-before-close.md | 40 +++++++++++++++++ quest/m1/go-mirror-delivery.md | 2 +- quest/m1/interop-contention.md | 29 ++++++++++++ quest/m1/quic/README.md | 2 +- quest/m1/quic/bbr-app-limited.md | 60 +++++++++++++------------ quest/m1/quic/bbr-release.md | 2 +- quest/m1/raw-stream-codes.md | 16 ++++--- quest/m1/redirect-resolve.md | 5 ++- quest/m1/rs2ts/README.md | 5 ++- quest/m1/rs2ts/lite.md | 1 + quest/m1/rs2ts/sans-io/README.md | 4 +- quest/m1/rs2ts/sans-io/async-feature.md | 27 +++++++++++ quest/m1/rs2ts/sans-io/ietf.md | 2 + quest/m1/rs2ts/translator.md | 8 +++- quest/m1/stats-producer-bench.md | 33 ++++++++++++++ quest/m2/cat/present.md | 2 +- quest/m3/libmoq-cmake-lib.md | 6 ++- quest/m3/libmoq-fetch.md | 6 ++- quest/m3/libmoq-hidden.md | 6 ++- quest/m3/libmoq-shutdown.md | 6 ++- 28 files changed, 248 insertions(+), 89 deletions(-) delete mode 100644 quest/m1/bbr-idle-burst.md create mode 100644 quest/m1/drain-before-close.md create mode 100644 quest/m1/interop-contention.md create mode 100644 quest/m1/rs2ts/sans-io/async-feature.md create mode 100644 quest/m1/stats-producer-bench.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea8dd63be9..57498db7be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,7 +68,7 @@ Releases are cut separately; bump only when asked. Each package's version lives - **Rust**: release-plz owns crate versions and Rust dependency requirements. - **JavaScript**: `js/*/package.json` packages with a `scripts.release` entry (skip private ones like `@moq/clock`, `@moq/wasm`), plus the matching workspace version in `bun.lock`. -- **Python**: `py/moq-rs/pyproject.toml` only; `py/moq-ffi` follows the `moq-ffi-v*` tag and Rust crate. +- **Python**: `py/moq-rs/pyproject.toml`, plus the matching `moq-rs` entry in the root `uv.lock`; `py/moq-ffi` follows the `moq-ffi-v*` tag and Rust crate. - **Swift**: `swift/VERSION`. **Kotlin**: `moq.version` in `kt/gradle.properties`. **Dart**: `version` in `dart/moq/pubspec.yaml`. Their FFI counterparts track the Rust crate. - **Go**: `go/wrapper/VERSION` holds a human-owned `MAJOR.MINOR` line; CI derives the patch, so only edit it for a breaking API. Leave the placeholder FFI version in `go.mod` alone. -- **OBS**: the plugin takes the `libmoq` crate's version (`cpp/obs/CMakeLists.txt`), so release-plz owns it. +- **OBS**: release builds take their version from the `libmoq-v*` tag that release-plz cuts for the `libmoq` crate (`cpp/obs/build.sh --libmoq-release`), not from any manifest, so there is nothing to bump. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 109d8bab30..609f957aee 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -71,7 +71,7 @@ asyncio.run(main()) For already-encoded live output, call `audio.flush(timestamp_us)` after each `audio.write_frame` with the same broadcast-clock PTS. It samples the transport handoff for catalog jitter. File, pipe, and network imports should omit `flush`; raw-pixel and PCM encoders inside the binding measure their own output. -Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails. +Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a track from `publish_video` or `publish_video_on_track`, resume with a keyframe: a delta frame before it fails. The three advertising operations, as the other bindings spell them: `client.create_broadcast(path)` (or `OriginProducer.create_broadcast`) returns diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index f9a6f6263b..8cc2695afe 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -57,7 +57,7 @@ session.shutdown() For already-encoded live output, call `audio.flush(timestampUs:)` after `writeFrame` with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit `flush`; built-in encoders observe their own output. -Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails. +Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a track from `publishVideo`, resume with a keyframe: a delta frame before it fails. The three advertising operations: `session.publish.createBroadcast(path:)` returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / diff --git a/js/net/src/stream.test.ts b/js/net/src/stream.test.ts index b84af33f50..8ecdede0ca 100644 --- a/js/net/src/stream.test.ts +++ b/js/net/src/stream.test.ts @@ -444,6 +444,20 @@ test("Reader decode rejects a stream that ends inside a message", async () => { await expect(reader.decode(sized)).rejects.toThrow("unexpected end of stream"); }); +test("Reader refuses an oversized value even when it is already buffered", async () => { + const size = 64 * 1024 * 1024 + 1; + const buffer = new Uint8Array(4 + size); + buffer.set([0x84, 0x00, 0x00, 0x01]); // the 4-byte varint for size + await expect(new Reader(undefined, buffer).string()).rejects.toThrow("exceeds max size"); + await expect(new Reader(undefined, buffer.subarray(4)).read(size)).rejects.toThrow("exceeds max size"); +}); + +test("Reader refuses a buffered decode whose fields together exceed the max size", async () => { + const half = 32 * 1024 * 1024; + const reader = new Reader(undefined, new Uint8Array(2 * half + 1)); + await expect(reader.decode((c) => [c.read(half), c.read(half + 1)])).rejects.toThrow("exceeds max size"); +}); + /** A stream reset as a transport delivers one: the peer's code, and nothing else useful. */ class Reset extends Error { readonly source = "stream" as const; diff --git a/js/net/src/stream.ts b/js/net/src/stream.ts index 5848accad2..8093e4495c 100644 --- a/js/net/src/stream.ts +++ b/js/net/src/stream.ts @@ -446,6 +446,9 @@ export class Cursor { #ensure(size: number) { const need = this.#offset + size; + // Checked here too, and on the whole decode like the fill, since bytes that are already + // buffered never reach the fill. + if (need > MAX_READ_SIZE) throw new Error(`read size ${need} exceeds max size ${MAX_READ_SIZE}`); if (need > this.#buffer.byteLength) throw new Short(need); } diff --git a/quest/m1/README.md b/quest/m1/README.md index 173dbbb937..8b0d88c905 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -32,6 +32,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Remove finish](/quest/m1/broadcast-remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause - [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 - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack +- [Drain before close](/quest/m1/drain-before-close.md) - a closing client delivers its queued stream finishes, so `moq import` ends the catalog cleanly over a real relay - [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport - [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust @@ -47,13 +48,14 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS scoped routes](/quest/m1/js-scoped-routes.md) - a scoped JS origin reader announces the preferred route among those in its scope, like Rust, with the fan-out benchmarked - [moqsrc stop](/quest/m1/moqsrc-stop.md) - moqsrc's stop blocks until its session ends, without deadlocking on a blocked pad push - [More tests under load](/quest/m1/test-flakes-2.md) - the second round of load-only failures, fixed at the cause +- [Interop contention](/quest/m1/interop-contention.md) - two `just test interop --all` matrices pass side by side, and `just test harness` runs from a clean checkout - [UnknownSession log flood](/quest/m1/unknown-session-logs.md) - streams reset before their WebTransport header stop being reported as UnknownSession at WARN - [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main - [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container - [Accept-side flags](/quest/m1/cli-given-flags.md) - dial-only and local verbs refuse every `--listen-*` flag instead of ignoring it - [JS catalog path](/quest/m1/js-catalog-path.md) - `@moq/net` broadcast consumers expose their path and `Catalog.watch` rejects escaping references, like Rust - [Full codec string](/quest/m1/publish-codec-string.md) - browser-published video carries the encoder's full RFC 6381 codec string, so native players decode it -- [TS export jitter](/quest/m1/ts-export-jitter.md) - the video reorder bound follows later catalogs and observed reordering, so a late B-frame never reorders TS output +- [TS export jitter](/quest/m1/ts-export-jitter.md) - the video reorder bound follows later catalogs and the declared reorder depth, so a late B-frame never reorders TS output; an undeclared stream can reorder once per new maximum depth - [TS import shared shift](/quest/m1/ts-import-shared-shift.md) - unflagged loop wraps move audio and video by one shift, so A/V sync holds across wraps - [PipeWire duplicate cameras](/quest/m1/pipewire-dup-cameras.md) - a webcam lists once with PipeWire enabled - [Catalog wall clock](/quest/m1/catalog-wall-clock.md) - `Clock::wall_clock` keeps the catalog's full precision instead of truncating to milliseconds @@ -104,7 +106,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Own the QUIC stack](/quest/m1/quic/README.md) - the moq-noq fork carries ACK progress, reliable reset, hierarchical scheduling, deadlines, probing, keep-alive, peer limits, careful resume, ECN, and qmux -- [BBR idle burst](/quest/m1/bbr-idle-burst.md) - a BBRv3 burst after a long idle paces near the learned bandwidth, proven by a fork regression - [P2P](/quest/m1/p2p/README.md) - opted-in clients serve each other over data channels and iroh while the relay stays the rendezvous and the fallback, under application policy - [One port](/quest/m1/one-port/README.md) - a relay speaks QUIC, STUN, WebRTC media, and SRT on one UDP port and HTTP, RTMP, and RTMPS on one TCP port - [Signed priority](/quest/m1/signed-priority.md) - on dev, every API priority is an `i8` with 0 as the unset midpoint, and hang's built-ins sit above it @@ -120,6 +121,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [#3126](/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - moq-bench reports per-interval latency percentiles so the ramp leaves the steady state - [Relay session bench](/quest/m1/bench-relay.md) - the same scenario through moq-relay's own connection handling - [Bench coverage](/quest/m1/bench-coverage.md) - Criterion targets for moq-mux containers, the hang catalog, moq-auth verification, and moq-pattern matching +- [Stats producer bench](/quest/m1/stats-producer-bench.md) - the stats drain and encode cost per tick, swept over held paths and tiers and run nightly - [Relay profiling](/quest/m1/performance-profiles.md) - reproducible CPU and allocation captures under the existing workloads - [Browser benchmarks](/quest/m1/browser-benchmarks.md) - measure JS transport, container, decode, and render costs in an identified browser - [Generated @moq/net](/quest/m1/rs2ts/README.md) - the browser runs moq-net as TypeScript generated from the Rust source, retiring js/net's hand-written protocol and model code diff --git a/quest/m1/bbr-idle-burst.md b/quest/m1/bbr-idle-burst.md deleted file mode 100644 index 480cbad830..0000000000 --- a/quest/m1/bbr-idle-burst.md +++ /dev/null @@ -1,31 +0,0 @@ -# [S] BBR idle burst - -## Goal - -After a long keep-alive-only idle, a BBRv3 (`delay`) sender paces its next -burst near the bandwidth it learned before, not at one packet per RTT. A -regression test proves it. - -## Plan - -- The likely fix already shipped: moq-dev/noq#5 tells the controller of - starvation before the next send, released in moq-noq 1.3.1 and pinned by - #4206. The reporter ran 1.3.0. Nobody has reproduced the stall on either. -- In the fork, add a virtual-time transport test: learn the bandwidth, run - keep-alive only for about five minutes, send 250 KB, and assert the pacing - rate stays at or above about 0.9x the earlier max bandwidth. It must fail on - 1.3.0 and pass on 1.3.1. The existing label tests do not check the rate. -- If 1.3.1 still stalls, find the remaining cause (a stale `bw_shortterm` or a - ProbeRTT effect after idle) and fix it in the fork. Dropping the estimate - after long idle is a policy change for the m2 study, not this quest. -- The `iroh` feature uses upstream noq, which lacks the fix; offering it there - belongs to the upstream quest. - -## Closes - -- [#4219](https://github.com/moq-dev/moq/issues/4219) - the first send after an idle period is paced at a trickle - -## Related - -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer the starvation fix to n0-computer/noq -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - broader media measurements diff --git a/quest/m1/data-capture-bindings.md b/quest/m1/data-capture-bindings.md index 11f969227f..dcb3ecc3dd 100644 --- a/quest/m1/data-capture-bindings.md +++ b/quest/m1/data-capture-bindings.md @@ -5,7 +5,8 @@ A moq-ffi publisher, and every wrapper over it (Python, Swift, Kotlin, Go, Dart), can pass a capture time with a JSON or binary snapshot `update` or stream `append`, so its data tracks advertise `delay` and `jitter` like a Rust -publisher's. Leaving it out keeps today's behaviour. `moq-json`'s `window` +publisher's. Leaving it out keeps today's behaviour. The Go and Python +wrappers gain the binary snapshot and stream producers they lack. `moq-json`'s `window` producer takes a capture time too. Settled scope: moq-ffi and its wrappers, not libmoq. @@ -30,13 +31,19 @@ not libmoq. stream producers do. Nothing in `moq-mux` publishes window mode, so there is no estimator to feed. - Wrappers follow per the cross-package sync table, each with a test that a - past capture time is accepted and a future one refused. Update + past capture time is accepted and a future one refused. +- Go (`go/wrapper/moq`) and Python (`py/moq-rs`) wrap only the JSON + producers; [#4137](https://github.com/moq-dev/moq/pull/4137) added + `publish_binary_snapshot` / `publish_binary_stream` to moq-ffi without + them. Add hand-written binary wrappers there, capture time included, so + the capture tests cover binary too. The maintainer asked for this. Update `doc/lib/{py,swift,kt,go,dart}`. Public API: breaking, so it lands on `dev`. A new parameter on the generated `update` and `append` breaks every published binding caller (Go, for one, has no optional arguments), and a `_with_x` twin is ruled out. The broadcast clock -`now()` is additive; `window::Producer::push` accepts `Timed`, source-compatible. +`now()` and the Go and Python binary producers are additive; +`window::Producer::push` accepts `Timed`, source-compatible. Wire: none. ## Related diff --git a/quest/m1/drain-before-close.md b/quest/m1/drain-before-close.md new file mode 100644 index 0000000000..f24ff82b57 --- /dev/null +++ b/quest/m1/drain-before-close.md @@ -0,0 +1,40 @@ +# [M] Deliver queued stream data before a client closes + +## Goal + +A process that finishes its tracks and then closes its `moq_tokio::Client` +delivers what it already queued, including each stream's FIN, before the +connection closes, bounded by a deadline. `moq import` at stdin EOF is the +consumer: a subscriber over a real relay sees the catalog finish instead of +`Error::Dropped`. + +## Plan + +- [#4303](https://github.com/moq-dev/moq/pull/4303) made `moq import` finish + the catalog at EOF, but only in-process: over a real session the process + exits and the close discards the queued finish. + [#4287](https://github.com/moq-dev/moq/pull/4287) added `Client::close`, + which sends the CONNECTION_CLOSE but does not wait for stream data. +- A QUIC close discards unacknowledged stream data, so the session has to + wait until its open send streams are written, finished, and acknowledged, + not only handed to the transport. The pending group and control writes live + in moq-net's session tasks; the transport wait lives in moq-tokio. +- Bound the wait so a stalled peer cannot hang an exiting process. Share the + deadline and the graceful path with + [Session close](/quest/m1/session-close.md), which waits for announce + acknowledgements the same way; `abort` and drop stay immediate. +- Cover every backend `Client::close` covers, and say which do not drain + (WebSocket and iroh end on drop today). +- Regression tests: a moq-tokio test, shaped like + `noq_client_close_reaches_server`, where the client finishes a track and + closes on a runtime dropped right after, and the server's subscriber reads + the finish rather than a drop. Then a CLI test piping a file through + `moq import` to a relay. + +Public API: likely additive (`Client::close` gains the drain, or a graceful +close sits beside `abort`). Wire: none. + +## Related + +- [Session close](/quest/m1/session-close.md) - the graceful end that withdraws announces +- [Graceful relay drains](/quest/m1/drain/README.md) - the server-side drain over GOAWAY diff --git a/quest/m1/go-mirror-delivery.md b/quest/m1/go-mirror-delivery.md index 4575713a8b..df439a1d7b 100644 --- a/quest/m1/go-mirror-delivery.md +++ b/quest/m1/go-mirror-delivery.md @@ -5,7 +5,7 @@ A recommendation, with a prototype, for delivering the Go binding's staticlibs without committing them to git. `moq-dev/moq-go-ffi` commits them straight into git today, at 60.2 MiB for linux, 52.7 for windows, and -39.1 for darwin. That puts the largest file at 60% of GitHub's 100 MB push +39.1 for darwin. That puts the largest file at 60% of GitHub's 100 MiB per-file limit, and each release adds about 210 MiB of history. ## Plan diff --git a/quest/m1/interop-contention.md b/quest/m1/interop-contention.md new file mode 100644 index 0000000000..f1bb5fe6e3 --- /dev/null +++ b/quest/m1/interop-contention.md @@ -0,0 +1,29 @@ +# [S] Interop matrices run side by side + +## Goal + +Two `just test interop --all` matrices can run at once on one machine, even +from one checkout, and both pass. `just test harness` runs from a clean +checkout. + +## Plan + +[#4228](https://github.com/moq-dev/moq/pull/4228) fixed the port +reservations and the canvas-blocked pause click, and stages Go per run. What +remains: + +- Under two concurrent matrices, `python -> js` and `go -> js` sometimes stall + the browser subscriber: the Pause control never appears within 30 s. It + was not seen in a single run. Find the cause (CPU starvation of headless + Chromium, a shared resource, or a real stall) before touching timeouts. +- `prepare_python` runs `just py build` in the workspace, so concurrent runs + rewrite the same `.venv` and maturin output (Codex on #4228). Build into the + run directory, as Go now does, or serialize the build. +- `just test harness` runs `harness.browser.ts` without installing workspace + dependencies or Playwright Chromium, so it only passes where CI's earlier + step prepared them. Provision them in the recipe, or reuse the path + `interop.sh` already takes. +- Prove it by running two `--all` matrices at once, several times, from one + checkout. + +Public API: none. Wire: none. diff --git a/quest/m1/quic/README.md b/quest/m1/quic/README.md index 7280d2ff8f..81e79f3342 100644 --- a/quest/m1/quic/README.md +++ b/quest/m1/quic/README.md @@ -53,7 +53,7 @@ This is a transport API change, not a MoQ wire change. - [Preserve QUIC packet identity in BBR](/quest/m1/quic/bbr-packet-identity.md) - ACKs and losses identify the right packet across QUIC spaces - [Finish each BBR ACK sample before using it](/quest/m1/quic/bbr-ack-sampling.md) - current delivery samples reach the model once with consistent metadata -- [Mark application starvation before the next BBR send](/quest/m1/quic/bbr-app-limited.md) - resumed bursts retain correct sample labels +- [BBR idle burst](/quest/m1/quic/bbr-app-limited.md) - a fork regression proves a burst after a long idle is paced at the learned bandwidth, closing #4219 - [Finish BBR bandwidth-probe feedback once](/quest/m1/quic/bbr-probe-feedback.md) - cruise rounds neither age probe history repeatedly nor retain probe-loss classification - [Recalibrate BBR startup pacing from measured RTT](/quest/m1/quic/bbr-startup-pacing.md) - measured RTT replaces the nominal startup rate for media senders - [Protect bandwidth samples during BBR ProbeRTT](/quest/m1/quic/bbr-probe-rtt.md) - intentionally reduced sending cannot masquerade as reduced capacity diff --git a/quest/m1/quic/bbr-app-limited.md b/quest/m1/quic/bbr-app-limited.md index dca67a99a8..ea26b55a4a 100644 --- a/quest/m1/quic/bbr-app-limited.md +++ b/quest/m1/quic/bbr-app-limited.md @@ -1,38 +1,40 @@ -# [M] Mark application starvation before the next BBR send +# [S] Prove a BBR burst after a long idle is paced at the learned bandwidth ## Goal -Packets sent after application starvation carry the correct historical -application-limited label, even when no ACK arrives during the idle gap. -The bandwidth model cannot mistake a source-limited sample for capacity. +After minutes of keep-alive-only idle, a BBRv3 (`delay`) sender paces its +next burst near the bandwidth it learned before, not at a trickle, and a +regression test in the fork proves it. ## Plan -In noq `1a26a8b064d21e316fe6769f068617975bd8a27b`, an empty unblocked -[transmit poll](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/connection/mod.rs#L1337) records starvation, but BBR receives the marker only in -[on_end_acks](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1732). -A resumed send can be stamped before that notification. Google's -[QUICHE BBR3](https://github.com/google/quiche/blob/535a2730e77d47e0dc03746555cc9c34b17bc9e9/quiche/quic/core/congestion_control/bbr3_sender.cc#L505) notifies its sampler immediately when application limited. - -Reproduce through the transport boundary: send and ACK one 1200-byte packet -with a 10-ms RTT, run an empty unblocked transmit poll, wait until 30 ms to -send another packet, then ACK it 10 ms later. There is no intervening ACK -to notify the controller of starvation. The existing public-callback -reproduction yields a non-limited sample; preserve a failing regression -without privately seeding the sampler marker. This establishes a label bug, -not a measured throughput regression. - -Communicate starvation before subsequent sends, preserving the delivery -boundary that ends the sampler's limited phase. Distinguish producer -starvation from cwnd, pacing, anti-amplification, receiver credit, and local -buffer limits; do not silently change receiver-limited policy. Cover streams, -datagrams, resumed backlog, repeated empty polls, and ACK batching. Coordinate -any controller event changes with the packet identity and ACK sampling fixes. -Keep state private where possible; document any public Controller change and -its consumers. No wire change is intended. Wire regressions into fork CI. +moq-dev/noq#5 (in moq-noq 1.3.1; main pins 1.3.2) fixed the label bug this +quest was opened for. The transport calls `Controller::on_app_limited` on +every empty poll that nothing held back, and BBR marks starvation before the +next send. Its tests cover streams, datagrams, batched ACKs, and backlogs +held by the window or the pacer. What is left is +[#4219](https://github.com/moq-dev/moq/issues/4219), reported on 1.3.0 and +not yet reproduced on either version: the first 250 KB after five idle +minutes took 5.2 s at 50 ms RTT, against 0.5 s with CUBIC. + +The fix plausibly covers it. The fork's max-bw filter ages only on +non-app-limited samples, and before #5 every other keep-alive was stamped +non-app-limited. Nothing measures it, though. Add a virtual-time transport +test in the fork: learn the bandwidth, idle on keep-alives for about five +minutes, send 250 KB, and assert the pacing rate stays at or above about +0.9x the earlier max bandwidth. Check that it fails on 1.3.0. + +If it still stalls, find the remaining cause, such as ProbeRTT entered during +the idle or a stale `bw_shortterm`, and fix it in the fork. Dropping the +estimate after a long idle is a policy change for the m2 study, not this +quest. The `iroh` feature uses upstream noq, which lacks #5; offering it +there belongs to the upstream quest. + +## Closes + +- [#4219](https://github.com/moq-dev/moq/issues/4219) - the first send after an idle period is paced at a trickle ## Related -- [Finish each BBR ACK sample](/quest/m1/quic/bbr-ack-sampling.md) - a separate ordering defect in the same callback lifecycle -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver correct labels before policy experiments -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer the general fix upstream +- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - ships any remaining fix +- [Upstream the fork](/quest/m1/quic/upstream.md) - offer the starvation fix upstream diff --git a/quest/m1/quic/bbr-release.md b/quest/m1/quic/bbr-release.md index 8966e3ae3d..41365f1707 100644 --- a/quest/m1/quic/bbr-release.md +++ b/quest/m1/quic/bbr-release.md @@ -24,7 +24,7 @@ and Google comparison do not gate these bug fixes. - [Preserve QUIC packet identity in BBR](/quest/m1/quic/bbr-packet-identity.md) - [Finish each BBR ACK sample before using it](/quest/m1/quic/bbr-ack-sampling.md) -- [Mark application starvation before the next BBR send](/quest/m1/quic/bbr-app-limited.md) +- [BBR idle burst](/quest/m1/quic/bbr-app-limited.md) - the starvation fix shipped in moq-noq 1.3.1; any remaining idle cause ships here - [Finish BBR bandwidth-probe feedback once](/quest/m1/quic/bbr-probe-feedback.md) - [Recalibrate BBR startup pacing from measured RTT](/quest/m1/quic/bbr-startup-pacing.md) - [Protect bandwidth samples during BBR ProbeRTT](/quest/m1/quic/bbr-probe-rtt.md) diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md index a4c1b517da..7338196d3e 100644 --- a/quest/m1/raw-stream-codes.md +++ b/quest/m1/raw-stream-codes.md @@ -23,12 +23,16 @@ on stream errors. WebTransport sessions keep the mapping they need. crates depend on crates.io releases, never a patch. A moq-tokio test over `moqt://` asserts a reset code arrives verbatim, beside `close_code.rs`. - Mixed versions: two moq peers on raw QUIC agree today because both map, and - wire changes must stay compatible with published versions. A fixed peer can - read both forms, since a mapped code lands in the HTTP/3 WebTransport range - that no application code reaches. An older peer misreads a fixed peer's raw - codes. Check which codes moq-net acts on (group stream resets, subscribe - STOP_SENDING): if any drives behaviour beyond reporting, this is a wire - break and retargets to `dev`. + wire changes must stay compatible with published versions. An older peer + still sends `error_to_http3(code)`, so a fixed peer that only skips the + mapping reports the large HTTP/3 value, not the code. The legacy form is + detectable, since it lands in the HTTP/3 WebTransport range that no moq + code reaches (`error_from_http3` returns `None` outside it), so the raw + receive path unmaps a code in that range and passes the rest through, with + a legacy-sender test per adapter. The other direction has no fix at the + receiver: an older peer misreads a fixed peer's raw codes. Check which codes + moq-net acts on (group stream resets, subscribe STOP_SENDING): if any drives + behaviour beyond reporting, this is a wire break and retargets to `dev`. Public API: none expected. Wire: raw QUIC stream error codes become the application's own values; compatible only if older peers merely report them. diff --git a/quest/m1/redirect-resolve.md b/quest/m1/redirect-resolve.md index 5c3f7e56d9..7ee9ef7014 100644 --- a/quest/m1/redirect-resolve.md +++ b/quest/m1/redirect-resolve.md @@ -19,8 +19,9 @@ the connection it documents. Recommendation: make it private. Nothing outside `moq-tokio` calls it (only its own unit tests), and the repo keeps things private until a consumer needs them. If a consumer turns up, the alternative is returning the same -`Result>` as the internal `target`, so empty and refused stay -distinct. Removing or changing a published method is a break, so this +`Result>` as the internal `target` does once the drain line lands +(on `main` it is still `Option`, folding a refusal into "keep the +current addresses"), so empty and refused stay distinct. Removing or changing a published method is a break, so this targets `dev`; update `doc/lib/rs` if it mentions the method. ## Required diff --git a/quest/m1/rs2ts/README.md b/quest/m1/rs2ts/README.md index e24e930580..a075224ac0 100644 --- a/quest/m1/rs2ts/README.md +++ b/quest/m1/rs2ts/README.md @@ -69,7 +69,10 @@ js/net it replaces, measured with the [browser benchmarks](/quest/m1/browser-ben - [#2822](https://github.com/moq-dev/moq/issues/2822) - close this issue when the quest finishes - [#2835](https://github.com/moq-dev/moq/issues/2835) - close this issue when the quest finishes +## Required + +- [Browser benchmarks](/quest/m1/browser-benchmarks.md) - the harness the no-downgrade report uses + ## Related - [#2850](/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md) - the same synchronous decode shape, in hand-written js/net today -- [Browser benchmarks](/quest/m1/browser-benchmarks.md) - the harness the no-downgrade report uses diff --git a/quest/m1/rs2ts/lite.md b/quest/m1/rs2ts/lite.md index 6cade8266c..256dd08783 100644 --- a/quest/m1/rs2ts/lite.md +++ b/quest/m1/rs2ts/lite.md @@ -28,4 +28,5 @@ Public API: breaks `@moq/net`; retargets to `dev`. Wire: none. - [rs2ts](/quest/m1/rs2ts/translator.md) - the translator - [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the session shape it translates - [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - the model shape it translates +- [The async feature](/quest/m1/rs2ts/sans-io/async-feature.md) - rs2ts reads moq-net without it - [Mock-clock tests](/quest/m1/rs2ts/mock-clock.md) - the tests that prove parity diff --git a/quest/m1/rs2ts/sans-io/README.md b/quest/m1/rs2ts/sans-io/README.md index de6cbe90c8..03a4cfb82b 100644 --- a/quest/m1/rs2ts/sans-io/README.md +++ b/quest/m1/rs2ts/sans-io/README.md @@ -13,11 +13,11 @@ reimplements the async helpers natively with Promises, so the translator reads the crate without the `async` feature. Split by layer so each lands on `dev` independently. -This README's own work is the `async` feature and the CI lane that builds -moq-net without it. +The line has no work of its own beyond its children. ## Required - [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the lite session is driven by bytes, stream events, and `tick(now)` - [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - origin, broadcast, track, and group handles run without a runtime, with time supplied by the caller +- [The async feature](/quest/m1/rs2ts/sans-io/async-feature.md) - the async helpers sit behind an `async` feature and a CI lane builds and tests moq-net without it - [Sans-IO IETF session](/quest/m1/rs2ts/sans-io/ietf.md) - the moq-transport session is driven the same way as lite diff --git a/quest/m1/rs2ts/sans-io/async-feature.md b/quest/m1/rs2ts/sans-io/async-feature.md new file mode 100644 index 0000000000..f7b7e35046 --- /dev/null +++ b/quest/m1/rs2ts/sans-io/async-feature.md @@ -0,0 +1,27 @@ +# [S] moq-net's async helpers sit behind a feature + +## Goal + +moq-net's async helper methods sit behind an `async` cargo feature, and a CI +lane builds and tests the crate without it, so rs2ts reads the no-runtime +crate that [Generated lite](/quest/m1/rs2ts/lite.md) translates. + +## Plan + +- The feature is on by default, so Rust callers see no change. JS + reimplements the helpers with Promises over the poll API. +- Generated lite needs the lite session and the model without the feature, + not IETF. Until the [Sans-IO IETF session](/quest/m1/rs2ts/sans-io/ietf.md) + lands, the IETF session can sit behind the feature too; that quest then + moves it out. +- The lane runs at least the tests that do not exercise the helpers; tests + that do stay behind the feature. + +Public API: moq-net's async helpers move behind a default feature, so a +`default-features = false` caller loses them; lands on `dev` with the line. +Wire: none. + +## Required + +- [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the session builds without a runtime +- [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - the model builds without a runtime diff --git a/quest/m1/rs2ts/sans-io/ietf.md b/quest/m1/rs2ts/sans-io/ietf.md index df8fad3ab9..542c1965a1 100644 --- a/quest/m1/rs2ts/sans-io/ietf.md +++ b/quest/m1/rs2ts/sans-io/ietf.md @@ -11,6 +11,8 @@ Follow whatever shape the lite session settles on. The IETF code is the largest module (about 12.7k non-test lines) and today compiles part of itself twice (for `Session` and `ControlStreamAdapter`); collapse that while here. +If the [async feature](/quest/m1/rs2ts/sans-io/async-feature.md) landed +first with the IETF session behind it, move the session out. Public API: breaks moq-net's IETF session API; retargets to `dev`. Wire: none. diff --git a/quest/m1/rs2ts/translator.md b/quest/m1/rs2ts/translator.md index 1eecf983f1..8459752e54 100644 --- a/quest/m1/rs2ts/translator.md +++ b/quest/m1/rs2ts/translator.md @@ -19,6 +19,12 @@ Mapping decided in planning: - Structs become classes, enums discriminated unions, traits interfaces. `Option` is `T | undefined`, `&[u8]` a `Uint8Array` view with no copy. + That collapses a nested `Option`, whose states the source relies on: + `model/track.rs::first_start` returns `Option>` to tell + "no successor" from an unstamped one, and `reach` behaves differently for + each. Recommendation: the subset lint rejects nested `Option`, and the source + names those states with an enum; a tagged TypeScript form for the inner + `Option` is the alternative. Either way nested states never merge silently. - `Drop` becomes an explicit `drop()` at each MIR drop point, exposed as `[Symbol.dispose]`; `Arc`/`Rc` of a type with drop glue become an explicit refcount. JS is single-threaded, so `Mutex` and atomics become plain @@ -37,7 +43,7 @@ Guidance: - Readability pass: inline single-use temporaries and keep source branch order, so a reviewer can read a generated diff. - Add a lint on moq-net (clippy or dylint) for the accepted subset: no - `unsafe`, no `async` outside the `async` feature, no `u64` bit operations, + `unsafe`, no `async` outside the `async` feature, no nested `Option`, no `u64` bit operations, no trait impls on foreign or primitive types, no `&mut` out-params to scalar or `Option` locals. Translator gaps become compile errors, not runtime `todo()`s. Document the subset in `rs/rs2ts/README.md`. diff --git a/quest/m1/stats-producer-bench.md b/quest/m1/stats-producer-bench.md new file mode 100644 index 0000000000..869404f06b --- /dev/null +++ b/quest/m1/stats-producer-bench.md @@ -0,0 +1,33 @@ +# [S] Stats producer fan-out benchmark + +## Goal + +A `moq-stats` benchmark measures what one relay pays per stats tick to drain +its registry and encode the traffic tracks, swept over held paths and tiers, +so a cost that grows with the whole table instead of the paths that changed +shows up as a slope. It runs at least nightly. + +## Plan + +- [#4299](https://github.com/moq-dev/moq/pull/4299) keeps an idle path in + every frame while the registry holds its counters, adding about 430 B per + idle path to each plain frame (maintainer's note on the PR). Nothing + measures what that, or the per-tick drain, costs as held paths grow. + `rs/moq-stats/benches/decode.rs` covers only the reader. +- Sweep held paths (for example 100 to 50k) against tiers, and the share of + paths that are idle versus changed this tick. Report time and allocations + per tick, and plain and compressed bytes per frame, the way `decode.rs` + reports its table. Include the point where a plain frame nears its size + cap, since a frame that is too large leaves stale counters behind. +- Drive the real producer path (`process_slot`, the snapshot encoders) over + a synthetic `Registry`, not a re-implementation of it. Exposing a bench + hook is fine if it stays out of the public API. +- `decode.rs` is not in CI either. Run both targets once in the nightly + benchmark smoke, as `moq-net`'s are. + +Public API: none. Wire: none. + +## Related + +- [Binary delta stats flavor](/quest/m2/stats-delta.md) - its gate needs the encode baseline this measures +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - compares these targets across PRs once it lands diff --git a/quest/m2/cat/present.md b/quest/m2/cat/present.md index 82d11a0334..8fcd518586 100644 --- a/quest/m2/cat/present.md +++ b/quest/m2/cat/present.md @@ -30,7 +30,7 @@ the option fails loud instead of dropping the credential. - `moq` CLI publish and subscribe get the flag through the shared connect config; `doc/bin/cli.md` and `doc/lib/rs/moq-net.md` gain it. - Tests: a Rust server's `Handshake::token()` sees kind `0x01` and the - bytes on every draft from both a Rust and a JS client (a JS server is out + bytes on every draft that carries the setup option, from both a Rust and a JS client (a JS server is out of scope: #4278 added no JS accept-side API, since nothing in `js/net` authorizes an IETF session); a JWT rides its AUTH stream and not the URL when a CAT holds the option; a lite offer with a CAT refuses at init; end to end against `moq auth serve` with a CAT from diff --git a/quest/m3/libmoq-cmake-lib.md b/quest/m3/libmoq-cmake-lib.md index 9a9887f2fe..ddd27b83d8 100644 --- a/quest/m3/libmoq-cmake-lib.md +++ b/quest/m3/libmoq-cmake-lib.md @@ -8,8 +8,8 @@ Today `BUILD_RUST_LIB` assumes `target//libmoq.a`, so any other layout links a stale library or fails to find one. ## Plan -Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. +Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. - `cmake/cargo-build.cmake` already parses cargo's JSON messages to find `moq.h` in its hashed `OUT_DIR`. Read the libmoq `compiler-artifact` @@ -24,6 +24,10 @@ Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-writ the build. Update `rs/libmoq/README.md` and `doc/lib/c` if they describe the path. +## Required + +- The maintainer keeps the hand-written libmoq instead of retiring it in the Generated C bindings line + ## Related - [libmoq shutdown](/quest/m3/libmoq-shutdown.md) - the other libmoq packaging fix OBS needs diff --git a/quest/m3/libmoq-fetch.md b/quest/m3/libmoq-fetch.md index a6c8430696..89ae92e986 100644 --- a/quest/m3/libmoq-fetch.md +++ b/quest/m3/libmoq-fetch.md @@ -6,8 +6,8 @@ A C embedder can fetch one cached group by sequence through the existing frame, handle, and terminal-status conventions. The new entry point is additive. ## Plan -Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. +Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. Mirror `MoqTrackConsumer::fetch_group` from `rs/moq-ffi/src/consumer.rs` in `rs/libmoq`, supporting raw and container-decoded delivery as the FFI does. @@ -18,3 +18,7 @@ Regenerate `moq.h` and update `doc/lib/c/index.md`, whose capability list alread claims group fetch. Decoder output configuration landed separately on the dev line. The `moq_group_request_*` tests in `rs/libmoq/src/test.rs` fetch from Rust for lack of this entry point; switch them to it. + +## Required + +- The maintainer keeps the hand-written libmoq instead of retiring it in the Generated C bindings line diff --git a/quest/m3/libmoq-hidden.md b/quest/m3/libmoq-hidden.md index 4095d694f6..87be221f7e 100644 --- a/quest/m3/libmoq-hidden.md +++ b/quest/m3/libmoq-hidden.md @@ -8,9 +8,13 @@ prefix) the way every other binding can: `moq_origin_announced` takes a caller can only list them by naming the dot segment in `prefix`. ## Plan -Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. +Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. - Adding a parameter breaks the C ABI, so this lands on `dev`. - Update `rs/libmoq/src/{api,origin}.rs`, the libmoq tests, `cpp/obs` if it calls `moq_origin_announced`, and `doc/lib/c/index.md`. + +## Required + +- The maintainer keeps the hand-written libmoq instead of retiring it in the Generated C bindings line diff --git a/quest/m3/libmoq-shutdown.md b/quest/m3/libmoq-shutdown.md index 8b4cc4870e..5fa707262b 100644 --- a/quest/m3/libmoq-shutdown.md +++ b/quest/m3/libmoq-shutdown.md @@ -10,8 +10,8 @@ libmoq statically, so the thread's code is unmapped under it. Any host that unloads the library has the same exposure. ## Plan -Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. +Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. Starts on `main`; libmoq's runtime (`rs/libmoq/src/ffi.rs`) is the same on both branches and the change is additive. @@ -49,6 +49,10 @@ both branches and the change is additive. Public API: additive on the libmoq C ABI (`moq_shutdown`). Wire: none. +## Required + +- The maintainer keeps the hand-written libmoq instead of retiring it in the Generated C bindings line + ## Related - [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - the same hazard class for the moq-ffi bindings From 5bd12e1afd9b1737056ff3654b9534fa8376291e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:16:09 -0700 Subject: [PATCH 64/87] fix(cli): refuse a client CA under --auth-public on a listener (#4364) Co-authored-by: Claude Opus 5.5 --- doc/bin/cli.md | 5 ++++- doc/bin/relay/auth.md | 4 ++-- rs/moq-cli/src/args.rs | 16 ++++++++++++++++ rs/moq-relay/src/auth.rs | 11 +++++++++++ rs/moq-relay/src/relay.rs | 13 +++---------- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 217af1dbc1..c9371c78ab 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -39,7 +39,10 @@ moq fetch [options] The **MoQ side** goes first and attaches the process to the network: `--connect ` dials a relay (the path is the auth path, `?jwt=` carries a token), and `--broadcast ` names the broadcast. A process can -instead host sessions with `--listen`, or both at once. `moq import --help` lists the sources and `moq import rtmp --help` a specific one. +instead host sessions with `--listen`, or both at once. A listener admits +clients by `--auth-url` or `--auth-public`, as the relay does (see +[Authentication](/bin/relay/auth)); public rules ignore certificates, so +`--auth-public` refuses to start with `--listen-tls-root`. `moq import --help` lists the sources and `moq import rtmp --help` a specific one. ```bash # Publish a file (remux to MPEG-TS without re-encoding) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index a8125ce6eb..096a5159c8 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -240,8 +240,8 @@ bad chain still fails there. What the certificate admits is the server's decision: the relay reports its facts in the request's `tls` and enforces the grant it gets back. `moq auth serve` grants a certificate only what `--mtls-publish` and `--mtls-subscribe` name, empty by default. Public rules -ignore certificates, so a relay on `--auth-public` refuses to start with -`listen.tls.root` or `web.https.root`. +ignore certificates, so a relay or `moq --listen` on `--auth-public` refuses +to start with `listen.tls.root` or `web.https.root`. Cluster peers are admitted the same way, so a mesh runs `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index 7a8f9f3780..8c3d26d1b9 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -465,6 +465,7 @@ impl MoqSide { } else if self.auth.url.is_some() || self.auth_public() { self.auth.validate()?; } + self.auth.validate_client_ca(!self.server.tls.root.is_empty())?; Ok(()) } @@ -1131,6 +1132,21 @@ mod tests { ); } + /// Public rules grant a certificate what they grant anyone, so a client CA on + /// a public listener refuses to start, as it does on the relay. + #[test] + fn a_client_ca_needs_an_auth_server() { + let parse = |auth: [&str; 2]| { + let mut argv = vec!["moq", "--listen-tcp-bind", "127.0.0.1:0", "--listen-tls-root", "ca.pem"]; + argv.extend(auth); + argv.extend(["import", "ts"]); + Invocation::try_parse_from(argv).expect("parse") + }; + let err = parse(["--auth-public", "**"]).moq.validate().unwrap_err().to_string(); + assert!(err.contains("--auth-public ignores"), "{err}"); + assert!(parse(["--auth-url", "http://127.0.0.1:4440/"]).moq.validate().is_ok()); + } + /// A listener for ordinary clients admits nobody without a decision, so it /// refuses to start with neither flag, and with both. #[test] diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index bd610e90b6..018086750c 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -273,6 +273,17 @@ impl Config { } } + /// Refuse a client CA under public rules, which grant a certificate what they + /// grant anyone. `client_ca` is whether any listener verifies client certificates. + pub fn validate_client_ca(&self, client_ca: bool) -> anyhow::Result<()> { + if client_ca && self.url.is_none() && self.public_grant().is_some() { + anyhow::bail!( + "a client CA (--listen-tls-root, --web-https-root) verifies client certificates, which --auth-public ignores; remove it, or grant certificates with --auth-url to `moq auth serve --mtls-*`" + ); + } + Ok(()) + } + /// Build the [`Auth`] this configuration describes. `tls` is the client /// identity an `https://` server is dialed with; `node` names this relay in /// every request. Must be called within a Tokio runtime, which drives the diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index 5670a7a821..cdf037dac9 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -123,16 +123,9 @@ impl Relay { .clone() .or_else(|| config.cluster.node.clone()) .unwrap_or_default(); - // Public rules grant a certificate what they grant anyone, so a client CA - // would only have browsers offer certificates for nothing. - if config.auth.url.is_none() - && config.auth.public_grant().is_some() - && !(config.listen.tls.root.is_empty() && config.web.https.root.is_empty()) - { - anyhow::bail!( - "--listen-tls-root and --web-https-root verify client certificates, which --auth-public ignores; remove them, or grant certificates with --auth-url to `moq auth serve --mtls-*`" - ); - } + config + .auth + .validate_client_ca(!(config.listen.tls.root.is_empty() && config.web.https.root.is_empty()))?; // No `[auth]` source means the embedder decides: it takes the admissions // before `run`, which refuses to start if nobody did. let (auth, admissions) = match config.auth.is_empty() { From 3863a4393d77db02bb9bf1b05a54c1d434464618 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:21:29 -0700 Subject: [PATCH 65/87] ci: keep the shared Rust cache inside the 10 GB budget (#4390) Co-authored-by: Claude Opus 5.5 --- .github/actions/rust-cache/action.yml | 4 ++-- .github/workflows/cache.yml | 20 ++++++++++++++++---- .github/workflows/interop.yml | 3 +++ .github/workflows/nightly.yml | 8 ++++---- .github/workflows/release-dart-ffi.yml | 6 ++---- .github/workflows/release-go-ffi.yml | 14 ++++---------- .github/workflows/release-kt-ffi.yml | 8 ++++---- .github/workflows/release-kt-lib.yml | 4 ++-- .github/workflows/release-swift-ffi.yml | 8 ++++---- .github/workflows/wasm.yml | 6 +++--- 10 files changed, 44 insertions(+), 37 deletions(-) diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 902eb34822..572c730101 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -38,8 +38,8 @@ runs: # even though it is kept in sync with the flake. # # Every segment the generated layout carries has to be carried here too: - # the runner pair, so the two architectures the warm job builds cannot - # read each other, and `target`, so changing `github-cache-mode` cannot + # the runner pair, so a job on an x86 runner cannot restore the ARM64 + # store, and `target`, so changing `github-cache-mode` cannot # restore the other payload. The trailing run id rolls the key on every # run, which is what lets a repeated `workflow_dispatch` seed a cold # cache against GitHub's immutable entries. diff --git a/.github/workflows/cache.yml b/.github/workflows/cache.yml index 49d77e8adc..6e1a129122 100644 --- a/.github/workflows/cache.yml +++ b/.github/workflows/cache.yml @@ -5,8 +5,14 @@ name: Cache # # Actions scopes cache reads to the current branch plus the default branch, so # only caches written here on `main` are reachable from a pull request. This -# workflow is therefore the single trusted writer, with one store per runner -# architecture. The action refuses to publish from a pull request regardless. +# workflow is therefore the single trusted writer. The action refuses to publish +# from a pull request regardless. +# +# The Rust store is ARM64 only, because every job that restores it runs there. +# Each store is about 6 GB against the repository's 10 GB budget, and Actions +# evicts least recently used first, so an x86 store nobody on a pull request +# read was enough to push out the one they all do. The x86 leg still pushes its +# dev shell, which the x86 jobs substitute. # # This runs the unscoped suite rather than the diff-scoped one: the point is to # leave artifacts for whatever a future PR happens to touch, not to check `main` @@ -45,7 +51,11 @@ jobs: strategy: fail-fast: false matrix: - runner: [ubuntu-24.04-arm, ubuntu-latest] + include: + - runner: ubuntu-24.04-arm + rust: true + - runner: ubuntu-latest + rust: false runs-on: ${{ matrix.runner }} timeout-minutes: 60 @@ -96,6 +106,7 @@ jobs: # `workflow_dispatch` above prime a cold store; the action otherwise saves # only on pushes to the default branch. - name: Rust cache + if: matrix.rust uses: ./.github/actions/rust-cache with: save-on-dispatch: true @@ -104,12 +115,13 @@ jobs: # and the test artifacts (codegen + linked test binaries). They share # little, and a PR usually needs both. - name: Check + if: matrix.rust run: nix develop --command just ci check --all env: MOQ_STRICT: 1 - name: Test - if: ${{ !cancelled() }} + if: ${{ matrix.rust && !cancelled() }} run: nix develop --command just ci test --all env: MOQ_STRICT: 1 diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 9302e8b973..6246ef393a 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -73,6 +73,9 @@ jobs: uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 with: cache-on-failure: true + # A pull request's entry is scoped to its own branch, where no later + # run reads it, while it spends the budget the shared store needs. + save-if: ${{ github.event_name != 'pull_request' }} # Playwright's Chromium isn't in the nix devShell. Install it plus its apt # runtime libs (`--with-deps` needs the host package manager) so the diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 546458adae..377e7f235a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -57,10 +57,10 @@ jobs: with: determinate: false - - name: Rust Cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - cache-on-failure: true + # Restore only, like `tests` below. Saving its own store cost 3.6 GB of the + # repository's 10 GB budget and evicted the one every pull request restores. + - name: Rust cache + uses: ./.github/actions/rust-cache # Runs even if the audit fails: a fresh advisory shouldn't hide a # compile break in the feature permutations. diff --git a/.github/workflows/release-dart-ffi.yml b/.github/workflows/release-dart-ffi.yml index 85f2fff849..0396251ace 100644 --- a/.github/workflows/release-dart-ffi.yml +++ b/.github/workflows/release-dart-ffi.yml @@ -92,10 +92,8 @@ jobs: with: targets: ${{ matrix.target }} - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - save-if: ${{ github.event_name != 'pull_request' }} + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Install cross-compilation tools (Linux ARM64) if: matrix.target == 'aarch64-unknown-linux-gnu' diff --git a/.github/workflows/release-go-ffi.yml b/.github/workflows/release-go-ffi.yml index 33e21c0c02..f7db014cd4 100644 --- a/.github/workflows/release-go-ffi.yml +++ b/.github/workflows/release-go-ffi.yml @@ -83,11 +83,8 @@ jobs: with: targets: ${{ matrix.target }} - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - # Don't let untrusted PR builds populate caches reused by trusted runs. - save-if: ${{ github.event_name != 'pull_request' }} + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Install cross-compilation tools (Linux ARM64) if: matrix.target == 'aarch64-unknown-linux-gnu' @@ -128,11 +125,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - # Don't let untrusted PR builds populate caches reused by trusted runs. - save-if: ${{ github.event_name != 'pull_request' }} + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Install uniffi-bindgen-go run: | diff --git a/.github/workflows/release-kt-ffi.yml b/.github/workflows/release-kt-ffi.yml index 4fb1c76a66..7694fb4b83 100644 --- a/.github/workflows/release-kt-ffi.yml +++ b/.github/workflows/release-kt-ffi.yml @@ -77,8 +77,8 @@ jobs: with: targets: ${{ matrix.target }} - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Install cross-compilation tools (Linux ARM64) if: matrix.target == 'aarch64-unknown-linux-gnu' @@ -123,8 +123,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Generate bindings env: diff --git a/.github/workflows/release-kt-lib.yml b/.github/workflows/release-kt-lib.yml index 93508bc8e6..92fcbf6422 100644 --- a/.github/workflows/release-kt-lib.yml +++ b/.github/workflows/release-kt-lib.yml @@ -170,8 +170,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. # Generate the UniFFI Kotlin so the sibling :moq-ffi project compiles. - name: Generate bindings diff --git a/.github/workflows/release-swift-ffi.yml b/.github/workflows/release-swift-ffi.yml index 658c06b654..bb4c757256 100644 --- a/.github/workflows/release-swift-ffi.yml +++ b/.github/workflows/release-swift-ffi.yml @@ -61,8 +61,8 @@ jobs: with: targets: ${{ matrix.target }} - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Build shell: bash @@ -96,8 +96,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Generate bindings env: diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index a1409cc72f..d0c8392de3 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -62,9 +62,9 @@ jobs: wasm: name: WASM if: github.event.action != 'closed' - # x64, not the arm runner the other jobs use: this is the configuration - # interop.yml already launches Playwright's Chromium on. - runs-on: ubuntu-latest + # ARM64 like check.yml, the only architecture cache.yml warms a Rust store + # for; the harness builds the relay natively, which that store covers. + runs-on: ubuntu-24.04-arm timeout-minutes: 60 steps: From a91bcb3c751f9c171902209928a932151b8a6dc8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:30:41 -0700 Subject: [PATCH 66/87] fix(net): settle an unanswered lite fetch when the subscriber closes (#4357) Co-authored-by: Claude Opus 5.5 --- js/net/src/lite/subscriber.test.ts | 114 ++++++++++++++++++++++++++++- js/net/src/lite/subscriber.ts | 86 +++++++++++++++++----- 2 files changed, 181 insertions(+), 19 deletions(-) diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index a26242cf66..f9dec8b901 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { Signal } from "@moq/signals"; import type { Probe as ProbeStats } from "../connection/stats.ts"; -import { error, reason } from "../error.ts"; +import { error, reason, StreamCode, StreamError } from "../error.ts"; import { HopSchema, isAnonymous, MAX_HOPS, Route, UNKNOWN_HOP } from "../hop.ts"; import * as Path from "../path.ts"; import { Writer } from "../stream.ts"; @@ -9,6 +9,7 @@ import * as Time from "../time.ts"; import { type AnnounceBroadcast, AnnounceInit, AnnounceOk, encodeAnnounceBroadcast } from "./announce.ts"; import { Probe } from "./probe.ts"; import { Subscriber } from "./subscriber.ts"; +import { TrackInfo } from "./track.ts"; import { Version } from "./version.ts"; test("closing the subscriber suppresses probe stream warnings", async () => { @@ -691,3 +692,114 @@ test("a draft-05 duplicate start is still a restart", async () => { announced.close(); subscriber.close(); }); + +interface FakeStream { + inbound: ReadableStreamDefaultController; + // Resolves once the subscriber waits on a read the test has not answered. + reading: Promise; + aborted: Promise; + // Hands the stream to the subscriber, for an open the session was told to park. + release: () => void; +} + +// A session whose streams the test answers by hand and that never fails them on its own, so +// only Subscriber.close() can end a wait. Opens numbered in `park` wait for `release()`. +function fakeSession(park: number[] = []) { + const streams: FakeStream[] = []; + const quic = { + createBidirectionalStream: () => { + let inbound!: ReadableStreamDefaultController; + let onRead!: () => void; + let onAbort!: (reason: unknown) => void; + let release!: () => void; + const reading = new Promise((resolve) => (onRead = resolve)); + const aborted = new Promise((resolve) => (onAbort = resolve)); + // No high water mark, so pull() means the subscriber is blocked on a read. + const readable = new ReadableStream( + { + start: (controller) => { + inbound = controller; + }, + pull: () => onRead(), + }, + { highWaterMark: 0 }, + ); + const writable = new WritableStream({ abort: (reason) => void onAbort(reason) }); + const opened = new Promise((resolve) => (release = () => resolve({ readable, writable }))); + if (!park.includes(streams.length)) release(); + streams.push({ inbound, reading, aborted, release }); + return opened; + }, + } as unknown as WebTransport; + return { quic, streams }; +} + +async function answerTrackInfo(stream: FakeStream): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ write: (chunk) => void chunks.push(new Uint8Array(chunk)) }), + ); + await new TrackInfo({}).encode(writer, Version.DRAFT_05); + for (const chunk of chunks) stream.inbound.enqueue(chunk); + stream.inbound.close(); +} + +function expectCut(err: unknown, cause: Error | undefined) { + if (cause) { + expect(err).toBe(cause); + } else { + expect(err).toBeInstanceOf(StreamError); + expect((err as StreamError).code).toBe(StreamCode.SessionClosed); + } +} + +// Lite has no FETCH_OK, so a publisher that never answers holds each setup stage until the +// subscriber closes. The stream that stage opened is reset, even one opening after the close. +test.each([ + ["the TRACK_INFO", "track", undefined], + ["the FETCH", "fetch", undefined], + ["the FETCH, on a session error", "fetch", new Error("session died")], + ["a stream slot for the FETCH", "open", undefined], +] as const)("closing the subscriber rejects a fetch waiting on %s", async (_, stage, cause) => { + const { quic, streams } = fakeSession(stage === "open" ? [1] : []); + const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n)); + + let settled = false; + const fetch = subscriber.fetchGroup(Path.from("room"), "video", 0).then( + () => { + settled = true; + return undefined; + }, + (err: unknown) => { + settled = true; + return err; + }, + ); + + await drainUntil(() => streams.length === 1); + if (stage === "track") { + await streams[0].reading; + } else { + await answerTrackInfo(streams[0]); + await drainUntil(() => streams.length === 2); + if (stage === "fetch") await streams[1].reading; + } + expect(settled).toBe(false); + + subscriber.close(cause); + expectCut(await fetch, cause); + + const stuck = streams[stage === "track" ? 0 : 1]; + stuck.release(); + await stuck.aborted; +}); + +test("a fetch started after the subscriber closes rejects without opening a stream", async () => { + const { quic, streams } = fakeSession(); + const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n)); + subscriber.close(); + + const err = await subscriber.fetchGroup(Path.from("room"), "video", 0).catch((err: unknown) => err); + expectCut(err, undefined); + expect(streams.length).toBe(0); +}); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index eb55837234..61c3e7aaf2 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -8,7 +8,7 @@ import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { groupBounds, hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; -import { type Reader, Stream } from "../stream.ts"; +import { type OpenOptions, type Reader, Stream } from "../stream.ts"; import { TAIL_GRACE_MS, Tail } from "../tail.ts"; import * as Time from "../time.ts"; import type * as track from "../track.ts"; @@ -672,17 +672,33 @@ export class Subscriber { // Opens a TRACK stream, reads the single TRACK_INFO, and FINs. Lite-05+ only. async #trackInfo(broadcast: Path.Valid, track: string): Promise { - const stream = await Stream.open(this.#quic); - try { + return this.#exchange(undefined, async (stream) => { await stream.writer.u53(StreamId.Track); await new TrackMessage(broadcast, track).encode(stream.writer, this.version); const info = await TrackInfo.decode(stream.reader, this.version); // The publisher FINs after TRACK_INFO; FIN our side too. stream.close(); return info; + }); + } + + // Opens a stream and runs a request/response exchange on it, resetting the stream if `run` + // fails. Subscriber.close() also resets it while `run` is pending, so a peer that never + // answers cannot hold it open, and a stream that opens after the close is reset at once. + async #exchange(options: OpenOptions | undefined, run: (stream: Stream) => Promise): Promise { + const closed = this.#closed.signal; + closed.throwIfAborted(); + const stream = await Stream.open(this.#quic, options); + const abort = () => stream.abort(error(closed.reason)); + closed.addEventListener("abort", abort); + try { + closed.throwIfAborted(); + return await run(stream); } catch (err) { stream.abort(error(err)); throw err; + } finally { + closed.removeEventListener("abort", abort); } } @@ -754,31 +770,48 @@ export class Subscriber { throw new Error("fetch group requires moq-lite-05 or newer"); } - const info = await this.#trackInfo(broadcast, track); - const priority = options.priority ?? 0; - const stream = await Stream.open(this.#quic, { sendOrder: sendOrder({ priority }) }); - + // Lite has no FETCH_OK, so a publisher that never answers would hold the setup forever. + // Subscriber.close() closing the group releases every caller at any stage, and resets + // the streams the setup opened. + const setup = this.#fetchSetup(broadcast, track, sequence, options); + let accepted: { stream: Stream; info: TrackInfo }; try { - await stream.writer.u53(StreamId.Fetch); - await new FetchMessage({ broadcast, track, priority, group: sequence }).encode( - stream.writer, - this.version, - ); - // A byte or an empty-group FIN accepts the fetch; a reset rejects it. - // done() buffers that byte so the response pump can decode it normally. - await stream.reader.done(); + accepted = await untilClosed(group, setup); } catch (err: unknown) { - stream.abort(error(err)); + // A setup that finishes just after the close hands back a stream nobody will read. + void setup.then( + ({ stream }) => stream.abort(error(err)), + () => void 0, + ); throw err; } - void this.#runFetchResponse(stream, group, Time.Timescale(info.timescale)); + void this.#runFetchResponse(accepted.stream, group, Time.Timescale(accepted.info.timescale)); } catch (err: unknown) { group.close(error(err)); throw err; } } + // Resolve the track's timescale, then open the FETCH stream and wait for it to be accepted. + async #fetchSetup( + broadcast: Path.Valid, + track: string, + sequence: number, + options: track.FetchGroupOptions, + ): Promise<{ stream: Stream; info: TrackInfo }> { + const info = await this.#trackInfo(broadcast, track); + const priority = options.priority ?? 0; + return this.#exchange({ sendOrder: sendOrder({ priority }) }, async (stream) => { + await stream.writer.u53(StreamId.Fetch); + await new FetchMessage({ broadcast, track, priority, group: sequence }).encode(stream.writer, this.version); + // A byte or an empty-group FIN accepts the fetch; a reset rejects it. + // done() buffers that byte so the response pump can decode it normally. + await stream.reader.done(); + return { stream, info }; + }); + } + // Read the FETCH response (bare zigzag-delta-timestamped frames) into the group, then // FIN. A stream-level failure aborts the group so its reader observes the gap. async #runFetchResponse(stream: Stream, group: netGroup.Producer, timescale: Time.Timescale): Promise { @@ -1135,16 +1168,33 @@ export class Subscriber { * session died, since those tracks were cut off rather than ended. */ close(err?: Error) { - this.#closed.abort(); + // A fetch or setup exchange cut off by the session is incomplete even on a deliberate + // close, so it always ends with an error. + const cut = err ?? new StreamError(StreamCode.SessionClosed, { message: "session closed" }); + this.#closed.abort(cut); for (const { track } of this.#subscribes.values()) { track.close(err); } this.#subscribes.clear(); + + // This also releases callers still awaiting acceptance. + for (const { group } of this.#fetches.values()) { + group.close(cut); + } } } +// Settles with `step`, or rejects with the group's error once it closes first. A publisher +// may never answer a FETCH, so Subscriber.close() closing the group is what releases it. +async function untilClosed(group: netGroup.Producer, step: Promise): Promise { + const value = await race([step, group.closed]); + const closed = group.closed.peek(); + if (closed !== undefined) throw closed ?? new Error("fetch closed before it was accepted"); + return value as T; +} + /** * A broadcast consumed from a lite session. It resolves `track.Consumer.query()` and * `.fetchGroup()` over the wire (lite-05+ TRACK / FETCH streams) by reaching into the From b234a38c0b4fb29b139edb79448ab44d7adce98c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:38:53 -0700 Subject: [PATCH 67/87] test(drill): retarget two mutations and check they apply in just check (#4358) Co-authored-by: Claude Opus 5.5 --- justfile | 7 ++++++ quest/m1/README.md | 1 - quest/m1/drill-sensitivity.md | 22 ------------------ test/drill/README.md | 4 +++- .../relay-withdraws-lost-publisher.patch | 14 +++++------ .../subscriber-leaks-broadcasts.patch | 18 +++++++-------- test/drill/sensitivity.sh | 23 +++++++++++++++++++ 7 files changed, 48 insertions(+), 41 deletions(-) delete mode 100644 quest/m1/drill-sensitivity.md diff --git a/justfile b/justfile index af3866c399..bbba70cb92 100644 --- a/justfile +++ b/justfile @@ -544,6 +544,7 @@ _check $BASE $TEST: just rs media-features just --justfile bench/justfile check quest check + just test drill-sensitivity --apply-only # Not covered by the line above: moq-wasm only exists on the wasm32 target. just rs wasm just py check @@ -570,6 +571,12 @@ _check $BASE $TEST: if echo "$files" | grep -qE '^(quest/|flake\.lock$)'; then quest check fi + # The drill mutations patch Rust source, so a Rust change can move the + # code they target. Only nightly runs the drills against them; this + # catches a stale patch in the PR that moved its code. + if echo "$files" | grep -qE '^(rs/|test/drill/)'; then + just test drill-sensitivity --apply-only + fi just py check "$files" just kt check "$files" just swift check "$files" diff --git a/quest/m1/README.md b/quest/m1/README.md index 8b0d88c905..759b3c5fb7 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,7 +17,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Required -- [Drill sensitivity](/quest/m1/drill-sensitivity.md) - the nightly drill-sensitivity job passes: the subscriber-leaks-broadcasts mutation applies to the current lite subscriber again - [Cluster routing](/quest/m1/cluster-routing.md) - an announcement says where a broadcast originates, not how to reach it, and a relay hears only the prefixes its clients asked for - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` diff --git a/quest/m1/drill-sensitivity.md b/quest/m1/drill-sensitivity.md deleted file mode 100644 index 5f2232c610..0000000000 --- a/quest/m1/drill-sensitivity.md +++ /dev/null @@ -1,22 +0,0 @@ -# [XS] The subscriber-leaks-broadcasts mutation applies again - -## Goal - -The nightly `Tests (test drill-sensitivity)` job passes. It fails on -[run 36240326747](https://github.com/moq-dev/moq/actions/runs/36240326747) -because `test/drill/mutations/subscriber-leaks-broadcasts.patch` no longer -applies ("1 out of 2 hunks FAILED" on `rs/moq-net/src/lite/subscriber.rs`) -after a later change to the lite subscriber, so the -`cancel_under_backpressure_releases_the_reader` drill proves nothing. - -## Plan - -- Retarget the patch the way - [#3953](https://github.com/moq-dev/moq/pull/3953) did: find where the lite - subscriber now releases the broadcasts a session fed when it ends, and - remove that behavior again. Keep the failure message the patch declares, or - update it if the drill now fails with a different but still correct one. -- If the release moved somewhere a patch cannot remove cleanly, the drill may - be pointing at the wrong layer; say so rather than force a patch. -- Prove it with `just test drill-sensitivity subscriber-leaks-broadcasts`, and - run the other two mutations to confirm they still apply. diff --git a/test/drill/README.md b/test/drill/README.md index db075b8753..bf3600f75d 100644 --- a/test/drill/README.md +++ b/test/drill/README.md @@ -112,7 +112,9 @@ protocol's reaction to loss and delay, not the kernel's rendering of them. ## Sensitivity The Nightly workflow runs all mutations, so patches that stop applying and drills -that stop detecting their recovery failures fail CI. +that stop detecting their recovery failures fail CI. `just check` also runs +`--apply-only` whenever Rust or this directory changes, so a patch that no longer +applies fails the PR that moved its code rather than the next nightly. `sensitivity.sh` removes one recovery behavior at a time and requires the drill covering it to fail. Each mutation is a patch under `mutations/`, applied to a diff --git a/test/drill/mutations/relay-withdraws-lost-publisher.patch b/test/drill/mutations/relay-withdraws-lost-publisher.patch index 1f30d190ee..47ff59c45d 100644 --- a/test/drill/mutations/relay-withdraws-lost-publisher.patch +++ b/test/drill/mutations/relay-withdraws-lost-publisher.patch @@ -6,25 +6,25 @@ # means a route outlives the session that announced it, so a crashed publisher's # name stays announced with nothing behind it. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs -index 729a23e4b..dac9990c6 100644 +index 8a0259fec..0b6035640 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs -@@ -2566,7 +2566,8 @@ struct AnnouncedRoute { +@@ -2842,7 +2842,8 @@ struct AnnouncedRoute { /// without recomputing the chain. route: crate::origin::Route, /// Dropping it retracts the route and rejects its queued requests. - dynamic: crate::origin::Dynamic, + // MUTATION: never dropped, so a route outlives the session that announced it. + dynamic: std::mem::ManuallyDrop, - /// One minted source per requested path, finished on a clean retraction and - /// aborted (via drop) when the session dies. + /// One minted source per requested path, each closed when its guard drops. sources: HashMap, -@@ -2578,7 +2579,7 @@ impl AnnouncedRoute { - fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic) -> Self { + /// Whether the GOAWAY drain already re-priced this route. +@@ -2858,7 +2859,7 @@ impl AnnouncedRoute { + fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic, wake: Arc) -> Self { Self { route, - dynamic, + dynamic: std::mem::ManuallyDrop::new(dynamic), sources: HashMap::new(), drained: false, - } + waker: std::task::Waker::from(wake.clone()), diff --git a/test/drill/mutations/subscriber-leaks-broadcasts.patch b/test/drill/mutations/subscriber-leaks-broadcasts.patch index 44bff5a4d2..328b418eb7 100644 --- a/test/drill/mutations/subscriber-leaks-broadcasts.patch +++ b/test/drill/mutations/subscriber-leaks-broadcasts.patch @@ -3,14 +3,14 @@ # # Removes the release a subscribing session performs when it ends: each announce # stream's `Announced` collection owns the routes and source guards for every -# broadcast the session fed, and dropping it aborts them. Wrapping it in -# `ManuallyDrop` keeps every handle downstream of it alive after the session is -# gone, so a cancelled reader's broadcast parks forever instead of closing. +# broadcast the session fed, and dropping it retracts and closes them. Wrapping +# it in `ManuallyDrop` keeps every handle downstream of it alive after the session +# is gone, so a cancelled reader's broadcast parks forever instead of closing. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs -index 729a23e4b..54aa7a5be 100644 +index 8a0259fec..92febbcdf 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs -@@ -1087,7 +1087,8 @@ struct PrefixRun { +@@ -1142,7 +1142,8 @@ struct PrefixRun { /// it comes from the connect config or the peer's SETUP, neither of which /// changes for the life of the session. link_cost: u64, @@ -19,13 +19,11 @@ index 729a23e4b..54aa7a5be 100644 + announced: std::mem::ManuallyDrop, // Lite06+: announce ids. Each received `active` implicitly assigns the next // per-stream ordinal; `ended`/`restart` reference it instead of repeating the - // path. Tracked even for announces we drop locally (reflected loops), since -@@ -1175,7 +1176,7 @@ impl AnnouncePrefix { - let run = PrefixRun { + // path, and lite-07 bases name it too. Tracked even for announces we drop +@@ -1233,5 +1234,5 @@ impl AnnouncePrefix { responder_origin, link_cost, - announced: Announced::default(), + announced: std::mem::ManuallyDrop::new(Announced::default()), - next_announce_id: 0, - announced_by_id: HashMap::new(), + decoder: lite::AnnounceDecoder::default(), }; diff --git a/test/drill/sensitivity.sh b/test/drill/sensitivity.sh index ba4c19d7cf..92ec1b03f9 100755 --- a/test/drill/sensitivity.sh +++ b/test/drill/sensitivity.sh @@ -32,6 +32,7 @@ CARGO=cargo KEEP=0 BASELINE=1 +APPLY_ONLY=0 SELECTED=() dir= log= @@ -68,6 +69,7 @@ Options: --list list the mutations and the drill each one must break --keep keep the mutated snapshots (prints each path) --no-baseline skip the unmutated run of each drill + --apply-only only check that each mutation still applies; builds nothing -h, --help this With no mutation named, every mutation runs. @@ -88,6 +90,10 @@ while [[ $# -gt 0 ]]; do BASELINE=0 shift ;; + --apply-only) + APPLY_ONLY=1 + shift + ;; -h | --help) usage exit 0 @@ -157,6 +163,14 @@ for patch in "$MUTATIONS"/*.patch; do checked=$((checked + 1)) echo "=== $name -> $drill" + if [[ $APPLY_ONLY -eq 1 ]]; then + if ! patch -p1 -d "$WORKSPACE" --dry-run --batch --forward --silent <"$patch"; then + echo " FAIL: '$name' does not apply to this tree" >&2 + failed=$((failed + 1)) + fi + continue + fi + if [[ $BASELINE -eq 1 ]]; then log=$(mktemp "${TMPDIR:-/tmp}/drill-baseline.XXXXXX") status=$(run_drill "$WORKSPACE" "$drill" "$log") @@ -221,6 +235,15 @@ if [[ $checked -eq 0 ]]; then exit 2 fi +if [[ $APPLY_ONLY -eq 1 ]]; then + if [[ $failed -gt 0 ]]; then + echo "$failed of $checked mutations do not apply; retarget them at the current code" >&2 + exit 1 + fi + echo "$checked of $checked mutations apply" + exit 0 +fi + if [[ $failed -gt 0 ]]; then echo "$failed of $checked mutations did not prove sensitivity" >&2 exit 1 From e1b70c2cfc8595b57662ab6e1f051c331beed9d2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:54:09 -0700 Subject: [PATCH 68/87] fix(net): select scoped routes after filtering, not before (#4363) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 6 + doc/concept/moq-lite.md | 4 +- js/net/bench/forward.ts | 91 ++++++++++++++ js/net/src/ietf/publisher.test.ts | 72 +++++++++++ js/net/src/ietf/publisher.ts | 14 +-- js/net/src/internal.ts | 13 +- js/net/src/lite/publisher.ts | 8 +- js/net/src/origin.test.ts | 48 +++++++- js/net/src/origin.ts | 192 +++++++++++++++++------------- js/net/src/wire.ts | 8 +- quest/m1/README.md | 1 - quest/m1/js-scoped-routes.md | 37 ------ rs/moq-net/src/model/origin.rs | 19 +++ 13 files changed, 372 insertions(+), 141 deletions(-) create mode 100644 js/net/bench/forward.ts delete mode 100644 quest/m1/js-scoped-routes.md diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 377e7f235a..02afbb0721 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -77,6 +77,12 @@ jobs: nix develop --command bun install --frozen-lockfile nix develop --command bun js/net/bench/broadcasts.ts + # A route above several interest heads lands once per head, so its re-price + # should expose any slope over heads times received routes. + - name: JS origin announce forwarding benchmark + if: ${{ !cancelled() }} + run: nix develop --command bun js/net/bench/forward.ts + # Fails if reading a fragmented frame costs more per byte as the frame grows. - name: JS stream reader benchmark if: ${{ !cancelled() }} diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index f1fc3ff021..c2947245ed 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -149,7 +149,9 @@ prefixes it is told about. A subscriber watching under a root sees advertisements named relative to that root. The pattern scope filters which prefixes are visible without changing a -route's prefix. Announce events carry the covered path, captures, and what +route's prefix. When several routes advertise one prefix, each reader sees the +best route its scope can use, so a cheaper route scoped elsewhere never hides +it. Announce events carry the covered path, captures, and what happened to it: Rust `announce::Update { prefix, captures: Option>, route, kind }` and TypeScript `Announce.Update { prefix, captures, route, kind }`, where the kind is diff --git a/js/net/bench/forward.ts b/js/net/bench/forward.ts new file mode 100644 index 0000000000..c87f7bb6c9 --- /dev/null +++ b/js/net/bench/forward.ts @@ -0,0 +1,91 @@ +/** Sweep interest heads and received routes for one route re-price forwarded from a session. */ +import * as Announce from "../src/announced.ts"; +import { Producer as BroadcastProducer } from "../src/broadcast.ts"; +import type { Established } from "../src/connection/established.ts"; +import { forwardAnnounced } from "../src/connection/forward.ts"; +import { Route } from "../src/hop.ts"; +import { Producer } from "../src/origin.ts"; +import * as Path from "../src/path.ts"; +import { registerWire } from "../src/wire.ts"; + +const headCounts = [1, 4, 16]; +const routeCounts = [8, 32, 128]; +const updates = 32; +let checksum = 0; + +// Let every interest stream's forwarding loop drain its queue. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** A session standing in for the wire: one announce stream per interest head, driven by the bench. */ +class FakeSession { + readonly discovery = true; + readonly streams: Announce.Producer[] = []; + readonly closed: Promise; + die!: () => void; + + constructor() { + registerWire(this, { consume: () => new BroadcastProducer().consume() }); + this.closed = new Promise((resolve) => { + this.die = () => resolve(null); + }); + } + + announced(): Announce.Consumer { + const stream = new Announce.Producer(); + this.streams.push(stream); + return stream.consume(); + } +} + +const announce = (stream: Announce.Producer, prefix: Path.Valid, cost: bigint, kind: Announce.Kind) => + stream.append({ prefix, captures: undefined, kind, route: Route.normalize({ cost }) }); + +console.log("touched,heads,routes,update_us"); +for (const touched of ["covering", "single"] as const) { + for (const headCount of headCounts) { + for (const routeCount of routeCounts) { + const origin = new Producer(); + const heads = Array.from({ length: headCount }, (_, index) => Path.from(`h${index}`)); + const scoped = origin.scope( + Path.empty(), + new Path.Patterns(heads.map((head) => Path.Pattern.subtree(head))), + ); + const session = new FakeSession(); + forwardAnnounced(session as unknown as Established, scoped); + if (session.streams.length !== headCount) throw new Error("expected one announce stream per head"); + + // Routes beneath the heads, spread across them, plus one above every head. The wire + // presents that covering route on each head's stream, so it lands once per head. + for (let index = 0; index < routeCount; index++) { + const head = index % headCount; + announce(session.streams[head], Path.join(heads[head], Path.from(`r${index}`)), 1n, "announced"); + } + for (const stream of session.streams) announce(stream, Path.empty(), 1n, "announced"); + await flush(); + + const path = touched === "covering" ? Path.empty() : Path.join(heads[0], Path.from("r0")); + const table = origin.broadcasts(); + if (table.peek().size !== routeCount + 1) throw new Error(`expected ${routeCount + 1} routes`); + + const start = performance.now(); + for (let index = 0; index < updates; index++) { + const cost = BigInt(index + 2); + if (touched === "covering") { + for (const stream of session.streams) announce(stream, path, cost, "updated"); + } else { + announce(session.streams[0], path, cost, "updated"); + } + await flush(); + if (table.peek().get(path)?.cost.warm !== cost) throw new Error("re-price did not land"); + checksum += table.peek().size; + } + const elapsed = performance.now() - start; + console.log(`${touched},${headCount},${routeCount},${((elapsed * 1000) / updates).toFixed(1)}`); + + session.die(); + await flush(); + origin.close(); + } + } +} +if (checksum === 0) throw new Error("benchmark did no work"); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 72b489e537..76c0c59d36 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -677,6 +677,78 @@ test("a subscription outside a covering route's claim does not hear it", async ( origin.close(); }); +/** + * A cheaper route claiming `room/chat` does not hide a costlier `room/video` route at the + * same prefix from a subscription at `room/video`. + */ +test("a subscription hears the best covering route its claim allows", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); + const scoped = (path: string) => + origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from(path))])); + const chat = scoped("room/chat").dynamic(Path.from("room"), { cost: 1n }); + const video = scoped("room/video").dynamic(Path.from("room"), { cost: 5n }); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 0n, namespace: Path.from("room/video") }), + accepted, + ); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + + // The first entry is the video route as the empty suffix, not a later broadcast beneath it. + publish(origin, Path.from("room/video/cam")); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix).toBe(Path.empty()); + + video.close(); + chat.close(); + subscription.close(); + origin.close(); +}); + +/** + * A served root collapses every covering route to the empty suffix. A narrow one claiming + * only `tenant/chat` must not hide a broader one from a subscription at `video`. + */ +test("a subscription hears a broader covering route when the narrowest cannot serve it", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const origin = new OriginProducer(); + const tenant = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.all()])); + const pub = new Publisher({ + quic: pair.server, + session: new NativeSession(pair.server, VERSION, true), + publish: tenant.consume(), + requiresSolicitation: true, + }); + const chat = origin + .scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("tenant/chat"))])) + .dynamic(Path.from("tenant")); + const broad = origin.dynamic(Path.empty(), { cost: 5n }); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace(new SubscribeNamespace({ requestId: 0n, namespace: Path.from("video") }), accepted); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + + // The first entry is the broad route as the empty suffix, not a later broadcast beneath it. + publish(origin, Path.from("tenant/video/cam")); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix).toBe(Path.empty()); + + broad.close(); + chat.close(); + subscription.close(); + origin.close(); +}); + /** * The claim is presented relative to the served origin's root, like the route's key: a * publisher serving `tenant` offers `room` (claiming `tenant/room/chat`) to a diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index de3479b97b..ee12f52118 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -11,7 +11,7 @@ import { Milli, Timescale } from "../time.ts"; import type { Subscriber as TrackSubscriber } from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import * as Varint from "../varint.ts"; -import { type Advertised, wireOf } from "../wire.ts"; +import { type Advertised, type Advertisements, wireOf } from "../wire.ts"; import type { Session } from "./adapter.ts"; import * as Cluster from "./cluster.ts"; import { requestReason, toRequestCode } from "./error.ts"; @@ -161,7 +161,7 @@ export class Publisher { // session leaves its broadcasts alone. The namespaces are advertised with an unsolicited // PUBLISH_NAMESPACE (see {@link runPublishNamespaces}), or on request if the peer asked // for that (see {@link runSubscribeNamespace}). - #advertised: Getter | undefined>; + #advertised: Getter; #publish?: OriginConsumer; // What every advertisement carries on a session that negotiated the MoQ Cluster @@ -779,7 +779,7 @@ export class Publisher { // waits for its reply only notifies listeners already registered. // TODO Make a better helper within Signals. let dispose!: Dispose; - const changed = new Promise | undefined>((resolve) => { + const changed = new Promise((resolve) => { dispose = this.#advertised.changed(resolve); }); @@ -904,7 +904,7 @@ export class Publisher { // through it and leave the namespace unadvertised until something unrelated // changed. // TODO Make a better helper within Signals. - const changed = new Promise | undefined>((resolve) => { + const changed = new Promise((resolve) => { dispose = this.#advertised.changed(resolve); }); @@ -915,10 +915,10 @@ export class Publisher { } const updated = new Map(); - for (const [covered, snap] of advertised) { + for (const [covered, candidates] of advertised) { // Unasked, a hidden namespace stays off the wire (MoQ Hidden). - if (hiddenBelow(Path.empty(), covered)) continue; - updated.set(covered, snap); + if (hiddenBelow(Path.empty(), covered) || candidates.length === 0) continue; + updated.set(covered, candidates[0]); } // A namespace that is gone, or that a republish replaced, takes its refusal with diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index fdda337057..0f00c90848 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -12,7 +12,7 @@ import type { Route } from "./hop.ts"; import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; import type { Groups, Producer, Request, Subscriber } from "./track.ts"; -import type { Advertised } from "./wire.ts"; +import type { Advertised, Advertisements } from "./wire.ts"; /** Normalize public group bounds into an inclusive start and exclusive end. */ export function groupBounds(groups: Groups = {}): { start: number; end?: number } { @@ -54,24 +54,25 @@ export function hiddenBelow(prefix: Path.Valid, path: Path.Valid): boolean { */ export function presented( prefix: Path.Valid, - table: ReadonlyMap, + table: Advertisements, carries: (covered: Path.Valid) => boolean, ): Map { const out = new Map(); let rootLen = -1; const requested = Path.Pattern.subtree(prefix); - for (const [covered, snap] of table) { + for (const [covered, candidates] of table) { if (!carries(covered)) continue; if (Path.hasPrefix(covered, prefix)) { - // A scoped route covers only what it claims, so it cannot serve a prefix outside that. - if (snap.claim && !snap.claim.overlaps(requested)) continue; if (covered.length < rootLen) continue; + // A scoped route covers only what it claims, so the best one that can serve the prefix wins. + const snap = candidates.find((candidate) => !candidate.claim || candidate.claim.overlaps(requested)); + if (!snap) continue; rootLen = covered.length; out.set(Path.empty(), snap); continue; } const suffix = Path.stripPrefix(prefix, covered); - if (suffix !== null) out.set(suffix, snap); + if (suffix !== null && candidates.length > 0) out.set(suffix, candidates[0]); } return out; } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 2327855f70..ed2a854dd8 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -9,7 +9,7 @@ import type * as Path from "../path.ts"; import { type Reader, type Stream, Writer } from "../stream.ts"; import { Milli, Timescale } from "../time.ts"; import type * as track from "../track.ts"; -import { type Advertised, wireOf } from "../wire.ts"; +import { type Advertised, type Advertisements, wireOf } from "../wire.ts"; import { AnnounceInit, AnnounceOk, type AnnounceRequest, encodeAnnounceBroadcast } from "./announce.ts"; import { Datagram as DatagramMessage } from "./datagram.ts"; import * as DatagramStream from "./datagram_stream.ts"; @@ -340,7 +340,7 @@ export class Publisher { #datagramWriter?: WritableStreamDefaultWriter; // Originated advertisements this session forwards. - #advertised: Getter | undefined>; + #advertised: Getter; #publish?: OriginConsumer; @@ -450,7 +450,7 @@ export class Publisher { // unrelated moved. // TODO Make a better helper within Signals. let dispose!: Dispose; - let changed = new Promise | undefined>((resolve) => { + let changed = new Promise((resolve) => { dispose = this.#advertised.changed(resolve); }); @@ -498,7 +498,7 @@ export class Publisher { if (!advertised) break; // Re-arm before reading, so an advertise that lands while we write is not lost. - changed = new Promise | undefined>((resolve) => { + changed = new Promise((resolve) => { dispose = this.#advertised.changed(resolve); }); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 7ad40d0fa9..5ed445a314 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1181,7 +1181,7 @@ test("a peer is offered and served the cheapest originated route", async () => { const cheap = origin.dynamic(prefix, { cost: 1n }); const pricey = origin.dynamic(prefix, { cost: 10n }); - expect(wireOf(consumer).advertised.peek()?.get(prefix)?.route).toEqual(Route.normalize({ cost: 1n })); + expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual(Route.normalize({ cost: 1n })); const pending = wireOf(consumer).demand(Path.from("live/cam")); const { value: req } = await cheap.requested().next(); const upstream = new BroadcastProducer(); @@ -1191,9 +1191,9 @@ test("a peer is offered and served the cheapest originated route", async () => { // An exact-path local broadcast competes with them on cost too. const local = origin.createBroadcast(prefix); local.announce({ cost: 5n }); - expect(wireOf(consumer).advertised.peek()?.get(prefix)?.route).toEqual(Route.normalize({ cost: 1n })); + expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual(Route.normalize({ cost: 1n })); local.announce({ cost: 0n }); - expect(wireOf(consumer).advertised.peek()?.get(prefix)?.route).toEqual(Route.normalize({ cost: 0n })); + expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual(Route.normalize({ cost: 0n })); local.close(); upstream.close(); @@ -1513,8 +1513,48 @@ test("a rooted reader presents the most specific covering route", () => { const broad = origin.dynamic(Path.from("room"), { cost: 1n }); const rooted = origin.scope(Path.from("room/alice"), new Path.Patterns([Path.Pattern.all()])); expect(rooted.broadcasts().peek().get(Path.empty())?.cost.warm).toBe(9n); - expect(wireOf(rooted.consume()).advertised.peek()?.get(Path.empty())?.route.cost.warm).toBe(9n); + expect(wireOf(rooted.consume()).advertised.peek()?.get(Path.empty())?.[0]?.route.cost.warm).toBe(9n); broad.close(); narrow.close(); origin.close(); }); + +test("a scoped reader picks the best route its scope can see at a prefix", async () => { + const origin = new Producer(); + const scoped = (pattern: string) => origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse(pattern)])); + const chat = scoped("*/chat").dynamic(Path.empty(), { cost: 1n }); + const video = scoped("*/video").dynamic(Path.empty(), { cost: 5n }); + const reader = scoped("room/video"); + + expect(reader.broadcasts().peek().get(Path.empty())?.cost.warm).toBe(5n); + expect(origin.broadcasts(Path.Pattern.parse("room/video")).peek().get(Path.empty())?.cost.warm).toBe(5n); + const announced = reader.announced(); + expect((await announced.next())?.route.cost.warm).toBe(5n); + announced.close(); + + // A session publishing the scoped view offers the video route too. + const offered = [...(wireOf(reader.consume()).advertised.peek()?.get(Path.empty()) ?? [])]; + expect(offered.map((advert) => advert.route.cost.warm)).toEqual([5n]); + + video.close(); + chat.close(); + origin.close(); +}); + +test("a scoped reader sees a local broadcast that only loses to a route outside its scope", () => { + const origin = new Producer(); + const chat = origin + .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*/chat")])) + .dynamic(Path.from("room/alice"), { cost: 0n }); + const local = origin.createBroadcast(Path.from("room/alice")); + local.announce({ cost: 5n }); + const reader = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*")])); + + expect(reader.broadcasts().peek().get(Path.from("room/alice"))?.cost.warm).toBe(5n); + // Unscoped, the cheaper route still wins the prefix. + expect(origin.broadcasts().peek().get(Path.from("room/alice"))?.cost.warm).toBe(0n); + + local.close(); + chat.close(); + origin.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index e69cd1d907..f5b9438238 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -17,7 +17,7 @@ import { StreamCode, StreamError } from "./error.ts"; import { isAnonymous, Route, routesEqual } from "./hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "./internal.ts"; import * as Path from "./path.ts"; -import { type Advertised, registerWire, wireOf } from "./wire.ts"; +import { type Advertised, type Advertisements, registerWire, wireOf } from "./wire.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; @@ -84,19 +84,39 @@ class Scope { return out; } - /** The advertised prefixes that may serve this scope, relative to its root. */ - projectRoutes( - values: ReadonlyMap | undefined, - ): ReadonlyMap | undefined { + /** + * The advertisements that may serve this scope, relative to its root. Every prefix at or + * above the root presents as the empty path, most specific first, since that is the order + * a request beneath the root resolves in. + */ + projectRoutes(values: Advertisements | undefined): Advertisements | undefined { if (!values || this === Scope.all) return values; - const out = new Map(); - const covering = new CoveringRoot(this.root); - for (const [path, value] of values) { - if (this.allowed && ![...this.allowed].some((pattern) => advertOverlaps(value, path, pattern))) continue; - const relative = covering.relative(path); - if (relative === undefined) continue; - // The claim moves with the key, so it compares against root-relative requests. - out.set(relative, value.claim ? { ...value, claim: value.claim.rebase(this.root) } : value); + const out = new Map(); + const covering: [Path.Valid, Advertised[]][] = []; + const allowed = this.allowed && [...this.allowed]; + for (const [path, candidates] of values) { + const relative = Path.stripPrefix(this.root, path); + const above = relative === null || relative === Path.empty(); + if (above && !Path.hasPrefix(path, this.root)) continue; + const visible = candidates + .filter((value) => !allowed || allowed.some((pattern) => advertOverlaps(value, path, pattern))) + // The claim moves with the key, so it compares against root-relative requests. + .map((value) => (value.claim ? { ...value, claim: value.claim.rebase(this.root) } : value)); + if (visible.length === 0) continue; + if (!above) { + out.set(relative, visible); + continue; + } + // Hold the empty path's place in the order until every covering prefix is known. + if (covering.length === 0) out.set(Path.empty(), []); + covering.push([path, visible]); + } + if (covering.length > 0) { + covering.sort(([a], [b]) => b.length - a.length); + out.set( + Path.empty(), + covering.flatMap(([, visible]) => visible), + ); } return out; } @@ -107,11 +127,6 @@ function advertOverlaps(advert: Advertised, prefix: Path.Valid, pattern: Path.Pa return advert.claim ? advert.claim.overlaps(pattern) : scopeOverlaps(pattern, prefix); } -/** The paths `entry` may serve beneath `prefix`, when its producer is scoped. */ -function claimOf(entry: RouteEntry, prefix: Path.Valid): Path.Patterns | undefined { - return entry.scope.allowed?.intersect(new Path.Patterns([Path.Pattern.subtree(prefix)])); -} - /** * Presents advertised prefixes relative to a root. Every prefix at or above the root * collapses to the empty path, where the most specific one wins, since that is the route @@ -180,11 +195,27 @@ export interface RequestSlot { export interface RouteEntry { readonly identity: object; readonly scope: Scope; + /** The paths the entry may serve beneath its prefix, when its producer is scoped. */ + readonly claim?: Path.Patterns; readonly route: Signal; readonly originated: boolean; readonly server?: ServeState; } +/** One advertisement at a prefix. `exact` marks an announced local broadcast, which is only its own path. */ +interface Candidate extends Advertised { + readonly exact: boolean; +} + +/** Orders advertisements at one prefix: the better route, then a local broadcast on a tie, then fewer hops. */ +function compareCandidates(a: Candidate, b: Candidate): number { + return ( + compareRoutes(a.route, b.route) || + Number(b.exact) - Number(a.exact) || + a.route.hops.length - b.route.hops.length + ); +} + /** Orders two routes by preference: identified before anonymous, then lower warm cost, then lower cold cost. */ function compareRoutes(a: Route, b: Route): number { const anonymous = Number(isAnonymous(a)) - Number(isAnonymous(b)); @@ -343,12 +374,11 @@ class OriginState { routes = new VersionedSignal | undefined>(new Map()); #snapshotVersion = ""; - #snapshot = { - remote: new Map(), - local: new Map(), - routes: new Map(), - visible: new Map(), - }; + #snapshot: { + candidates: ReadonlyMap; + routes: ReadonlyMap; + visible: ReadonlyMap; + } = { candidates: new Map(), routes: new Map(), visible: new Map() }; /** The full route table is built once per mutation, regardless of observer count. */ available = new Derived([this.local, this.advertisedLocal, this.routes], () => this.snapshot().routes); @@ -356,43 +386,57 @@ class OriginState { visible = new Derived([this.local, this.advertisedLocal, this.routes], () => this.snapshot().visible); snapshot(): { - remote: ReadonlyMap; - local: ReadonlyMap; + candidates: ReadonlyMap; routes: ReadonlyMap; visible: ReadonlyMap; } { const version = `${this.local.version}/${this.advertisedLocal.version}/${this.routes.version}`; if (version === this.#snapshotVersion) return this.#snapshot; - const remote = new Map(); - const local = new Map(); + const candidates = this.candidates(); const available = new Map(); - for (const [path, routes] of this.routes.peek() ?? []) { - const entry = preferredEntry(routes); - if (!entry) continue; - const value = { identity: entry.identity, route: entry.route.peek(), claim: claimOf(entry, path) }; - remote.set(path, value); - available.set(path, value.route); - } - for (const [path, front] of this.local.peek() ?? []) { - const routes = this.routes.peek()?.get(path); - if (!this.localWins(path, routes && preferredEntry(routes))) continue; - const value = { identity: front, route: this.advertisedLocal.peek()?.get(path) ?? Route.default }; - local.set(path, value); - available.set(path, value.route); - } const visible = new Map(); - for (const [path, route] of available) { - if (!hiddenBelow(Path.empty(), path)) visible.set(path, route); + for (const [path, [best]] of candidates) { + available.set(path, best.route); + if (!hiddenBelow(Path.empty(), path)) visible.set(path, best.route); } - this.#snapshot = { remote, local, routes: available, visible }; + this.#snapshot = { candidates, routes: available, visible }; this.#snapshotVersion = version; return this.#snapshot; } + /** + * Every advertisement per prefix, most preferred first, without the `skip`ped entries. + * Readers select after filtering by their scope, so a cheaper route they cannot see + * never hides one they can. + */ + candidates(skip?: (entry: RouteEntry) => boolean): Map { + const out = new Map(); + for (const [path, entries] of this.routes.peek() ?? []) { + const list: Candidate[] = []; + for (const entry of entries) { + if (skip?.(entry)) continue; + list.push({ identity: entry.identity, route: entry.route.peek(), claim: entry.claim, exact: false }); + } + if (list.length > 0) out.set(path, list); + } + const advertised = this.advertisedLocal.peek(); + for (const [path, front] of this.local.peek() ?? []) { + const local = { identity: front, route: advertised?.get(path) ?? Route.default, exact: true }; + const list = out.get(path); + if (list) list.push(local); + else out.set(path, [local]); + } + for (const list of out.values()) { + // Stable, so equal routes keep the table's newest-first order. + if (list.length > 1) list.sort(compareCandidates); + } + return out; + } + // Originated advertisements sessions should forward: exact-path announces plus // originated dynamics. Identity is the local front or the route entry, so a // republish diffs as retract-then-announce and a re-price as another active. - originated = new Signal | undefined>(new Map()); + originated = new Signal(new Map()); // Broadcasts materialized from a served route, keyed by exact path. Shared by every // request for the path so repeats reuse one accept; dropped (and closed) when the @@ -471,28 +515,12 @@ class OriginState { /** Rebuild the publisher-facing originated table after an advertisement write. */ rebuildOriginated(): void { - const local = this.local.peek(); - const advertised = this.advertisedLocal.peek(); - const routes = this.routes.peek(); - if (!local && !advertised && !routes) { + if (!this.local.peek() && !this.advertisedLocal.peek() && !this.routes.peek()) { this.originated.set(undefined); return; } - const next = new Map(); - for (const [prefix, entries] of routes ?? []) { - const mine = preferredEntry(entries, received); - if (mine) - next.set(prefix, { identity: mine.identity, route: mine.route.peek(), claim: claimOf(mine, prefix) }); - } // A local broadcast and an originated dynamic at one path compete on cost, as they do for requests. - for (const [path, route] of advertised ?? []) { - const front = local?.get(path); - const entries = routes?.get(path); - if (front && this.localWins(path, entries && preferredEntry(entries, received))) { - next.set(path, { identity: front, route }); - } - } - this.originated.set(next); + this.originated.set(this.candidates(received)); } /** @@ -792,6 +820,7 @@ export class Producer implements Table { const entry: RouteEntry = { identity: {}, scope: this.#scope, + claim: this.#scope.allowed?.intersect(new Path.Patterns([Path.Pattern.subtree(prefix)])), route: new Signal(route), originated, server, @@ -1339,29 +1368,32 @@ export class Consumer { #listed(patterns: Path.Patterns, hidden: boolean): Map { const next = new Map(); const covering = new CoveringRoot(this.#scope.root); - const { remote, local } = this.#state.snapshot(); const scopes = [...patterns] .sort((a, b) => Path.compareSpecificity(b.specificity(), a.specificity())) .map((pattern) => ({ pattern, head: scopeHead(pattern) })); - for (const [table, exact] of [ - [remote, false], - [local, true], - ] as const) { - for (const [path, entry] of table) { - const scope = scopes.find( + for (const [path, candidates] of this.#state.snapshot().candidates) { + // The first candidate this reader can see wins, since the preferred one overall may not be. + let entry: Candidate | undefined; + let scope: (typeof scopes)[number] | undefined; + for (const candidate of candidates) { + scope = scopes.find( ({ pattern, head }) => - (exact ? pattern.matches(path) : advertOverlaps(entry, path, pattern)) && + (candidate.exact ? pattern.matches(path) : advertOverlaps(candidate, path, pattern)) && (hidden || !hiddenBelow(head, path)), ); - if (!scope) continue; - const relative = covering.relative(path); - if (relative === undefined) continue; - next.set(relative, { - identity: entry.identity, - route: entry.route, - captures: scopeCaptures(scope.pattern, path), - }); + if (scope) { + entry = candidate; + break; + } } + if (!entry || !scope) continue; + const relative = covering.relative(path); + if (relative === undefined) continue; + next.set(relative, { + identity: entry.identity, + route: entry.route, + captures: scopeCaptures(scope.pattern, path), + }); } return next; } diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 3af39f89ff..9065dc3561 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -45,7 +45,7 @@ export interface OriginProducer { export interface OriginConsumer { routes(path: Path.Valid): boolean; readonly broadcasts: Getter | undefined>; - readonly advertised: Getter | undefined>; + readonly advertised: Getter; /** The announced local broadcast at `path`, when it is the route peers are offered there. */ local(path: Path.Valid): broadcast.Consumer | undefined; demand(path: Path.Valid): Promise; @@ -59,6 +59,12 @@ export interface Advertised { readonly claim?: Path.Patterns; } +/** + * Every originated advertisement per prefix, most preferred first. A reader takes the first + * one its scope admits, so a cheaper route it cannot use never hides one it can. + */ +export type Advertisements = ReadonlyMap; + /** The protocol-facing operation behind an established session. */ export interface Established { consume(path: Path.Valid): broadcast.Consumer; diff --git a/quest/m1/README.md b/quest/m1/README.md index 759b3c5fb7..b188550233 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -44,7 +44,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Splice edge cases](/quest/m1/splice-edges.md) - an unstamped successor, a pruned segment's boundary group, and a warm head during a takeover are each handled correctly - [Track tail hardening](/quest/m1/track-tail-hardening.md) - Rust and JS wait out a track's tail by the same rules, with the known hang, count, truncation, grace, and memory holes closed - [Session death parity](/quest/m1/session-death.md) - a local close ends tracks cleanly in both languages, and JS group readers see the session's error on session death -- [JS scoped routes](/quest/m1/js-scoped-routes.md) - a scoped JS origin reader announces the preferred route among those in its scope, like Rust, with the fan-out benchmarked - [moqsrc stop](/quest/m1/moqsrc-stop.md) - moqsrc's stop blocks until its session ends, without deadlocking on a blocked pad push - [More tests under load](/quest/m1/test-flakes-2.md) - the second round of load-only failures, fixed at the cause - [Interop contention](/quest/m1/interop-contention.md) - two `just test interop --all` matrices pass side by side, and `just test harness` runs from a clean checkout diff --git a/quest/m1/js-scoped-routes.md b/quest/m1/js-scoped-routes.md deleted file mode 100644 index 27f91eb06c..0000000000 --- a/quest/m1/js-scoped-routes.md +++ /dev/null @@ -1,37 +0,0 @@ -# [S] JS scoped readers pick from their own routes - -## Goal - -A scoped `@moq/net` origin reader, and a connection publishing that scoped -view, announces a prefix whenever some route there overlaps its scope, and -presents the preferred route among those, as moq-net does. For example, with -a cheap `*/chat` route and a costlier `*/video` route both at `""`, a -`room/video` reader sees the video route instead of nothing. - -The cost of `forwardAnnounced` fanning one advertisement out across interest -streams is measured, and fixed only if the sweep shows it matters. - -## Plan - -Correctness first. `OriginState.snapshot()` in `js/net/src/origin.ts` keeps -one `preferredEntry` per path before `#listed` applies the reader's scope, -and `rebuildOriginated()` makes the same early choice, so a reader whose -scope excludes the globally preferred entry drops the path even though -`bestEntry` would route a request through another one. moq-net filters each -entry by the cursor's scope (`RouteEntry::overlaps` in -`rs/moq-net/src/model/origin.rs`) before choosing. Keep the candidates until -the scope applies. The shared unscoped snapshot is the fast path the -`js/net/bench/broadcasts.ts` sweep guards; keep it, and run that sweep before -and after. Raised by Codex on https://github.com/moq-dev/moq/pull/4234. - -Then the fan-out. `forwardAnnounced` in `js/net/src/connection/forward.ts` -opens one announce stream per interest head and keeps a `Dynamic` plus a -`drive` task per stream, so a peer advertising a prefix above several heads -(`""` for `a/**` and `b/**`) is stored and repriced once per head, and closing -one stream can drop the entry a request was using while an identical one -remains. Extend the nightly JS benchmark with a sweep over heads and routes -(the repo's fan-out rule), then decide: if cost grows with heads times routes -at realistic sizes, share or reference-count received prefixes across -streams; if not, record the numbers in the PR and leave it. - -No public API or wire change is expected. diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index bfb20e19b5..a93764e92e 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -5670,6 +5670,25 @@ mod tests { assert_eq!(request.path().as_str(), "room/chat"); } + #[tokio::test] + async fn scoped_cursor_selects_among_the_routes_it_can_see() { + let producer = origin(1).produce(); + let scoped = |pattern: &str| { + producer + .scope("", &Patterns::from(pattern.parse::().unwrap())) + .unwrap() + }; + let _chat = scoped("*/chat").dynamic("", Route::default().with_cost(1)).unwrap(); + let _video = scoped("*/video").dynamic("", Route::default().with_cost(5)).unwrap(); + + let mut video = producer + .consume() + .scope("", &scopes(&["room/video"])) + .unwrap() + .announced(); + assert_eq!(video.assert_next_active("").cost, Cost::new(5)); + } + #[tokio::test] async fn dynamic_accepts_a_max_depth_prefix() { let producer = origin(1).produce(); From 2b3f8a83eacebab3ba24a37fc7585cb174990441 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 13:02:09 -0700 Subject: [PATCH 69/87] chore(quest): audit the quest tree (#4393) Co-authored-by: Claude Opus 5.5 --- quest/README.md | 6 +- quest/m0/README.md | 107 +++--------- quest/m0/audio-quality-harness/README.md | 25 +-- quest/m0/audio-quality-harness/browser.md | 20 ++- quest/m0/bbb-sd.md | 39 ----- quest/m0/plan-av-clock.md | 27 ++- quest/m0/release.md | 89 ---------- quest/m0/wildcard/README.md | 29 ++-- quest/m0/wildcard/demand.md | 49 ------ quest/m0/wildcard/resolve.md | 105 ------------ ...l-clock-latency-target-for-synchronized.md | 29 ++-- ...bandwidth-grant-in-moq-audio-instead-of.md | 5 + ...r-a-synchronous-decode-so-the-publisher.md | 4 +- ...otation-is-not-atomic-across-thread-per.md | 4 +- ...s-dropping-one-split-server-resizes-the.md | 5 +- ...ic-tracks-and-preserve-sequences-across.md | 24 ++- ...coder-captures-the-rewind-generation-at.md | 13 ++ quest/m1/3489-ts-import-stream-liveness.md | 6 +- quest/m1/README.md | 52 ++---- quest/m1/announce-live-apps.md | 12 +- quest/m1/announce-live-bindings.md | 33 ---- quest/m1/archive/README.md | 11 +- quest/m1/audio-codecs/encode-backend.md | 6 + .../native.md => m1/audio-quality-native.md} | 6 +- quest/m1/audio-warmup.md | 6 +- quest/m1/auth/README.md | 2 + .../auth/expired-error.md} | 7 +- quest/m1/bbr-ack-cleanup.md | 10 +- quest/m1/bench-ci.md | 6 +- quest/m1/binding-surface.md | 8 +- quest/m1/broadcast-remove.md | 21 --- quest/m1/browser-benchmarks.md | 25 +-- quest/m1/c/README.md | 17 +- quest/m1/c/package.md | 4 +- quest/m1/c/retire.md | 8 +- quest/m1/cache-expiry-growth.md | 7 +- quest/m1/cache-wall-eviction.md | 6 + quest/m1/captions-msf.md | 14 +- quest/m1/capture-control.md | 19 ++- quest/m1/cli-given-flags.md | 3 +- quest/m1/cli-inspect/README.md | 24 --- quest/m1/cli-inspect/caught-up.md | 41 ----- quest/m1/cli-inspect/ls.md | 29 ---- quest/m1/close-codes.md | 4 + quest/m1/cluster-routing.md | 12 +- quest/m1/cpp/cancel.md | 44 ++--- quest/m1/data-capture-bindings.md | 5 +- quest/m1/decoded-frames.md | 47 ------ quest/m1/doc-samples-go-dart.md | 13 +- quest/m1/drain/README.md | 2 +- quest/m1/dropped-sources.md | 2 +- quest/m1/epoch.md | 2 +- quest/m1/export-linger.md | 4 - quest/m1/ffi-shape/README.md | 22 ++- quest/m1/ffi-shape/codec.md | 13 +- quest/m1/ffi-shape/json.md | 23 +-- quest/m1/ffi-shape/media.md | 4 +- quest/m1/ffi-shape/net.md | 4 +- quest/m1/flate-binary.md | 50 ++++++ quest/m1/frame-slot-charge.md | 53 ++++++ quest/m1/gateway-live-clock.md | 32 ---- quest/m1/gpu-ci.md | 20 ++- quest/m1/gpu-pool-reservation.md | 33 ---- quest/m1/ietf-announce-count.md | 24 --- quest/m1/ietf-max-age.md | 47 ------ quest/m1/ietf-uni-stream-types.md | 23 ++- quest/m1/iroh-lite-wip.md | 6 +- quest/m1/js-announce-caught-up.md | 20 --- quest/m1/js-ietf-datagram.md | 2 +- quest/m1/js-subscribe-abandonment.md | 14 +- quest/m1/keyframe-trigger.md | 37 ---- quest/m1/kt-jvm-exit.md | 4 - quest/m1/ladder/README.md | 10 +- quest/m1/lite-count-settle.md | 28 ---- quest/m1/merge-queue.md | 11 +- quest/m1/moq-c.md | 33 ---- quest/m1/mux-wasm-target.md | 26 --- quest/m1/obs-moq-video/README.md | 10 +- quest/m1/obs-moq-video/audio-playback.md | 16 +- quest/m1/obs-moq-video/linux-bundle.md | 7 +- quest/m1/obs-moq-video/rate-control.md | 9 +- quest/m1/obs-moq-video/source.md | 12 +- quest/m1/open-gop-leading-pictures.md | 16 +- quest/m1/opus-conceal.md | 4 + quest/m1/p2p/README.md | 17 +- quest/m1/p2p/cost-scopes.md | 3 +- quest/m1/p2p/transit.md | 4 - quest/m1/p2p/watch.md | 3 +- ...relay-cpu-is-vdso-clock-reads-the-drive.md | 5 +- ...use-sendmsg-zc-for-large-udp-gso-trains.md | 8 +- ...ter-tx-pool-buffers-for-zero-copy-sends.md | 9 + quest/m1/perf/README.md | 6 +- quest/m1/perf/egress-keepalive.md | 2 +- quest/m1/perf/egress-requeue.md | 4 - quest/m1/perf/uring-one-enter.md | 2 +- quest/m1/perf/uring-quiescence.md | 3 +- quest/m1/performance-comparisons.md | 2 +- quest/m1/performance-profiles.md | 3 +- quest/m1/pop-skipping/README.md | 158 ------------------ quest/m1/pop-skipping/peer-reconfigure.md | 70 -------- quest/m1/pop-skipping/rank.md | 91 ---------- quest/m1/pop-skipping/warm-advertise.md | 51 ------ quest/m1/processor/README.md | 19 ++- quest/m1/processor/advertise-auth.md | 34 ++-- quest/m1/processor/grant-lease.md | 12 +- quest/m1/processor/media-contract.md | 15 +- quest/m1/publish-codec-string.md | 8 +- quest/m1/qos/README.md | 6 + quest/m1/qos/final-lag-sample.md | 7 + quest/m1/qos/stats/README.md | 7 +- quest/m1/qos/stats/encoder-feedback.md | 8 +- quest/m1/qos/stats/js.md | 4 - quest/m1/qos/stats/rust.md | 4 - quest/m1/quic/README.md | 35 ++-- quest/m1/quic/bbr-ack-sampling.md | 35 ---- quest/m1/quic/bbr-app-limited-edges.md | 9 +- quest/m1/quic/bbr-app-limited.md | 1 - quest/m1/quic/bbr-loss-parity.md | 1 - quest/m1/quic/bbr-loss-undo.md | 29 ---- quest/m1/quic/bbr-packet-identity.md | 32 ---- quest/m1/quic/bbr-probe-feedback.md | 28 ---- quest/m1/quic/bbr-probe-rtt.md | 30 ---- quest/m1/quic/bbr-release.md | 36 ---- quest/m1/quic/bbr-startup-pacing.md | 33 ---- quest/m1/quic/deadline.md | 7 +- quest/m1/quic/peer-limits.md | 5 +- quest/m1/quic/release.md | 2 - quest/m1/quic/upstream.md | 33 ++-- quest/m1/raw-stream-codes.md | 5 +- quest/m1/relay-memory.md | 15 +- quest/m1/remove-live.md | 50 ++++++ quest/m1/route-cost.md | 27 --- quest/m1/rs2ts/remove-wasm.md | 3 +- quest/m1/session-death.md | 5 + quest/m1/signal-race.md | 30 ---- quest/m1/signed-priority.md | 21 ++- quest/m1/track-demand.md | 8 +- quest/m1/track-tail-hardening.md | 6 +- quest/m1/track-tail-interop.md | 19 +++ quest/m1/transport-upgrade/README.md | 4 +- quest/m1/transport-upgrade/js.md | 11 +- quest/m1/ts-export-byte-schedule.md | 4 - quest/m1/ts-import-shared-shift.md | 15 +- quest/m1/video-keyframe-flag.md | 24 --- quest/m1/watch-audio-time-stretch.md | 5 +- ...ing-requirements-broadcast-contribution.md | 111 ++++-------- ...ipewire-dma-bufs-safely-into-the-vulkan.md | 91 +++------- quest/m2/703-experimental-webgpu-renderer.md | 26 --- quest/m2/823-svc-support.md | 22 --- quest/m2/README.md | 16 +- quest/m2/aac-encode-refusal.md | 22 --- quest/m2/capture-clock-source.md | 20 --- quest/m2/cat/README.md | 5 +- quest/m2/cat/verify.md | 14 +- quest/m2/cpp-conan.md | 9 +- quest/m2/cpp-vcpkg.md | 5 +- quest/m2/flate/README.md | 27 +-- quest/m2/flate/bindings.md | 72 +++----- quest/m2/flate/track.md | 55 ------ quest/m2/gop-overhead.md | 5 - quest/m2/intra-refresh/README.md | 2 +- quest/m2/intra-refresh/bindings.md | 30 ++-- quest/m2/intra-refresh/consumer-warmup.md | 30 ++-- quest/m2/js-discontinuity.md | 35 ++-- quest/m2/latency-ledger.md | 9 +- quest/m2/mobile-ownership.md | 5 - quest/m2/multipath-spike.md | 7 +- quest/m2/obs-wave-layout.md | 17 -- quest/m2/pipewire-camera-planes.md | 4 +- quest/m2/quic-bbr-google.md | 6 +- ...p-limited.md => quic-bbr-natural-drain.md} | 4 - quest/m2/quic-kernel-pacing.md | 9 +- quest/m2/quic-probe.md | 6 +- quest/m2/redundant-ingest.md | 24 ++- quest/m2/routing-cost-domains.md | 21 +-- quest/m2/stats-delta.md | 2 +- quest/m2/teleop/README.md | 18 +- quest/m2/teleop/browser-package.md | 6 +- quest/m2/teleop/docs.md | 6 +- quest/m2/teleop/mavlink.md | 21 ++- quest/m2/teleop/robot.md | 43 ++--- quest/m2/unreal.md | 1 - quest/m3/README.md | 5 +- quest/m3/dpdk.md | 7 +- quest/m3/libmoq-cmake-lib.md | 33 ---- quest/m3/libmoq-fetch.md | 24 --- quest/m3/libmoq-hidden.md | 20 --- quest/m3/libmoq-shutdown.md | 58 ------- quest/m3/suffix-announce.md | 42 +++++ quest/m3/upstream-forks.md | 4 + quest/m3/video-embedded.md | 5 + quest/m3/video-hardware.md | 6 + quest/m4/msfts-convergence.md | 4 - 193 files changed, 1150 insertions(+), 2708 deletions(-) delete mode 100644 quest/m0/bbb-sd.md delete mode 100644 quest/m0/release.md delete mode 100644 quest/m0/wildcard/demand.md delete mode 100644 quest/m0/wildcard/resolve.md delete mode 100644 quest/m1/announce-live-bindings.md rename quest/{m0/audio-quality-harness/native.md => m1/audio-quality-native.md} (91%) rename quest/{m2/auth-expired-error.md => m1/auth/expired-error.md} (61%) delete mode 100644 quest/m1/broadcast-remove.md delete mode 100644 quest/m1/cli-inspect/README.md delete mode 100644 quest/m1/cli-inspect/caught-up.md delete mode 100644 quest/m1/cli-inspect/ls.md delete mode 100644 quest/m1/decoded-frames.md create mode 100644 quest/m1/flate-binary.md create mode 100644 quest/m1/frame-slot-charge.md delete mode 100644 quest/m1/gateway-live-clock.md delete mode 100644 quest/m1/gpu-pool-reservation.md delete mode 100644 quest/m1/ietf-announce-count.md delete mode 100644 quest/m1/ietf-max-age.md delete mode 100644 quest/m1/js-announce-caught-up.md delete mode 100644 quest/m1/keyframe-trigger.md delete mode 100644 quest/m1/lite-count-settle.md delete mode 100644 quest/m1/moq-c.md delete mode 100644 quest/m1/mux-wasm-target.md delete mode 100644 quest/m1/pop-skipping/README.md delete mode 100644 quest/m1/pop-skipping/peer-reconfigure.md delete mode 100644 quest/m1/pop-skipping/rank.md delete mode 100644 quest/m1/pop-skipping/warm-advertise.md delete mode 100644 quest/m1/quic/bbr-ack-sampling.md delete mode 100644 quest/m1/quic/bbr-loss-undo.md delete mode 100644 quest/m1/quic/bbr-packet-identity.md delete mode 100644 quest/m1/quic/bbr-probe-feedback.md delete mode 100644 quest/m1/quic/bbr-probe-rtt.md delete mode 100644 quest/m1/quic/bbr-release.md delete mode 100644 quest/m1/quic/bbr-startup-pacing.md create mode 100644 quest/m1/remove-live.md delete mode 100644 quest/m1/route-cost.md delete mode 100644 quest/m1/signal-race.md delete mode 100644 quest/m1/video-keyframe-flag.md delete mode 100644 quest/m2/703-experimental-webgpu-renderer.md delete mode 100644 quest/m2/823-svc-support.md delete mode 100644 quest/m2/aac-encode-refusal.md delete mode 100644 quest/m2/capture-clock-source.md delete mode 100644 quest/m2/flate/track.md delete mode 100644 quest/m2/obs-wave-layout.md rename quest/m2/{quic-bbr-app-limited.md => quic-bbr-natural-drain.md} (95%) delete mode 100644 quest/m3/libmoq-cmake-lib.md delete mode 100644 quest/m3/libmoq-fetch.md delete mode 100644 quest/m3/libmoq-hidden.md delete mode 100644 quest/m3/libmoq-shutdown.md create mode 100644 quest/m3/suffix-announce.md diff --git a/quest/README.md b/quest/README.md index 3f2ded513b..3a687ff8d5 100644 --- a/quest/README.md +++ b/quest/README.md @@ -7,8 +7,8 @@ grouped into milestones ordered by priority. ## Plan -m0 is everything in flight now: the release API gates, the release itself, and -the reusable Pronto GPU path. m1 is the next wave across reliability, features, +m0 is everything in flight now: announce and wildcard routing, the local +origin, and audio playout (jitter target, quality harness, A/V clock). m1 is the next wave across reliability, features, performance, and planning. m2 holds later features, design studies, and experiments. m3 is deferred: work whose first step is outside this repository. m4 waits on an upstream release or external dependency to ship. Priority is @@ -17,7 +17,7 @@ under the repository rules. ## Required -- [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: the release API gates, the release, and the Pronto GPU path +- [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: announce and wildcard routing, the local origin, and audio playout - [m1: next wave](/quest/m1/README.md) - reliability, capabilities, performance, and the planning that settles their contracts - [m2: later work](/quest/m2/README.md) - deferred features, design studies, and experiments - [m3: deferred](/quest/m3/README.md) - gated on the outside world: hardware nobody has, a partner, or a provider's offer diff --git a/quest/m0/README.md b/quest/m0/README.md index e621b682fb..9aa54fa283 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -2,106 +2,41 @@ ## Goal -Settle the public contracts of `moq-archive`, `moq-e2ee`, `moq-sock`, -`moq-uring`, `moq-audio`, `moq-video`, `moq-transcode`, and `moq-nvenc` -before the imminent release, while supplying the reusable GPU media support -needed to remove raw-pixel CPU transfers from the Pronto CARLA demo. These are -independent immediate tracks rather than mutual prerequisites. Then cut the -release moq.pro adopts from the merged tree. +The work in flight now, in two independent tracks. Routing: a publisher stops +sending announce updates the wire cannot tell apart, a service claims the +prefix it could serve instead of enumerating broadcasts, and localhost workers +read only what their relay ingested. Audio playout: the target is a measured +estimate of arrival timing in both languages, a browser regression fails a +nightly run, and the audio playhead becomes the clock video follows. ## Plan -dev landed on main as #3793 on 2026-09-20; -[Release](/quest/m0/release.md) names what gates the release that follows. -Published API or wire breaks still land on dev; the quest's Plan says so. +The release API gates (#3829..#3878) and the release that followed them are +done. moq.pro tracks this repository as a submodule rather than a release, so +no release quest gates this milestone. The Pronto GPU integration lives in +moq.pro. -The archive, E2EE, and uring crates are 0.0.x; socket, audio, video, -transcode, and nvenc are 0.1.x so dependents can take compatible patches. Their -API quests gate the release; a published break to a 0.1.x crate targets dev. -Inspect transitive public exposure before changing a shared symbol: `moq-tokio` -publicly re-exports `moq-sock`'s bind module. Keep that re-export and its -current names. +Routing: announce-update dedupe is a wire-compatible fix on every version. The +wildcard line is prefix-only on the wire; its resolve and demand work is done +on the line branch and waits to land. Local origin serves the relay's +ingested-only view on the internal listener. -Keep the useful boundaries: archive owns storage and codecs, E2EE owns -protection rather than catalogs, sock owns runtime-neutral sockets, and uring -owns the local worker and its I/O. Prefer standard Rust ranges to a new public -range type. Keep uring's root `Config` reachable, since worker is private; -renaming `TxBuf` or moving bind names does not improve an ownership contract. +Audio playout: the jitter target replaces the round-trip guess. The harness's +browser lane grades it nightly and records the traces it replays; the native +lane is a standalone m1 quest, since nothing here waits on it. The A/V clock +builds on the jitter target's per-track spread. -Their package boundaries are explicit: - -- `moq-archive` replaces reversed integer-pair bounds with validated finite - `RangeInclusive` values, unifies streaming and paginated listing under - archive-owned query/entry types, and hides path helpers that are not consumer - APIs. Persisted object paths and bytes remain unchanged. -- `moq-e2ee` replaces raw/profile-global construction with application-owned - secrets and epoch-scoped `Credential`, `Generation`, `Epoch`, and track/group - handles. Raw crypto, catalog policy, retransmission internals, and the global - `Publication` registry leave the public surface. -- `moq-sock` makes incomplete reuseport groups unservable and retains every - member socket for the served group's lifetime. -- `moq-uring` derives worker and steering identity from owned sockets and - connections instead of independently supplied handles or shard values. -- Published `moq-tokio` keeps its worker signatures and `bind` re-export while - adapting internal plumbing. Its root names do not move. - -The media crates are 0.1.x too, so a published break to them targets dev. -Adapt callers in other packages without breaking their published APIs, C -layouts, or wire formats. Do not bump versions as part of these quests. - -Their package boundaries are explicit: - -- `moq-audio` owns the PCM/layout and codec configuration split, decoder entry - point, publication authority, and extensible audio frame and packet - construction. -- `moq-video` owns frame conversion and construction, decoder output policy, - synchronous codec thread confinement, capture timestamps and rational rates, - extensible group configuration and `cut` naming, and its feature defaults. -- `moq-transcode` adopts the video rate, group, output, and feature contracts in - its public configuration and observations without adding another media model. -- `moq-nvenc` narrows its safe facade around owned resources and completion, - while loading and incompatibility become fallible public errors. -- Published `moq-mux` gains only the additive shared `rate` namespace. Published - `moq-ffi`, `libmoq`, and language-binding signatures, layouts, and sentinel - behavior remain unchanged while their internals adapt. - -The agreed media direction is small, honest APIs: typed PCM layouts, rational -video rates, extensible GOP and frame records, explicit ownership, and no knobs -that claim behavior they do not provide. Synchronous codecs remain public and -thread-confined; async sinks own codec execution. Native versus CPU output is a -choice, not a promise that every native backend yields a GPU surface. OpenH264 -becomes optional but stays enabled by default. Rendering becomes opt-in; -inexpensive native codec defaults remain. - -The Pronto GPU quests remain an independent deliverable within m0. They define -portable Vulkan/CUDA ownership, safe partial NVENC initialization, and a strict -GPU conversion path without changing either API audit. Product integration and -installation live in moq.pro. - -Each implementation updates its existing README, examples, and affected docs -inline and adds regression coverage to normal or nightly CI. Feature checks -exercise each media crate independently, since workspace feature unification -hides missing gates. Cross-platform compilation and hardware execution are -separate evidence. The audits were source-based, not a cryptographic review, -fresh compilation, benchmark, or Linux runtime validation. - -API-preserving implementation, codec additions, allocation work, and hardware -proof remain in the existing backlog. Keep audio's integrated packetizing -Producer and transcode's validated Ladder and coalescing active cursor. Keep -one video Frame/Surface hierarchy and its deliberate native/wgpu type interop; -do not add another media abstraction or a renderer crate during stabilization. +Published API or wire breaks still land on dev; each quest's Plan says so. ## Required -- [Release](/quest/m0/release.md) - the release moq.pro adopts: binding docs, an upgrade page, and a staging soak gate it rather than the merge - [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - a publisher sends an announce update only when the wire route changed - [Wildcard](/quest/m0/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability - [Local origin](/quest/m0/local-origin.md) - localhost workers read only the broadcasts their relay ingested, from the internal listener -- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a browser playout latency regression fails a nightly run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess - [A/V clock](/quest/m0/plan-av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles -- [SD rendition for bbb](/quest/m0/bbb-sd.md) - `just pub bbb` publishes a pre-encoded 360p rung beside the 720p source, for localhost demos and moq.pro's fleet demo ## Related -- [Pronto GPU integration](https://github.com/moq-dev/moq.pro/tree/main/quest/main/pronto/gpu) - CARLA bridge, release adoption and desktop installation +- [Pronto GPU integration](https://github.com/moq-dev/moq.pro/tree/main/quest/m0/pronto/gpu) - CARLA bridge, release adoption and desktop installation diff --git a/quest/m0/audio-quality-harness/README.md b/quest/m0/audio-quality-harness/README.md index 0daecf0d58..457844c109 100644 --- a/quest/m0/audio-quality-harness/README.md +++ b/quest/m0/audio-quality-harness/README.md @@ -6,8 +6,10 @@ A regression in audio playout latency fails a run instead of arriving as a bug report. The harness plays a broadcast over an impaired path, counts what the listener would actually have heard (underruns, short quanta, discarded samples, skip-aheads) and what each stage of the pipeline contributed to the delay, and -grades the result against a checked-in budget. It runs in the browser and -natively, on the same jitter profiles, reporting the same numbers. +grades the result against a checked-in budget. This line is the browser; +the native lane is [Native audio +quality](/quest/m1/audio-quality-native.md), on the same jitter profiles and +reporting the same numbers. Boundaries: audio only. The stage breakdown is defined generically so video can adopt it later, but no video assertion ships here. No perceptual scoring: the @@ -15,16 +17,17 @@ grade is glitches and latency, not an opinion about how it sounds. ## Plan -Two quests, browser first, because that is where the traces and the reported -bug both are. The native lane follows against the same budgets and the same -metric schema, so the two implementations can be compared rather than merely -both passing. +Browser only, because that is where the traces and the reported bug both +are. The native lane moved to m1 as a standalone quest (decided in the +2026-09-28 quest audit): nothing in m0 waits on it, and it follows against the +same budgets and metric schema so the two implementations can be compared +rather than merely both passing. The starting point is not a blank page. The reporter on #3477 already built a -working browser harness on their fork (`fperex/moq`, branch `debug/rt-audio`): -a CDP driver, a beacon sink, a trace analyzer, a ring replay, a five-scenario -`bench.sh`, and a `compare.mjs` that prints before-and-after tables. Upstream -that rather than reinventing it. The raw traces it shipped with are gone, so +working browser lane on their fork (`fperex/moq`, branch +`debug-findings-solution`, under `test/audio-quality/`): a Playwright-driven +matrix over `moq-shaper`, a budget file graded under `--enforce`, a nightly +job, and a replay runtime. Upstream that rather than reinventing it. The raw traces it shipped with are gone, so this harness records fresh ones, and the [jitter target's watch quest](/quest/m0/audio-jitter-target/watch.md) replays them (decided with the maintainer during the merged-PR audit). @@ -47,10 +50,10 @@ each of those dimensions moves the expected floor. ## Required - [Browser](/quest/m0/audio-quality-harness/browser.md) - upstream the fork's harness, grade it against a budget, run it nightly -- [Native](/quest/m0/audio-quality-harness/native.md) - the same profiles and budgets through `moq play` on a dummy device ## Related +- [Native audio quality](/quest/m1/audio-quality-native.md) - the same profiles and budgets through `moq play` on a dummy device - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the estimator this exists to keep honest - [Latency ledger](/quest/m2/latency-ledger.md) - promotes this harness's probes into a public API - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - graded by this harness once it lands diff --git a/quest/m0/audio-quality-harness/browser.md b/quest/m0/audio-quality-harness/browser.md index 887f864544..a47aed7969 100644 --- a/quest/m0/audio-quality-harness/browser.md +++ b/quest/m0/audio-quality-harness/browser.md @@ -13,16 +13,18 @@ production path is the one without cross-origin isolation. ## Plan -Upstream the reporter's harness from `fperex/moq` branch `debug/rt-audio` -rather than rebuilding it, keeping the attribution. It already has the CDP -driver, the beacon sink, the analyzer, the ring replay, `bench.sh`'s five -scenarios, and `compare.mjs`. What it does not have is a home in `test/`, a -budget, or a schedule. +Upstream the reporter's lane from `fperex/moq` branch +`debug-findings-solution` rather than rebuilding it, keeping the attribution. +It already has a built `test/audio-quality/` lane: `just test audio-quality` +over `moq-shaper`, a Playwright-driven Chromium matrix, a `budgets.json` +graded by `grade.ts` under `--enforce`, a nightly job that keeps a failure's +run directory, and `chromium`, `safari`, and `replay` runtimes. It also +carries player, estimator, and shaper changes (checked 2026-09-28: 222 commits +ahead of and 148 behind `main`), so land the lane on its own and hold its +schema to the contract below rather than taking the branch wholesale. -- Land the driver and analyzer under `test/`, alongside the existing `interop` - and `drill` lanes, wired into the `justfile` the way they are. Playwright is - already in the tree for the harness quests, so prefer it over a bespoke CDP - driver if the switch is cheap; if it is not, say so and keep CDP. +- Land the lane under `test/`, alongside the existing `interop` and `drill` + lanes, wired into the `justfile` the way they are. - Keep the instrumentation ad-hoc for now. The probes stay a debug surface, not public API; promoting them is [Latency ledger](/quest/m2/latency-ledger.md), which nothing here waits on. diff --git a/quest/m0/bbb-sd.md b/quest/m0/bbb-sd.md deleted file mode 100644 index 287e5455ed..0000000000 --- a/quest/m0/bbb-sd.md +++ /dev/null @@ -1,39 +0,0 @@ -# [S] SD rendition for the bbb demo - -## Goal - -`just pub bbb` publishes `bbb.hang` with two video renditions, the 720p -source and a 360p ~600 kbps rung, and a player watching it through `just dev` -(including moq.dev's pages on localhost) switches between them on bandwidth -and viewport size. The SD rung is pre-encoded, so publishing costs no encode -CPU. moq.pro's always-on demo consumes the same asset. - -## Plan - -- Encode `bbb-sd.mp4`: video only, H.264 360p ~600 kbps, from `bbb.mp4` with - identical frame count and timestamps, and keyframes forced at the source's - keyframe times so both renditions switch on the same boundaries. Fragment it - like the other assets. `just pub encode-bbb-sd` now reproduces the uploaded - asset; `bbb.mp4` stays unchanged. The source has 14,313 visible frames and - two duplicate packets marked discard. Preserve the visible frames, and - extend the final SD sample duration to match the source audio's loop period: - otherwise independent inputs drift by about 39 ms per loop. -- `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. WHEP and non-multitrack RTMP serve the - 720p one, the largest, whatever the mapping order. -- Verified: both catalog renditions have a `bitrate`, and - `just pub check-bbb` compares all visible timestamps and keyframes across - three loops. Nightly CI runs this check against the hosted assets. -- Chromium with `just dev` switches down and back up on viewport changes and - explicit bitrate caps. A local UDP proxy capped at 1.3 Mbps also produced - rendered 720p -> 360p -> 720p transitions, with reported receive estimates - of about 1.63 Mbps while capped and 23.3 Mbps after recovery. -- Remaining: investigate an earlier throttle run that kept HD selected and - skipped groups for the 45-second observation window. The cause is not yet - established; the successful instrumented run does not explain it. Verify - moq.dev's localhost pages and the related moq.pro fleet integration. - -## Related - -- [moq.pro demo simulcast](https://github.com/moq-dev/moq.pro/blob/main/quest/m0/demo-simulcast.md) - the always-on fleet demo switches to this asset diff --git a/quest/m0/plan-av-clock.md b/quest/m0/plan-av-clock.md index 22f14a12c6..c82e3ff855 100644 --- a/quest/m0/plan-av-clock.md +++ b/quest/m0/plan-av-clock.md @@ -14,13 +14,14 @@ the next re-anchor. Settled: per-track handles, and this quest lands them. `sync.track("audio")` and `sync.track("video")` each report their advertised delay and measured spread, and one is nominated as the clock source. `SyncInput` -(`js/watch/src/sync.ts:21-46`, today `delay`, `buffer`, `probe`, `audio`, -`video`) breaks once, and a third track joins without another pair of inputs. -The measured spread per track comes from -[Watch](/quest/m0/audio-jitter-target/watch.md); its branch carries flat -`audioSpread` and `videoSpread` inputs in place of `probe`, which this quest -folds into the handles. `SyncInput` is a published `@moq/watch` shape, so -the break lands on dev. +(`js/watch/src/sync.ts`, today `delay`, `buffer`, and `probe`) breaks once, +and a third track joins without another pair of inputs; `Sync.register` +already keeps one jitter entry per track and grows into the handles. The +measured spread per track comes from the [Audio jitter +target](/quest/m0/audio-jitter-target/README.md) line, whose watch branch +replaces `probe` with per-track spread inputs that this quest folds into the +handles. `SyncInput` is a published `@moq/watch` shape, so the break lands on +dev. Recommendations for the implementation: @@ -29,16 +30,15 @@ Recommendations for the implementation: postMessage path (`js/watch/src/audio/ring-buffer.ts`) the worklet posts an estimate and the main thread extrapolates between posts. - Video reads a locally extrapolated clock, re-synced once per audio quantum, - so the per-frame `sync.wait()` (`js/watch/src/video/decoder.ts:332`) never + so the per-frame `sync.wait()` (`js/watch/src/video/decoder.ts`) never crosses a thread. - Transitions. On mute or audio track end the reference falls back to the wall clock at the last audio-derived value, so video does not jump. A ring re-stall reads as the playhead pausing, and the reference pauses with it. -- Reset coupling stays: `` already flushes the ring alongside - `sync.reset()` (`js/watch/src/element.ts:301`, `:620-621`). +- Reset coupling stays: `Player.reset()` (`js/watch/src/player.ts`) already + flushes the audio ring alongside `sync.reset()`. - The text renderer is the third track: it reads `sync.now()` - (`js/watch/src/text/renderer.ts:261`) to drive the cue clock and prune cues - at `:263-265`. + (`js/watch/src/text/renderer.ts`) to drive the cue clock and prune cues. - Close the player gaps [#4170](https://github.com/moq-dev/moq/pull/4170) left, since the handles own them. `Sync.received` only ever lowers its reference, so after the earliest subscribed track leaves, playback stays @@ -51,10 +51,9 @@ Recommendations for the implementation: ## Required -- [Watch](/quest/m0/audio-jitter-target/watch.md) - lands the per-track spread inputs this shape carries +- [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the estimator this sits on, and the per-track spread inputs this shape carries ## Related -- [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the estimator this sits on - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - stretching needs a clock to converge toward - [Watch worker](/quest/m1/watch-worker.md) - moves `Sync` into a worker afterwards; keep the handles free of main-thread assumptions diff --git a/quest/m0/release.md b/quest/m0/release.md deleted file mode 100644 index 81072cc0a6..0000000000 --- a/quest/m0/release.md +++ /dev/null @@ -1,89 +0,0 @@ -# [M] Cut the release moq.pro adopts - -## Goal - -The first release from the merged tree is the one moq.pro pins: every -binding's docs match the surface it exposes, an upgrade page walks a -consumer from the last main release to this one, and the merged relay has -run on staging long enough that the origin and HLS rewrites are trusted. -The binding restructure in [FFI shape](/quest/m1/ffi-shape/README.md) -follows this release rather than riding it. - -## Plan - -Write `doc/setup/upgrade.md`, one section per package group, each break -with its PR and the replacement call: - -- net: announcements are prefix routes (#3225); serving folds into - `origin::Producer::dynamic` and broadcasts announce themselves (#3400, - #3581); `Reload`/`Shared` collapse into one `Connection` (#3614, #3636); - `writeDatagram` is `insertDatagram` (#3666); reader group and frame limits - are explicit (#3647); groups expire on timestamps alone; an oversized group - aborts with GROUP_TOO_LARGE instead of shedding its head (#3585); the - `Latency` type became `max_age` and delivery order became the `Ordered` - handle (#2688, #2955); the four moq-lite stream codes sent from the - reserved range moved to 0x36-0x39 in the draft's own range; subscriptions resume - across routes sharing a first hop (#3312); the send estimate is split among - JS publishers (#3616); @moq/net and @moq/pattern mirror Rust (`consume`, `Time.Milli`, - one `readFrame()`, `InvalidPattern`); the announce and request names; and - moq-tokio's names sit under their modules (`connection::Goaway`, `cli::Duration`, - `transport::Session`, `watch::Files`, `resolve()`; #3745). -- hang and json: the catalog `timeline` is `archive` (#3612); `json` and - `binary` catalog sections (#3109) take one options object (#3640); Rust - `modify()` is fallible and a failed dropped edit aborts the track (#3644), - paired with JS `update()`/`mutate()` and the Rust `mutate()`; an fMP4 export - fragment is a group (#3573). -- watch and play: `latency` splits into `delay` and `buffer` and - `--latency-max` is renamed (#3396); `moq play` has a real playout clock with - `--delay` (#3528); Firefox hardware encoding and screen-source scaling - (#3535). -- relay and CLI: embedders own listeners and workers (#3638); the cluster - origin is constructed once (#3582); LAN discovery is partitioned by - application (#3621) and meshes CLI and relay peers (#3648); config merges - with provenance (#3587); auth is one contract, a `Request` in and a - `Grant` with a lease out, and `--auth-api-mode` is gone (#3688); the CLI parses - with usage-rs and refuses the flags it dropped (#3030); moq-native is - moq-tokio (#2896). Released spellings refuse rather than warn or silently - alias: `--cluster-linger` is gone; `--cluster-connect` needs a full URL; - TOML `connect`/`failover_delay`/`listen`/`disable_verify`/`[server]`/`[client]` - name `url`/`race`/`bind`/`insecure`/`[listen]`/`[connect]`; CLI `--origin`/ - `--name`/`--latency-max` and `publish`/`subscribe` name `--hop`/`--broadcast`/ - `--max-age` and `import`/`export`. Unused `#[deprecated]` items are gone. - JS `announced()` always drops reflected announces (`ignoreSelf` is gone); - an `oct` JWK without `kty` is refused. The gstmoq properties - `estimated-send-bitrate`/`estimated-recv-bitrate` are `estimated-*-rate` - with no alias, a runtime failure for a `gst-launch` line. -- bindings: the Go module is `moq.dev/moq` with `context.Context` on every - blocking call (#2957); `MoqAudioCodec` is an `opus()` object (#3671); the - configuration setters are fallible (#3642); durations are microseconds and - the rate estimates are `estimated_*` (#3744); `MoqVideoDecoderOutput.format` - picks I420 or RGBA decode output, additively. - -Release-notes outline, the additions worth leading with, in order of value to -a consumer: prefix routes and wildcard Pattern events (#3225, #3649); -self-announcing broadcasts and `dynamic` handles (#3400, #3581); the archive -catalog and store (#3612); the delay/buffer split and playout clock (#3396, -#3528); GROUP_TOO_LARGE (#3585); explicit reader limits and timestamp-only -expiry (#3647); publish robustness (Firefox hardware encoding, file demux, -`stalled` on lagging renditions #3630, stream resets at boundaries #3580); -relay embedding and the LAN mesh (#3638, #3648, #3621, #3587); one auth -contract with leases (#3688, #3739); data tracks and captions (#3109, #3640); one `Connection` with URL -replacement (#3614, #3636); first-hop resume and the shared send estimate -(#3312, #3616). The m0 release API quests settle archive, sock, and uring -before release without making the independent Pronto or media tracks a release -prerequisite. `moq-e2ee` ships the `moq-e2ee-00` epoch profile; the -[E2EE](/quest/m1/e2ee/README.md) questline owns its twin and interop. - -The soak bullet below is cleared by hand: the merged relay serves moq.pro -staging with `/metrics` watched and a fresh viewer joining a days-old -`moq import ts` broadcast over HLS at the end; the bounded `moq_json::window` -timeline (#3240) is what makes that hold, and only a long run proves it. dev -landed on main as #3793; before cutting, run `just check --all` and -`just test interop --all` on the release revision and record it. Then cut the release under the existing release-plz and npm workflows; this -quest bumps no versions itself. - -Public API: none beyond the required quests. Wire: none. - -## Required - -- The merged relay has soaked on moq.pro staging and the maintainer has signed it off diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 0feb062b21..6bc8b6153b 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -1,4 +1,4 @@ -# Wildcard advertisements +# [L] Wildcard advertisements ## Goal @@ -38,7 +38,10 @@ widest prefix that covers it (`**` is the root) and the request is the authority, so the advertise half of this questline is re-scoped to prefix claims resolved against pattern interest. The three workloads above still hold: the transcoder claims the root and refuses what it will not serve. -Resolve and Demand are additive and land on main. +Resolve and Demand are additive and land on main. Both are done on the line +branch (#4050 re-resolves on a refusal; 9d059b1b9 lists and demands covered +renditions in the browser player), so main no longer lists them; the line +branch moves to `quest/m0/wildcard/README` to match this path. ### What already exists, and what does not @@ -64,7 +67,7 @@ the requester's excluded hop, ordered by `route_order` (`rs/moq-net/src/model/origin.rs:633`), served on demand by the session that announced it and cached per prefix in `ServeState.served` (`:764`). That is the split-horizon-safe lookup the old `origin::Dynamic` could not provide, and it is what -[Resolve](/quest/m0/wildcard/resolve.md) now extends rather than replaces. +resolve extends rather than replaces. Request resolution, by contrast, is still prefix-only (`best_server` in `rs/moq-net/src/model/origin.rs`). The pattern matcher itself exists: @@ -123,15 +126,13 @@ field. The seed still has a floor, because standby and running claims of equal specificity do meet: a standby concrete claim (`with_cost(1000)` is the existing per-broadcast convention) shares a tier with a running publisher's - concrete announcement and with warm-advertise's exact-path warm routes. The + concrete announcement. The floor MUST exceed the deployment's enforced maximum charged-link count times its enforced maximum link cost (32 links at cost at most 5 gives a bound of 160, with producing origins seeded at 0), or a nearby standby outranks a distant running copy and the mesh starts a second encode of a stream it is already serving. That floor replaces the ad-hoc standby bias the moq.pro (downstream) transcode worker - carries today, and it is the same stride discipline - [pop-skipping](/quest/m1/pop-skipping/README.md) states for provider - economics. + carries today. - **One cost varint, not the pair.** `Cost` is `{ warm, cold }` because a relay that is carrying a broadcast discounts the warm half. A wildcard carries nothing and can never be warm, so the two halves are provably equal and the @@ -154,7 +155,7 @@ field. composer waiting for an announcement that only demand would produce. The browser player currently enforces the opposite (`js/watch`'s `#isPathAnnounced` hides a catalog rendition with no exact-path - announcement); [Demand](/quest/m0/wildcard/demand.md) makes a covering wildcard count as + announcement); demand makes a covering wildcard count as availability there. - **Refusal is a typed stream reset, with no negative cache.** An advertiser resets a subscribe it will not serve, and the reset carries which KIND of @@ -256,21 +257,13 @@ no generation, so a client that must distinguish recording generations reads the catalog's archive entry ([archive](/quest/m1/archive/README.md)) rather than announce state. -## Required - -- [Resolve](/quest/m0/wildcard/resolve.md) - a relay resolves a subscribe or - FETCH for an unannounced path against the best matching wildcard -- [Demand](/quest/m0/wildcard/demand.md) - the browser player subscribes to a - catalog-referenced broadcast a wildcard covers, breaking the lazy-rendition - deadlock - ## Related - [path-patterns](/quest/m1/path-patterns.md) - owns the pattern dialect and the shared matcher advertisements reuse - [archive](/quest/m1/archive/README.md) - an archive advertises the catch-all pattern, and its catalog names the generations a wildcard cannot -- [pop-skipping](/quest/m1/pop-skipping/README.md) - it owns the route cost and - the rank hash this reuses +- [Cluster routing](/quest/m1/cluster-routing.md) - origin selection by cost + with an HRW tie-break, built on this line's specificity - [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - derived output moves under the source's `@` segment, which the suffix patterns still match diff --git a/quest/m0/wildcard/demand.md b/quest/m0/wildcard/demand.md deleted file mode 100644 index 80be0b35ed..0000000000 --- a/quest/m0/wildcard/demand.md +++ /dev/null @@ -1,49 +0,0 @@ -# [S] Demand - -## Goal - -The browser player subscribes to a catalog-referenced broadcast a wildcard -covers, instead of hiding renditions that are not announced. Without this, a -lazily-produced rendition is a deadlock: the encoder starts on demand, and the -player never demands what it hides. - -## Plan - -The gate is JS-only, and it is already prefix-aware. -[moq#3225](https://github.com/moq-dev/moq/pull/3225) made `#isPathAnnounced` -(`js/watch/src/broadcast.ts:216`) hold the set of announced prefixes and accept -any that covers the path, so a route at `room/` already makes -`room/alice/cam.hang` selectable without naming it. What it cannot do is match -a pattern, since it tests with `Path.hasPrefix` (`:223`). - -So the remaining work is narrow: teach the JS client the wildcard -advertisement (`js/net/src/announced.ts` and `js/net/src/lite/announce.ts`, -mirroring the moq-net wildcard advertisement) -and make the covering test use `Path.Pattern` (`js/net/src/path.ts:526`) -rather than prefix containment. -Withdrawal of the last covering wildcard hides the rendition again, the same -reactive shape announcements have today. - -Do not simply delete the gate. It exists so the player does not subscribe to -absent broadcasts and so renditions appear and disappear reactively with -announcements. The Rust side needs nothing here: `moq-mux::Source` resolves -references through `request_broadcast`, which -[resolve](/quest/m0/wildcard/resolve.md) teaches to consult patterns. - -Two existing soft spots to not reintroduce: the first evaluation runs before -the announcement stream has populated, briefly hiding cross-broadcast -renditions on startup; and a token without announce visibility over the -sibling's path hides it permanently even though a direct subscribe would work. -A covering wildcard fixes the second only if patterns are forwarded under the -subscriber's scope, which advertise's rebasing rule guarantees. - -Tests: a rendition whose broadcast is covered only by a wildcard is listed and -playable, subscribing it is what starts production (the subscribe arrives -before any announcement), the rendition disappears when the last covering -wildcard is withdrawn, and a concrete announcement arriving later changes -nothing visibly. - -## Required - -- [Resolve](/quest/m0/wildcard/resolve.md) - recognizing the wildcard is useless - until the relay routes the resulting subscribe through it diff --git a/quest/m0/wildcard/resolve.md b/quest/m0/wildcard/resolve.md deleted file mode 100644 index c582df54ad..0000000000 --- a/quest/m0/wildcard/resolve.md +++ /dev/null @@ -1,105 +0,0 @@ -# [L] Resolve - -## Goal - -A relay resolves a subscribe or FETCH for an unannounced path against the best -matching wildcard. - -## Plan - -Specificity before cost is an explicit routing policy: a catch-all must not -silently take over a path still claimed by a concrete service, even when that -service refuses the request. Keep the draft and regressions aligned with it. - -[moq#3225](https://github.com/moq-dev/moq/pull/3225) built the table this quest -needs. `Consumer::request_broadcast` resolves a local broadcast first, then -`best_server`, which filters routes to those covering the path, drops any whose -hop chain contains the requester's excluded hop, keeps the longest covering -prefix, and orders the survivors by `route_order` -(`rs/moq-net/src/model/origin.rs:633`). The winning session serves the request -on demand, and `ServeState.served` (`:764`) caches the result per path so -repeat requests share one upstream subscription. A route never passes through -`origin::Dynamic`'s shared FIFO, so requester identity and the hop chain are -both available to selection. - -So this quest extends a working table rather than standing one up: teach the -route entries to hold a pattern instead of only a literal prefix, and teach -selection the tiering and pooling below. Keep the exclusion filter where it is, -applied before selection, so an out-of-band request can never be served back -through the peer that made it. - -Among the survivors, only the tier selected by the matcher's shared structural -specificity is consulted, with equal-specificity patterns forming one pool. A -refusal from that tier never falls through to a less -specific one, so `**/transcode.pro` shadows the archive's `**` for -every transcode path, matched or refused. Selection within the tier is lowest -accumulated cost first, then a hash of the REQUESTED path against each -advertiser's origin id. Keying on the request rather than the pattern is the -whole point: hashing the pattern would hand one advertiser every path matching -it. A concrete announcement is maximally specific and shadows every wildcard -regardless of cost. A terminal refusal from that concrete claim never falls -through to a wildcard; it shadows until the claim is withdrawn. - -Both lookup kinds route through this table: subscribe via `recv_subscribe`'s -existing fallback, and FETCH the same way, since the archive's whole use case -is serving stored groups to FETCH for paths nothing announces. A FETCH selects -the same advertiser through the same hash and completes without installing a -route. - -A served path is not announced downstream, so a wildcard never manufactures -announcements. But a resolved upstream SUBSCRIPTION must be installed as a -ROUTE on the path's origin node, seeded with the wildcard's accumulated cost, -not parked in the request-level `served` cache alone. Preserve the route's -wildcard provenance and specificity: installing an exact-path node must not -promote it into a concrete announcement. A concrete announcement arriving -later lands on the same node and wins by specificity, and the front's -ordinary route change moves consumers at a group boundary. A -cache-only answer would strand every bound consumer on the wildcard -subscription with nothing able to migrate or stop it. When the concrete claim -is a DIFFERENT publisher, its consumers end and resubscribe rather than -splicing, per the first-hop resume rule ([moq#3312](https://github.com/moq-dev/moq/pull/3312)); this quest's -obligation is the route install that makes selection between them possible. Repeat requests for one path -share that one route, keyed to survive the exclusion filter rather than -handing one peer's answer to another. - -An upstream reset is a refusal. A capacity code re-resolves once against the -routing table, with the refusing advertiser excluded from that attempt. The -exclusion is what makes the retry safe: the reset and the advertiser's -retraction travel independently, so the table may not have learned yet, and -re-resolution may equally find no other advertiser and return unroutable. That -is a correct outcome, not a case to handle away. Every other code, and any -unrecognized one, is terminal and propagates. Hold no state either way. - -Tests, at the process level with real sessions rather than an in-process stand-in: - -- One path always selects the same advertiser, and a fixed set of many paths - spreads across advertisers rather than piling onto one. Do not assert that two - particular paths differ: a correct hash may legitimately rank the same - advertiser first for both, so that assertion fails valid implementations. -- A request arriving from a peer that appears in the cheapest wildcard's hop - list selects a clean alternative, or fails unroutable, and never opens a - cyclic subscription. -- Two requesters with different excluded origins do not receive each other's - resolved broadcast. -- A concrete announcement from the SAME publisher takes over from the wildcard - route at a group boundary, without announcement churn; a concrete claim from a - DIFFERENT publisher ends the wildcard-served subscription instead of splicing - into it. -- A high-cost concrete claim shadows a cheaper wildcard, including after - that wildcard has served the path; a terminal concrete refusal does not - fall through while the concrete claim remains advertised. -- A FETCH for an unannounced archived path resolves through the catch-all the - same way a subscribe does. -- A capacity refusal re-resolves onto another advertiser exactly once, and a - second capacity refusal is terminal rather than looping. -- A retraction racing an in-flight request (the request arrives after the - advertiser filled its last slot) ends with the requester served by another - advertiser, or unroutable, but never hung and never looping. -- A permanent refusal does not re-resolve, so a request for a path nobody serves - costs exactly one round trip. -- A path matched by both a suffix pattern and the catch-all resolves against - the suffix pattern's pool only, and a terminal refusal from it never reaches - the catch-all advertiser. -- A refused subscribe resets rather than hanging, and leaves no state behind. -- A wildcard retracted mid-serve does not disturb the subscription already - running. diff --git a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md index 53be1396c0..b85a2da169 100644 --- a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md +++ b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md @@ -1,11 +1,11 @@ -# [S] hang: expose the broadcast wall clock +# [XS] hang: document the broadcast wall clock ## Goal -A browser application can read the broadcast's fixed PTS-to-wall mapping -through `js/hang`, alongside archive timeline records when present, so an application that knows its -viewers share a clock can compute the delay that renders one frame at one -instant everywhere, and a DVR view can map presentation time to wall time. +A browser application can find how to map presentation time to wall time +from `doc/lib/js/hang.md`, so an application that knows its viewers share a +clock can compute the delay that renders one frame at one instant everywhere, +and a DVR view can label its timeline. The library itself never synchronizes playback on wall time. That is the decision behind [#2278](https://github.com/moq-dev/moq/issues/2278): frame @@ -17,16 +17,15 @@ sync exchange over a track. ## Plan -Expose the catalog contract selected by the continuous broadcast clock quest, -using one mapping across tracks and source restarts. The current timeline is -a broadcast-wide segment index, not a per-rendition track. Reuse the existing -consumer and signal machinery where present; do not assume the old `setWall` -producer or create per-record clock epochs. Keep `js/watch` arrival-based Sync -unchanged. Document PTS-to-wall conversion and the requirement that an -application knows whether remote clocks are synchronized. - -Verify application access using the built-in publisher integration, including -a live-only broadcast with no archive timeline. +The API already exists: the catalog root's optional `clock` (`ClockSchema`, +`Clock`) and `wallClockTime(clock, pts, ptsTimescale)` in +`js/hang/src/catalog/clock.ts`, exported from `@moq/hang/catalog`. Only the +docs are missing. Add a short section to `doc/lib/js/hang.md`: read `clock` +from a catalog root, convert a frame's PTS with `wallClockTime`, note that a +live-only broadcast carries it without an `archive` entry, that the mapping is +fixed for the broadcast's life, and that comparing wall times across machines +requires the application to know their clocks are synchronized. Keep +`js/watch` arrival-based Sync unchanged. ## Closes diff --git a/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md b/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md index 38e28ccf8d..946a68a0a6 100644 --- a/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md +++ b/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md @@ -33,6 +33,11 @@ grant; `Options::bandwidth` documents that their own loop; the capture driver's `_reservation` goes away. Public entry points for a manual ceiling stay `Producer::set_bitrate` and `Encoder::set_bitrate`. +- The audio-codecs line branch moves the Opus encoder behind a backend seam: + the rate setter and its floor live in + `rs/moq-audio/src/encode/backend/libopus.rs` there, and + `Encoder::set_bitrate` dispatches through the backend. Target whichever + shape is on the base when this starts. - Floor: `set_opus_bitrate` refuses anything outside `opus::bitrate_floor(codec_rate, frame_size).max(500)` to `300_000 * channels` (`encoder.rs`, `rs/moq-audio/src/opus.rs`). `Policy::min` defaults to a tenth of diff --git a/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md b/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md index 4496a84f70..9f04697e6c 100644 --- a/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md +++ b/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md @@ -26,11 +26,11 @@ buffered bytes run out. `tryDecode` returns undefined and consumes nothing in that case, and `decode`/`decodeMaybe` are the one async driver that fills and retries. The primitives (`u62`, `u53`, `read`, `string`, ...) are that driver applied to the `Cursor` reads, and the group and FETCH frame loops drain every -buffered frame with `tryDecode` (`js/net/bench/frames.ts`). The 23 +buffered frame with `tryDecode` (`js/net/bench/frames.ts`). The 22 `static async decode` message decoders under `js/net/src/lite/`, plus four `decodeMaybe` variants, still await a primitive per field. -- Convert all 23 decoders to a single synchronous body over a `Cursor`, with +- Convert all 26 decoders to a single synchronous body over a `Cursor`, with the async form as `reader.decode(...)` rather than a second copy. `Message` in `lite/message.ts` becomes a sync size-prefixed wrapper. - The publisher drains controls synchronously in its loop and diff --git a/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md b/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md index 1ec7587a17..00547d3bf6 100644 --- a/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md +++ b/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md @@ -21,8 +21,8 @@ own `tls::reload_certs` watcher, and snapshots its own mTLS roots. endpoint. `uring::Workers::bind` reads one pair once and never reloads. The primitive already exists: `ServeCerts` implements -`rustls::server::ResolvesServerCert` (`rs/moq-tokio/src/tls.rs:2857`) and -`reload_certs` (`:2931`) swaps its contents from the file watcher. What is +`rustls::server::ResolvesServerCert` in `rs/moq-tokio/src/tls.rs`, and +`reload_certs` there swaps its contents from the file watcher. What is missing is sharing it. - Build the `ServeCerts` and its watcher once, on the shared runtime, in diff --git a/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md b/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md index 43f4a0afed..891110d96d 100644 --- a/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md +++ b/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md @@ -25,8 +25,9 @@ failing. Check the socket group and connection-ID steering on a surviving session while unused handles are dropped, and prove all serving stops when the group terminates. Wire the tests into normal or nightly CI. -Public API: no further `moq-tokio` ownership change. The prerequisite may -change `moq-sock`'s 0.1.x API. Wire: no format change. Close #2964 only when +Public API: no further `moq-tokio` ownership change. The hardened group +already exists (`Group::bind` and `Group::complete` over `Claim` in +`rs/moq-sock/src/shard.rs`). Wire: no format change. Close #2964 only when both the dev ownership proof and this integration are complete. ## Closes diff --git a/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md b/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md index 0db7792501..5369139899 100644 --- a/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md +++ b/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md @@ -15,22 +15,21 @@ but the Rust and JavaScript models violate different parts of that invariant. ### Rust resets sequences when a dynamic producer is replaced A closed dynamic track is removed from the broadcast's weak cache. The next -subscription creates a fresh `track::Request` (`rs/moq-net/src/model/track.rs:3779`), -and `Request::new` creates a fresh `TrackState`. Because `max_sequence` -(`:199`) is empty, both `append_group` (`:1184`) and `append_datagram` -(`:1216`) restart at sequence 0. +subscription creates a fresh `track::Request` (`rs/moq-net/src/model/track.rs`), +and `Request::new` creates a fresh `TrackState`. Because its `max_sequence` +is empty, both `append_group` and `append_datagram` restart at sequence 0. That conflicts with the relay's logical track splicing. -`resume::Producer::takeover` (`rs/moq-net/src/model/resume.rs:328`) retains +`resume::Producer::takeover` (`rs/moq-net/src/model/resume.rs`) retains the previous live edge and starts a replacement at `latest + 1`. Groups from a restarted producer are therefore filtered until its counter catches up, causing the same playback stall fixed for JavaScript in #2953. -The takeover tests in `resume.rs` (`takeover_computes_boundary` `:2313`, -`takeover_splices_mid_group` `:3257`, -`takeover_splices_a_replacement_that_resends_the_head` `:3346`, -`takeover_rolls_past_a_finished_group` `:3467`, -`takeover_after_empty_segment_keeps_live_edge` `:3617`) create their +The takeover tests in `resume.rs` (`takeover_computes_boundary`, +`takeover_splices_mid_group`, +`takeover_splices_a_replacement_that_resends_the_head`, +`takeover_rolls_past_a_finished_group`, +`takeover_after_empty_segment_keeps_live_edge`) create their replacement groups with explicit sequences, so none of them exercises `append_group()` on a restarted producer; using it there would create group 0 and leave the subscriber stalled. Those are the tests to extend. Explicit group @@ -40,7 +39,7 @@ making the catch-up window longer. ### JavaScript permits concurrent same-name dynamic producers `BroadcastProducer.subscribe()` calls the internal `subscribe` with -`register = false` (`js/net/src/broadcast.ts:49-55`). Multiple publishing-side +`register = false` (`js/net/src/broadcast.ts`). Multiple publishing-side subscriptions for the same name therefore enqueue independent requests and create independent `track.Producer` instances. @@ -59,8 +58,7 @@ multiple subscribers fanning out from it. one request and share its accepted producer. - Subscription options from all subscribers remain aggregated on that request. `track::Request` already does this in Rust: it carries `prev_subscription` - (`track.rs:3786`) and re-combines the aggregate whenever a subscriber - changes (`:3905-3919`). + and re-combines the aggregate whenever a subscriber changes. - After that producer closes, a later request creates a new producer but continues the group and datagram sequence namespace for that broadcast and name. diff --git a/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md b/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md index 059adeefb5..ac20db956b 100644 --- a/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md +++ b/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md @@ -31,6 +31,19 @@ The container signals a playhead generation, not a codec reset: native decode stops flushing on it. This quest is whether watch still calls `decoder.reset()` to drop in-flight WebCodecs chunks when that generation bumps. +Reproduce before fixing; this is likely a false positive. Since +[#3711](https://github.com/moq-dev/moq/pull/3711) timelines only move +forward: a discontinuity continues from the live edge and the Rust consumer +refuses a rewind (`TimestampRewind`), so every chunk queued before the bump is +stamped below the new group, not against a distant new timeline. A +`decoder.reset()` would also contradict the documented "not a decoder flush" +contract of the discontinuity counter (`container::Consumer::discontinuity`, +and `continuous` in `js/hang/src/container/consumer.ts`), and the same +finding was ruled a false positive for native play in +[#4374](https://github.com/moq-dev/moq/pull/4374). If a stale frame cannot be +made to surface in a browser harness, close #3056 with that evidence and +delete this quest instead of adding the reset. + ## Closes - [#3056](https://github.com/moq-dev/moq/issues/3056) - close this issue when the quest finishes diff --git a/quest/m1/3489-ts-import-stream-liveness.md b/quest/m1/3489-ts-import-stream-liveness.md index abae2dc67b..8107a665b5 100644 --- a/quest/m1/3489-ts-import-stream-liveness.md +++ b/quest/m1/3489-ts-import-stream-liveness.md @@ -32,8 +32,10 @@ audio half is visible, late, through #3372's resync line. resync message. The SRT gateway reports nothing today; that surface is [SRT import stats](/quest/m1/srt-import-stats.md). - Name and shape the counters so the TR 101 290 quest adopts them as its - `PID_error` check, and leave the catalog `stalled` bit alone; that is the - ladder and client stats work. + `PID_error` check, and leave the catalog `stalled` bit alone: the importer + already sets it for a quiet video PID (`Stream::tick` after each decode + batch, #3630). These counters add no timeout; anything that must bound a + wait on a silent PID (the shared-shift quest) brings its own. - Tests with the issue's stimulus shape: suppress one PID's PES while keeping its PCR and continuity legal, assert the row's count stops and the gap grows; audio and SCTE-35 arms. diff --git a/quest/m1/README.md b/quest/m1/README.md index b188550233..93cdfc7aad 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -18,27 +18,19 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Required - [Cluster routing](/quest/m1/cluster-routing.md) - an announcement says where a broadcast originates, not how to reach it, and a relay hears only the prefixes its clients asked for -- [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached -- [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` - [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` - [Go cancel test](/quest/m1/go-origin-gc.md) - the Go request-cancel test keeps its origin alive, so the collector can't close it mid-test - [Worker socket count](/quest/m1/worker-socket-count.md) - the moq-tokio worker test counts only its own listener's sockets -- [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race -- [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay -- [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below +- [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi and every wrapper configure and observe audio playout delay +- [moq-binary folds into moq-flate](/quest/m1/flate-binary.md) - on dev, moq-flate and @moq/flate own the opaque snapshot and stream tracks and moq-binary is deleted +- [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, flate, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone - [Error messages](/quest/m1/error-display.md) - Python, Go, and Dart print `MoqError` with Rust's message, as Kotlin and Swift do -- [Remove finish](/quest/m1/broadcast-remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause -- [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 - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [Drain before close](/quest/m1/drain-before-close.md) - a closing client delivers its queued stream finishes, so `moq import` ends the catalog cleanly over a real relay - [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport - [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one -- [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Live in apps](/quest/m1/announce-live-apps.md) - the demo and `@moq/room` show "no broadcasts" from the `live` marker, which waits for the first session on page load -- [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 - [kio waiter overflow](/quest/m1/kio-waiter-lost.md) - a retained `Waiter` past 8 lists stops adding a duplicate entry to lists it already recorded - [Capture re-anchor](/quest/m1/capture-reanchor.md) - a repeating or restarting device clock never rewinds native capture during a fast backlog drain - [Splice edge cases](/quest/m1/splice-edges.md) - an unstamped successor, a pruned segment's boundary group, and a warm head during a takeover are each handled correctly @@ -52,38 +44,37 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container - [Accept-side flags](/quest/m1/cli-given-flags.md) - dial-only and local verbs refuse every `--listen-*` flag instead of ignoring it - [JS catalog path](/quest/m1/js-catalog-path.md) - `@moq/net` broadcast consumers expose their path and `Catalog.watch` rejects escaping references, like Rust +- [#2075](/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - @moq/publish gates the first catalog snapshot until every reserved track is described - [Full codec string](/quest/m1/publish-codec-string.md) - browser-published video carries the encoder's full RFC 6381 codec string, so native players decode it - [TS export jitter](/quest/m1/ts-export-jitter.md) - the video reorder bound follows later catalogs and the declared reorder depth, so a late B-frame never reorders TS output; an undeclared stream can reorder once per new maximum depth - [TS import shared shift](/quest/m1/ts-import-shared-shift.md) - unflagged loop wraps move audio and video by one shift, so A/V sync holds across wraps - [PipeWire duplicate cameras](/quest/m1/pipewire-dup-cameras.md) - a webcam lists once with PipeWire enabled - [Catalog wall clock](/quest/m1/catalog-wall-clock.md) - `Clock::wall_clock` keeps the catalog's full precision instead of truncating to milliseconds - [IPv6 TLS names](/quest/m1/ipv6-tls-name.md) - dialing an IPv6 literal completes TLS on WebSocket as on noq, including a bare `::1` host override -- [Capture control](/quest/m1/capture-control.md) - on dev, `encode::Capture` replaces `CaptureOptions`, an unsupported `cut()` errors, and dropping the last `Control` cancels in-flight opens +- [Capture control](/quest/m1/capture-control.md) - on dev, `encode::Capture` replaces `CaptureOptions` without a `clock` field (it reads the catalog's), an unsupported `cut()` errors, and dropping the last `Control` cancels in-flight opens - [Video surface](/quest/m1/video-surface.md) - on dev, moq-ffi's `native` becomes `surface`, refused on platforms with no surface - [HLS discontinuity sequence](/quest/m1/hls-discontinuity-sequence.md) - on dev, `Segment::discontinuity` is the absolute sequence, so every cursor agrees -- [Strict Redirect::resolve](/quest/m1/redirect-resolve.md) - on dev, `Redirect::resolve` can no longer quietly turn a refused redirect into a redial - [RTMP TLS only](/quest/m1/rtmp-tls-only.md) - an RTMP listener configured for TLS can refuse plaintext instead of sniffing and serving it - [HLS linger](/quest/m1/hls-linger.md) - `moq_hls::Server` serves an ended broadcast for its playlist window plus grace, so the moq.pro edge drops its own pool -- [Gateway live clock](/quest/m1/gateway-live-clock.md) - moq-srt, moq-rtmp, and HLS import publish on the broadcast clock, so encoder reconnects don't restart timestamps +- [Remove live()](/quest/m1/remove-live.md) - on dev, importers publish stream timestamps verbatim, the catalog clock maps them to wall time, and an encoder restart becomes a new epoch - [iroh versions](/quest/m1/iroh-lite-wip.md) - `iroh://` negotiates the configured versions, so `moq-lite-07-wip` can be opted into - [Go and Dart doc samples](/quest/m1/doc-samples-go-dart.md) - Go and Dart doc samples compile against their wrappers - - [Data capture in bindings](/quest/m1/data-capture-bindings.md) - moq-ffi and every wrapper pass a data frame's capture time, and the JSON window producer takes one - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass - [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust +- [#2991](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - one dynamic producer per track name in both languages, with the sequence namespace surviving a replacement - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers -- [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships +- [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; the catalog entry and format may break in place, since no archives exist - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest - [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 +- [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` - [Shaper virtual time](/quest/m1/shaper-virtual-time.md) - `moq-shaper` tests judge seeded decisions on paused time, not on wall-clock delivery under load - [Rust compressed gate test](/quest/m1/json-compressed-gate-rs.md) - `rs/moq-json` proves a snapshot delta is gated on its encoded size - [Shrink the JS overflow test](/quest/m1/json-rolls-snapshot-test.md) - the `js/json` roll-on-overflow test uses a small `maxGroupBytes` budget instead of megabytes of JSON -- [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed - [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 - [Generated C bindings](/quest/m1/c/README.md) - C generated from moq-ffi ships as `moq-c` 0.8.0 and replaces the hand-written libmoq -- [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 +- [OBS native codecs](/quest/m1/obs-moq-video/README.md) - replace FFmpeg video and audio decoding with moq-video and moq-audio, deliver GPU frames, and use native audio/video encoders - [Opus concealment](/quest/m1/opus-conceal.md) - a lost Opus packet conceals the last packet's length, not 120 ms - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 - [Opus catalog rate](/quest/m1/opus-catalog-rate.md) - MKV Opus import publishes the 48 kHz codec rate in the catalog, not the OpusHead input rate @@ -91,19 +82,15 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [CMAF surround Opus](/quest/m1/cmaf-opus-surround.md) - fMP4 import and export carry an Opus channel mapping table - [js/hang dOps pre-skip](/quest/m1/js-dops-pre-skip.md) - CMAF encoding in js/hang stops hard-coding a 312-sample pre-skip - [GPU CI](/quest/m1/gpu-ci.md) - NVIDIA tests run nightly on a self-hosted GPU runner, and `just rs nvidia` runs them locally instead of skipping -- [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match - [JS rendition ranking](/quest/m1/js-ranked.md) - `@moq/hang` ranks video renditions like Rust, and `@moq/watch`'s fallback uses it - [Audio rendition pick](/quest/m1/audio-ranked.md) - single-track FLV/RTMP and WHEP serve the best audio rendition, not the first by name - [FLV rebind before header](/quest/m1/flv-rebind.md) - single-track FLV switches to a better rendition announced before the header - [FLV catalog stream](/quest/m1/flv-catalog-stream.md) - on dev, `flv::Export` takes a catalog stream like fmp4, replacing `with_select` -- [Keyframe trigger](/quest/m1/keyframe-trigger.md) - an application can ask the built-in capture encoder for a keyframe -- [Video keyframe flag](/quest/m1/video-keyframe-flag.md) - encoded video marks its keyframes, so a requested cut never forces an extra one after a cadence keyframe -- [QoS](/quest/m1/qos/README.md) - broadcast health: relay starvation and timeliness histograms, and client stats broadcasts from publishers and viewers +- [Own the QUIC stack](/quest/m1/quic/README.md) - the moq-noq fork carries ACK progress, reliable reset, hierarchical scheduling, deadlines, peer limits, and qmux +- [QoS](/quest/m1/qos/README.md) - broadcast health: relay starvation and timeliness histograms, and client stats broadcasts from publishers and viewers, on dev - [Drain](/quest/m1/drain/README.md) - relay restarts drain sessions over GOAWAY instead of hard-dropping them +- [Strict Redirect::resolve](/quest/m1/redirect-resolve.md) - on dev, `Redirect::resolve` can no longer quietly turn a refused redirect into a redial - [Transport upgrade](/quest/m1/transport-upgrade/README.md) - a session that came up over WebSocket moves to QUIC once the QUIC dial lands, handing over at a group boundary -- [Own the QUIC stack](/quest/m1/quic/README.md) - the moq-noq fork carries - ACK progress, reliable reset, hierarchical scheduling, deadlines, probing, - keep-alive, peer limits, careful resume, ECN, and qmux - [P2P](/quest/m1/p2p/README.md) - opted-in clients serve each other over data channels and iroh while the relay stays the rendezvous and the fallback, under application policy - [One port](/quest/m1/one-port/README.md) - a relay speaks QUIC, STUN, WebRTC media, and SRT on one UDP port and HTTP, RTMP, and RTMPS on one TCP port - [Signed priority](/quest/m1/signed-priority.md) - on dev, every API priority is an `i8` with 0 as the unset midpoint, and hang's built-ins sit above it @@ -129,26 +116,25 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies - [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on every version - [Plan: cache age-out](/quest/m1/cache-wall-eviction.md) - a swept benchmark decides whether the track cache ages groups out on wall time without a write +- [Frame slot charge](/quest/m1/frame-slot-charge.md) - a group's frame slots past the first four count against the cache pool, including capacity a released group keeps - [Relay memory](/quest/m1/relay-memory.md) - remeasure what an announcement costs after prefix routes -- [PoP skipping](/quest/m1/pop-skipping/README.md) - short cold paths for unpopular broadcasts without losing warm backhaul dedup -- [Route cost in the JS origin](/quest/m1/route-cost.md) - the browser origin ranks routes by cost and hops like Rust instead of newest-first - [Front parking](/quest/m1/origin-front-parks.md) - an unroutable request waits on a front instead of re-asking on every route-table move - [Publish channel count](/quest/m1/publish-audio-channel-count.md) - forcing a channel count on an Audio.Capture stops costing the subscriber gaps of silence - [JS abandonment](/quest/m1/js-subscribe-abandonment.md) - a viewer returning during IETF subscribe setup keeps its track across microtasks - [IETF stream types](/quest/m1/ietf-uni-stream-types.md) - padding streams are discarded stream-only and an unknown uni type closes the session, per draft-21 -- [#2991](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - one dynamic producer per track name in both languages, with the sequence namespace surviving a replacement - [Epoch primitive](/quest/m1/epoch.md) - one `Epoch` type in moq-net and @moq/net, carried as a trailing `@` path segment, shared by e2ee and broadcast epochs - [E2EE](/quest/m1/e2ee/README.md) - TypeScript and Rust peers interoperate over encrypted broadcasts no relay can decrypt - [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - each publish of a name gets a fresh `@` epoch, viewers follow the newest live one at once, and bare names still resolve on every version -- [Processor](/quest/m1/processor/README.md) - a customer-run worker publishes an on-demand contribution with scoped access +- [Processor](/quest/m1/processor/README.md) - a customer-run worker publishes an on-demand contribution under its own service prefix with scoped access - [#3056](/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - watch: the video decoder resets on a declared discontinuity - [#933](/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - the catalog rotation follows the live camera's orientation -- [#2075](/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - @moq/publish gates the first catalog snapshot until every reserved track is described - [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the Opus producer follows its bandwidth grant through the settled `moq_mux::rate::Control` - [Ladder](/quest/m1/ladder/README.md) - a transcode ladder adapts to the uplink it publishes over, instead of encoding every live rung at its ceiling - [LOC duration marker](/quest/m1/loc-duration-marker.md) - LOC producers write the marker once released consumers skip it -- [#2278](/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md) - hang: expose the fixed catalog-root broadcast clock without synchronizing library playback to wall time +- [#2278](/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md) - hang: document reading the catalog-root clock and converting PTS to wall time, without synchronizing library playback to wall time - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - js/watch: the audio ring converges by time-stretching instead of skipping or going silent +- [Native audio quality](/quest/m1/audio-quality-native.md) - the browser lane's profiles, budgets, and metric schema run against `moq play` on a dummy device +- [fMP4 emsg](/quest/m1/emsg.md) - event messages survive fMP4 import, and the timed-metadata contract ID3, SCTE-35, and FLV script tags share is settled with them - [#2279](/quest/m1/2279-hang-typed-scte-35-ad-cue-signaling-carried-opaquely.md) - hang: SCTE-35 cues arrive immediately on an independent metadata track, optionally associated with a rendition - [Caption import](/quest/m1/captions-import.md) - fMP4 and MKV subtitle tracks import as text renditions instead of erroring or being dropped - [MSF caption roles](/quest/m1/captions-msf.md) - an MSF caption, subtitle, or sign-language track survives conversion to a hang catalog @@ -165,7 +151,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [SRT import stats](/quest/m1/srt-import-stats.md) - the SRT gateway reports the same per-stream counters instead of nothing - [Text availability](/quest/m1/text-schema.md) - a text track publishes its own coverage index instead of copying the media timeline - [ID3 catalog section](/quest/m1/id3.md) - timed ID3 as a first-class container-neutral catalog section -- [fMP4 emsg](/quest/m1/emsg.md) - event messages survive fMP4 import, and the timed-metadata contract ID3, SCTE-35, and FLV script tags share is settled with them - [FLV script tags](/quest/m1/flv-script.md) - onMetaData and AMF data messages survive RTMP and FLV import - [Release profile](/quest/m1/release-profile.md) - every release build gets fat LTO, one codegen unit, and stripping from the workspace profile instead of three script exports - [Size report](/quest/m1/size-report.md) - a nightly job reports every shipped artifact's size, native and JS, and alerts when one grows @@ -179,7 +164,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - a Kotlin/JVM program exits cleanly whatever the moq-ffi runtime thread is doing, like Python does since #3766 - [Dart publish](/quest/m1/dart-publish.md) - the packages are built and dry-run clean but exist nowhere consumers can install from - [Dart codec parity](/quest/m1/dart-codecs.md) - Dart is the one binding that cannot originate media -- [moq-mux on wasm32](/quest/m1/mux-wasm-target.md) - the crate's two wasm blockers are fixed and the target stays in the clippy lane - [#2850](/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md) - js/net: decode messages synchronously from buffered bytes and delete the publisher read-ahead queue - [Install moq](/quest/m1/moq-installer.md) - one command installs or upgrades the released CLI on macOS and Linux - [Install URL](/quest/m1/moq-install-url.md) - moq.dev serves the canonical installer at /install.sh diff --git a/quest/m1/announce-live-apps.md b/quest/m1/announce-live-apps.md index b9e7a4a995..c6f9969516 100644 --- a/quest/m1/announce-live-apps.md +++ b/quest/m1/announce-live-apps.md @@ -10,7 +10,9 @@ origin stream opened before the first connection goes live. ## Plan -- #4261 (on `dev`) adds the `live` event; the consumers it touches +- #4261 (on `dev`) adds the `live` event, and #4266 the same marker in the + bindings. Open #4384 renames the announce events to Start/Update/End/Live; + follow its names if it lands first. The consumers it touches (`demo/web/src/index.ts`, `js/room/src/room.ts`, `js/watch/src/broadcast.ts`, `js/moq-boy`) skip it today. - Page load: an origin stream opened before any session connects has no @@ -32,11 +34,3 @@ origin stream opened before the first connection goes live. Public API: when `@moq/net` emits `live` changes; any state signal on `@moq/room` or `@moq/watch` is additive. Lands on `dev` with #4261. Wire: none. - -## Required - -- [JS caught up](/quest/m1/js-announce-caught-up.md) - the `live` marker (#4261) - -## Related - -- [Bindings caught up](/quest/m1/announce-live-bindings.md) - the same marker in the bindings diff --git a/quest/m1/announce-live-bindings.md b/quest/m1/announce-live-bindings.md deleted file mode 100644 index fe2464bbab..0000000000 --- a/quest/m1/announce-live-bindings.md +++ /dev/null @@ -1,33 +0,0 @@ -# [M] Bindings see when an announce consumer has caught up - -## Goal - -moq-ffi, libmoq, and every wrapper (`py`, `swift`, `kt`, `go`, `dart`) yield -the same flat announce event Rust does: `Announced`, `Updated`, or `Retracted` -carrying the announce, or `Live` once every route live at subscribe time has -been delivered. A binding app can list what is live and stop, with no timer. - -## Plan - -Mirror the Rust `announce::Event` from -[Caught up](/quest/m1/cli-inspect/caught-up.md) one to one: - -- moq-ffi: `MoqAnnounceConsumer::next` returns `MoqAnnounceEvent`, a uniffi - enum with the four variants, replacing `MoqAnnounceUpdate::active()`. Stop - filtering `Live` in `rs/moq-ffi/src/origin.rs`. -- libmoq: the `on_announce` handle's `moq_announce_update.kind` gains a LIVE - value with no route fields, replacing the active flag. Stop skipping - `Event::Live` in `rs/libmoq/src/origin.rs`. -- Hand-written wrappers and `doc/lib/{py,swift,kt,go,dart,c}` follow, with a - test per binding that an empty origin still yields `Live`. - -Public API: breaking in moq-ffi, libmoq's C ABI, and every wrapper, so it -retargets to `dev` with the Rust break. Wire: none. - -## Required - -- [Caught up](/quest/m1/cli-inspect/caught-up.md) - settles the event shape this mirrors - -## Related - -- [JS caught up](/quest/m1/js-announce-caught-up.md) - the same event in `@moq/net` diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index cf79f69604..dd334aa40b 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -13,10 +13,19 @@ After normal catalog discovery, an HLS media playlist is generated by downloading only the timeline, never media objects. The catalog's root `archive` entry and the `moq-archive` object layout have -landed. The rest of the line is additive on top of them. +landed on main; the line may still reshape both. ## Plan +### Compatibility + +Decided (2026-09-28): the line may break the hang `archive` catalog entry and +the recording format in place on `main`, without a version bump or a `dev` +detour (for example the track-timeline rework in #4280). No archives exist +yet, so nothing recorded or published depends on either shape. This is an +exception to the main/dev rule for this line only; once a release ships +recordings, later format changes go through the entry's format version. + ### Landed The segment engine is in `rs/moq-mux/src/timeline.rs`: diff --git a/quest/m1/audio-codecs/encode-backend.md b/quest/m1/audio-codecs/encode-backend.md index 5c6dbc1073..8d18a9a16f 100644 --- a/quest/m1/audio-codecs/encode-backend.md +++ b/quest/m1/audio-codecs/encode-backend.md @@ -28,6 +28,12 @@ quest adds AAC through platform encoders; no software AAC dependency is selected backend's first packet. A backend that reports its own header (a magic cookie, `csd-0`, `MF_MT_USER_DATA`) must produce one equal to the synthesized ASC, asserted in its tests. +- The line branch's `aac-encode-refusals` quest makes `Config::encode` refuse + a channel count no channelConfiguration names instead of writing stereo, so + that synthesis is fallible and `Producer` refuses such a layout at + construction. JS matches: `@moq/hang`'s `audioSpecificConfig` throws for the + same counts (#4119, on the line). This absorbs the former m2 + aac-encode-refusal quest, a duplicate of that line quest. - Frame size is the codec's (1024 samples for AAC), so `frame_duration` is validated per codec rather than against the Opus table. - Bitrate updates go through the backend; one that cannot change rate diff --git a/quest/m0/audio-quality-harness/native.md b/quest/m1/audio-quality-native.md similarity index 91% rename from quest/m0/audio-quality-harness/native.md rename to quest/m1/audio-quality-native.md index a614645dd3..ff4d16ab81 100644 --- a/quest/m0/audio-quality-harness/native.md +++ b/quest/m1/audio-quality-native.md @@ -40,7 +40,11 @@ budget. Only then compare end-to-end totals, with the backend-dependent stages isolated: this lane deliberately accepts real device callback noise, so a difference in totals alone proves nothing about the estimator. +Standalone in m1 rather than a child of the m0 [Audio quality +harness](/quest/m0/audio-quality-harness/README.md) line (decided in the +2026-09-28 quest audit): nothing in m0 waits on it. The native jitter target +it grades is done on the jitter target line. + ## Required - [Browser](/quest/m0/audio-quality-harness/browser.md) - defines the metric schema, the budget file, and the extracted shaper -- [Native jitter target](/quest/m0/audio-jitter-target/native.md) - the estimator this lane grades and compares against the browser; without it there is no target series and the budgets would be set against a playout path that holds nothing diff --git a/quest/m1/audio-warmup.md b/quest/m1/audio-warmup.md index 926c6321a7..1b50e058d5 100644 --- a/quest/m1/audio-warmup.md +++ b/quest/m1/audio-warmup.md @@ -15,14 +15,16 @@ frames decode independently and set nothing; HE-AAC is out of scope. publish `warmup` for Opus; `js/publish` does the same for its Opus track. - `rs/moq-audio/src/decode` already trims Opus `pre_skip` at stream start; the warmup trim is the same mechanism keyed on the container consumer's - non-continuous signal, and the subscription's maximum age grows by `warmup` - as the video consumer quest does. `js/watch` audio mirrors it. + non-continuous signal (added in Rust by the open-GOP quest), and the + subscription's maximum age grows by `warmup` as the video consumer quest + does. `js/watch` audio mirrors it. - Tests in both languages: a mid-stream join discards exactly the warmup span and a continuous listener loses nothing. ## Required - [Catalog warmup](/quest/m1/catalog-warmup.md) - the field this reads and writes +- [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - adds the Rust non-continuous signal the trim keys on ## Related diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index dcba80f86f..b95317d571 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -108,6 +108,8 @@ existing lite-06 ALPN. grant does not, and REQUEST_UPDATE refreshes it - [moq-transport](/quest/m1/auth/moq-transport.md) - the same exchange as a setup-option extension on draft-17+, specified in a new draft +- [Expired token error](/quest/m1/auth/expired-error.md) - an expired token + reports `Error::Expired`, not `Unauthorized`, in Rust, JS, and the bindings - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every binding through moq-ffi and libmoq - [Token in band](/quest/m1/auth/token-in-band.md) - the credential can leave diff --git a/quest/m2/auth-expired-error.md b/quest/m1/auth/expired-error.md similarity index 61% rename from quest/m2/auth-expired-error.md rename to quest/m1/auth/expired-error.md index 013ab441fe..cdd88e4446 100644 --- a/quest/m2/auth-expired-error.md +++ b/quest/m1/auth/expired-error.md @@ -14,6 +14,11 @@ refusal as final. `EXPIRED_AUTH_TOKEN`, and back again when refusing. - Carry it through moq-ffi's error mapping and each wrapper. +Moved from m2 into the auth line: relay tokens refuse an expired +token with `AUTH_ERROR { Expired }`, so without this moq-net cannot tell it +apart from `Unauthorized`. + ## Required -- [In-band auth](/quest/m1/auth/README.md) - the AUTH streams that carry these codes +- [Lite stream](/quest/m1/auth/lite.md) - the lite AUTH streams that carry `Expired` +- [moq-transport](/quest/m1/auth/moq-transport.md) - the extension that carries `EXPIRED_AUTH_TOKEN` diff --git a/quest/m1/bbr-ack-cleanup.md b/quest/m1/bbr-ack-cleanup.md index 702b7b2124..3439bf801e 100644 --- a/quest/m1/bbr-ack-cleanup.md +++ b/quest/m1/bbr-ack-cleanup.md @@ -31,8 +31,8 @@ reclamation. Let the implementation choose the simplest representation that handles sparse packet numbers, reordering, and separate Initial, Handshake, and Data spaces. Bound retained state without repeatedly sweeping live entries or scanning large unused packet-number gaps. Keep the existing -sampling and congestion policies unchanged; loss-sample repair and ECN -response belong to their own quests. +sampling and congestion policies unchanged; loss-sample repair belongs to +its own quest. Extend existing tests for ordered, reordered, duplicate, and batched ACKs; loss and spurious loss; expiry; overlapping packet numbers in different @@ -50,11 +50,11 @@ substitute one hardware-specific millisecond limit for the scaling check. Land the fix in the fork, offer it upstream or record why not, publish an immutable fork release, and pin the corrected dependency chain here before -completing this quest. Do not wait for the broader QUIC stack release or the -ECN fix. Update internal packet-lifetime comments inline; no new user guide +completing this quest. Do not wait for the broader QUIC stack release. Update internal packet-lifetime comments inline; no new user guide is needed. ## Related -- [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - preserve packet metadata needed by the separate loss-sample repair +- [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - preserve packet metadata needed by the separate loss-sample repair; both edit `bbr3/mod.rs`, so sequence them +- [BBR starvation edges](/quest/m1/quic/bbr-app-limited-edges.md) - also edits `bbr3/mod.rs`; one owner there at a time - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - reusable measurement guidance, not a prerequisite for this fix diff --git a/quest/m1/bench-ci.md b/quest/m1/bench-ci.md index b317185d53..4df11aa246 100644 --- a/quest/m1/bench-ci.md +++ b/quest/m1/bench-ci.md @@ -23,8 +23,8 @@ a GitHub App: - PR: one job builds base and head on the same runner and runs only the selected targets, saving and comparing Criterion baselines. Selection is the - changed crates plus their dependents, from the impact map that - [Thin justfiles](/quest/m1/tooling/justfiles.md) lands. No selected bench + changed crates plus their dependents, from the impact map that the + [tooling line](/quest/m1/tooling/README.md) lands. No selected bench means no job. Extend `bench/run.sh` with a Criterion-only, crate-scoped mode behind a recipe instead of writing a second runner. Hosted runners vary by about 3%, so the comment highlights only changes Criterion calls @@ -45,7 +45,7 @@ a GitHub App: ## Required -- [Thin justfiles](/quest/m1/tooling/justfiles.md) - owns the diff-to-crate impact map the PR job reuses +- [Tooling](/quest/m1/tooling/README.md) - owns the diff-to-crate impact map the PR job reuses ## Related diff --git a/quest/m1/binding-surface.md b/quest/m1/binding-surface.md index e789e32710..35bafa5f2a 100644 --- a/quest/m1/binding-surface.md +++ b/quest/m1/binding-surface.md @@ -2,7 +2,7 @@ ## Goal -moq-ffi, libmoq, and every wrapper (Python, Go, Swift, Kotlin, Dart) expose +moq-ffi and every wrapper (Python, Go, Swift, Kotlin, Dart) expose `decode::Options::delay` and `decode::Consumer::delay()` so applications can configure and observe audio playout delay. @@ -11,9 +11,9 @@ configure and observe audio playout delay. - One PR, each wrapper touched once, in its own idiom: durations as the language's duration type where the wrapper already uses one, handles over flat methods where a surface has more than one call. -- Add the libmoq implementation and tests in `rs/libmoq`, then regenerate - `moq.h`. Keep the additions compatible with the published C ABI. -- Update `doc/lib/{py,swift,kt,go,dart,c}` in the same PR. +- moq-ffi only, not libmoq: the [generated C](/quest/m1/c/README.md) and + C++ bindings inherit it from moq-ffi. +- Update `doc/lib/{py,swift,kt,go,dart}` in the same PR. - Test configuration and observed delay in every wrapper that has tests. ## Required diff --git a/quest/m1/broadcast-remove.md b/quest/m1/broadcast-remove.md deleted file mode 100644 index f95e2b8349..0000000000 --- a/quest/m1/broadcast-remove.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] Remove finish - -## Goal - -On `dev`, the deprecated broadcast end APIs are gone in every language, and -waiting for a broadcast to close says only that it closed. - -## Plan - -This is a published API break, so it targets `dev`. - -- Remove `broadcast::Producer::finish`, `abort`, and `Consumer::is_finished`, - and the `finished` and `abort` fields they read. -- `Consumer::closed()`, `Consumer::poll_closed`, and `Dynamic::closed()` return - `()` rather than an `Error` cause. -- Remove the deprecated binding `finish` methods, `moq_publish_finish`, and - JS's `close(abort)` parameter. - -## Required - -- `main` merged into `dev` once #4031 lands, so the deprecations exist there diff --git a/quest/m1/browser-benchmarks.md b/quest/m1/browser-benchmarks.md index 06043fac8d..b8ed38d6ef 100644 --- a/quest/m1/browser-benchmarks.md +++ b/quest/m1/browser-benchmarks.md @@ -2,21 +2,24 @@ ## Goal -A reproducible browser suite measures JS transport and media costs that the Rust -Criterion targets and native relay load generator do not exercise. +A reproducible real-browser suite measures JS transport and media costs that +the Rust Criterion targets, the native relay load generator, and the Bun +microbenchmarks do not exercise. ## Plan -`bench/run.sh::criterion_targets` discovers Cargo targets only. Existing JS unit -tests validate behavior, and `test/wasm` validates browser interop, but neither -provides a repeatable JS performance comparison. Reuse the existing relay/browser -harness pieces and add a focused recipe with artifacts under the benchmark -conventions. Microbenchmarks may run in Bun; browser conclusions must come from -an identified browser version on a real WebTransport connection. +The JS microbenchmarks already exist: the four Bun sweeps in `js/net/bench` +(`broadcasts`, `reader`, `frames`, `track`) run nightly and cover the origin +map, fragmented `Reader` reads, group frame decode, and track retention. This +quest is only the real-browser half: conclusions come from an identified +browser version on a real WebTransport connection. `test/wasm` validates +browser interop but measures nothing. Reuse the existing relay/browser harness +pieces and add a focused recipe with artifacts under the benchmark conventions. -- Cover `js/net/src/stream.ts` with buffered controls, fragmented varints, and - payloads from small audio through large keyframes. Sweep chunk sizes and record - CPU, wall time, allocation volume, GC pauses, and bytes copied where measurable. +- Measure the `js/net/src/stream.ts` path in the browser over WebTransport, + with payloads from small audio through large keyframes, recording CPU, wall + time, allocation volume, GC pauses, and bytes copied where measurable. Add a + Bun sweep only for a cost the browser run finds and the four miss. - Cover CMAF encode/decode with fixed audio/video fixtures and multiple samples. Keep fixture generation and relay startup outside timed intervals. - Add publish/watch scenarios measuring delivered/decoded/presented frames, diff --git a/quest/m1/c/README.md b/quest/m1/c/README.md index 8ed159fb22..92aec50f5d 100644 --- a/quest/m1/c/README.md +++ b/quest/m1/c/README.md @@ -27,23 +27,22 @@ Decided: (NULL on success) with `moq_error_message()`, results come through out-params, records are owned structs with `moq__free`, and names are the uniffi names in snake case. -- The generated package takes over the `moq-c` name and `moq::c` target that - #4288 gives the hand-written crate, so C users migrate once. Its first release - is 0.8.0, a minor bump over the hand-written 0.7.x. +- The line targets `dev`: #4288 already renamed the hand-written crate to + `moq-c` (`rs/moq-c`) there, and the generated package takes over that name + and `moq::c` target, so C users migrate once. Its first release is 0.8.0, a + minor bump over the hand-written 0.7.x. - Docs change inline: the consumer quest rewrites `doc/lib/c`, and retirement adds an upgrade note. No separate guide. The line owns the end-to-end check: every `doc/lib/c` sample and the C interop client build and run against the released 0.8.0 archive, not only in-tree. -The hand-written crate's open feature quests are parked in m3 until this line -retires it: [fetch](/quest/m3/libmoq-fetch.md), -[hidden](/quest/m3/libmoq-hidden.md), -[CMake library](/quest/m3/libmoq-cmake-lib.md), and -[shutdown](/quest/m3/libmoq-shutdown.md). +The hand-written crate gets no more feature work: its shutdown, CMake library, +and fetch quests were abandoned for this line, and hidden is done on dev. ## Required +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the generator fork, package recipe, and OBS move this line builds on - [C backend](/quest/m1/c/backend.md) - the fork emits an ergonomic C header and implementation from moq-ffi, with its own tests - [moq-c package](/quest/m1/c/package.md) - the generated header ships as `moq-c` 0.8.0 with `moq::c`, pkg-config, and a release workflow - [C consumers](/quest/m1/c/consumers.md) - the C interop client and `doc/lib/c` samples move onto the generated API @@ -51,6 +50,4 @@ retires it: [fetch](/quest/m3/libmoq-fetch.md), ## Related -- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the generator fork and package recipe this line extends -- [libmoq becomes moq-c](/quest/m1/moq-c.md) - the rename whose name this line inherits - [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes moq-ffi, which the generated C then follows for free diff --git a/quest/m1/c/package.md b/quest/m1/c/package.md index 686ecc5ea8..3a8a602f62 100644 --- a/quest/m1/c/package.md +++ b/quest/m1/c/package.md @@ -5,7 +5,8 @@ The generated C ships as the `moq-c` package, version 0.8.0: a release archive with the header, the moq-ffi staticlib, a CMake config exporting `moq::c`, and `moq-c.pc`, built the same way `cpp/moq` builds the C++ package. It replaces the -hand-written crate's artifacts under the same names. +hand-written `rs/moq-c` crate's artifacts (renamed from libmoq on dev by #4288) +under the same names. ## Plan @@ -24,5 +25,4 @@ hand-written crate's artifacts under the same names. ## Required -- [libmoq becomes moq-c](/quest/m1/moq-c.md) - the rename that frees the `moq-c` name and `moq::c` target this package takes over - [C backend](/quest/m1/c/backend.md) - the generator output this packages diff --git a/quest/m1/c/retire.md b/quest/m1/c/retire.md index cb3aa49e20..5a821f75e3 100644 --- a/quest/m1/c/retire.md +++ b/quest/m1/c/retire.md @@ -8,9 +8,11 @@ it are deleted. `doc/setup/upgrade.md` tells C users how to move. ## Plan -- Fold in any retirement step #4288 already planned for the renamed crate. -- Decide the parked m3 libmoq quests: delete each whose outcome the generated - API already provides, and say so in the PR. +- #4288 renamed libmoq to `moq-c` on dev and left a code-free `rs/libmoq` stub + whose last release points at `moq-c`; dev's + `quest/m1/libmoq-retire.md` deletes that stub. This quest retires the + hand-written `rs/moq-c` itself, so fold in or delete that quest, whichever + is still open. ## Required diff --git a/quest/m1/cache-expiry-growth.md b/quest/m1/cache-expiry-growth.md index fc2ea10f9d..0aafd2b2c1 100644 --- a/quest/m1/cache-expiry-growth.md +++ b/quest/m1/cache-expiry-growth.md @@ -18,7 +18,12 @@ cached groups, not a leak elsewhere. An earlier run saw growth on IETF only, but lite was then 30x slower per round, so it wrote far fewer groups; compare by groups written, not by wall time. -Reproduce with a focused test first. Unexpired groups at higher throughput, +#4378 (after these runs) fixed one candidate cause: an ended track's latest +group was exempt from idle expiry and the pool sweep never reached an ended +track, so stale consumers pinned its groups. Re-measure on current `main` +first; if RSS now plateaus, delete this quest. + +Otherwise reproduce with a focused test. Unexpired groups at higher throughput, expiry not running on some path, or groups held outside the pool's accounting would each explain it. Fix what is actually wrong. Consider whether an unbounded default is the right default for an origin at all. diff --git a/quest/m1/cache-wall-eviction.md b/quest/m1/cache-wall-eviction.md index 6451b320b8..083a2857e5 100644 --- a/quest/m1/cache-wall-eviction.md +++ b/quest/m1/cache-wall-eviction.md @@ -18,6 +18,12 @@ instead of only on a write. The pool's idle expiry is out of scope. groups past `max_age` until the pool's idle expiry (`Pool::gc`, driven by the origin driver without a write) or byte pressure reclaims them. `max_age_does_not_drive_wall_eviction` pins that behaviour. +- JS already does the opposite: `#prune` in `js/net/src/track.ts` evicts a + group once it has been idle on the wall clock (`performance.now`) past + `maxAge`, on its own timer, and `js/net/bench/track.ts` benches the publish + cost against the retained window. So `max_age` means different things per + language today. The decision settles both: either Rust gains a wall term or + JS moves to media time, and the loser's tests and docs change with it. - Prototype the alternative behind a bench-only switch: each track keeps a deadline for its oldest group on `max(wall elapsed, pts)`, armed on the timers the origin driver already runs, and evicts on expiry. diff --git a/quest/m1/captions-msf.md b/quest/m1/captions-msf.md index eb68c70696..5079a87ee4 100644 --- a/quest/m1/captions-msf.md +++ b/quest/m1/captions-msf.md @@ -2,16 +2,18 @@ ## Goal -An MSF catalog's caption, subtitle, and sign-language tracks survive -conversion into a hang catalog. Today they are dropped with a warning, so a +An MSF catalog's caption and subtitle tracks survive conversion into a hang +catalog. Today they are dropped with a warning, so a round trip through MSF silently loses every caption. ## Plan `from_msf` skips any track "with no role, with an unsupported role (caption, subtitle, sign language, audio description, custom roles)". Now that the hang -`text` section exists with a `TextRole` deliberately mirroring the MSF role -registry, three of those map straight across and stop being unsupported. +`text` section exists with a `TextRole` borrowing the MSF role names, two of +those map straight across and stop being unsupported: `TextRole` has only +`Subtitle`, `Caption`, and `Unknown(String)`, which preserves any other role +verbatim. Map the roles, and derive the rest of `TextConfig` from what MSF carries: language onto `lang`, the display name onto `label`, and the packaging onto @@ -23,7 +25,9 @@ garbage. Prove the round trip in both directions, so an MSF catalog converted to hang and back keeps its caption tracks, roles, and languages. Audio description is deliberately left unmapped: it is an audio rendition with a role, not timed -text, and mapping it into `text` would be wrong. +text, and mapping it into `text` would be wrong. Sign language is likewise a +video rendition, so it stays unmapped too. A custom role maps to +`TextRole::Unknown` only when the track's codec is a text format. Per cross-package sync, mirror any schema movement in `js/msf`. diff --git a/quest/m1/capture-control.md b/quest/m1/capture-control.md index 0072552113..958853788f 100644 --- a/quest/m1/capture-control.md +++ b/quest/m1/capture-control.md @@ -1,4 +1,4 @@ -# [S] Capture Control: settled name, loud cut, prompt cancel +# [M] Capture Control: settled name, loud cut, prompt cancel, catalog clock ## Goal @@ -14,6 +14,12 @@ on `dev` only, get their final shape before release: startup probe, `capture::open`, `Sink::open`, or an encode is in flight. Today those awaits never see the handle close, so a camera or permission prompt can outlive its owner. +- Capture publishers stamp on the clock their catalog advertises, with no + separate clock to pass. Today both `CaptureOptions` carry their own + `clock: moq_mux::Clock`, and `Default` builds a fresh one, so a caller + relying on the default publishes against a mapping the catalog never + advertised. `moq import capture` passes `catalog.clock()`, the only correct + value. ## Plan @@ -29,6 +35,12 @@ Decided: `CutUnsupported`, and record the choice here. - Race every await in the driver against the controls closing, rather than only the idle wait, so the probe and the demand-driven opens both cancel. +- Drop the `clock` field from both options; `Control::new` already takes the + catalog producer, so it reads `catalog.clock()`. Update moq-cli and any + binding that forwards a clock. The clock fixtures in both crates already + pass the catalog's clock, so they keep grading the same path. This absorbs + the former m2 capture-clock-source quest: it breaks the same `dev` options, + so one break lands instead of two. This is a `dev` break layered on #4184; land it on `dev` before the release that first publishes these handles. @@ -36,8 +48,3 @@ that first publishes these handles. Tests: a backend without forced keyframes surfaces `CutUnsupported` to the caller; dropping the last `Control` during a slow fake open or probe returns from `Driver::run` without finishing the open. - -## Related - -- [Video keyframe flag](/quest/m1/video-keyframe-flag.md) - the same cut throttle, counting cadence keyframes -- [Capture clock source](/quest/m2/capture-clock-source.md) - drops the `clock` field from these options diff --git a/quest/m1/cli-given-flags.md b/quest/m1/cli-given-flags.md index 2b270033e9..1bb750ba4e 100644 --- a/quest/m1/cli-given-flags.md +++ b/quest/m1/cli-given-flags.md @@ -2,8 +2,7 @@ ## Goal -`moq fetch`, `moq ls` (on the [CLI inspect](/quest/m1/cli-inspect/README.md) -line), and the local verbs behind `Invocation::reject` refuse any listener +`moq fetch`, `moq ls` (#4032, on `dev`), and the local verbs behind `Invocation::reject` refuse any listener flag they would never use, as they already do for `--listen` and the cluster flags. Today `MoqSide::given()` in `rs/moq-cli/src/args.rs` lists only some of them, so `--listen-version`, `--listen-tls-*`, `--listen-preferred-*`, and diff --git a/quest/m1/cli-inspect/README.md b/quest/m1/cli-inspect/README.md deleted file mode 100644 index 718aa7fb50..0000000000 --- a/quest/m1/cli-inspect/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# CLI inspection - -## Goal - -`moq ls` answers "what is live on this relay" and `moq fetch` reads one group -of a track, over MoQ with the session's own auth, instead of a separate `curl` -to the relay's HTTP `/announced` and `/fetch` endpoints. A guide shows an -operator how to inspect a relay: what is live, how to watch it change, how to -read a group, and where the stats live. - -Non-goals: listing tracks or catalogs, showing routes, hops, or sources, and -changing the relay's HTTP endpoints. - -## Plan - -This README owns the guide, written once both verbs land: a new -`doc/bin/inspect.md` covering `moq ls` and `--follow`, `moq fetch`, their -`curl` equivalents, and reading the relay's stats track, linked from `doc/bin/cli.md`, -`doc/bin/relay/http.md`, and the site sidebar. - -## Required - -- [Caught up](/quest/m1/cli-inspect/caught-up.md) - moq-net's announce consumer yields a `Live` marker once the initial set has landed, and shell completion drops its settle timer -- [ls](/quest/m1/cli-inspect/ls.md) - `moq ls` prints the live set and exits, or follows changes, as paths or JSON lines diff --git a/quest/m1/cli-inspect/caught-up.md b/quest/m1/cli-inspect/caught-up.md deleted file mode 100644 index dcec2f6787..0000000000 --- a/quest/m1/cli-inspect/caught-up.md +++ /dev/null @@ -1,41 +0,0 @@ -# [M] An announce consumer knows when it has caught up - -## Goal - -A Rust consumer of `origin::Consumer::announced()` is told, once and in -order, that the routes live at subscribe time have all been delivered, so -"list what is live" needs no guess. Today the stream replays the current -routes and continues live with no boundary, and moq-cli's `--broadcast` -completion (`rs/moq-cli/src/complete.rs`) stops on a 30ms settle / 500ms -budget timer instead. - -## Plan - -- `AnnounceConsumer::next()` yields an enum: each route update as today, - then a single `Live` marker once the initial set has been delivered, then - live updates. `Live` comes after every replayed update, so a caller that - stops at it has seen the whole set. This changes a published return type, - so the quest lands on `dev`. -- The fact is per source, but consumers read a merged origin, which today - has no source registry. Each remote announce subscription (a session's - subscriber, a cluster peer) holds a pending guard from the origin until - its initial set has landed; in-process routes are replayed synchronously - and are never pending. A cursor yields `Live` once every guard pending at - subscribe time has cleared. A source that closes before clearing drops - its guard, so a dead session cannot stall the marker. No cost while no - guard is pending. -- A source clears on the wire's boundary: `AnnounceOk.active` on lite-05+, - `AnnounceInit` on lite-01/02. Versions without one (lite-03/04, IETF) - clear on a settle timer owned by the session, the one place it lives. -- Move completion onto `Live`; the timer survives only in the session for - marker-less versions. Look at the other settle and deadline loops - (`moq-bench` startup, relay cluster discovery, test `settle()` helpers) - and move any that want the initial set. -- Test: a relay with N announced broadcasts yields `Live` after exactly N - updates on each lite version that carries a count, an empty relay yields - it immediately, and a session that closes before its count arrives does - not block it. - -Public API: breaking on moq-net (`next()` returns an enum). Wire: none. JS -parity is [a separate quest](/quest/m1/js-announce-caught-up.md), and an -IETF count is [another](/quest/m1/ietf-announce-count.md). diff --git a/quest/m1/cli-inspect/ls.md b/quest/m1/cli-inspect/ls.md deleted file mode 100644 index a5791a8d6a..0000000000 --- a/quest/m1/cli-inspect/ls.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] The ls verb lists active announcements - -## Goal - -`moq --connect ls [prefix]` prints the broadcasts live under `prefix`, -one path per line relative to the connect URL's root, and exits once caught -up. `--follow` first prints the announce stream's initial replay, one `+ path` -per active route, then `+ path` / `- path` as broadcasts come and go. `--json` -prints one `{"path": .., "active": bool}` per line in either mode. Like the -relay's `/announced`, it lists announced prefixes, which by convention are -broadcast paths. An `Updated` event (a new route for a path already live) is -not printed. `--follow` runs until interrupted; if the announce stream ends, -it exits non-zero. - -## Plan - -- A MoQ verb beside import/export/play, not a stageable one: it consumes the - origin and never publishes. It is a subscriber-only session, so it works - with a subscribe-only token. -- Update `doc/bin/cli.md`: add the verb to the table, and point the Debugging - section at `moq ls` next to `curl /announced`. The verb table still lists - `token` where the code has `auth`; fix it while there. -- Test: against an in-process relay, snapshot mode prints exactly the - announced set and exits, `--follow` prints the initial replay then a `-` when - a publisher leaves, and `--json` lines parse. - -## Required - -- [Caught up](/quest/m1/cli-inspect/caught-up.md) - snapshot mode exits on the consumer's `Live` marker, so `ls` follows it onto `dev` diff --git a/quest/m1/close-codes.md b/quest/m1/close-codes.md index 4e52f19d62..8478905c3b 100644 --- a/quest/m1/close-codes.md +++ b/quest/m1/close-codes.md @@ -11,6 +11,10 @@ WebTransport. Never `Transport("connection closed")` or its own `Internal`. Both bugs are upstream; fix them at the source, release, and bump the pins. +Targets `dev`: #4262 (open) does this, and the qmux release pulls in +`web-transport-trait` 0.5 (`SendStream::set_priority` takes `i32`), a Rust +API break. It reports `@moq/qmux` already keeps the first close. + - qmux 0.5.1 (`moq-dev/web-transport`) lets later writes overwrite the recorded close in `session.rs`: the WS Close frame read after APPLICATION_CLOSE (reader loop, backend `send_replace`), a local `close()` diff --git a/quest/m1/cluster-routing.md b/quest/m1/cluster-routing.md index 13d66d28a8..4c82131756 100644 --- a/quest/m1/cluster-routing.md +++ b/quest/m1/cluster-routing.md @@ -104,13 +104,21 @@ make MoQ's common case. registry's replay lands, on every new prefix rather than in a rare race. - What remains of announce compression's hop-tail half (`Hop Base` and `Hop Keep` in the lite draft) once only cluster boundaries carry hops. +- What replaces `--hop` first-hop failover. Today two publishers sharing a Hop + ID are one source that relays fail over between at a group boundary + (`doc/bin/cli.md` "Redundant publishers", + `doc/concept/use-case/contribution.md`). Inside a cluster no announcement + carries a hop list, so two encoders on different ingest relays become two + origins. Keep the documented behavior or change the docs in the same PR. ## Required +- [Local origin](/quest/m0/local-origin.md) - workers stop reading hop chains before they go +- [Wildcard](/quest/m0/wildcard/README.md) - the specificity, pool spread, and reply identity this selection builds on - moq.pro's routing simulator reports ([quest](https://github.com/moq-dev/moq.pro/blob/main/quest/m1/routing-simulator.md)) ## Related - [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - cuts duplicate updates on today's routing -- [Local origin](/quest/m0/local-origin.md) - workers stop reading hop chains before they go -- [Wildcard](/quest/m0/wildcard/README.md) - the specificity, pool spread, and reply identity this selection builds on +- [Redundant ingest](/quest/m2/redundant-ingest.md) - builds on the `--hop` failover this must keep or replace +- [Routing cost domains](/quest/m2/routing-cost-domains.md) - cost across the cluster boundaries this keeps path vector diff --git a/quest/m1/cpp/cancel.md b/quest/m1/cpp/cancel.md index a3974224bb..8faad9eacf 100644 --- a/quest/m1/cpp/cancel.md +++ b/quest/m1/cpp/cancel.md @@ -2,33 +2,25 @@ ## Goal -A caller can tell a cancelled or consumed future is dead before reading it, -and a read of one fails with a message naming the misuse. Today the generated -`uniffi::Future` declares `void cancel() noexcept` on an lvalue, so -`future.cancel(); future.get();` compiles and aborts with nothing to check -first; `cpp/moq/README.md` documents the abort instead of giving callers a -guard. +Callers are told to check `valid()` before reading a future that may be +cancelled, consumed, or moved from, and the tests prove it goes false in each +case. ## Plan -- Mirror `std::future`, the idiom C++ callers know: once cancelled, consumed - by `then()`, or moved from, a future is invalid and `valid()` returns false. - Calling `get()` on an invalid future is a precondition violation, as it is - for `std::future`, and it aborts with a message naming `cancel`/`then`/move - rather than a bare abort. Decided with the maintainer during the #4307 - review. -- Rejected: `&&`-qualifying `cancel()`, since `std::move(f).cancel(); f.get();` - still compiles; and `get()` returning an error, since expected mode has no - error value for it: generic `E` has no invalid-state variant, and the line - README already rejects `std::variant`. -- Decided in [#4100](https://github.com/moq-dev/moq/pull/4100): cancel - abandons the future, so no continuation runs. That still holds. -- The `Future` template lives in the kixelated `uniffi-bindgen-cpp` fork, so - this is a fork change and a new `-kixelated.N` tag, then the pin bump in - `flake.nix` and every place its comment lists. -- Update the README to point callers at `valid()`, and check it in the - in-tree callers that can hold a cancelled future (`cpp/moq`, the probe, - `cpp/obs`). Test `valid()` after `cancel()`, after `then()`, and after a - move. +The fork half is done on the line: the `-kixelated.2` generator's +`uniffi::Future` has `valid()`, false once `get()`, `then()`, `cancel()`, or a +move took its state, like `std::future`, and a read of an invalid future aborts +with "it was consumed or cancelled". `cpp/moq/test/probe.cpp` checks `valid()` +after `cancel()`. Decided in the #4307 review; `&&`-qualifying `cancel()` and an +error-returning `get()` stay rejected. -Public API: additive `valid()` on the unreleased C++ package. Wire: none. +What remains: + +- `cpp/moq/README.md` (the Error and Cancellation sections) still only says a + misused future aborts; point callers at `valid()` instead. +- Extend the probe to check `valid()` after `then()` and after a move. +- Check the in-tree callers that can hold a cancelled future (`cpp/moq`, + `cpp/obs`) against `valid()`. + +Public API: none beyond the unreleased C++ package's `valid()`. Wire: none. diff --git a/quest/m1/data-capture-bindings.md b/quest/m1/data-capture-bindings.md index dcb3ecc3dd..faefa6dc99 100644 --- a/quest/m1/data-capture-bindings.md +++ b/quest/m1/data-capture-bindings.md @@ -46,7 +46,10 @@ no optional arguments), and a `_with_x` twin is ruled out. The broadcast clock `window::Producer::push` accepts `Timed`, source-compatible. Wire: none. +## Required + +- [JSON and flate namespaces](/quest/m1/ffi-shape/json.md) - moves the data producers this changes, so the two breaks land in order rather than colliding + ## Related -- [FFI shape](/quest/m1/ffi-shape/README.md) - moves the data producers into a json namespace - [Generated C bindings](/quest/m1/c/README.md) - replaces libmoq, so C inherits this from moq-ffi diff --git a/quest/m1/decoded-frames.md b/quest/m1/decoded-frames.md deleted file mode 100644 index 64a63052d7..0000000000 --- a/quest/m1/decoded-frames.md +++ /dev/null @@ -1,47 +0,0 @@ -# [M] Preserve decoded frame ownership across bindings - -## Goal - -Bindings retain the existing `moq_video::Frame` and its backing surface until -the consumer is finished. A native consumer can access platform surfaces; -portable bindings can request CPU pixels without a second frame or conversion implementation. - -## Plan - -`moq_video::Frame` already owns a timestamp and `Surface`, with native backing, -resizing, and CPU conversions. Reuse it. The eager conversion to remove is in -`rs/libmoq/src/video.rs`: `consume_task` calls `surface.into_i420()` before -inserting a byte-only frame into the handle slab. - -Retain the existing Frame through the binding's owned handle. Define release, -borrowed-view validity, thread/device affinity, and GPU-completion ownership. -A native view cannot outlive the frame or permit producer-pool reuse while a -consumer is still using it. Cancellation and delayed completion retain the same -ownership guarantees. CPU conversion uses existing Surface methods on demand. -Do not create a parallel surface enum, decoder, resize engine, or cleanup -callback protocol. - -The moq-ffi boundary carries the owned frame; libmoq mirrors it through the -Cross-Package Sync table. Native views are exposed where OBS, on the generated -C++ over moq-ffi, needs them; other bindings expose portable pixels until a -consumer asks. Shared ownership does not require every -language to expose every platform surface. - -Define and implement the shared binding contract here. OBS owns graphics -imports and presentation; the FFI video consumer owns rendition subscription -and portable delivery. The C decoder output layout has landed; consume its -format and size controls without another struct layout change. Adding -fields to a published C struct is not automatically additive. - -Test handle release, conversion failures, cancellation, delayed consumption, -and retained ownership through the existing libmoq/FFI test lanes. Platform -adapters own their hardware import proof. Update `moq.h`, affected wrappers, -and C/binding documentation; run `just test interop --all` in CI. - -Public API: owned frame access and conversion at the binding boundary. Wire: -none. Consume the settled main frame/output contracts without replacing them. - -## Related - -- [OBS source](/quest/m1/obs-moq-video/source.md) - consumes native views through the generated C++ -- [Mobile ownership](/quest/m2/mobile-ownership.md) - deferred platform capture and native mobile integration diff --git a/quest/m1/doc-samples-go-dart.md b/quest/m1/doc-samples-go-dart.md index af68aa5f44..6680e0a034 100644 --- a/quest/m1/doc-samples-go-dart.md +++ b/quest/m1/doc-samples-go-dart.md @@ -20,11 +20,16 @@ knows neither language. imports are compile errors. Handle what the samples actually use; prefer adjusting a sample so it reads naturally and still compiles over growing the extractor. -- Wire each into its language's `check` recipe (`go/justfile`, - `dart/justfile`) the way `py/justfile` and `rs/justfile` call it, and add - `doc/lib/samples.sh` and the doc page to the paths that trigger those - checks in CI. +- Wire each into its language's check script, `sh/go/check.sh` and + `sh/dart/check.sh` on the tooling line, the way `sh/kt/check.sh` and + `sh/py/samples.sh` call `samples.sh`. Add `doc/lib/go/`, `doc/lib/dart/`, + and `doc/lib/samples.sh` to the `go` and `dart` patterns of the impact + map in `sh/dispatch.sh`, as the `py`, `kt`, and `swift` ones already have. - Prove it by renaming one wrapper method locally and watching each check fail on the doc sample. Public API: none. Wire: none. + +## Required + +- [Tooling](/quest/m1/tooling/README.md) - the `sh//` check scripts and the impact map this extends diff --git a/quest/m1/drain/README.md b/quest/m1/drain/README.md index 284fd4aa71..786b65f106 100644 --- a/quest/m1/drain/README.md +++ b/quest/m1/drain/README.md @@ -58,4 +58,4 @@ by the stop deadline and encoder reconnect. ## Related -- [pop-skipping](/quest/m1/pop-skipping/README.md) - its same-PoP link price and full eligible pairing become important when a deployment adds a second relay per PoP +- [Cluster routing](/quest/m1/cluster-routing.md) - the configured topology and link costs a second relay per PoP joins diff --git a/quest/m1/dropped-sources.md b/quest/m1/dropped-sources.md index 08a0ce43bb..4c20212884 100644 --- a/quest/m1/dropped-sources.md +++ b/quest/m1/dropped-sources.md @@ -27,7 +27,7 @@ Public API: none expected; error values consumers observe change. Wire: none. ## Required -- [Unauthorized](/quest/m1/auth/unauthorized.md) - #4179 supplies shared Unauthorized errors and revoked-stream handling +- [Auth](/quest/m1/auth/README.md) - its Unauthorized quest (#4179, done on the line) supplies shared Unauthorized errors and revoked-stream handling ## Related diff --git a/quest/m1/epoch.md b/quest/m1/epoch.md index d829397624..1ebdeaacdd 100644 --- a/quest/m1/epoch.md +++ b/quest/m1/epoch.md @@ -21,7 +21,7 @@ line builds on, and it replaces the e2ee-local `moq_e2ee::Epoch`. like `@alice` is valid today and stays valid: strict parsing already keeps it from reading as an epoch. Rejecting it instead would break the path contract and land on `dev`. Check how the split interacts with - [path patterns](/quest/m1/path-patterns.md) and + path patterns (done on the [auth line](/quest/m1/auth/README.md)) and hidden broadcasts (a leading `.`, see `doc/concept/moq-lite.md`). - `moq-e2ee` uses the shared type. Update [draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md) so the path is diff --git a/quest/m1/export-linger.md b/quest/m1/export-linger.md index d8e40d1f0f..fe0f3a0808 100644 --- a/quest/m1/export-linger.md +++ b/quest/m1/export-linger.md @@ -32,7 +32,3 @@ once and exits 1 with `json: dropped`, even while `moq_tokio` is mid-reconnect - Tests: a relay-backed CLI test that restarts the publisher within the linger and sees output resume, one that lets it expire and checks exit 1, and a clean FIN that exits 0. - -## Closes - -- [#3926](https://github.com/moq-dev/moq/issues/3926) - close this issue when the quest finishes diff --git a/quest/m1/ffi-shape/README.md b/quest/m1/ffi-shape/README.md index 6e20982499..346223a423 100644 --- a/quest/m1/ffi-shape/README.md +++ b/quest/m1/ffi-shape/README.md @@ -3,7 +3,7 @@ ## Goal A binding consumer finds each layer where Rust keeps it: moq-net at the root, -and `media`, `json`, `audio`, and `video` as their own namespaces, each type +and `media`, `json`, `flate`, `audio`, and `video` as their own namespaces, each type constructed from the lower-layer handle it wraps. `BroadcastProducer` and `BroadcastConsumer` stop carrying every layer's verbs, and every moq-ffi type and verb maps to a Rust one. A docs page shows the layers in each language. @@ -18,7 +18,10 @@ Settled shape: - Groups by role, not crate: the root is moq-net (client, server, session, origin, broadcast, track, group); `media` merges hang and moq-mux, since a binding never sees that split (catalog, import producers, container - consumers); `json`, `audio`, and `video` own their producers and consumers. + consumers); `json`, `flate`, `audio`, and `video` own their producers and + consumers. `flate` holds the opaque snapshot and stream tracks moq-ffi + publishes as `publish_binary_*` today (#4137), named after the crate they + fold into in [moq-binary folds into moq-flate](/quest/m1/flate-binary.md). - A layer's type is constructed from the handles its Rust constructor takes, not reached through an accessor on the broadcast: JSON wraps a track (`moq_json::snapshot::Producer::new(track, config)`), so it also works on a @@ -32,14 +35,16 @@ Settled shape: namespaces. - `demand()` is the one way to watch subscribers; producers drop their `name`/`is_used`/`used`/`unused` duplicates. -- libmoq renames its C symbols to the same groups (`moq_json_*`, - `moq_media_*`), with `cpp/obs` adapting. +- moq-ffi only. libmoq and `cpp/obs` are out of scope: the + [generated C](/quest/m1/c/README.md) and [C++](/quest/m1/cpp/README.md) + bindings inherit this shape from moq-ffi, so reshaping the hand-written C + ABI would break C users twice. -Each child reshapes one group end to end: moq-ffi, all five wrappers, libmoq, -and the `doc/lib` samples, per the cross-package table. This README owns the +Each child reshapes one group end to end: moq-ffi, all five wrappers, and the +`doc/lib` samples, per the cross-package table. This README owns the work no child does: -- A layers guide under `doc/lib` mapping net, media, json, audio, and video to +- A layers guide under `doc/lib` mapping net, media, json, flate, audio, and video to each language's module, linked from every binding page. - The bindings section of the following release's upgrade page: old call to new call per language. @@ -47,8 +52,7 @@ work no child does: ## Required -- [Release](/quest/m0/release.md) - the restructure follows the release rather than riding it -- [JSON](/quest/m1/ffi-shape/json.md) - the pilot: json becomes its own namespace wrapping a track in every binding and sets the per-language pattern +- [JSON](/quest/m1/ffi-shape/json.md) - the pilot: json and flate become their own namespaces wrapping a track in every binding and set the per-language pattern - [Net](/quest/m1/ffi-shape/net.md) - client and server take config records, snapshots are records, and the verbs match moq-net - [Media](/quest/m1/ffi-shape/media.md) - catalog, import, and container consume move under `media` - [Codecs](/quest/m1/ffi-shape/codec.md) - audio and video encoders and decoders move under their own namespaces with one constructor shape diff --git a/quest/m1/ffi-shape/codec.md b/quest/m1/ffi-shape/codec.md index 1f72300318..a5668cfc19 100644 --- a/quest/m1/ffi-shape/codec.md +++ b/quest/m1/ffi-shape/codec.md @@ -17,9 +17,16 @@ key apart from its rendition; pick one convention for both. Go's through `demand()` only. Both groups stay behind their cargo features and off wasm. -libmoq's codec symbols follow. - -Public API: breaking in every binding and libmoq. Wire: none. +The video encoder's output mirrors moq-video's `encode::Gop`: +`MoqVideoEncoderOutput.gop: Option` becomes a `MoqVideoGop` enum with a +`Keyframe { interval }` variant, defaulting to keyframes at two seconds, and +documented as non-exhaustive like the core. The wrappers expose it as an enum +their callers construct, not one they are asked to match, so +[intra-refresh bindings](/quest/m2/intra-refresh/bindings.md) adds the refresh +variant additively instead of breaking `gop` a second time. Go gets no uniffi +default, so its zero value must read as keyframe mode. + +Public API: breaking in every binding. Wire: none. ## Required diff --git a/quest/m1/ffi-shape/json.md b/quest/m1/ffi-shape/json.md index 0ff6f35227..3cbf8969d6 100644 --- a/quest/m1/ffi-shape/json.md +++ b/quest/m1/ffi-shape/json.md @@ -1,11 +1,12 @@ -# [M] JSON gets its own namespace in every binding +# [M] JSON and flate get their own namespaces in every binding ## Goal -JSON tracks live under `json` in moq-ffi and every wrapper, constructed from a -track producer or consumer as in `moq-json`, and `BroadcastProducer`/`BroadcastConsumer` lose -`publish_json_*`/`subscribe_json_*`. The per-language namespace pattern this -sets is what the other children copy. +JSON tracks live under `json` and opaque tracks under `flate` in moq-ffi and +every wrapper, constructed from a track producer or consumer as in `moq-json` +and `moq-flate`, and `BroadcastProducer`/`BroadcastConsumer` lose +`publish_json_*`/`subscribe_json_*` and `publish_binary_*`. The per-language +namespace pattern this sets is what the other children copy. ## Plan @@ -25,10 +26,10 @@ keep doing so, and the rest may follow. Watch for Go import cycles: a subpackage takes the root's broadcast handle, so the root must not import it back. -libmoq's JSON symbols move to `moq_json_*`. +`flate` is the same shape over opaque bytes: moq-ffi's `binary.rs` +(`publish_binary_snapshot`, `publish_binary_stream`, #4137) moves under it, +mirroring `moq_flate::{snapshot, stream}` once +[moq-binary folds into moq-flate](/quest/m1/flate-binary.md). If that fold has +not landed, name the namespace `flate` anyway rather than `binary`. -Public API: breaking in every binding and libmoq. Wire: none. - -## Required - -- [Release](/quest/m0/release.md) - the restructure follows the release rather than riding it +Public API: breaking in every binding. Wire: none. diff --git a/quest/m1/ffi-shape/media.md b/quest/m1/ffi-shape/media.md index e936b29668..e2a0a53714 100644 --- a/quest/m1/ffi-shape/media.md +++ b/quest/m1/ffi-shape/media.md @@ -24,9 +24,7 @@ once the shape is in front of you, and prefer one path. Go's `FetchMediaGroup` takes an options struct. Media producers watch subscribers through `demand()` only. -libmoq's media symbols move to `moq_media_*`, and `cpp/obs` adapts. - -Public API: breaking in every binding and libmoq. Wire: none. +Public API: breaking in every binding. Wire: none. ## Required diff --git a/quest/m1/ffi-shape/net.md b/quest/m1/ffi-shape/net.md index b91d2770ac..cfcf2da2e7 100644 --- a/quest/m1/ffi-shape/net.md +++ b/quest/m1/ffi-shape/net.md @@ -36,9 +36,7 @@ are renamed. this line removes, but dropping it costs every quick-start a hop (raised in #3959). -libmoq's affected symbols follow. - -Public API: breaking in every binding and libmoq. Wire: none. +Public API: breaking in every binding. Wire: none. ## Required diff --git a/quest/m1/flate-binary.md b/quest/m1/flate-binary.md new file mode 100644 index 0000000000..812c11c613 --- /dev/null +++ b/quest/m1/flate-binary.md @@ -0,0 +1,50 @@ +# [M] moq-binary folds into moq-flate + +## Goal + +Opaque binary tracks live in `moq-flate` and `@moq/flate`: the `snapshot` and +`stream` modes `moq-binary` and `@moq/binary` provide today move there beside +the group-scoped codec, and `moq-binary` and `@moq/binary` are deleted. One +package owns compressed and opaque tracks, so there is no second "compressed +track" wrapper to build. + +## Plan + +Decided in the 2026-09-28 quest audit: `moq-binary` already composes +`moq-flate` into per-group windows (each group one sync-flushed DEFLATE +stream), which is what the m2 flate line planned to add as a new track wrapper. +Folding the two removes the duplicate instead of building it. + +- Rust: move `rs/moq-binary/src/{snapshot,stream}` and `Compression` into + `moq-flate` as `moq_flate::{snapshot, stream}`, keeping the codec + (`Encoder`/`Decoder`) at the root. `moq-flate` gains the `moq-net` + dependency. Delete `rs/moq-binary` and its workspace member, and repoint + `moq-mux` (`src/binary.rs`, `src/error.rs`) and `rs/libmoq` if it still + exists. +- JS: move `js/binary/src/{snapshot,stream,compression.ts}` into `@moq/flate` + as `Snapshot` and `Stream`, adding the `@moq/net` and `@moq/signals` + dependencies, and delete `js/binary`. +- Wire and catalog: unchanged. The hang catalog's `binary` section and + `moq_mux::binary` keep their names; they describe the track's content, and + the catalog section is wire. +- moq-ffi: rename `binary.rs` and its `publish_binary_*`, `MoqBinaryConfig`, + and producer types after `flate`, so every binding names the crate it wraps. + If [FFI shape](/quest/m1/ffi-shape/README.md) has already given them a + `flate` namespace, follow it instead. +- Open: whether `Compression::None` survives the move. An uncompressed opaque + track still needs a home, so the recommendation is to keep it and document + that the crate name is not a promise every track is deflated. +- Docs: fold `doc/lib/rs/moq-binary.md` into a `moq-flate` page and + `doc/lib/js/binary.md` into a `@moq/flate` page, fix `doc/.vitepress/config.ts`, + `doc/lib/{rs,js}/index.md`, `doc/concept/hang.md`, the android workflow path + filter, and add an upgrade note in `doc/setup/upgrade.md`. Grep for + `moq-binary`, `moq_binary`, and `@moq/binary`. + +Public API: breaking. `moq-binary` and `@moq/binary` are published and +deleted, and moq-ffi's binary names change, so this lands on `dev`. +`moq-flate` and `@moq/flate` grow additively. Wire: none. + +## Related + +- [Compressed tracks](/quest/m2/flate/README.md) - the hand-written wrappers expose these tracks +- [FFI shape](/quest/m1/ffi-shape/README.md) - gives the flate tracks their binding namespace diff --git a/quest/m1/frame-slot-charge.md b/quest/m1/frame-slot-charge.md new file mode 100644 index 0000000000..d7c199e5aa --- /dev/null +++ b/quest/m1/frame-slot-charge.md @@ -0,0 +1,53 @@ +# [S] Frame slots are cached for free + +## Goal + +A group's frame slots are charged against the cache pool, so a track producing +many small frames per group is billed for what it holds. The fixed per-group +overhead already covers the first few slots; this is the growth past them, and +the capacity a released group keeps. + +## Plan + +Re-planned from the closed #3546 against the current accounting in +`rs/moq-net/src/model/group.rs`. + +`GroupState::cache` and its `cache::Charge` count frame payload bytes only. The +frames live in a `VecDeque`, and `CACHE_OVERHEAD` prices exactly +`FRAME_SLOTS` (4) of its slots, the capacity `VecDeque` rounds the first frame +up to. Two gaps follow: + +- The deque grows geometrically past those four slots, and every slot beyond + them is `size_of::()` the pool never sees. It only bites where many + small frames share one group (chat, telemetry); a group of kilobyte video + frames is dominated by payload. +- `GroupState::release` (on abort, too-large, or a dropped producer) calls + `frames.clear()`, which keeps the capacity, then zeroes `cache` and clears + the charge. A consumer still holding the group pins those slots uncharged. + Dropping the deque's storage on release is likely enough here. + +The fix is not a bigger constant. The charge has to follow the deque's +capacity: charge the growth at each `charge.add` site (`write_frame`, the +`write_frames` loop, `create_frame`, and `create_frame_owned`), keeping +`FRAME_SLOTS` as the part `CACHE_OVERHEAD` already paid. + +Decide what `MAX_CACHE_BYTES` compares against before touching any of it. +`GroupState::would_overflow` reads `cache` as a payload limit, and the tests +and its doc ("maximum total size of frames") read it that way, so folding +slots into `cache` silently changes the per-group ceiling. Either keep a +separate payload counter for that check or restate the limit; do not let one +field mean both. + +`rs/moq-net/tests/group_charge.rs` weighs the process with a counting +allocator and is the place to prove it: add a many-small-frames shape beside +the one-frame-per-group case, and a unit test that crosses a deque growth +boundary and asserts the pool charge moved. + +Found by CodeRabbit on #3523, which fixed the one-frame-per-group undercount +that was OOM-killing relays serving chat, and deliberately left out of it. + +Public API: none, unless `MAX_CACHE_BYTES` is restated. Wire: none. + +## Related + +- [Relay memory](/quest/m1/relay-memory.md) - the per-announcement half of the same question, whose figures also predate the current accounting diff --git a/quest/m1/gateway-live-clock.md b/quest/m1/gateway-live-clock.md deleted file mode 100644 index ee39a7b1c4..0000000000 --- a/quest/m1/gateway-live-clock.md +++ /dev/null @@ -1,32 +0,0 @@ -# [S] Gateways publish on the broadcast clock - -## Goal - -`moq-srt`, `moq-rtmp`, and HLS import map source timestamps onto the -catalog's broadcast clock, as `moq import` does since -[#4122](https://github.com/moq-dev/moq/pull/4122). Today only the CLI calls -`.live()`; the gateways publish the encoder's timestamps verbatim, so the -advertised wall time is wrong for a source whose PTS does not start near zero, -and an encoder reconnect that restarts its timestamps rewinds or is refused. - -## Plan - -Decided: each gateway opts into `live()`. The importer default stays -verbatim, since tests and callers that pin `Config::with_clock` rely on it. - -Guidance: - -- SRT: `rs/moq-srt/src/ts.rs` builds the `ts::Import`. RTMP: the server's - publish path and the pull path in `dial.rs` build `FlvImport`. -- HLS import (`rs/moq-hls/src/import.rs`) runs one fMP4 importer per - rendition, and `live()` gives each its own anchor, so renditions would map - their first frames to different instants. They share one source clock and - need one mapping: share the `Anchor` across the broadcast's importers, or - pick another way to anchor the playlist once. Audio and video in separate - HLS renditions are the case to test. -- A reconnect that reuses the broadcast is where this pays off; check each - gateway's reconnect keeps the same importer (and anchor) or deliberately - starts a new one. -- Tests per gateway: a source starting at a large PTS publishes near the - broadcast clock's now, and a restart to zero continues forward. -- Docs: the gateway pages under `doc/bin/` that describe timestamps. diff --git a/quest/m1/gpu-ci.md b/quest/m1/gpu-ci.md index 0c0a8a054e..64bacaeeec 100644 --- a/quest/m1/gpu-ci.md +++ b/quest/m1/gpu-ci.md @@ -26,16 +26,19 @@ skipping inside the Nix shell. tests (Android, D3D11, PipeWire). Inside that selection a missing GPU fails the test instead of returning early. Keep the no-driver tests (`missing_driver_errors_instead_of_panicking`) outside it. -- `just rs nvidia`: symlink only those three libraries (by soname) from - `/usr/lib/x86_64-linux-gnu` into a private directory, put that on - `LD_LIBRARY_PATH`, and run that selection. Fail when a library is missing - instead of skipping. `just rs vulkan-cuda` puts the whole host directory on - the path, which lets host libraries shadow the Nix ones; fold it into this - recipe, since its `vulkan_cuda_` tests are the same kind. Those also need +- `just rs nvidia`, a one-line recipe over `sh/rs/nvidia.sh` in the tooling + line's `sh//` layout: symlink only those three libraries (by + soname) from `/usr/lib/x86_64-linux-gnu` into a private directory, put that + on `LD_LIBRARY_PATH`, and run that selection. Fail when a library is missing + instead of skipping. `just rs vulkan-cuda` (`sh/rs/vulkan-cuda.sh`) puts the + whole host driver directory on the path, which lets host libraries shadow + the Nix ones; fold it into this script and recipe, since its `vulkan_cuda_` + tests are the same kind. Those also need the Vulkan loader to find the host NVIDIA ICD: point it at the ICD manifest and expose the driver libraries it names, or keep them in their own recipe. -- Nightly: a job in `.github/workflows/nightly.yml` runs `just rs nvidia` on - the self-hosted runner. A self-hosted runner on a public repository must +- Nightly: a job in `.github/workflows/nightly.yml` runs `nix develop + --command just rs nvidia` on the self-hosted runner, a recipe and not a + script path, like every other workflow step. A self-hosted runner on a public repository must never run untrusted code: only `schedule` and `workflow_dispatch`, with the job gated to `refs/heads/main`, never `pull_request`; a dedicated label only this job selects; read-only `permissions`. Read GitHub's self-hosted runner @@ -49,6 +52,7 @@ Public API: none. Wire: none. ## Required +- [Tooling](/quest/m1/tooling/README.md) - the `sh/` script layout and recipe-only workflows this follows - A self-hosted runner is registered for moq-dev/moq on the maintainer's host, with the NVIDIA driver ## Related diff --git a/quest/m1/gpu-pool-reservation.md b/quest/m1/gpu-pool-reservation.md deleted file mode 100644 index d0fad928ec..0000000000 --- a/quest/m1/gpu-pool-reservation.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] GPU frame pool back-pressure is a reservation, not an error - -## Goal - -A caller feeding imported Vulkan frames through `moq_video::frame::cuda::Converter` -can drop a frame when the bounded GPU pool is full without matching an error. -Exhaustion is expected back-pressure; only real failures are errors. - -## Plan - -`Converter::convert` and `cuda::Frame::resize` take a pooled buffer and report -a full pool as `Error::Unsupported` with a prose message (#3869), so the CARLA -bridge in moq.pro can only drop-and-continue by matching the text. - -Split the reservation from the work, the way the bandwidth allocator hands out -a `Reservation`: `Converter::reserve() -> Option` returns `None` when -every buffer is live, and `Slot::convert(&vulkan::Frame) -> Result` -does the GPU work on the held buffer, failing only for a genuine error. The -slot returns its buffer to the pool on drop, converted or not. Apply the same -shape to the resize pool. Keep the pool itself crate-private and its capacity -bound unchanged. - -Tests on the injected allocator: `reserve` yields exactly `capacity` slots and -then `None`, dropping an unconverted slot frees it, and a failed conversion -does not leak the buffer. Update the `just rs vulkan-cuda` hardware test and -`doc/lib/rs/moq-video.md` inline. - -Public API: `moq-video` 0.1.x, breaking for `Converter::convert` callers, so -it targets dev. Wire: none. - -## Related - -- [Bandwidth allocator](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the reservation-handle precedent diff --git a/quest/m1/ietf-announce-count.md b/quest/m1/ietf-announce-count.md deleted file mode 100644 index 5c5b92695f..0000000000 --- a/quest/m1/ietf-announce-count.md +++ /dev/null @@ -1,24 +0,0 @@ -# [M] An IETF namespace subscription says how many routes it replays - -## Goal - -A moq-transport session that negotiates a new extension learns the size of -the initial set a SUBSCRIBE_NAMESPACE replays, the way `AnnounceOk.active` -tells a moq-lite session, so the announce `Live` marker fires on the count -instead of the settle timer. Without the extension the timer stays. - -## Plan - -- Specify the extension in a `drafts/` document (a new one, or an existing - lcurley extension draft if one fits), with its setup parameter and where - the count rides, and validate with `just drafts check`. -- Implement it in `rs/moq-net`'s IETF publisher and subscriber; mirror in - `js/net` if its IETF path carries announces. -- Test: a negotiated session clears on the count with N routes and with none; - an un-negotiated one still clears on the timer. - -Public API: none. Wire: a new opt-in extension. - -## Required - -- [Caught up](/quest/m1/cli-inspect/caught-up.md) - the per-source clear this feeds diff --git a/quest/m1/ietf-max-age.md b/quest/m1/ietf-max-age.md deleted file mode 100644 index 04c6f11ce2..0000000000 --- a/quest/m1/ietf-max-age.md +++ /dev/null @@ -1,47 +0,0 @@ -# [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 have no - absent value, so `None` is a sentinel both languages can represent: send - 2^53-1 (`Number.MAX_SAFE_INTEGER`) and read any value at or above it as - `None`. Decided over the largest varint (2^62-1) because JS reads Max Age - as a u53, so that sentinel would not survive a JS hop - ([#4188](https://github.com/moq-dev/moq/pull/4188)). 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/ietf-uni-stream-types.md b/quest/m1/ietf-uni-stream-types.md index 9efb7e0ed5..6b0ca089c9 100644 --- a/quest/m1/ietf-uni-stream-types.md +++ b/quest/m1/ietf-uni-stream-types.md @@ -8,19 +8,17 @@ so it ships on main. ## Plan -`rs/moq-net/src/ietf/session.rs` routes every non-SETUP uni stream to -`run_uni_group` (`:708`), which rejects padding and unknown types alike while +`run_unis` in `rs/moq-net/src/ietf/session.rs` routes every non-SETUP uni +stream to `run_uni_group`, which rejects padding and unknown types alike while leaving the session alive. That stream-only rejection reaches the wire as -INTERNAL_ERROR on both branches, because nothing registers a code for it: on -dev the handler maps a session-scoped error to `StreamError::Internal` and -aborts the reader (`:697-704`); on main it is `reader.stop(to_stream_code(&err))` -(`:583` there), which falls through to INTERNAL_ERROR the same way. +INTERNAL_ERROR, because nothing registers a code for it: the handler maps a +session-scoped error to `StreamError::Internal` and aborts the reader. draft-21 settles what each stream type means: a stream whose type the endpoint does not recognize MUST close the session, and a padding stream (type 0x132B3E28) MUST be discarded, which an endpoint may do by cancelling -it. The tree negotiates up to draft-20 (`rs/moq-net/src/ietf/version.rs:12`), -so apply that split to every supported draft and check the earlier ones for +it. The tree negotiates drafts 14 through 22 (`ietf::Version` in +`rs/moq-net/src/ietf/version.rs`), so apply that split to every supported draft and check the earlier ones for the padding type value and whether draining is required. - Classify stream types before spawning a group handler. Handle PADDING per @@ -28,15 +26,16 @@ the padding type value and whether draining is required. stream-only code, and propagate a genuinely unknown type to the session driver as a protocol violation. Keep ordinary group failures scoped to their streams. -- The test `unknown_uni_type_does_not_claim_the_session_closed` - (`session.rs:1267`) asserts the current behavior, an INTERNAL_ERROR stop and +- The test `unknown_uni_type_does_not_claim_the_session_closed` in + `session.rs` asserts the current behavior, an INTERNAL_ERROR stop and no session close, and flips: an unknown type now closes the session and stops nothing on its own. - Add a padding test asserting a stream-only cancel with no session close, and - keep `a_group_for_a_retired_alias_is_stopped_with_cancelled` (`:1255`), which + keep `a_group_for_a_retired_alias_is_stopped_with_cancelled`, which pins that a dropped group never closes the session. Consult [draft-21 section 11.5](https://www.ietf.org/archive/id/draft-ietf-moq-transport-21.html) for the wording, and [draft-19 section 3.4 and section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-19.html) plus [draft-20 section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-20.html) -for the drafts the tree negotiates. +and [draft-22](https://www.ietf.org/archive/id/draft-ietf-moq-transport-22.html) +for the other drafts the tree negotiates. diff --git a/quest/m1/iroh-lite-wip.md b/quest/m1/iroh-lite-wip.md index e733761633..f6be7e23dc 100644 --- a/quest/m1/iroh-lite-wip.md +++ b/quest/m1/iroh-lite-wip.md @@ -16,9 +16,9 @@ constant rather than the configured `Versions` (`versions.alpns()`, which config and then ignored on iroh. Thread the configured versions through instead. -The relay's WebSocket listener and `moq-ffi`'s transport also read -`moq_net::ALPNS` directly. Check whether they have the same hole; fix them -here if it is the same small change, otherwise report it. +`moq-ffi`'s transport (`rs/moq-ffi/src/transport.rs`) also offers +`moq_net::ALPNS` directly; fix it here too. The relay's WebSocket listener +already honors the configured versions. Scope is those two files. Decided by the maintainer: the finalized lite-07 ALPN stays `moq-lite-07`, as `drafts/draft-lcurley-moq-lite.md` already says. Peers from the yanked diff --git a/quest/m1/js-announce-caught-up.md b/quest/m1/js-announce-caught-up.md deleted file mode 100644 index c568f1fdbb..0000000000 --- a/quest/m1/js-announce-caught-up.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] @moq/net announce consumers know when they have caught up - -## Goal - -`@moq/net`'s announce consumer yields the same `Live` marker the Rust -consumer gets from [Caught up](/quest/m1/cli-inspect/caught-up.md), with -the same ordering and per-source semantics, so a browser app can render "no -broadcasts" instead of a spinner that never resolves. - -## Plan - -Mirror the Rust shape, name, and marker-less fallback: one flat event, -`Announced`, `Updated`, `Retracted` (each carrying the announce), or `Live`, -replacing the update's `kind` field. Test the same cases -in JS. Public API: the announce consumer's yield type changes, so it lands -with the Rust break. Wire: none. - -## Required - -- [Caught up](/quest/m1/cli-inspect/caught-up.md) - settles the API shape this mirrors diff --git a/quest/m1/js-ietf-datagram.md b/quest/m1/js-ietf-datagram.md index 417609fe0a..db210e7b25 100644 --- a/quest/m1/js-ietf-datagram.md +++ b/quest/m1/js-ietf-datagram.md @@ -26,4 +26,4 @@ accept `OBJECT_DATAGRAM` as the drafts define; no project draft changes. ## Required -- [Datagram groups](/quest/m1/moxygen/datagram.md) - the Rust side this mirrors and interops with +- [moxygen interop](/quest/m1/moxygen/README.md) - its datagram groups quest (done on the line) is the Rust side this mirrors and interops with diff --git a/quest/m1/js-subscribe-abandonment.md b/quest/m1/js-subscribe-abandonment.md index df3ed49137..771a8da131 100644 --- a/quest/m1/js-subscribe-abandonment.md +++ b/quest/m1/js-subscribe-abandonment.md @@ -9,13 +9,15 @@ of receiving an abandonment error. The setup path is the same on main, so the fix lands there. -- `waitAbandoned` (`js/net/src/ietf/subscriber.ts:423-428`) checks demand and - resolves through `Promise.race`; another microtask can attach a viewer before - the catch closes the producer at `:449`. Reproduce that ordering in - `js/net/src/ietf/subscriber.test.ts`: the case at `:673` covers only the - established serving loop. +- `waitAbandoned` (in `#runSubscribe`, `js/net/src/ietf/subscriber.ts`) + checks demand and resolves through `race`; another microtask can attach a + viewer before the catch awaits `sessionCause` and rejects the request. + Reproduce that ordering in `js/net/src/ietf/subscriber.test.ts`: "returning + demand survives a blocked unsubscribe" covers only the established serving + loop. - Recheck demand and commit the close in the same synchronous continuation, - the way the serving loop does (`:511-523`). When demand returns, keep the + the way the serving loop after SUBSCRIBE_OK does (its `producer.used` + re-check before `producer.close()`). When demand returns, keep the existing setup operation and timeout budget. - Cover abandonment before SUBSCRIBE_OK, demand returning before the commit, and late setup completion. Verify cancellation and alias cleanup still happen diff --git a/quest/m1/keyframe-trigger.md b/quest/m1/keyframe-trigger.md deleted file mode 100644 index 67e430de51..0000000000 --- a/quest/m1/keyframe-trigger.md +++ /dev/null @@ -1,37 +0,0 @@ -# [M] On-demand keyframe trigger - -## Goal - -An application publishing through the built-in capture path can ask for a -keyframe. `Encoder::cut()`, `Sink::cut()`, and the ffi/libmoq `cut` already -force one, refusing with `CutUnsupported` when a backend cannot, but the -turnkey capture paths have no way in. - -## Plan - -`Backend::encode(frame, cut)` honors a cut (NVENC via the `FORCEIDR` picture -flag with `repeatSPSPPS` so the IDR carries its parameter sets, deliberately -not `pictureType` which `enablePTD` ignores; openh264, VAAPI, VideoToolbox and -Media Foundation the same way) and `can_cut()` answers at open whether it can. -What is missing is a caller-facing trigger on the turnkey paths: - -- `publish_capture` relies on every backend opening with a keyframe and - otherwise rides the GOP cadence; its `Options` carry no trigger. -- `js/publish`'s encode path already calls `encoder.encode(frame, { keyFrame })`, - but `lastKeyframe` is a closure-local `let` with no external trigger. - `Config.keyframeInterval` is cadence, not on demand. - -Give both a trigger the caller owns: a handle on the Rust capture path, and a -Signal the JS encode effect reads instead of its local variable. Coalesce -requests, so several arriving within one frame interval produce one IDR rather -than a run of them, and rate limit at the publisher so a caller in a loop -cannot pin the encoder at all-IDR. - -Additive on both sides, so it lands on `main`. Real callers exist regardless -of whether a wire-level request ever ships: a resume, a recording cut, a -rendition switch, and an application that knows its own tune-in moment. - -## Related - -- [GOP overhead](/quest/m2/gop-overhead.md) - whether a long GOP driven by a - keyframe request is worth designing at all diff --git a/quest/m1/kt-jvm-exit.md b/quest/m1/kt-jvm-exit.md index 62f5681d8f..71fbe5e115 100644 --- a/quest/m1/kt-jvm-exit.md +++ b/quest/m1/kt-jvm-exit.md @@ -34,7 +34,3 @@ out of scope: it kills the process rather than destroying the VM. exit code. Run it in the existing `kt` CI job. Public API: none; the hook is internal to the binding. Wire: none. - -## Related - -- [libmoq shutdown](/quest/m3/libmoq-shutdown.md) - the same hazard class for the C ABI and the OBS plugin diff --git a/quest/m1/ladder/README.md b/quest/m1/ladder/README.md index 79943bff1e..0c242cc396 100644 --- a/quest/m1/ladder/README.md +++ b/quest/m1/ladder/README.md @@ -27,9 +27,13 @@ What remains is the publisher side. The allocator estimate by `track::Info::priority`, filling a tier before the next sees a bit and splitting max-min fair within one. A controller that assigns descending priorities down the ladder gets correct allocation from that -alone. Send order is the same number, not a separate question: -`Priority::cmp` (`rs/moq-net/src/lite/priority.rs`) ranks by track priority -first, so the same number decides what to produce and what to send first; +alone. Send order is a different number today: `Priority::cmp` +(`rs/moq-net/src/lite/priority.rs`) ranks first by `Priority.track`, which is +the subscriber's priority from SUBSCRIBE (`msg.priority` in +`rs/moq-net/src/lite/publisher.rs`), not the publisher's `Info::priority`. +The controller quest makes the publisher's number the tiebreak after it, so +the same number decides what to produce and, among equal subscriber +priorities, what to send first; [Scope track priority](/quest/m1/track-priority-scope.md) settles what that ranking means on the first mile versus a cluster session before the controller depends on it. diff --git a/quest/m1/lite-count-settle.md b/quest/m1/lite-count-settle.md deleted file mode 100644 index 2cb41dcf52..0000000000 --- a/quest/m1/lite-count-settle.md +++ /dev/null @@ -1,28 +0,0 @@ -# [S] lite-07 subscribers settle on the stream count - -## Goal - -On moq-lite-07, Rust and JS subscribers stop waiting for a subscription's tail -once they have read the headers of as many group streams as SUBSCRIBE_END -counts, so a group the publisher skipped or never opened costs no grace. A -late stream below the end is still accepted within the grace, which stays for -a stream reset before its header arrived. lite-05 and -06 keep the DROP -accounting JS already has and Rust track tail adds. - -## Plan - -- Rust and JS subscribers now settle on the received header count for lite-07. - lite-05 and -06 keep their range and DROP accounting. The grace still covers - counted streams reset before their headers arrive. -- Local Rust and JS regressions cover late streams, missing/reset streams, - skipped sequences, and zero streams. JS also verifies a reset after its header - and groups still being read across the subscription's FIN. -- Remaining: add the count-specific Rust-JS case to track-tail interop. That - harness currently exposes a relay start-floor defect: when a newer group - arrives first, earlier in-flight groups can be lost. Finish the count proof - once that defect is resolved; keep this quest and its PR open until then. - -## Related - -- [Track tail interop](/quest/m1/track-tail-interop.md) - the Rust-JS case this adds its count check to -- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - makes the count exact by keeping a reset stream's header diff --git a/quest/m1/merge-queue.md b/quest/m1/merge-queue.md index 002b723959..d84f2cee10 100644 --- a/quest/m1/merge-queue.md +++ b/quest/m1/merge-queue.md @@ -17,9 +17,10 @@ and there is no merge queue, so nothing re-runs them on the combined tree. queue tests the combination once, at merge time. - Make the workflows ready: every workflow providing a required check (today `Check` and `Test` in `.github/workflows/check.yml`) also runs on - `merge_group`. `just ci` scopes by diffing against `GITHUB_BASE_REF`, - which a merge group does not set, so pass the group's base - (`github.event.merge_group.base_sha`) explicitly. Check the concurrency + `merge_group`. `just ci check|test` runs `sh/dispatch.sh`, which scopes + by diffing against its `BASE` argument, else `origin/$GITHUB_BASE_REF`. A + merge group sets no `GITHUB_BASE_REF`, so pass the group's base + (`github.event.merge_group.base_sha`) as `BASE`. Check the concurrency group and the `closed`-only skip still behave for queue refs. - Document it in `CONTRIBUTING.md`: PRs merge through the queue, a dequeued PR means the combination failed, and how agents enqueue (the @@ -29,3 +30,7 @@ and there is no merge queue, so nothing re-runs them on the combined tree. settings to use rather than changing it. Public API: none. Wire: none. + +## Required + +- [Tooling](/quest/m1/tooling/README.md) - `just ci` and `sh/dispatch.sh`, the entry point the queue runs diff --git a/quest/m1/moq-c.md b/quest/m1/moq-c.md deleted file mode 100644 index ba18262125..0000000000 --- a/quest/m1/moq-c.md +++ /dev/null @@ -1,33 +0,0 @@ -# [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 `AGENTS.md` Cross-Package - Sync table, and quests that name libmoq. Grep the whole repository. -- Publish a final `libmoq` release whose README and description point at - `moq-c`, then stop publishing it. The maintainer cuts releases; the PR only - 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/mux-wasm-target.md b/quest/m1/mux-wasm-target.md deleted file mode 100644 index 17fc354710..0000000000 --- a/quest/m1/mux-wasm-target.md +++ /dev/null @@ -1,26 +0,0 @@ -# [S] moq-mux compiles for wasm32 - -## Goal - -`cargo check -p moq-mux --target wasm32-unknown-unknown` passes. Two things -in the crate compile natively only through workspace feature unification and -break on the wasm target, which is what stops anything above `moq-net` from -reaching the browser through Rust. - -## Plan - -Found by the #2907 spike; both are latent bugs independent of any browser -work. - -- `tokio::time::Instant` in `rs/moq-mux/src/codec/{av1,h264,h265}/split.rs`: - `moq-mux` declares tokio with only the `macros` feature, so this compiles - natively only because another crate turns the runtime on. Use - `web_async::time`, the migration `moq-net` already made. -- `pub trait Stream: Send + 'static` in `rs/moq-mux/src/catalog/stream.rs`: - moq-net's wasm stats types are `Rc>`, so the supertrait cannot - be satisfied. Apply the `MaybeSend` treatment `moq-net` has in - `src/util.rs`. -- Add the crate to the wasm clippy lane in `rs/justfile` beside `moq-wasm` - and `moq-net`, so it stays green. - -Public API: none. Wire: none. diff --git a/quest/m1/obs-moq-video/README.md b/quest/m1/obs-moq-video/README.md index cdc4cdcbe4..1e41ad84c8 100644 --- a/quest/m1/obs-moq-video/README.md +++ b/quest/m1/obs-moq-video/README.md @@ -2,11 +2,11 @@ ## Goal -Remove the MoQ OBS plugin's dependency on OBS/system FFmpeg ABI versions by decoding subscribed video with moq-video. Add audio playback and publishing with moq-audio, and opt-in video publishing with moq-video. OBS retains scene composition, audio mixing, and output timing. This integration serves MoQ publishing and playback, not general OBS recording or other streaming outputs. +Remove the MoQ OBS plugin's dependency on OBS/system FFmpeg ABI versions by decoding subscribed video with moq-video and subscribed audio with moq-audio. Add audio publishing with moq-audio, and opt-in video publishing with moq-video. OBS retains scene composition, audio mixing, and output timing. This integration serves MoQ publishing and playback, not general OBS recording or other streaming outputs. ## Plan -Portability and FFmpeg removal lead. The current MoQ source uses libavcodec, libavutil, and libswscale for video; it has no audio playback. Its swresample linkage is unused. Video replacement can therefore remove direct FFmpeg dependencies without waiting for audio. The plugin reaches codecs through the generated C++ package over moq-ffi (the migration quest linked below lands first); build `libmoq_ffi` statically with only the codec features needed here; OS frameworks and runtime GPU drivers remain valid dependencies. Verify plugin imports instead of promising a completely static OBS plugin. +Portability and FFmpeg removal lead. The current MoQ source decodes video with libavcodec, libavutil, and libswscale, and audio with libavcodec (`moq_source_decode_audio_frame` in `cpp/obs/src/moq-source.cpp`). Its swresample linkage is unused. FFmpeg linkage goes away only once both the video source replacement and the audio decode replacement land. The stranded audio branch (#3498, merged only into `codex/obs-audio-receive-base`) is abandoned; the audio playback quest replans it on the generated C++. The plugin reaches codecs through the generated C++ package over moq-ffi (the migration quest linked below lands first); build `libmoq_ffi` statically with only the codec features needed here; OS frameworks and runtime GPU drivers remain valid dependencies. Verify plugin imports instead of promising a completely static OBS plugin. Attempt GPU delivery immediately, starting on macOS. Windows and Linux can ship independently. Prefer direct surface reuse, allow GPU conversion/blits, and automatically fall back to CPU delivery when import is unavailable or fails. Stats must show the actual decoder/encoder, delivery path, and fallback reason. Retaining a texture handle is insufficient unless pool ownership and synchronization also prevent reuse while work is in flight. @@ -14,12 +14,12 @@ Initial video decoding covers H.264, HEVC, and AV1 where moq-video has an availa Publishing remains opt-in, with one **Use MoQ encoders** choice for video and audio. Keep the existing OBS encoder mode. Internal OBS encoder adapters call moq-video/moq-audio, preserving OBS's A/V handling and the existing encoded MoQ output. The combined choice is enabled only when both adapters are present. Start with H.264, supported HEVC, and Opus; defer AV1/AAC encoding and PCM publishing UI. Keep bitrate separate from **Low latency**, **Balanced** (default), and **Quality** presets. Presets describe supported buffering/compression controls, not an end-to-end delay promise. -The quests separate portable decoding, platform GPU delivery, audio, and publishing so each can land and be validated independently. The existing CPU decode path is a fallback primitive, not a GPU implementation: it explicitly converts every surface to I420. Native frame ownership must cross the FFI boundary without that conversion. +The quests separate portable decoding, platform GPU delivery, audio, and publishing so each can land and be validated independently. moq-ffi's decoded frames own their surface and convert to CPU pixels only on request (#4094, on `dev`); a native decode exposes the platform surface as a borrowed view, which each platform quest extends to its own surface type. ## Required -- [Video source replacement](/quest/m1/obs-moq-video/source.md) - remove FFmpeg and attempt macOS GPU delivery immediately, with a working CPU fallback on other platforms -- [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - add synchronized subscribed audio through moq-audio +- [Video source replacement](/quest/m1/obs-moq-video/source.md) - remove the FFmpeg video decode and attempt macOS GPU delivery immediately, with a working CPU fallback on other platforms +- [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - replace the FFmpeg audio decode with moq-audio - [Windows decoded frames](/quest/m1/obs-moq-video/decode-windows.md) - present decoded D3D11 surfaces in OBS without CPU readback - [Linux decoded frames](/quest/m1/obs-moq-video/decode-linux.md) - present supported native decoded surfaces with visible CPU fallback - [Linux bundle](/quest/m1/obs-moq-video/linux-bundle.md) - attach a portable Linux x86_64 tarball to every obs-moq release once FFmpeg is gone diff --git a/quest/m1/obs-moq-video/audio-playback.md b/quest/m1/obs-moq-video/audio-playback.md index d7510a8cef..d7da76a3f4 100644 --- a/quest/m1/obs-moq-video/audio-playback.md +++ b/quest/m1/obs-moq-video/audio-playback.md @@ -1,15 +1,20 @@ -# [L] Add moq-audio playback to the OBS source +# [L] Replace the OBS source's FFmpeg audio decode with moq-audio ## Goal -Subscribed MoQ audio plays through OBS alongside video using statically linked moq-audio codec code, including Opus, AAC-LC, and PCM. Audio playback can ship independently of video replacement and publishing. +The MoQ source decodes subscribed audio with statically linked moq-audio instead of libavcodec, covering Opus, AAC-LC, and PCM, and keeps its multichannel speaker placement. Audio can ship independently of video replacement and publishing. ## Plan -- The current source is video-only. Reuse the moq-ffi audio consumer (`subscribe_audio`, a `next()` future per frame, cancel on drop), then feed converted PCM to `obs_source_output_audio`. Keep device playback inside OBS; do not enable moq-audio device capture/playback features or open a second audio device. -- Map catalog tracks, sample rates, channel layouts, and timestamps explicitly. Support mono/stereo initially, with explicit rejection or a tested OBS conversion for other layouts. Share the source's media timebase with video, preserve reconnect and rendition changes, and bound audio buffering. `latency_max_ms` controls stalled-group skipping, not desired A/V playout delay. +- Today the source already plays audio: `moq_source_subscribe_audio` in `cpp/obs/src/moq-source.cpp` receives encoded frames through libmoq's `moq_consume_audio`, and `moq_source_decode_audio_frame` decodes them with libavcodec before `obs_source_output_audio`. Replace that decode, not the playback path. +- Decode through the generated C++ package with `MoqBroadcastConsumer::decode_audio` (a `MoqAudioConsumer` with a `next()` future per frame, cancel on drop), then feed its PCM to `obs_source_output_audio`. `decode_audio` accepts Opus and AAC-LC today; add PCM there rather than in the plugin. Keep device playback inside OBS; do not enable moq-audio device capture/playback features or open a second audio device. +- Map catalog tracks, sample rates, channel layouts, and timestamps explicitly. Channel layouts: map each channel count to the default layout moq-audio uses, the WAVE convention (3 is 2.1, 4 is quad, 6 is 5.1, 8 is 7.1), picking the nearest OBS `speaker_layout`, and refuse counts OBS cannot place rather than guess. This replaces `audio_layout_to_speakers`, which maps from FFmpeg layouts, and absorbs the former m2 obs-wave-layout quest. Note the mapping in `doc/bin/obs.md`. +- Share the source's media timebase with video, preserve reconnect and rendition changes, and bound audio buffering. `latency_max_ms` controls stalled-group skipping, not desired A/V playout delay. - Preserve OBS mixer, monitoring, mute, and volume behavior. Release every frame on output, conversion failure, stop, and late completion. Do not hold source state locks across callbacks into OBS. -- Verify audible output and recorded PCM, A/V synchronization with timestamped test media, rate changes, silence, stalls, reconnect, source replacement, and teardown. Exercise Opus, AAC-LC and PCM fixtures, not just callback counts. Update source documentation and Stats. +- Remove the audio FFmpeg includes and codec mapping. Whichever of this and the video source replacement lands second removes the remaining FFmpeg CMake linkage. +- Verify audible output and recorded PCM, A/V synchronization with timestamped test media, rate changes, silence, stalls, reconnect, source replacement, teardown, and a 5.1 fixture placed on the right speakers. Exercise Opus, AAC-LC and PCM fixtures, not just callback counts. Update source documentation and Stats. + +Decided: the earlier receive branch (#3498, merged only into the stranded `codex/obs-audio-receive-base`) is abandoned rather than rebased; this quest starts from the plugin on the generated C++. ## Required @@ -18,3 +23,4 @@ Subscribed MoQ audio plays through OBS alongside video using statically linked m ## Related - [Video source replacement](/quest/m1/obs-moq-video/source.md) - coordinate the shared timestamp and source lifecycle without blocking audio rollout +- [Channel layouts](/quest/m1/audio-codecs/layout.md) - the moq-audio layouts this mapping mirrors diff --git a/quest/m1/obs-moq-video/linux-bundle.md b/quest/m1/obs-moq-video/linux-bundle.md index fb55f31b51..8a10983f3a 100644 --- a/quest/m1/obs-moq-video/linux-bundle.md +++ b/quest/m1/obs-moq-video/linux-bundle.md @@ -6,15 +6,16 @@ Every `obs-moq-v*` release attaches a Linux x86_64 tarball that loads into a sto ## Plan -- The only reason `obs-build` in `.github/workflows/libmoq.yml` skips Linux is FFmpeg: the source links nix/distro libavcodec, which is not portable. The FFmpeg removal is the blocker; once the plugin is C++ over libmoq plus libobs and Qt6, a Linux build has no extra runtime dependency that OBS itself does not already carry. +- The only reason `obs-build` in `.github/workflows/libmoq.yml` skips Linux is FFmpeg: the source links nix/distro libavcodec for both video and audio, which is not portable. The FFmpeg removal (video source replacement plus audio playback) is the blocker; once the plugin is C++ over moq-ffi plus libobs and Qt6, a Linux build has no extra runtime dependency that OBS itself does not already carry. - Build on `ubuntu-24.04` (glibc 2.39, the floor OBS's own Linux packages target) against the libobs and Qt6 headers OBS's plugin template uses; the template's `.deb` recipe is the reference. Ship the plain archive layout the other platforms use (`obs-moq-*-x86_64-unknown-linux-gnu.tar.gz` with `bin/64bit/obs-moq.so` and `data/`), extractable into `~/.config/obs-studio/plugins/obs-moq/`. A `.deb` is optional and separate. - Flatpak OBS cannot load a plugin from the host filesystem. Verify a real Flatpak install and support it only if the sandbox's runtime ABI matches, documenting the `~/.var/app/com.obsproject.Studio/config/obs-studio/plugins/` path. Otherwise, state plainly that Flatpak is unsupported. -- `cpp/obs/build.sh --target x86_64-unknown-linux-gnu` produces the tarball, and the matrix in `obs-build` gains the row; nothing else in the release pipeline changes. Keep the nightly `just obs ci` compile as the PR gate. +- `cpp/obs/build.sh --target x86_64-unknown-linux-gnu` produces the tarball, and the matrix in `obs-build` gains the row; nothing else in the release pipeline changes. Keep the nightly `just obs ci` compile as the PR gate; #4370 (open) compiles the OBS plugin on every PR, which covers the Linux compile if it lands first. - Verify by loading the tarball into the oldest supported OBS 32 release and current stable on Ubuntu 24.04 and one non-Debian distro (Fedora), publishing and subscribing against a relay, and inspecting the `.so` with `ldd` for nothing beyond libobs, Qt6, glibc, and OS libraries. ## Required -- [Video source replacement](/quest/m1/obs-moq-video/source.md) - removes the FFmpeg linkage that makes a Linux binary non-portable +- [Video source replacement](/quest/m1/obs-moq-video/source.md) - removes the FFmpeg video linkage that makes a Linux binary non-portable +- [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - removes the FFmpeg audio linkage ## Related diff --git a/quest/m1/obs-moq-video/rate-control.md b/quest/m1/obs-moq-video/rate-control.md index c4a8aeda4d..3f98e38e03 100644 --- a/quest/m1/obs-moq-video/rate-control.md +++ b/quest/m1/obs-moq-video/rate-control.md @@ -12,8 +12,9 @@ configured rate. ## Plan -Uses the reservation surface from libmoq (`moq_session_bandwidth`, -`moq_bandwidth_reserve`, `moq_reservation_grant`). Apply grants through +Uses the moq-ffi reservation surface through the generated C++ package +(`MoqSession::bandwidth`, `MoqBandwidth::reserve`, `MoqReservation::grant`), +not libmoq, since the plugin moves off libmoq first. Apply grants through the shape `moq_mux::rate::Control` uses (drops at once, raises ramp, hysteresis) rather than pushing every change into `obs_encoder_update`; whether that policy sits in moq-ffi behind the reservation or in the plugin depends on @@ -24,3 +25,7 @@ whether a second binding wants it. Verify against a shaped uplink and with ## Required - [OBS migration](/quest/m1/cpp/obs.md) - the plugin is on the generated C++ first + +## Related + +- [Audio follows the grant](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the shared rate policy audio adopts; OBS audio still reserves only diff --git a/quest/m1/obs-moq-video/source.md b/quest/m1/obs-moq-video/source.md index 15d9450789..e0178523f9 100644 --- a/quest/m1/obs-moq-video/source.md +++ b/quest/m1/obs-moq-video/source.md @@ -1,24 +1,24 @@ -# [XL] Replace OBS source FFmpeg decoding with moq-video +# [XL] Replace OBS source FFmpeg video decoding with moq-video ## Goal -The MoQ source loads and plays supported video without directly linking FFmpeg libraries. Attempt macOS GPU delivery in the first implementation; automatically fall back to CPU delivery when native presentation is unavailable. Windows and Linux initially retain a portable CPU path. +The MoQ source loads and plays supported video without FFmpeg's video libraries (libswscale, and libavcodec/libavutil for video). Attempt macOS GPU delivery in the first implementation; automatically fall back to CPU delivery when native presentation is unavailable. Windows and Linux initially retain a portable CPU path. ## Plan - Replace `cpp/obs/src/moq-source.cpp` video decode and conversion with moq-video through the generated C++ package: the moq-ffi video consumer for the CPU path, including frame ownership and cancellation semantics. Support H.264, HEVC, and available AV1 decoding; report unsupported VP8/VP9 explicitly until their follow-up lands. -- Extend the owned frame abstraction to retain native surfaces across the FFI boundary. The current CPU consumer calls `into_i420()` unconditionally; a native path must avoid it. Prefer an options object and owned opaque frame handle with explicit release and optional CPU conversion over parallel platform-specific subscription APIs. Document device/thread affinity, borrowed views, synchronization, cancellation, and pool lifetime. Do not introduce caller cleanup callbacks. +- Decode with `MoqVideoDecoderOutput.native` set: each `MoqVideoDecodedFrame` retains the decoder's surface, `native()` borrows it (`PixelBuffer` on macOS) for as long as the frame lives, and `pixels(format)` is the CPU fallback. Hold the frame until OBS's GPU work reading it completes; held frames hold decoder pool slots. This surface landed on `dev` (#4094). - Implement the macOS presentation probe immediately: retain VideoToolbox PixelBuffer/IOSurface storage, inspect OBS graphics import and rendering support, and convert to OBS's expected color format on the GPU if necessary. Adapt the source render path to import textures on the graphics thread; preserve source timing instead of simply drawing the newest frame. An asynchronous CPU source API alone does not prove native GPU delivery. - Bound decoded frames retained by the render thread. On import failure, switch to the existing I420 delivery path and show the reason in Stats. Keep fallback stable for the stream/device configuration rather than retrying every frame; re-probe on a relevant configuration change or restart. Device loss and resize must retire old surfaces only after rendering completes. - Preserve timestamps, range/primaries, stride and plane layout, catalog/rendition changes, reconnect, visibility/deactivation behavior, and existing source settings. Carry frame-generation identity so late callbacks cannot display frames from a replaced source. -- Remove FFmpeg includes, CMake discovery/linkage, unit stubs, compile recipe requirements, and unused swresample linkage. Update OBS build/install docs and `doc/lib/cpp` together. libobs/Qt and native OS/GPU dependencies remain. -- Validate new code with decoded pixels and moving timestamps, GPU copy/readback traces, and p50/p95 decode-to-presentation delay. Exercise CPU fallback, unsupported codec, GPU import failure, device loss, repeated start/stop, rendition change, and delayed terminal completion. Verify no AVCodec/AVUtil/SWScale/SWResample imports using platform binary inspection. Load the artifact against the oldest supported OBS release and current stable release, using the repo's supported version policy at implementation time. +- Remove the video FFmpeg includes and swscale linkage. Audio still decodes through libavcodec until the audio playback quest replaces it, so whichever of the two lands second removes the remaining FFmpeg includes, CMake discovery/linkage, unit stubs, compile recipe requirements, and the unused swresample linkage. Update OBS build/install docs and `doc/lib/cpp` together. libobs/Qt and native OS/GPU dependencies remain. +- Validate new code with decoded pixels and moving timestamps, GPU copy/readback traces, and p50/p95 decode-to-presentation delay. Exercise CPU fallback, unsupported codec, GPU import failure, device loss, repeated start/stop, rendition change, and delayed terminal completion. Verify no SWScale imports (and no AVCodec/AVUtil/SWResample imports once audio playback has landed) using platform binary inspection. Load the artifact against the oldest supported OBS release and current stable release, using the repo's supported version policy at implementation time. ## Required - [OBS migration](/quest/m1/cpp/obs.md) - the plugin is on the generated C++ before decode changes -- [Decoded frame ownership](/quest/m1/decoded-frames.md) - the decoded-frame consumer moq-ffi lacks, retaining the existing frame and defining native-view lifetime before OBS imports it ## Related - [VP8/VP9 decoding](/quest/m1/obs-moq-video/vpx.md) - restores deferred codec coverage independently +- [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - removes the audio half of the FFmpeg linkage diff --git a/quest/m1/open-gop-leading-pictures.md b/quest/m1/open-gop-leading-pictures.md index 8c53a747a8..c1f84922ea 100644 --- a/quest/m1/open-gop-leading-pictures.md +++ b/quest/m1/open-gop-leading-pictures.md @@ -25,16 +25,23 @@ everyone. sample of a group to `keyframe`, and `js/watch/src/video/decoder.ts` submits it as `"key"`): for the first group after any non-continuous transition, skip delta frames stamped before that group's keyframe. That covers a - subscribe, a declared discontinuity, and a latency skip: `#checkLatency` + subscribe, a declared discontinuity, and a latency skip: `#checkMaxAge` records the skip through `#gap` and `next()` reports the next frame with `continuous: false`. Latency skip also bumps playhead generation (startup delay) but does not flush the decoder. Leading pictures after that non-continuous transition are still skipped, as above; a viewer that skipped into a later GOP lacks its references just like a cold join. Every continuous group is passed through untouched. -- The same rule in the Rust decode path (`moq-video` decode consumers), with - an equivalent non-continuous signal from `container::Consumer`, so native - playback and the transcoder tune in the same way. +- The same rule in the Rust decode path (`moq-video` decode consumers), so + native playback and the transcoder tune in the same way. +- This quest owns the Rust non-continuous signal, which audio warmup and + consumer warmup reuse rather than each adding one. Today + `moq_mux::container::Consumer::poll_read` returns a bare frame, and + `discontinuity()` is a counter bumped on a declared marker group, an + unproven delivered hole, or a latency skip, but not on the subscribe itself. + Add the equivalent of JS `continuous`: false on the first frame after the + subscribe and after every bump, true otherwise. It changes the moq-mux + consumer API, so pick main or dev by whether the shape is additive. - Tests: a synthetic group with a keyframe followed by two earlier-stamped deltas is trimmed on the first group and kept on the second; and a viewer that plays continuously, then latency-skips into a later open GOP, has that @@ -48,3 +55,4 @@ everyone. ## Related - [Consumer warmup](/quest/m2/intra-refresh/consumer-warmup.md) - the `recovery_frame_cnt > 0` case this rule does not cover +- [Audio warmup](/quest/m1/audio-warmup.md) - keys its Opus pre-roll trim on the same signal diff --git a/quest/m1/opus-conceal.md b/quest/m1/opus-conceal.md index 507d45a24c..cac6f8255e 100644 --- a/quest/m1/opus-conceal.md +++ b/quest/m1/opus-conceal.md @@ -11,6 +11,10 @@ last real one, instead of 120 ms. The API is unchanged and lands on main. libopus's `frame_size` on every call, and libopus conceals exactly that many samples. Record the last packet's sample count (`opus_packet_get_nb_samples`) and pass it for an empty packet; it is always a multiple of 2.5 ms. +- The audio-codecs line branch moves this code to + `rs/moq-audio/src/decode/backend/libopus.rs` (same `max_frame_size`); + land the fix wherever the code lives when this starts, and port it on the + line's next merge from main otherwise. - Refuse loss before any packet has decoded, rather than surfacing libopus's `BUFFER_TOO_SMALL`. - Document on `decode` how much an empty packet conceals. diff --git a/quest/m1/p2p/README.md b/quest/m1/p2p/README.md index bb0550f52c..b0817e2379 100644 --- a/quest/m1/p2p/README.md +++ b/quest/m1/p2p/README.md @@ -95,8 +95,8 @@ the same independence without fighting the browser. Rust already does the serving-side work: `best_route` re-runs on every table change and a live subscription re-splices onto a cheaper route with the same first hop at a group boundary, while an anonymous chain never wins. The JS -origin re-selects on provider change but ranks newest-first; that is -[route cost in the JS origin](/quest/m1/route-cost.md). The JS handshake +origin ranks the same way (`compareRoutes` in `js/net/src/origin.ts`: cost, +then fewest hops, then newest). The JS handshake already declares a random hop id, so browser hops are identified; the roster id is that hop id, held once per origin rather than once per session. @@ -110,16 +110,8 @@ relay's route ties the relay and loses on chain length. [Cost across scopes](/quest/m1/p2p/cost-scopes.md) writes the rule before the watcher depends on it. -### Fallback: a Rust + WASM in-tab hop - -If JS transit or ranking proves hard, the browser side can be moq-net in -WASM: `moq-wasm` already runs it over `web-transport-wasm`. A WASM hop would -hold the relay and peer sessions in Rust, giving one implementation of -signaling, policy, ranking, transit, and migration, and TS apps would connect -to it over an in-memory transport. It costs a web-sys data channel poll -transport, an in-memory bridge into `@moq/net`, and parsing every frame twice -in the tab. Recorded here so that decision is made with numbers, not -re-derived. +No Rust + WASM in-tab hop: [rs2ts](/quest/m1/rs2ts/remove-wasm.md) removes the +WASM build, so the browser side stays TypeScript. ### Risks @@ -148,7 +140,6 @@ re-derived. ## Related - [Peer grants](/quest/m1/auth/peer-grant.md) - the hop-bound credential a direct session presents; HMAC keys issue none -- [Route cost in the JS origin](/quest/m1/route-cost.md) - the watcher-side route pick this line needs - [One port](/quest/m1/one-port/README.md) - the relay answers STUN on its QUIC port - [E2EE](/quest/m1/e2ee/README.md) - what a peer would need if the token scope stopped being the trust boundary - [qmux on the QUIC core](/quest/m1/quic/qmux.md) - the stream core the unordered follow-up rides diff --git a/quest/m1/p2p/cost-scopes.md b/quest/m1/p2p/cost-scopes.md index 41f31fbd2b..1aecaa1c19 100644 --- a/quest/m1/p2p/cost-scopes.md +++ b/quest/m1/p2p/cost-scopes.md @@ -49,5 +49,4 @@ implementation quest it needs. ## Related -- [Route cost in the JS origin](/quest/m1/route-cost.md) - the ranking that consumes the rule -- [PoP skipping](/quest/m1/pop-skipping/README.md) - the mesh-side use of warm versus cold +- [Cluster routing](/quest/m1/cluster-routing.md) - the mesh-side use of cost diff --git a/quest/m1/p2p/transit.md b/quest/m1/p2p/transit.md index 9f54cff6aa..20a8f23d9d 100644 --- a/quest/m1/p2p/transit.md +++ b/quest/m1/p2p/transit.md @@ -32,10 +32,6 @@ the id appended and never on A; a chain already containing the id is dropped; a retraction on A retracts on B; two watchers of one tab share one upstream subscription. -## Required - -- [Route cost in the JS origin](/quest/m1/route-cost.md) - cost and hops must be carried on the entry before they can be forwarded - ## Related - [Watch opts in](/quest/m1/p2p/watch.md) - the first topology that needs a forwarding tab diff --git a/quest/m1/p2p/watch.md b/quest/m1/p2p/watch.md index 45bd5aa23b..476879b88d 100644 --- a/quest/m1/p2p/watch.md +++ b/quest/m1/p2p/watch.md @@ -12,7 +12,7 @@ goes away, with no visible interruption. `hang-watch` and `hang-publish` gain a `p2p` attribute that constructs `Peers` on the shared connection's origin with the demo's ICE servers and a `max` from the page; `demo/web` exposes the toggle. The route pick is the -origin's, from [cost ranking](/quest/m1/route-cost.md) under the rule from +origin's, from its cost ranking (`compareRoutes` in `js/net/src/origin.ts`) under the rule from [cost across scopes](/quest/m1/p2p/cost-scopes.md): a peer already carrying the broadcast wins, and its retraction falls back to the relay. @@ -26,5 +26,4 @@ watcher tab re-serving to another watcher through - [Signaling and policy](/quest/m1/p2p/signal.md) - [moq-cli joins](/quest/m1/p2p/cli.md) - the native hop the second topology shows - [Transit in the JS origin](/quest/m1/p2p/transit.md) -- [Route cost in the JS origin](/quest/m1/route-cost.md) - [Cost across scopes](/quest/m1/p2p/cost-scopes.md) diff --git a/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md b/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md index a4883e683e..a5fccaa027 100644 --- a/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md +++ b/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md @@ -21,7 +21,10 @@ the tokio worker path: That is `clock_gettime`. Roughly 2.5% of relay CPU spent reading the clock. The profile is the since-deleted quiche driver's; re-measure on noq before -and after. +and after. The closed, unmerged prototype +[#3136](https://github.com/moq-dev/moq/pull/3136) froze the clock per turn +behind an RAII guard on that driver; its shape and tests are a starting +point. Where the reads are: diff --git a/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md b/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md index d0f0e32311..5315677c57 100644 --- a/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md +++ b/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md @@ -8,7 +8,13 @@ beats `SendMsg` end to end before it is on by default. ## Plan -Follow-up to #2875. +Follow-up to #2875. The closed, unmerged prototype +[#3224](https://github.com/moq-dev/moq/pull/3224) (on the quiche-era dev +tree) did this together with #3204's fixed buffers, opt-in behind +`udp::Config::send_zc_threshold`. On loopback it was 6 to 9% slower, but +`IORING_SEND_ZC_REPORT_USAGE` showed the kernel copying there, so loopback +measures only the forced-copy overhead: the sweep needs a remote peer +through a physical NIC. The UDP path already assembles up to 64 KiB GSO trains in stable pool buffers, then submits `SendMsg` and recycles the buffer at the first CQE. Large trains are the promising case for `SENDMSG_ZC`; individual QUIC datagrams are likely below the copy-avoidance crossover. diff --git a/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md b/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md index 72f7cc1a8a..86f4ebb14a 100644 --- a/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md +++ b/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md @@ -9,6 +9,15 @@ measurable gain over plain `SendMsgZc` or the change is dropped. ## Plan Follow-up to #2875 and dependent on the `SENDMSG_ZC` experiment in #3201. +The closed, unmerged prototype [#3224](https://github.com/moq-dev/moq/pull/3224) +built this; reuse its lease and quarantine handling. + +Fixed buffers on `SENDMSG_ZC` need Linux 6.15 (vectored registered-buffer +support), above moq-uring's 6.12 floor, so the fixed path must fall back: +#3224 retried with ordinary `SENDMSG` on `EINVAL` or `EOPNOTSUPP` and +disabled later attempts on that ring. #3224 also filled the SQE `buf_index` +with a raw-SQE adapter because the `io-uring` crate did not expose it for +this opcode; check the current crate first. The TX pool already owns stable `Box<[u8]>` allocations and grows lazily. If zero-copy send wins, registering those allocations lets send SQEs reference fixed buffers and can reduce repeated page accounting on the large-train path. diff --git a/quest/m1/perf/README.md b/quest/m1/perf/README.md index 025eda859d..95ae136cdd 100644 --- a/quest/m1/perf/README.md +++ b/quest/m1/perf/README.md @@ -3,7 +3,7 @@ ## Goal Reduce relay CPU per session, raise the per-worker throughput ceiling, and -hold tail latency on the dev thread-per-core stack by eliminating measured +hold tail latency on the thread-per-core stack by eliminating measured hot-path costs: redundant copies, locks, atomics, clock reads, allocations, and syscalls. Not io_uring specific: anything on the relay's hot path qualifies, including the shared moq-net model layer and kio. @@ -14,6 +14,10 @@ outcome that abandons the quest. ## Plan +Quests branch from main unless they say otherwise; +[Run to quiescence](/quest/m1/perf/uring-quiescence.md) needs dev, where +`kio`'s `Tasks::poll` changed (#4156). + Planning quests can settle their contracts independently. Facts from the 2026-09 hot-path survey, so quests don't re-litigate them: diff --git a/quest/m1/perf/egress-keepalive.md b/quest/m1/perf/egress-keepalive.md index d07b00d9b3..208b9efa9c 100644 --- a/quest/m1/perf/egress-keepalive.md +++ b/quest/m1/perf/egress-keepalive.md @@ -23,7 +23,7 @@ before optimizing the remaining publisher overhead. Extend the existing group and track Criterion targets and add a bounded session regression to normal CI. Keep -`slow_batch_reader_survives_expiry_with_keep_alive`, and cover expiry scans +`slow_prefetch_reader_survives_expiry` (`rs/moq-net/src/model/track.rs`), and cover expiry scans while a batch drains, cancellation, and eventual expiry after reads stop. Report delivered bytes, refresh cost, CPU, and throughput for paired runs; fewer refresh calls alone are not evidence of a win. diff --git a/quest/m1/perf/egress-requeue.md b/quest/m1/perf/egress-requeue.md index ed9ae1d090..90ba398717 100644 --- a/quest/m1/perf/egress-requeue.md +++ b/quest/m1/perf/egress-requeue.md @@ -29,7 +29,3 @@ Linux. Latency must not regress at the chosen budget. A no-win keeps 1. The [quiescence quest](/quest/m1/perf/uring-quiescence.md) sweeps this budget together with its pass budget; land whichever runs first and fold the other's sweep in. - -## Closes - -- [#3120](https://github.com/moq-dev/moq/issues/3120) - close this issue when the quest finishes diff --git a/quest/m1/perf/uring-one-enter.md b/quest/m1/perf/uring-one-enter.md index 4cd8e3f239..5ff2baf3de 100644 --- a/quest/m1/perf/uring-one-enter.md +++ b/quest/m1/perf/uring-one-enter.md @@ -10,7 +10,7 @@ a ratio of two totals. ## Plan -Branch from dev. The loop in `Worker::block_on` +Branch from main. The loop in `Worker::block_on` (rs/moq-uring/src/worker.rs:164-187) runs one task pass, then `pump` (`submit()` at worker.rs:250, then reap and dispatch), then `maybe_park`, whose enter (`submit_and_wait(1)` or a timed `enter(to_submit, 1, diff --git a/quest/m1/perf/uring-quiescence.md b/quest/m1/perf/uring-quiescence.md index 529aae15c7..88b4879722 100644 --- a/quest/m1/perf/uring-quiescence.md +++ b/quest/m1/perf/uring-quiescence.md @@ -15,7 +15,8 @@ exhaustion, or to a quantum, before touching the ring. ## Plan -Branch from dev. Keep the fairness the one-pass rule protects: a forward wake +Branch from dev: `kio`'s `Tasks::poll` changed only there (#4156 merged +`Pollable` into `Task`), and the pass budget builds on that version. Keep the fairness the one-pass rule protects: a forward wake chain must not starve the caller's other arms, and one connection's backlog must not starve the socket. diff --git a/quest/m1/performance-comparisons.md b/quest/m1/performance-comparisons.md index e9fcaf8b3a..ab8a640e72 100644 --- a/quest/m1/performance-comparisons.md +++ b/quest/m1/performance-comparisons.md @@ -38,7 +38,7 @@ while extending this harness rather than creating another benchmark runner. ## Required -- [Thin justfiles](/quest/m1/tooling/justfiles.md) - finish benchmark script relocation before changing its lifecycle +- [Tooling](/quest/m1/tooling/README.md) - the recipe and script layout `just bench` runs under ## Related diff --git a/quest/m1/performance-profiles.md b/quest/m1/performance-profiles.md index eb52ef23b1..e0c5da304c 100644 --- a/quest/m1/performance-profiles.md +++ b/quest/m1/performance-profiles.md @@ -11,7 +11,7 @@ cost. Profiling is opt-in and has no production overhead when disabled. `bench/run.sh` already owns the builds, relay PID, workload, and host samples, but has no profiler integration. Reuse that lifecycle instead of adding a second launcher. `Cargo.toml` already has a `profiling` profile and -`rs/moq-native/src/jemalloc.rs` already supports on-demand heap dumps. +`rs/moq-tokio/src/jemalloc.rs` already supports on-demand heap dumps. - Add a focused `just` recipe selecting workload, duration, and capture mode through one configuration. Reuse locked builds and the existing profiling Cargo profile; @@ -46,3 +46,4 @@ verify current supported versions and pin any newly installed tools. - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - repeatable results and artifact metadata - [Relay memory](/quest/m1/relay-memory.md) - retained route and announcement memory +- [Release profile](/quest/m1/release-profile.md) - also changes `[profile.profiling]`; land one, then rebase the other diff --git a/quest/m1/pop-skipping/README.md b/quest/m1/pop-skipping/README.md deleted file mode 100644 index e3c1185615..0000000000 --- a/quest/m1/pop-skipping/README.md +++ /dev/null @@ -1,158 +0,0 @@ -# Cache-aware PoP skipping - -## Goal - -Give an unpopular broadcast a short cold path without sacrificing the backhaul -deduplication a sparse mesh gets once the broadcast is warm. Every relay connects -to all healthy relays in its own PoP, its base-graph neighbor PoPs, and the PoPs at -graph distance two. A cold subscriber uses the direct distance-two session rather -than forwarding through an idle intermediate; a relay with any warm track advertises -the cheaper route, pulling later subscribers back onto the copy the cluster already -has. Full eligible-PoP pairing is accepted for now; on the topology this was -designed against it takes live persistent relay sessions from 29 to 78 (of 120 for -a full mesh). - -For `sjc0 -> dal0 -> iad0`, with the publisher in IAD: - -1. Cold SJC sees direct `sjc0 -> iad0` at 5 and `sjc0 -> dal0 -> iad0` at 6, so it - takes the direct session on price rather than on a tie-break. -2. Another SJC relay reaches that warm copy over the same-PoP link for 1, against 5 - to open its own. -3. A DAL subscriber initially pulls directly from IAD at cost 3. DAL then ranks - ahead of SJC because its cold route is cheaper (3 against 5), so SJC migrates at - a group boundary and both share DAL's one IAD pull. -4. SEA makes the same local decision and can join the already-warm aggregation - tree rather than opening another copy from IAD. - -## Plan - -### Where this stands after prefix routes - -[moq#3225](https://github.com/moq-dev/moq/pull/3225) made an announcement a -route over a path *prefix* and deleted the machinery this questline had -already landed: the warm-cost discount, `COST_LINGER`, the -`(cold, hash)` adoption gate, the handover hold, and the per-broadcast front -that hosted all of it. A relay now forwards accumulated costs only. - -What survives is the part that was expensive to get right: - -- `origin::Cost { warm, cold }` on the wire, decoded on lite-06, with - `Cost::UNKNOWN` reading an inexpressible cold as the ceiling rather than as - free. -- `route_order`, which still breaks a warm tie on the lower cold cost ahead of - hop count. -- `DRAIN_COST`, and the hop list as the loop check. -- The link-price decisions below, which were rulings about the topology rather - than about the code that read them. - -So this questline is no longer "add a rank to a working discount". It is -re-deriving warmth on a route model that prices prefixes, then putting the -adoption gate back on top. - -### The tension prefix routes introduce - -Warmth is a property of one broadcast. A route covers a prefix, which is a -claim about a set of paths. A relay carrying `pid/foo.hang` knows nothing about -the rest of `pid/`, so there is no honest way to discount the prefix route it -already advertises: doing so would attract subscribers for every cold path -underneath it. - -### Decisions - -- **A carrying relay advertises the exact broadcast path as its own route, - priced warm.** Per-broadcast warmth becomes a more specific route rather than - a discount on a broader one. Nothing new goes on the wire, and the existing - selection rule already prefers it. -- **Selection keeps specificity ahead of cost.** `best_server` filters to the - longest covering prefix and only then orders by `route_order`, and the lite - draft says the same. This matches longest-prefix-match everywhere it appears - (IP forwarding, BGP, DNS closest encloser, URL routers), and for the same - reason: routes of different prefix length describe different destination - sets, so comparing their costs asks "what does this broadcast cost" against - "what would anything under here cost". Cost decides between routes that cover - the same thing, which is exactly where the warm/cold pair was designed to - work. -- **A draining carrier retracts its exact-path route rather than repricing - it.** Under specificity-first, forgoing the discount is not enough: a route - priced at the ceiling still wins on specificity and keeps attracting - subscribers a drain is trying to move. Retraction drops the claim, and the - broader route the content is still reachable through takes over. This - replaces the draft's ceiling-exemption paragraph, which was written when the - discount rode a single per-broadcast advertisement. -- **Adoption and resume need different identities.** Adoption keys on the - *last* hop of a route, the peer that advertised it and the parent a relay - would be adopting; resuming a subscription keys on the *first* hop, who - produced the content, which alternate routes to one publisher deliberately - share. The pre-#3225 front kept both (`FrontState.publisher` for the first, - `handover_allowed` and the hold for the last), with `same_identity` comparing - either and refusing `Hop::UNKNOWN` on both. [moq#3312](https://github.com/moq-dev/moq/pull/3312) restored that comparison rule and the - publisher half as the first-hop resume; [Rank](/quest/m1/pop-skipping/rank.md) owns - the carrier half rather than reusing the wrong one. -- The operator hand-authors one undirected base graph. Same-PoP connectivity is - unconditional, not a self-edge operators must remember. The radius-two closure - and shortest base-graph distance are derived and validated. -- The initial reference link costs are local 1, base neighbor 3, and distance-two - skip 5. The invariant that buys PoP skipping is `skip < 2 * neighbor`: a cold - skip must beat the equivalent two-edge path outright, so 5 against 6 works while - anything from 6 up preserves the old cold path and defeats this quest. Keeping - the skip strictly below rather than equal to the two-edge path means the choice - is made on price, not on the hop-count tie-break below it. -- No link is free, including a same-PoP one. A local transfer still costs a NIC, a - hop of latency, and a copy; what is genuinely free is bytes already flowing, and - that is the warm route's job, not the link price's. A floor of 1 also prices - chain *length*, so a PoP converges on a flat tree around one puller instead of - daisy-chaining at no cost, and it means adopting a parent strictly increases the - adopter's own cold cost, which leaves the per-broadcast hash as a tie-break - between equally-placed relays rather than the only thing keeping the order - strict. -- Warmth is broadcast-wide: demand for any track makes the broadcast warm. When - demand drains, the exact-path route follows the existing 30-second spliced-track - lifecycle (`TRACK_IDLE_LINGER`) while at least one track copy remains retained. - Do not add a second timer with a different definition; `COST_LINGER` was that - timer and is already gone. A retained track has canceled its upstream - subscription, so "warm" here intentionally means reusable route/track state plus - hysteresis, not that every future byte is already in memory. -- Provider economics are directional and dominate locality in a mixed-provider - deployment. Conceptually the metric is `(serving provider egress class, - topology distance)`: pulling from an unmetered relay can be cheaper than the - reverse direction, while equal provider classes retain the 1/3/5 locality - order. Keep these components structured until the final peer cost is encoded; - choose an encoding whose economic stride exceeds the maximum accumulated - topology distance allowed by the bounded hop list, which at a 32-entry chain - and a 5-cost worst link is 160. -- Cluster sessions use `moq-lite-06`, explicitly. Lite05 silently drops - the cost; the MoQT Cluster extension is not the chosen cluster wire for this - questline. -- Fleet rollout stays downstream: moq.pro owns rendering the priced radius-two - topology, the two-phase Lite06 cluster cutover, and the staging and live - route-verification proofs. This questline completes when the mechanism above - lands here. - -### Peer reconfiguration - -The URL-backed half is complete in -[moq#2874](https://github.com/moq-dev/moq/pull/2874): canonical identity stays -separate from dial configuration, so changing `?cost=` or an inline credential -replaces the active session while an identical render is a no-op. It also -preserves the last-good topology on malformed input, keeps an identical fallback -session alive, redacts credentials from parse errors, and tracks overlapping -gossip paths so an old unannounce cannot stale a replacement. The remaining -boundary is structured policy that does not live in the URL: the two directional -costs of one bidirectional session, which one `?cost=` cannot split. - -## Required - -- [Warm advertise](/quest/m1/pop-skipping/warm-advertise.md) - a carrying relay - advertises the exact broadcast path as a warm route, and retracts it when - draining or idle -- [Rank](/quest/m1/pop-skipping/rank.md) - rank warm relay candidates by cold - cost and adopt only downhill, with a hold that outlasts cost propagation -- [Peer reconfigure](/quest/m1/pop-skipping/peer-reconfigure.md) - structured - peer entries carry the charged cost, the declared cost, and the credential, - and a change redials, proving both sides of an asymmetric link - -## Related - -- [drain](/quest/m1/drain/README.md) - a second relay per PoP makes the same-PoP link price and its connection cardinality operationally important -- [wildcard](/quest/m0/wildcard/README.md) - it reuses this questline's route cost, and needs a cluster on Lite06 -- [relay-memory](/quest/m1/relay-memory.md) - a denser mesh multiplies whatever a non-selected route costs diff --git a/quest/m1/pop-skipping/peer-reconfigure.md b/quest/m1/pop-skipping/peer-reconfigure.md deleted file mode 100644 index 21313053e4..0000000000 --- a/quest/m1/pop-skipping/peer-reconfigure.md +++ /dev/null @@ -1,70 +0,0 @@ -# [M] Peer reconfigure - -## Goal - -A cluster peer entry can carry structured policy that has no home in a URL: -the price this relay charges to pull from the peer, the price it declares to -the peer for the reverse direction, and the credential. Changing any of it at -runtime replaces the live session the same way a `?cost=` change does today, -and an identical render stays a no-op. - -## Plan - -The typed public configuration, URL/object normalization, credentials, and -symmetric policy are supplied by dev. This quest removes the explicit refusal -of asymmetric costs and wires their distinct meanings without another change -to the peer configuration type. Preserve the compatibility and validation -rules below rather than implementing a second parser. - -[moq#2874](https://github.com/moq-dev/moq/pull/2874) landed the URL half: -`?cost=` and an inline `?jwt=` are dial configuration, the query-less URL is -the identity, and a changed render replaces the session while preserving -inactive fallbacks and the last-good topology. What it cannot express is an -asymmetric link from one side. One `?cost=N` is both what this relay charges -locally to pull from the peer and what it declares in SETUP as its own egress -price, so pricing the two directions differently needs the peer to list us -with its own `?cost=`. - -Decisions: - -- Peer entries in `cluster.connect` and `connect_api` accept an object beside - the bare URL string, deserialized untagged into the existing `DialTarget`: - `url` (required, still the canonical identity), `cost` (what this relay - charges to pull from the peer, the routing input), `egress` (what it declares - in SETUP as its own price toward the peer, defaulting to `cost`), and - `token` (replaces an inline `?jwt=`). `DialTarget` grows `egress` and a - normalized credential, with an inline `?jwt=` and an object `token` parsed - to the same representation, and its equality covers every field, so an - `egress`-only or `token`-only update is a change and the token is never - dropped. Unknown fields reject the whole list, so a typo keeps the - last-good topology exactly as a malformed URL does, and so does an object - whose `url` still carries `?cost=` or `?jwt=`: policy has one home per - form, and a mixed entry is rejected rather than given a precedence a - migration could silently get wrong. -- Gossip and mDNS keep advertising URLs only. Their allowlist admits `?cost=` - alone, and the draft already makes a declared price an assertion the - receiver may override, so a peer never needs to push structured policy at us. -- Any field change replaces the session, the rule [moq#2874](https://github.com/moq-dev/moq/pull/2874) - set for `?cost=`. A URL entry and an object entry that normalize to the same - `DialTarget` are one entry, deduplicated as `parse_peer_list` already does; - two entries for one identity with differing policy are the conflict that - rejects the list, whatever their forms. Reconciling cost in place without a - redial is deliberately not done: it would be a second code path for one - field. -- No per-peer wire version. ALPN negotiation picks the best common version per - session and the global `--version` list is the only pin; a per-peer - override is a fleet cutover concern that stays downstream. - -The split lands in `moq_tokio::Client` as separate charged and declared costs -(today `with_cost` sets both), and in the relay's session setup so the routing -side reads the charged value while SETUP carries the declared one. - -Tests: the asymmetric link in both directions, two relays each pricing the -other differently, with routes ranked per side from the charged value and the -declared value visible on the far side; a `connect_api` update that changes -only `egress` or only `token` redials that peer and no other; an identical -object render is a no-op; equivalent URL and object forms of one peer -deduplicate while differing policies for one identity conflict; an unknown -field, or an object whose `url` carries `?cost=` or `?jwt=`, keeps the -previous list. Update `doc/bin/relay/cluster.md` and -`doc/bin/relay/config.md` with the object form. diff --git a/quest/m1/pop-skipping/rank.md b/quest/m1/pop-skipping/rank.md deleted file mode 100644 index 955bf6f0bc..0000000000 --- a/quest/m1/pop-skipping/rank.md +++ /dev/null @@ -1,91 +0,0 @@ -# [L] Rank - -## Goal - -A relay adopts another relay's warm copy only when that relay is strictly -closer to the publisher, so a PoP converges on one aggregation point instead of -a coin flip, and no two relays can adopt each other. - -## Plan - -Once [Warm advertise](/quest/m1/pop-skipping/warm-advertise.md) lands, two -relays carrying one broadcast both advertise it warm and tie: warm cost cannot -separate them, and only the deterministic hash would, so the aggregation root is -picked at random. In the `sjc0 -> dal0 -> iad0` topology that lets SJC (two -links from IAD) win over DAL (one link), and the cluster carries the extra -backhaul it was supposed to remove. - -Break that tie on cold cost, which is the relay's own distance to the publisher -with warm discounts removed and is already on the wire and already ranked below -warm in `route_order`. Adoption descends `(cold cost, hash(broadcast path, relay -hop id))`: lower cold wins, equal cold takes the lower hash, and a relay keeps -its own upstream rather than adopting a peer that ranks above it. Including the -broadcast path in the hash spreads ownership instead of making one relay win -every broadcast. A relay advertises its *own* rank, not that of a parent it -adopted, so every warm edge descends and cycles cannot form. - -Adopting a parent adds that link to the adopter's cold cost, so it can only rank -above its parent afterwards. That is what makes descent automatic, and it is why -no link may be priced free. - -### The hold, and why it is not optional - -Cold is a value each relay reports about itself, so a report still crossing the -mesh can be lower than what its sender would say now. Rings of relays can each -rank a stale neighbour below themselves and all let go at once, leaving the -broadcast with no source. Rising costs are the whole hazard; if costs only fell, -a stale value would only make a peer look worse than it is. A GOAWAY prices a -route at the ceiling while neighbours still remember it cheap, so the trigger is -a rolling restart, not an exotic race. - -Hold a re-parent onto another relay long enough for the costs it rests on to -land, and re-evaluate when the hold expires rather than committing to the -decision that armed it. The sizing rule is "longer than an announcement crosses -the mesh", plus a stable per-relay spread so a PoP does not reconsider on one -instant. The hold covers only trading a working upstream for a better one: -an idle relay is pulling nothing, a one-hop chain is the publisher itself, and -leaving a drained or vanished route stays immediate. - -### Two identities, not one - -Adoption is a statement about the adjacent relay, and that is a different -identity from the first-hop resume rule [moq#3312](https://github.com/moq-dev/moq/pull/3312) landed. -The pre-#3225 front kept both, because they answer different questions: - -- The **first** hop is who produced the content. Alternate routes to one - publisher deliberately share it, which is what makes them spliceable, and it - is what the resume rule keys on. -- The **last** hop is the peer that advertised the route: the parent a relay - would be adopting. `handover_allowed` and the hold both keyed on it, and the - rank hash was taken over it (`fnv_key(name, [peer])`). - -Use the last hop here. Keying adoption on the first hop would make every carrier -of one broadcast look like the same peer, so a changed parent would go -undetected and two relays could adopt each other, which is the failure the hold -exists to prevent. - -What this quest reuses from the resume rule is the comparison rather than the -field: `Hop::UNKNOWN` identifies nobody and never matches itself, so two -anonymous relays must not pass for one relay reconnecting and skip the gate. - -Update `drafts/draft-lcurley-moq-lite.md` in the same change, restoring the -adoption-rank rule that -[moq#3278](https://github.com/moq-dev/moq/pull/3278) removed. - -### Tests - -The asymmetric chain (DAL outranks SJC regardless of hash), the equal-cost race -(exactly the lower hash keeps its upstream while the other adopts it), a -three-node transitive tree, simultaneous updates, a ring of relays each holding -a stale cheaper report (which must not leave the broadcast sourceless), route -loss and reversion, and an unknown-cold peer losing to a known cheap one in both -hash directions. A live track must migrate only at a group boundary and must not -see announcement churn. - -Write every gate test over both hash directions, or it proves nothing beyond a -lucky hash. - -## Required - -- [Warm advertise](/quest/m1/pop-skipping/warm-advertise.md) - there is nothing - to rank until two relays can both advertise one broadcast as warm diff --git a/quest/m1/pop-skipping/warm-advertise.md b/quest/m1/pop-skipping/warm-advertise.md deleted file mode 100644 index 39a23daaef..0000000000 --- a/quest/m1/pop-skipping/warm-advertise.md +++ /dev/null @@ -1,51 +0,0 @@ -# [L] Warm advertise - -## Goal - -A relay carrying a broadcast advertises that broadcast's exact path as its own -route, priced warm, so later subscribers reach the copy the cluster already has -instead of opening another one. It retracts that route when the broadcast goes -idle or its own path starts draining. - -## Plan - -Warmth is per-broadcast and a route covers a prefix, so the discount cannot ride -the prefix route a relay already advertises: carrying `pid/foo.hang` says -nothing about the rest of `pid/`. Advertise the exact path instead, as a second, -more specific route. `best_server` already filters to the longest covering -prefix before ordering by cost, so the warm route wins for that one broadcast -and changes nothing for its neighbors. - -Price it the way the deleted discount did: warm zero (the ingress is already -paid for), cold forwarded accumulated, since cold prices the path this relay -would have to open if it were not already carrying. Two relays both carrying -then tie on warm and are separated by cold, which is what -[Rank](/quest/m1/pop-skipping/rank.md) builds on. - -Lifecycle follows the state that already exists rather than a new timer. -The route appears when a track under the broadcast has demand and stays while -any track retains its source copy inside `TRACK_IDLE_LINGER`; it is retracted -once the last copy expires. `COST_LINGER` was the parallel five-second timer -with a different definition and is already gone with -[moq#3225](https://github.com/moq-dev/moq/pull/3225); do not reintroduce it. - -Retract rather than reprice when the serving path drains. Under -specificity-first selection a ceiling-priced exact-path route still outranks -every broader route, so a drain that only repriced would keep attracting the -subscribers it is trying to move. Dropping the claim lets the broader route the -content is still reachable through take over. Update -`drafts/draft-lcurley-moq-lite.md` in the same change: this replaces the -ceiling-exemption paragraph that -[moq#3278](https://github.com/moq-dev/moq/pull/3278) removed, and it is a behavior change the -draft has to carry. - -Make both Lite and IETF publishers advertise from one warmth signal, so a -cluster selecting Lite06 and one on the Cluster extension mean the same thing by -warm. - -Tests: two tracks with staggered demand keep one warm route alive; demand -returning during retention does not churn the advertisement; the route is -retracted after the last copy expires and is not re-advertised afterwards; a -draining path retracts rather than repricing, and a subscriber on it moves to -the broader route; a second relay carrying the same broadcast ties on warm and -is separated by cold. diff --git a/quest/m1/processor/README.md b/quest/m1/processor/README.md index 102b88eef7..041fc85997 100644 --- a/quest/m1/processor/README.md +++ b/quest/m1/processor/README.md @@ -4,7 +4,8 @@ A customer runs a worker in its own environment, connects outbound to a MoQ deployment, reads only eligible source media, and publishes an on-demand -contribution at `/.pro`. The platform supplies +contribution under the processor's own prefix, mirroring the source path +(for example `./`). The platform supplies registration, scoped credentials, routing, demand, status, and usage visibility; it does not upload or execute customer code. @@ -14,14 +15,23 @@ service contract, and credential minting) stay downstream in moq.pro. The contract is not vision-specific: captioning, moderation, telemetry extraction, and custom transforms use the same worker lifecycle. +## Plan + +Decided: derived output follows [Wildcard](/quest/m0/wildcard/README.md)'s +service-prefix layout, not `/.pro`. Suffix routing is +dropped everywhere, and a prefix claim needs the variable part of the path +trailing, so the processor claims its prefix and mirrors the source path +beneath it. The source's catalog reaches the output through a +cross-broadcast reference ([media contract](/quest/m1/processor/media-contract.md)). + ## Required - [Processor media contract](/quest/m1/processor/media-contract.md) - define contribution references, source relations, and correlation in the Hang catalog - [Advertise-only authorization](/quest/m1/processor/advertise-auth.md) - a - worker may advertise its contribution suffix without receiving permission to - publish arbitrary matching paths + worker may advertise its service prefix without receiving permission to + publish arbitrary paths under it - [Expiring media grants](/quest/m1/processor/grant-lease.md) - enforce short-lived exact grants on already-open consumer and producer handles @@ -31,4 +41,5 @@ and custom transforms use the same worker lifecycle. publishes frame-correlated detections and proves demand, reconnect, failover, and teardown end to end - [Wildcard advertisements](/quest/m0/wildcard/README.md) - lets a dormant - processor advertise what it could serve without enumerating live sources + processor advertise what it could serve without enumerating live sources, + and sets the service-prefix layout diff --git a/quest/m1/processor/advertise-auth.md b/quest/m1/processor/advertise-auth.md index 26c1d8faaa..1c8fc2bead 100644 --- a/quest/m1/processor/advertise-auth.md +++ b/quest/m1/processor/advertise-auth.md @@ -2,18 +2,24 @@ ## Goal -A v1 worker credential can advertise an allowed wildcard without receiving -permission to publish any path matching it. Relays enforce advertise and -publish as independent capabilities before external processor credentials are +A v1 worker credential can advertise an allowed prefix without receiving +permission to publish any path under it. Relays enforce advertise and publish +as independent capabilities before external processor credentials are minted. ## Plan -Add an explicit advertise pattern union to the v1 claims, token SDKs, origin -scope, and relay authorization model. Wildcard authorization checks that -scope rather than borrowing the publish union. A concrete announcement or -publish request still requires publish permission, so an advertise-only worker -cannot bypass the demand exchange. +Add an explicit advertise prefix scope to the v1 claims, token SDKs, origin +scope, and relay authorization model. [Wildcard](/quest/m0/wildcard/README.md) +checks advertisements against `moq_auth::Claims.publish` today; this quest +gives them their own scope instead of borrowing the publish one. A concrete +announcement or publish request still requires publish permission, so an +advertise-only worker cannot bypass the demand exchange. + +Decided: the advertise scope is prefix-only. Advertising is prefix-only on +every wire (Wildcard's decision) and suffix routing is dropped, so leading-star +and suffix advertise patterns have nothing to authorize. Token claim patterns +keep their suffix support for publish and subscribe. Preserve current customer credentials in the wire and authorization design: existing claims retain their current publish-implies-advertise behavior, while @@ -21,7 +27,11 @@ the new v1 claim separates the capabilities. Land the claims, SDK, origin-scope, relay authorization, and tests without combining the release or the moq.pro (downstream) pin rollout into this quest. -Cover containment, rebasing, leading-star and suffix patterns, missing versus -empty advertise scope, v0 compatibility, token revalidation, concrete announce, -publish, FETCH, and a wildcard demand that receives only an exact short-lived -publish grant. +Cover containment, rebasing, missing versus empty advertise scope, v0 +compatibility, token revalidation, concrete announce, publish, FETCH, and a +prefix demand that receives only an exact short-lived publish grant. + +## Required + +- [Wildcard](/quest/m0/wildcard/README.md) - the prefix advertisements this scopes +- [Auth](/quest/m1/auth/README.md) - the v1 claims and relay authorization this extends diff --git a/quest/m1/processor/grant-lease.md b/quest/m1/processor/grant-lease.md index d58b3b226d..66ee0bf16a 100644 --- a/quest/m1/processor/grant-lease.md +++ b/quest/m1/processor/grant-lease.md @@ -18,9 +18,15 @@ missing, late, broader, or mismatched refresh closes them. Reconnecting with an expired grant is denied as it is today. Keep deadline enforcement in the relay authorization owner rather than a -cooperative worker timer. Cover an idle open handle, active source reads, -active publication, refresh before expiry, refresh after demand ends, relay -clock skew within the token policy, disconnect races, HTTP and HLS rejection of +cooperative worker timer. Build on what exists: `Grant::deadline` +(`rs/moq-auth/src/grant.rs`, #4237) already pins an accepted grant to a fixed +deadline, and dev's `moq_auth::lease` (#3943) re-checks a session on cadence +and reports why it ended. Extend those to the handles a grant opened rather +than adding a second timer. No clock-skew grace: open #4368 makes expiry +exact and drops `CLOCK_SKEW`, so a deadline in the past is expired. + +Cover an idle open handle, active source reads, active publication, refresh +before expiry, refresh after demand ends, disconnect races, HTTP and HLS rejection of the worker audience, and unrelated traffic continuing through an existing pooled HLS consumer after a worker grant expires. diff --git a/quest/m1/processor/media-contract.md b/quest/m1/processor/media-contract.md index 72d53ebec7..f2f042408f 100644 --- a/quest/m1/processor/media-contract.md +++ b/quest/m1/processor/media-contract.md @@ -8,9 +8,14 @@ can resolve without eagerly subscribing to processor output. ## Plan -This quest also defines the generic catalog-level contribution reference -itself: a catalog entry that names another contribution and its relative -broadcast without opening it, which the processor contract specializes. +Build on hang's existing per-rendition relative `broadcast` reference +(`broadcast` on the video rendition in `rs/hang/src/catalog/video/mod.rs`, +with its audio, text, JSON, and binary counterparts): it already names another +broadcast relative to the catalog's, and Rust rejects one that escapes. The +processor output lives under the processor's prefix, mirroring the source +path, so the source catalog reaches it through that relative reference. Add +only what it lacks: a catalog-level contribution entry that names a whole +contribution without opening it, which the processor contract specializes. The processor publishes a normal Hang contribution rather than an arbitrary fragment for an edge to merge. Output renditions carry their own schema and an @@ -26,3 +31,7 @@ Land Rust and JavaScript catalog bindings, resolver behavior, and fixtures for video, audio, text, missing output, malformed relations, lazy resolution, and relative-path escape. The release and the moq.pro (downstream) pin rollout stay out of this quest. + +## Related + +- [JS catalog path](/quest/m1/js-catalog-path.md) - JS rejects an escaping `broadcast` reference like Rust diff --git a/quest/m1/publish-codec-string.md b/quest/m1/publish-codec-string.md index b86f6c557b..b19ce6fe49 100644 --- a/quest/m1/publish-codec-string.md +++ b/quest/m1/publish-codec-string.md @@ -22,7 +22,9 @@ Guidance: - The catalog is built from the resolved config today, before any frame is encoded. Either hold the rendition out of the catalog until the first output reports its `decoderConfig`, or update it then; keep the stall and - jitter reporting working either way. + jitter reporting working either way. Holding it out must go through the + reservation gate the #2075 quest adds to `#runCatalog`, or it recreates the + partial first snapshot that quest prevents. - Check what each browser returns for a bare hint. If one echoes the hint back, derive the string from the bitstream (SPS for H.264/H.265, the sequence header for AV1, the uncompressed header for VP9), or fail loud @@ -31,3 +33,7 @@ Guidance: catalog follows it. - Tests with the fake `VideoEncoder`: a bare-hint probe whose output reports a full string publishes the full string. + +## Required + +- [#2075](/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - the catalog reservation gate this rendition hold must go through diff --git a/quest/m1/qos/README.md b/quest/m1/qos/README.md index a5f0720f65..3bf61cf061 100644 --- a/quest/m1/qos/README.md +++ b/quest/m1/qos/README.md @@ -31,6 +31,12 @@ The counters and channels land here. The moq.pro (downstream) dashboard work, including the health badge, connection-health drill-down, and stream preflight, consumes them downstream. +Decided (2026-09-28): the whole line, including the client stats line, targets +`dev`. The moq-stats schema change +([#4145](https://github.com/moq-dev/moq/pull/4145)) breaks the published +`moq-stats` crate, and a line cannot close with part of it on `main` and part +on `dev`. + ## Required - [Starvation](/quest/m1/qos/starvation.md) - per broadcast, how far behind diff --git a/quest/m1/qos/final-lag-sample.md b/quest/m1/qos/final-lag-sample.md index 6ee05196af..6b4727e4d2 100644 --- a/quest/m1/qos/final-lag-sample.md +++ b/quest/m1/qos/final-lag-sample.md @@ -26,6 +26,13 @@ interval disappears with it. Public API: none. Wire: none, only the histogram's values change. +Overlaps the lag-splice quest on the line branch +([#4381](https://github.com/moq-dev/moq/pull/4381)), which also changes how +`FrontierInner::sample` holds `unsampled` weight and when a spliced segment's +source stops being sampled. Land them in sequence on the same sampler, not in +parallel, and make the final drop-time sample keep any weight lag-splice +defers. + ## Required - [Starvation](/quest/m1/qos/starvation.md) - the sampler this extends (#4298) diff --git a/quest/m1/qos/stats/README.md b/quest/m1/qos/stats/README.md index 929e56f1c3..b74ec7f73f 100644 --- a/quest/m1/qos/stats/README.md +++ b/quest/m1/qos/stats/README.md @@ -46,7 +46,8 @@ Decisions settled while planning: authorization, or route-selection input. - **A published break goes through dev**, because `moq-stats` is a published crate and the generic producer is a breaking change, and the moq-json rework there is what the - producers build on. + producers build on. The parent QoS line targets `dev` with it (#4145), so + every quest here does too. ## Required @@ -58,7 +59,3 @@ Decisions settled while planning: crate, and the browser publisher and player report through it - [Encoder feedback](/quest/m1/qos/stats/encoder-feedback.md) - a Rust encoder subscribes to its viewers' stats and adapts its bitrate - -## Closes - -- [#3608](https://github.com/moq-dev/moq/issues/3608) - close this issue when the questline finishes diff --git a/quest/m1/qos/stats/encoder-feedback.md b/quest/m1/qos/stats/encoder-feedback.md index 11518d8cc7..f6391b0b7c 100644 --- a/quest/m1/qos/stats/encoder-feedback.md +++ b/quest/m1/qos/stats/encoder-feedback.md @@ -27,7 +27,9 @@ prefix. Keyframe requests stay out. a stalled share above a threshold steps the target down like a bandwidth drop, recovery follows the existing attack curve, and the estimate stays the ceiling. - Audio follows the same signal with its narrower ladder. + Audio does not follow its grant today (`Options::bandwidth` in + `rs/moq-audio/src/encode/producer.rs` reserves only), so it follows this + signal only once the grant quest lands; until then the loop drives video. - `moq import --feedback ` and `moq transcode --feedback ` wire it; each rung of the ladder reads its own broadcast's track. - Test with the CLI publishing to a relay and two `moq play --stats` viewers @@ -46,5 +48,5 @@ prefix. Keyframe requests stay out. - [Ladder](/quest/m1/ladder/README.md) - the transcode ladder that adapts to its uplink today -- [Keyframe trigger](/quest/m1/keyframe-trigger.md) - the keyframe request - this loop does not send +- [Audio follows the grant](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - + the audio rate follow this signal would feed diff --git a/quest/m1/qos/stats/js.md b/quest/m1/qos/stats/js.md index 4a95727e1f..280092e32c 100644 --- a/quest/m1/qos/stats/js.md +++ b/quest/m1/qos/stats/js.md @@ -35,7 +35,3 @@ dashboard reads relay stats through the package instead of its own copies. - [Schema and library](/quest/m1/qos/stats/schema.md) - the wire shape this mirrors - -## Closes - -- [#2735](https://github.com/moq-dev/moq/issues/2735) - close this issue when the quest finishes diff --git a/quest/m1/qos/stats/rust.md b/quest/m1/qos/stats/rust.md index 4073c38fa2..2a9f7ebb07 100644 --- a/quest/m1/qos/stats/rust.md +++ b/quest/m1/qos/stats/rust.md @@ -36,7 +36,3 @@ publisher of a `.hang` broadcast can learn whether its viewers played it. - [Schema and library](/quest/m1/qos/stats/schema.md) - the producer and the media types - -## Closes - -- [#2734](https://github.com/moq-dev/moq/issues/2734) - close this issue when the quest finishes diff --git a/quest/m1/quic/README.md b/quest/m1/quic/README.md index 81e79f3342..36c4c19aa2 100644 --- a/quest/m1/quic/README.md +++ b/quest/m1/quic/README.md @@ -8,10 +8,10 @@ MoQ's schedule and offers it upstream when it is general. One core serves the tokio backend, the thread-per-core `moq-uring` backend, iroh, and qmux. The features are per-stream acknowledgment progress, reliable stream resets, hierarchical stream scheduling with per-broadcast fairness, the shared stream -state machine used by qmux, capacity probing for media, per-stream -deadlines, deadline-based and wider limits for relay peers. The experiments that may join them (GCC, FEC, receive -timestamps, kernel pacing, buffer pools, probing, L4S, careful resume) live -in [m2](/quest/m2/README.md). +state machine used by qmux, per-stream deadlines, and wider limits for relay +peers. The experiments that may join them (GCC, FEC, receive timestamps, +kernel pacing, buffer pools, media probing, L4S, careful resume, deadline +keep-alive) live in [m2](/quest/m2/README.md) and do not gate this line. ## Plan @@ -20,15 +20,15 @@ One stack carries every change on MoQ's own QUIC paths; a build with the `iroh` feature also compiles upstream noq, and iroh connections are outside what these quests reach. -The seven BBR correctness fixes follow the fork bootstrap. They are separate -PRs, but one owner should work in the shared controller code at a time. -Their controller-level regressions extend the shared test `Sim` in -`bbr3/mod.rs` with only what each needs, rather than adding another -simulation loop; a fix at the transport boundary still needs a transport -test through the real callbacks. The existing loops stay, since the fork -merges upstream weekly and a port would conflict. -The [BBR release](/quest/m1/quic/bbr-release.md) delivers them without waiting -for the remaining transport features. The +The seven BBR correctness fixes shipped in moq-noq 1.3.1 (#4206). The +remaining BBR quests here and [BBR ACK cleanup](/quest/m1/bbr-ack-cleanup.md) +all edit `bbr3/mod.rs`, so one owner should work there at a time. +Controller-level regressions extend the shared test `Sim` in `bbr3/mod.rs` +with only what each needs, rather than adding another simulation loop; a fix +at the transport boundary still needs a transport test through the real +callbacks. The existing loops stay, since the fork merges upstream weekly and +a port would conflict. Each BBR fix ships in a fork patch release without +waiting for the remaining transport features. The [Google comparison](/quest/m2/quic-bbr-google.md) is a separate study. Rules the line keeps: @@ -51,14 +51,7 @@ This is a transport API change, not a MoQ wire change. ## Required -- [Preserve QUIC packet identity in BBR](/quest/m1/quic/bbr-packet-identity.md) - ACKs and losses identify the right packet across QUIC spaces -- [Finish each BBR ACK sample before using it](/quest/m1/quic/bbr-ack-sampling.md) - current delivery samples reach the model once with consistent metadata - [BBR idle burst](/quest/m1/quic/bbr-app-limited.md) - a fork regression proves a burst after a long idle is paced at the learned bandwidth, closing #4219 -- [Finish BBR bandwidth-probe feedback once](/quest/m1/quic/bbr-probe-feedback.md) - cruise rounds neither age probe history repeatedly nor retain probe-loss classification -- [Recalibrate BBR startup pacing from measured RTT](/quest/m1/quic/bbr-startup-pacing.md) - measured RTT replaces the nominal startup rate for media senders -- [Protect bandwidth samples during BBR ProbeRTT](/quest/m1/quic/bbr-probe-rtt.md) - intentionally reduced sending cannot masquerade as reduced capacity -- [Preserve BBR state across a spurious loss episode](/quest/m1/quic/bbr-loss-undo.md) - consecutive losses preserve the original recovery snapshot -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - publish and pin the corrected controller independently of later features - [Align BBR loss handling with draft-06](/quest/m1/quic/bbr-loss-parity.md) - losses use their own sample and undo re-enters ProbeUp through Refill - [Mark BBR starvation wherever the source runs dry](/quest/m1/quic/bbr-app-limited-edges.md) - partial polls count, local send caps do not, receiver credit is pinned - [Deliver the application close before io_uring teardown](/quest/m1/quic/uring-close.md) - @@ -99,7 +92,7 @@ This is a transport API change, not a MoQ wire change. - [FEC experiment](/quest/m2/quic-fec.md) - a measured verdict on transport redundancy - [Kernel pacing](/quest/m2/quic-kernel-pacing.md), [Send batching](/quest/m2/quic-send-batching.md), - [Send buffer pools](/quest/m2/quic-buffer-pool.md), [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - + [Send buffer pools](/quest/m2/quic-buffer-pool.md), [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - the syscall, allocation, and controller spikes - [Multipath spike](/quest/m2/multipath-spike.md) - a noq capability that MoQ does not use yet diff --git a/quest/m1/quic/bbr-ack-sampling.md b/quest/m1/quic/bbr-ack-sampling.md deleted file mode 100644 index 63fcb2b098..0000000000 --- a/quest/m1/quic/bbr-ack-sampling.md +++ /dev/null @@ -1,35 +0,0 @@ -# [M] Finish each BBR ACK sample before using it - -## Goal - -Each ACK updates the BBR model and control parameters from a completed, -consistent sample. Bandwidth, delivered bytes, application-limited status, -and inflight describe the same observation. - -## Plan - -In [the audited callback order](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1656), on_ack updates the model per -packet before on_end_acks computes delivery_rate and delivered. Current -packet metadata is combined with the preceding ACK's values. Follow the -ordering in [draft section 5.2.3](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.2.3) and -[Google QUICHE](https://github.com/google/quiche/blob/535a2730e77d47e0dc03746555cc9c34b17bc9e9/quiche/quic/core/congestion_control/bbr2_sender.cc#L271), adapted to noq's event boundary. - -Reproduce both failures in the fork: the first completed 1200-byte/10-ms -sample leaves max_bw at zero; and, with an aged prior bandwidth maximum, a -120 KB/s application-limited sample is admitted under the next burst's -non-limited metadata even though that burst delivers 1.2 MB/s. Verify fresh -samples are consumed once, with the correct labels and post-ACK inflight. - -Cover batched ACKs, reordered ACKs, invalid/too-short intervals, loss-only -events, and idle restart. Preserve the transport's RTT and loss event -ordering while fixing the source of stale state. Reuse the existing -controller simulations and add regressions to the fork's CI. Coordinate -with packet identity work if the callback contract changes; settle the API -with the maintainer and update its consumers and docs in the same PR. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic -- [Packet identity](/quest/m1/quic/bbr-packet-identity.md) - shares the controller boundary diff --git a/quest/m1/quic/bbr-app-limited-edges.md b/quest/m1/quic/bbr-app-limited-edges.md index 58a62a00fa..fc47d4d255 100644 --- a/quest/m1/quic/bbr-app-limited-edges.md +++ b/quest/m1/quic/bbr-app-limited-edges.md @@ -29,11 +29,10 @@ when a transmit poll sends nothing. In moq-dev/noq Transport-boundary tests through the real callbacks: a partial poll that drains, a sender blocked only by `send_window`, and a stream blocked by -receiver credit. Stacks on the seven fixes' noq branches until they merge; -does not gate [the BBR release](/quest/m1/quic/bbr-release.md). No public -API or wire change is intended. +receiver credit. Builds on the seven fixes released in moq-noq 1.3.1. No public API or wire +change is intended. ## Related -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - includes the first app-limited fix this extends -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measures natural draining on the corrected controller +- [BBR idle burst](/quest/m1/quic/bbr-app-limited.md) - the first app-limited fix this extends +- [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - measures natural draining on the corrected controller diff --git a/quest/m1/quic/bbr-app-limited.md b/quest/m1/quic/bbr-app-limited.md index ea26b55a4a..f0ea70c90c 100644 --- a/quest/m1/quic/bbr-app-limited.md +++ b/quest/m1/quic/bbr-app-limited.md @@ -36,5 +36,4 @@ there belongs to the upstream quest. ## Related -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - ships any remaining fix - [Upstream the fork](/quest/m1/quic/upstream.md) - offer the starvation fix upstream diff --git a/quest/m1/quic/bbr-loss-parity.md b/quest/m1/quic/bbr-loss-parity.md index 647d1123a5..7b36bf4f53 100644 --- a/quest/m1/quic/bbr-loss-parity.md +++ b/quest/m1/quic/bbr-loss-parity.md @@ -33,5 +33,4 @@ no `Controller` or wire change. ## Related -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - the corrected baseline this builds on - [Upstream the fork](/quest/m1/quic/upstream.md) - offers this fix alongside the seven diff --git a/quest/m1/quic/bbr-loss-undo.md b/quest/m1/quic/bbr-loss-undo.md deleted file mode 100644 index ceae058e03..0000000000 --- a/quest/m1/quic/bbr-loss-undo.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] Preserve BBR state across a spurious loss episode - -## Goal - -Declaring a loss episode spurious restores the state saved before that -episode, even when several packets were declared lost. Later losses in the -same episode cannot overwrite the original recovery snapshot. - -## Plan - -[note_loss](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1513) saves undo state on every lost packet, including -after an earlier packet reduced the model. With a 100,000-byte long-term -inflight bound, a 10,000-byte BDP, a 20,000-byte cwnd, and two successive -1200-byte packet losses during ProbeUp, undo retained 7000 bytes rather than -the original bound. Existing single-loss simulations miss this case. - -Align snapshot lifetime with recovery semantics using -[Google Linux's recovery entry](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L2240) and -[draft section 5.5.11](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.5.11). Verify saved model bounds, -window, and any restorable phase across multiple losses in one event and -across ACK events. A new episode must get a new snapshot; real loss must -still constrain sending. Cover ProbeRTT interaction and add regressions to -the fork's CI. Keep the fix internal with no wire change. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic diff --git a/quest/m1/quic/bbr-packet-identity.md b/quest/m1/quic/bbr-packet-identity.md deleted file mode 100644 index c3ecbc27f8..0000000000 --- a/quest/m1/quic/bbr-packet-identity.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] Preserve QUIC packet identity in BBR - -## Goal - -BBR associates every send, ACK, and loss with the correct packet across -Initial, Handshake, and application-data spaces. Reused packet numbers never -alias or invalidate an ordered lookup. - -## Plan - -The audit of noq-proto 1.3.0 and upstream `1a26a8b` found separate internal -packet queues, but [every public Controller callback forces Data](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1881). -Sending Initial packets 0 and 1 followed by Handshake packet 0 makes an ACK -for the Handshake packet select Initial packet 0 instead. - -Fix identity at the transport/controller boundary in moq-dev/noq. Cover -coalesced sends, repeated packet numbers across spaces, losses, key discard, -and path ownership through the real transport callbacks. Private helpers -that accept a space while the public path discards it are insufficient. - -Land a regression that sends the overlapping sequence above and verifies the -ACK uses the Handshake packet's send time and delivery snapshot. Check every -controller consumer if the public Controller contract changes, including -other controllers and custom implementations. Settle any public API change -with the maintainer before implementation; document it inline. No QUIC wire -change is intended. Run the regressions in the fork's CI. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic diff --git a/quest/m1/quic/bbr-probe-feedback.md b/quest/m1/quic/bbr-probe-feedback.md deleted file mode 100644 index 7dc6f54b08..0000000000 --- a/quest/m1/quic/bbr-probe-feedback.md +++ /dev/null @@ -1,28 +0,0 @@ -# [S] Finish BBR bandwidth-probe feedback once - -## Goal - -Finishing a bandwidth probe advances the bandwidth-history window once. -Later cruise losses are not classified as feedback from that probe. - -## Plan - -[adapt_long_term_model](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L991) leaves ack_phase at ProbeStopping and -bw_probe_samples set. Every subsequent cruise round can advance cycle_count, -and a later loss can reduce the long-term model as if it came from probing. -Compare [Google Linux](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L1664), -[Google QUICHE](https://github.com/google/quiche/blob/535a2730e77d47e0dc03746555cc9c34b17bc9e9/quiche/quic/core/congestion_control/bbr2_probe_bw.cc#L119), and the draft's -AdaptLongTermModel transition. - -Exercise a completed loss-free probe followed by several cruise rounds. -Assert the filter advances only once and retains the intended probe-cycle -history. Inject a later cruise loss and verify only the appropriate -short-term response applies. Include application-limited feedback and -ProbeRTT entry so neither leaves stale probe classification. Land these -regressions in the fork's CI without changing public APIs or the wire. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic diff --git a/quest/m1/quic/bbr-probe-rtt.md b/quest/m1/quic/bbr-probe-rtt.md deleted file mode 100644 index 955816ea5e..0000000000 --- a/quest/m1/quic/bbr-probe-rtt.md +++ /dev/null @@ -1,30 +0,0 @@ -# [S] Protect bandwidth samples during BBR ProbeRTT - -## Goal - -Packets deliberately rate-limited by ProbeRTT cannot lower the bandwidth -model as if they measured a network capacity reduction. Normal sampling -resumes after the protected delivery interval. - -## Plan - -[handle_probe_rtt](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1182) omits the application-limited marker present -in [Google Linux](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L920) and -[draft section 5.3.4.3](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.3.4.3). The audited controller -sends unmarked packets during ProbeRTT even with a backlogged application. -QUICHE's BBR3 ProbeRTT also lacks an explicit marker; the requirement here -is the draft/Linux behavior, not universal Google parity. - -Reproduce that unmarked send, then test entry, the full ProbeRTT interval, -exit, and delayed ACKs for packets sent during the interval. Verify reduced -samples do not replace a learned capacity solely because ProbeRTT reduced -the window, while a legitimate higher sample can still raise the model. -Do not mark the connection application-limited forever or change the -ProbeRTT policy merely to mask a sampling bug. Add the regressions to the -fork's CI; keep the fix internal with no wire change. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic diff --git a/quest/m1/quic/bbr-release.md b/quest/m1/quic/bbr-release.md deleted file mode 100644 index 41365f1707..0000000000 --- a/quest/m1/quic/bbr-release.md +++ /dev/null @@ -1,36 +0,0 @@ -# [M] Release the BBR correctness fixes - -## Goal - -Published MoQ consumers receive the seven corrected BBR behaviors through -immutable releases of the noq fork and its adapters. Fixes do not wait for -qmux, stream scheduling, or other unrelated QUIC features. - -## Plan - -After the fork bootstrap, publish the corrected dependency chain and update -MoQ's manifest and lockfile pins. Record the parent commit, carried patches, -and upstream status. Follow the existing fork packaging rules; no mutable -branch or workspace-only patch may stand in for a release. - -Verify the fork's regression suite and MoQ's default and supported runtime -builds against the released artifacts, including compatibility of controller -consumers if a callback API changed. Run a media-shaped transfer through the -real QUIC stack to confirm handshake sampling, pacing, and recovery work -together. This integration check is required; the broader media-flow study -and Google comparison do not gate these bug fixes. - -## Required - -- [Preserve QUIC packet identity in BBR](/quest/m1/quic/bbr-packet-identity.md) -- [Finish each BBR ACK sample before using it](/quest/m1/quic/bbr-ack-sampling.md) -- [BBR idle burst](/quest/m1/quic/bbr-app-limited.md) - the starvation fix shipped in moq-noq 1.3.1; any remaining idle cause ships here -- [Finish BBR bandwidth-probe feedback once](/quest/m1/quic/bbr-probe-feedback.md) -- [Recalibrate BBR startup pacing from measured RTT](/quest/m1/quic/bbr-startup-pacing.md) -- [Protect bandwidth samples during BBR ProbeRTT](/quest/m1/quic/bbr-probe-rtt.md) -- [Preserve BBR state across a spurious loss episode](/quest/m1/quic/bbr-loss-undo.md) - -## Related - -- [Release the stack](/quest/m1/quic/release.md) - later feature releases follow the same packaging rules -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - broader media measurements after the corrected release diff --git a/quest/m1/quic/bbr-startup-pacing.md b/quest/m1/quic/bbr-startup-pacing.md deleted file mode 100644 index 13f6adfe69..0000000000 --- a/quest/m1/quic/bbr-startup-pacing.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] Recalibrate BBR startup pacing from measured RTT - -## Goal - -The first available measured RTT replaces BBR's nominal 1-ms startup -pacing estimate. A media sender that stays application-limited does not keep -an inflated pacing rate for its entire session. - -## Plan - -The audited 12-KB initial window retains a 33,276,000-byte/s pacing rate -after a measured 10-ms RTT. Startup only raises its rate, and an -application-limited flow need not leave Startup. [Google Linux](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L443) -reinitializes pacing once an RTT measurement becomes available. - -Review and reuse [upstream PR #802](https://github.com/n0-computer/noq/pull/802) -if still applicable, preserving its author's attribution rather than -reimplementing it. Check the actual ACK/RTT callback order: the configured -initial RTT is not evidence of a measurement, and the first ACK can precede -the transport RTT update. Recalculate the send quantum consistently. - -Add CI regressions for measured RTTs above and below 1 ms, a continuously -application-limited source, and a secondary path initialized after the -handshake. Preserve subsequent bandwidth-driven Startup growth. Report any -public RTT-estimator API change and document it inline; no wire change is -intended. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic -- [noq #800](https://github.com/n0-computer/noq/issues/800) - existing upstream report; do not duplicate it diff --git a/quest/m1/quic/deadline.md b/quest/m1/quic/deadline.md index 4c518af2e4..8450cadfa4 100644 --- a/quest/m1/quic/deadline.md +++ b/quest/m1/quic/deadline.md @@ -30,9 +30,10 @@ Implement in the fork. ACK-frequency extension noq already implements) on the next packet and arm a shortened probe at `max(deadline - rtt - now, min_pto)`. Never probe past the congestion window; the probe is a scheduling choice, not extra credit. -- moq-net: `Subscription::serve_group` sets the deadline from the - subscription's latency target and the group's expiry, whichever is sooner; - a subscription with neither sets none. The reset error code maps to the +- moq-net: the per-group `GroupServe` machine in the lite and IETF + publishers (`lite/publisher.rs`, `ietf/publisher.rs`) sets the deadline + when it opens the stream, from the subscription's latency target and the + group's expiry, whichever is sooner; a subscription with neither sets none. The reset error code maps to the existing group-expired code on the MoQ wire, so a viewer sees the same signal it sees for a relay-side expiry today. diff --git a/quest/m1/quic/peer-limits.md b/quest/m1/quic/peer-limits.md index 88eecc44be..7c36f74bc2 100644 --- a/quest/m1/quic/peer-limits.md +++ b/quest/m1/quic/peer-limits.md @@ -30,7 +30,8 @@ Tests: a cluster session sees the raised `MAX_STREAMS` after SETUP and a viewer session does not; the io_uring path applies the same values; a `peer` table below the defaults is refused at resolve time. -## Related +## Required - [io_uring flow control](/quest/m1/uring-flow-control-windows.md) - the - static windows on the same workers + io_uring workers hardcode their windows today, so the peer values have + nothing to raise there until the static windows reach them diff --git a/quest/m1/quic/release.md b/quest/m1/quic/release.md index d2f3878932..fee1d41bdf 100644 --- a/quest/m1/quic/release.md +++ b/quest/m1/quic/release.md @@ -24,8 +24,6 @@ the parent applies. ## Required -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - preserve the corrected controller in later stack releases - - [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - the WebTransport-required transport extension - [Hierarchical stream scheduling](/quest/m1/quic/scheduler.md) - the new diff --git a/quest/m1/quic/upstream.md b/quest/m1/quic/upstream.md index 320c4d357d..294e9c0e44 100644 --- a/quest/m1/quic/upstream.md +++ b/quest/m1/quic/upstream.md @@ -15,7 +15,7 @@ lands in the fork on MoQ's schedule; once a feature has shipped in a MoQ release and its shape has stopped moving, split it into an upstream PR with the tests it landed with. -Offer the seven [BBR correctness fixes](/quest/m1/quic/bbr-release.md) and +Offer the seven BBR correctness fixes (moq-noq 1.3.1, #4206) and their [loss](/quest/m1/quic/bbr-loss-parity.md) and [starvation](/quest/m1/quic/bbr-app-limited-edges.md) follow-ups with their regressions before promoting BBR as the default. Reuse existing @@ -36,17 +36,16 @@ Then the feature proposal order, each linked to its producing quest: on, so its regressions are found upstream and not only here; 1. per-stream acknowledgment progress ([ACK progress](/quest/m1/quic/ack-progress.md)); 2. `RESET_STREAM_AT` ([reliable reset](/quest/m1/quic/reliable-reset.md)); -3. keep-alive by deadline ([keep-alive](/quest/m2/quic-keep-alive.md)); -4. hierarchical send groups ([scheduler](/quest/m1/quic/scheduler.md)); -5. careful resume as a `Controller` wrapper ([careful resume](/quest/m2/quic-careful-resume.md)); -6. ECT(1) marking and its accounting ([L4S](/quest/m2/quic-ecn.md)); -7. per-stream deadlines ([deadlines](/quest/m1/quic/deadline.md)); -8. the measured media-headroom mechanism ([probe](/quest/m2/quic-probe.md)); -9. the qmux crate over the shared stream state machine ([qmux](/quest/m1/quic/qmux.md)). +3. hierarchical send groups ([scheduler](/quest/m1/quic/scheduler.md)); +4. per-stream deadlines ([deadlines](/quest/m1/quic/deadline.md)); +5. the qmux crate over the shared stream state machine ([qmux](/quest/m1/quic/qmux.md)). -The next experiments (receive timestamps, GCC, FEC, kernel pacing, send -batching, buffer pools, the BBR3 app-limited check) join the list only with -a positive verdict. +The m2 features (keep-alive by deadline, careful resume as a `Controller` +wrapper, ECT(1) marking, the media-headroom mechanism) are offered when they +land, but do not gate this quest: an m1 quest must not wait on m2 work. The +next experiments (receive timestamps, GCC, FEC, kernel pacing, send +batching, buffer pools, the natural-drain check) join the list only with a +positive verdict. Record in this quest what upstream accepted, what it asked to see as an extension crate, and what it declined; a declined change stays in the fork @@ -55,22 +54,22 @@ offered and answered. ## Required -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - the corrected controller and its regression evidence - [Align BBR loss handling with draft-06](/quest/m1/quic/bbr-loss-parity.md) - [Mark BBR starvation wherever the source runs dry](/quest/m1/quic/bbr-app-limited-edges.md) - [Per-stream ACK progress](/quest/m1/quic/ack-progress.md) - [Reliable stream reset](/quest/m1/quic/reliable-reset.md) -- [Keep-alive by deadline](/quest/m2/quic-keep-alive.md) - [Hierarchical stream scheduling](/quest/m1/quic/scheduler.md) -- [Careful resume on reconnect](/quest/m2/quic-careful-resume.md) -- [L4S on the backbone](/quest/m2/quic-ecn.md) - [Per-stream deadlines](/quest/m1/quic/deadline.md) -- [Discover media headroom](/quest/m2/quic-probe.md) - [qmux on the QUIC stream state machine](/quest/m1/quic/qmux.md) ## Related - [Receive timestamps](/quest/m2/quic-receive-ts.md), [GCC](/quest/m2/quic-gcc.md), - [FEC](/quest/m2/quic-fec.md), [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - + [FEC](/quest/m2/quic-fec.md), [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - experiments that join the list with a positive verdict +- [Keep-alive by deadline](/quest/m2/quic-keep-alive.md), + [Careful resume on reconnect](/quest/m2/quic-careful-resume.md), + [L4S on the backbone](/quest/m2/quic-ecn.md), + [Discover media headroom](/quest/m2/quic-probe.md) - m2 features offered + upstream when they land diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md index 7338196d3e..f4e50e3373 100644 --- a/quest/m1/raw-stream-codes.md +++ b/quest/m1/raw-stream-codes.md @@ -15,8 +15,9 @@ on stream errors. WebTransport sessions keep the mapping they need. `web-transport-iroh` and `web-transport-quinn` (moq-dev/web-transport) do the same. A raw peer's code 5 reads as `None` or another value, and ours reaches it as a large HTTP/3 code. -- [Close codes](/quest/m1/close-codes.md) fixed the same mix-up for - `ApplicationClosed` (noq#11, #4262). Let each stream know whether its +- [Close codes](/quest/m1/close-codes.md) fixes the same mix-up for + `ApplicationClosed` (noq#11 is released; #4262 is still open on `dev`, so + this follows it there unless trait 0.5 reaches main first). Let each stream know whether its session is raw and skip the mapping there, in all three adapters, with a round-trip test per adapter against a plain QUIC peer. - Release the fixed crates and bump the pins here in the same quest; published diff --git a/quest/m1/relay-memory.md b/quest/m1/relay-memory.md index a3c5f6fd82..b4d9037702 100644 --- a/quest/m1/relay-memory.md +++ b/quest/m1/relay-memory.md @@ -28,17 +28,14 @@ committed, since they need `#[doc(hidden)]` size probes on private types. Rebuild them from this description and restate the per-broadcast and per-route cost, the per-peer session bookkeeping (`announce_ids`, `held`, `watched`), and the shed threshold on a degree-5, 1 GB node, before anyone -quotes a number again. Two committed directions depend on the answer: -chat-shaped traffic (one broadcast per channel or per chatter) and -[PoP skipping](/quest/m1/pop-skipping/README.md), which triples average -degree and adds a second, more specific route per carried broadcast. +quotes a number again. Chat-shaped traffic (one broadcast per channel or +per chatter) depends on the answer. -Asking peers for announcements only while something watches was considered -and dropped: every loop-free way to forward coalesced interest through a -cyclic mesh (a hop budget, an originator set, cost-decreasing interest over -coarse claims) adds teardown churn or new wire state, for a saving nobody -has measured. Shrink the table itself instead. +On-demand announcements are now [Cluster routing](/quest/m1/cluster-routing.md)'s +plan: a relay learns only the prefixes its own clients request, which bounds +the table by demand. These numbers size that saving. ## Related +- [Cluster routing](/quest/m1/cluster-routing.md) - on-demand announcements shrink the table this measures - [Perf](/quest/m1/perf/README.md) - the hot-path work that owns the remaining per-cell cost diff --git a/quest/m1/remove-live.md b/quest/m1/remove-live.md new file mode 100644 index 0000000000..99c040ec2c --- /dev/null +++ b/quest/m1/remove-live.md @@ -0,0 +1,50 @@ +# [M] Importers publish stream timestamps; the catalog clock maps them to wall + +## Goal + +The fMP4, MPEG-TS, and FLV importers in `rs/moq-mux` have no `live()`: every +importer publishes the stream's own timestamps verbatim (after PTS unwrap), +and the catalog's root `clock` is what maps them to wall time. `moq import` +(`rs/moq-cli/src/publish.rs`) stops calling it. An encoder that restarts its +timestamps ends the broadcast with an error instead of being re-anchored +forward onto the old one; turning the republish into a new epoch is the +broadcast epoch line's outcome, not this quest's. The SRT, RTMP, and HLS gateways, which reuse these +importers, get the same behavior. + +## Plan + +Decided (2026-09-28, replacing the gateway live-clock quest, which planned +the opposite: every gateway opting into `live()`): + +- Timestamps stay verbatim from the stream. Rewriting them onto an + arrival-time anchor breaks same-hop importers, which must derive every + timestamp from the input alone (the hop-aligned import quest in + [#4388](https://github.com/moq-dev/moq/pull/4388)), and hides the source's + own timeline from anything downstream. +- The catalog clock carries the mapping. An importer establishes the root + `clock` so the stream's PTS converts to wall time (the first frame is live + on arrival), rather than translating each timestamp. Open: whether the + importer sets the catalog clock from its first frame (the mapping is fixed + at construction today, `moq_mux::Clock` and `catalog::Config::with_clock`) + or the caller builds the catalog once the first PTS is known. Every track of + one input, and every rendition of one HLS import, shares that one mapping. +- An encoder restart (a PTS rewind or a signalled time-base discontinuity) is + a new epoch, not a forward re-anchor: the importer ends the broadcast with + an error, and the caller republishes, which the broadcast epoch line turns + into a fresh `@`. Until that line lands, a restart fails loud. +- Delete `live()` from `ts::Import`, `fmp4::Import`, and `flv::Import`, the + crate-private `clock::Anchor`, and `SourceMap` if nothing else uses it. + fMP4 passthrough stops rewriting `tfdt`. A published `moq-mux` API break, + so this targets `dev`. + +Tests: per importer, a source starting at a large PTS publishes that PTS and +a catalog clock that maps it to near the arrival time; a rewind ends the +broadcast with an error. Update `doc/lib/rs/moq-mux.md` (the `live()` +paragraph), `doc/bin/cli.md`, and the gateway pages under `doc/bin/`, and +replace `ts_import_publishes_on_the_broadcast_clock` in moq-cli. + +## Related + +- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - the new epoch an encoder restart becomes +- [Catalog wall clock](/quest/m1/catalog-wall-clock.md) - the PTS-to-wall conversion this relies on, at full precision +- [TS import shared shift](/quest/m1/ts-import-shared-shift.md) - the TS re-anchor shift that must stay input-derived diff --git a/quest/m1/route-cost.md b/quest/m1/route-cost.md deleted file mode 100644 index 9268d3d06f..0000000000 --- a/quest/m1/route-cost.md +++ /dev/null @@ -1,27 +0,0 @@ -# [S] Route cost in the JS origin - -## Goal - -The `@moq/net` origin serves a path through the best route it knows, ranked by -cost and then hop count the way Rust's origin does, instead of the newest -announce. The lite-06 route cost that arrives on every announce is read rather -than dropped. - -## Plan - -`announce.ts` already decodes `Cost { warm, cold }` and `hop.ts` already -carries the hop chain; neither reaches `OriginState.remote`, which keeps -providers newest-first. Carry both on the provider, rank with the same order -as `route_order` in `rs/moq-net/src/model/origin.rs` (cost, chain length, a -deterministic tiebreak), including `Cost::UNKNOWN` for an announce that -carries no cost (free to reach, cold path at the ceiling, so hop count decides -as it did before route cost existed), and re-pick when the chosen route is -retracted. - -Tests in `js/net` with the mock transport pair: two sessions announcing the -same path at different costs, the cheaper one serves, its retraction moves -the consumer to the other. - -## Related - -- [P2P](/quest/m1/p2p/README.md) - the watcher-side route pick that line needs diff --git a/quest/m1/rs2ts/remove-wasm.md b/quest/m1/rs2ts/remove-wasm.md index 2f8d0a2c21..56a7f6b490 100644 --- a/quest/m1/rs2ts/remove-wasm.md +++ b/quest/m1/rs2ts/remove-wasm.md @@ -10,8 +10,7 @@ deleted: the browser runs moq-net as generated TypeScript instead. Decided in planning: keep the experiment until generated lite ships, then delete it rather than polish it. Remove its entries from the size report, the justfiles, the wasm clippy lane (keep `moq-net` and `moq-mux` there if -anything still targets wasm32), and the docs, and update the P2P questline's -note about `moq-wasm`. +anything still targets wasm32), and the docs. Public API: removes the unpublished `@moq/wasm` package. Wire: none. diff --git a/quest/m1/session-death.md b/quest/m1/session-death.md index 22ac9be5c5..0eec5aa84a 100644 --- a/quest/m1/session-death.md +++ b/quest/m1/session-death.md @@ -15,6 +15,11 @@ Rust and JS end a session's tracks and groups the same way: https://github.com/moq-dev/moq/pull/4120 made a dying session end its tracks with the session's error in both languages, and left two gaps. +Since then #4351 and #4378 changed Rust abort semantics: an abort keeps the +finished groups and drops only the open ones, so readers get what finished +and then the abort, or a clean end once the declared end settled. #4385 (open) +mirrors that in JS. Build the clean end below on those semantics. + Decided by the maintainer: - **A local close is a close, not an error.** JS already ends tracks cleanly diff --git a/quest/m1/signal-race.md b/quest/m1/signal-race.md deleted file mode 100644 index 90695f5847..0000000000 --- a/quest/m1/signal-race.md +++ /dev/null @@ -1,30 +0,0 @@ -# [S] Signal.race releases its listeners when it loses a race - -## Goal - -`Signal.race` from `@moq/signals` no longer leaves a `changed` listener on -each signal when its own promise is raced and loses. Today it disposes them -only when one of the signals changes, so `js/net/src/origin.ts`'s -`#changed()`, raced against `closed` in `connection/forward.ts`, keeps -listeners for as long as the table stays quiet. Awaiting it directly still -works, with the same signature. - -## Plan - -Make it consistent with the free `race()` and `effect.race` from -[JS retention](https://github.com/moq-dev/moq/pull/4085), which already -subscribe to `Once`/`GetPromise` values and dispose them when the race -settles. `Signal.race` returns that same kind of awaitable instead of a -native promise: it attaches its signal listeners when first awaited or -subscribed, and releases them when it settles or when its last subscriber -detaches. Racing it through `race()` or `effect.race` then cleans up both -sides. Settle the exact return type at PR time, keeping `await` source -compatible; if the change is a published type break, stop and bring it back. - -Audit the callers in `js/net` (`announced`, `broadcast`, `group`, `origin`, -`track`, both subscribers) for the ones that race the result, and add a -listener-count test for the `origin.ts` case that fails today. - -## Required - -- JS retention (#4085) merged, which adds the free `race()` this builds on diff --git a/quest/m1/signed-priority.md b/quest/m1/signed-priority.md index 12ea0eb5ee..9fcfa54d10 100644 --- a/quest/m1/signed-priority.md +++ b/quest/m1/signed-priority.md @@ -15,10 +15,15 @@ Decided with the maintainer: - moq-lite carries `p + 128` (flip the top bit). The mapping is one-to-one and keeps order, so a given byte means what it means today; only the API number changes. The default becomes byte 128. -- IETF carries `128 - p`, saturating. An unset priority goes out as the - draft's usual 128, and an absent IETF priority decodes to 0. The cost is - that i8 -128 and -127 share byte 255, and IETF bytes 0 and 1 both decode to - 127. Pin both ends in tests. +- IETF carries `127 - p`. Both mappings are one-to-one over the whole `i8` + range, with no saturation. An unset priority goes out as IETF 127, not the + draft's usual 128, and an absent IETF priority decodes to 0, the unset + default. Pin both ends in tests. +- Invariant, kept from the [moxygen line](/quest/m1/moxygen/README.md)'s + default-priority quest (#4273): one urgency on both wires, so the IETF byte + is always `255 -` the lite byte. Mapping IETF as `128 - p` to hit the + draft's 128 would break it, which is why the default is one step off the + draft there. - hang's built-in priorities move above 0, so hang media outranks a track that never set one. Something like catalog 40, text 30, audio 20, video 10; the spacing is the implementer's call. Rust and JS keep matching values. @@ -36,8 +41,12 @@ moq-archive's `Info::priority` follows. Its version-1 `.info` stores the `doc/concept/moq-lite.md` (the 0..255 knob) and `doc/concept/standard.md` (IETF 128 maps to 127) move to the new range and mapping. -Report the wire impact in the PR: none in format, but the default byte on -moq-lite moves again. +Report the wire impact in the PR: none in format, but the default byte moves +again, from 127 to 128 on moq-lite and from 128 to 127 on IETF. + +## Required + +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - ships the 127 default and the one-urgency invariant this re-maps ## Related diff --git a/quest/m1/track-demand.md b/quest/m1/track-demand.md index 40ed280607..6dc5cea5bc 100644 --- a/quest/m1/track-demand.md +++ b/quest/m1/track-demand.md @@ -15,12 +15,8 @@ moq-binary, moq-relay, moq-transcode, moq-stats, and libmoq. Both waits already surface the track's abort reason, so callers keep their errors. Keep `abort_unused` if its race still needs an owner. JS mirrors the Rust shape in `js/net`. -Lands after the release, alongside the [FFI shape](/quest/m1/ffi-shape/README.md) -line, so moq-net breaks once. Its PR retargets to `dev`. +Lands on `dev` alongside the [FFI shape](/quest/m1/ffi-shape/README.md) +line, so moq-net breaks once. Public API: breaking in moq-net and the layer crates, and in `@moq/net`. Wire: none. - -## Required - -- [Release](/quest/m0/release.md) - the break follows the release diff --git a/quest/m1/track-tail-hardening.md b/quest/m1/track-tail-hardening.md index d6fbb68bf4..f1e912491c 100644 --- a/quest/m1/track-tail-hardening.md +++ b/quest/m1/track-tail-hardening.md @@ -53,9 +53,8 @@ decide in the PR, applying the choice to both languages: and the end waits out the whole grace (JS was reported to wait and Rust not, but Rust's `covers(owed)` reads the same way despite the comment in `route_datagram`; confirm with a test first). Recommendation: no, datagrams - are best effort. On lite-07 the SUBSCRIBE_END stream count - ([lite-count-settle](/quest/m1/lite-count-settle.md)) settles without - looking at sequences, which makes this moot there; older versions keep the + are best effort. On lite-07 the SUBSCRIBE_END stream count (#4224) + settles without looking at sequences, which makes this moot there; older versions keep the grace as the documented stopgap. - **Does a group at or past the declared end abort the track, or only that group?** Rust aborts the track with `ProtocolViolation`; JS aborts the group @@ -75,5 +74,4 @@ drafts already require. ## Related - [Track tail interop](/quest/m1/track-tail-interop.md) - the Rust-JS check that both sides now agree -- [lite-07 count settle](/quest/m1/lite-count-settle.md) - replaces sequence coverage with the stream count on lite-07 - [Session death](/quest/m1/session-death.md) - how a tail ends when the session dies under it diff --git a/quest/m1/track-tail-interop.md b/quest/m1/track-tail-interop.md index 7489cbdfb3..4e9bb23e53 100644 --- a/quest/m1/track-tail-interop.md +++ b/quest/m1/track-tail-interop.md @@ -25,5 +25,24 @@ What stood in the way when the Rust half landed: first frame. It needs a mode that reads a track to its end and reports how it ended and which groups it saw. +Also cover the stream count: + +- On moq-lite-07 add a count case: both subscribers settle on the + SUBSCRIBE_END stream count (#4224), so a group the publisher skipped or + never opened ends the track without waiting out the grace. Folded in from + the lite-count-settle quest, whose local Rust and JS regressions landed; + this Rust-JS case was all it had left. +- The harness exposed a relay start-floor defect: when a newer group arrives + first, earlier in-flight groups can be lost. #4387 fixes it, so the count + proof waits on it. + QUIC on localhost rarely reorders, so this is a smoke check that the end is delivered and clean. The ordering race itself stays in the unit tests. + +## Required + +- #4387 merges: a relayed subscription resolves its start from its source, so earlier in-flight groups are not lost (it adds quest/m1/relay-late-joiner-history.md) + +## Related + +- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - makes the count exact by keeping a reset stream's header diff --git a/quest/m1/transport-upgrade/README.md b/quest/m1/transport-upgrade/README.md index b933806797..094817c6e0 100644 --- a/quest/m1/transport-upgrade/README.md +++ b/quest/m1/transport-upgrade/README.md @@ -34,8 +34,8 @@ forbidden to a moq-transport client). The origin's multi-route front prefers the newest of two equal routes and `resume` splices each track at a group boundary, capping the old segment so the old session's subscription ends at the boundary on its own. The JavaScript handover is the -[client goaway](/quest/m1/drain/client-goaway.md) quest's, so the JS half -requires it. +[drain](/quest/m1/drain/README.md) line's (client goaway, done there), so the +JS half requires it. Shared decisions: diff --git a/quest/m1/transport-upgrade/js.md b/quest/m1/transport-upgrade/js.md index 4cb58a3c59..f5c8562035 100644 --- a/quest/m1/transport-upgrade/js.md +++ b/quest/m1/transport-upgrade/js.md @@ -11,8 +11,9 @@ nothing changes. ## Plan -Lands in `js/net`, after the [client goaway](/quest/m1/drain/client-goaway.md) -quest ships the handover it reuses: dial the replacement while the old session +Lands in `js/net`, after the [drain](/quest/m1/drain/README.md) line ships +the GOAWAY handover it reuses (client goaway is done on the line, JS +group-boundary handover is its remaining child): dial the replacement while the old session keeps serving, swap the origin wiring once it is established, leave the old session to close on its own or at the handover cap. See the [questline](/quest/m1/transport-upgrade/README.md) for the shared decisions. @@ -31,6 +32,9 @@ session to close on its own or at the handover cap. See the may not name a redirect URI, and an empty one is legal) and closes at the configured cap. A WebTransport attempt that fails after WebSocket won is logged at debug and the session stays on WebSocket. +- A self-sent GOAWAY must gate new requests on the old session too, not only + a received one: [JS GOAWAY requests](/quest/m1/drain/js-goaway-requests.md) + covers the received case, so check it also covers this path. - On a successful upgrade delete the URL from `websocketWon`. - Tests in the browser harness against the in-tree relay: with the WebTransport dial delayed past the head start, a watched track keeps every @@ -43,4 +47,5 @@ session to close on its own or at the handover cap. See the ## Required -- [Client goaway](/quest/m1/drain/client-goaway.md) - the handover this upgrade reuses +- [Drain](/quest/m1/drain/README.md) - the GOAWAY handover this upgrade reuses +- [JS GOAWAY requests](/quest/m1/drain/js-goaway-requests.md) - no new request opens on a session that is going away diff --git a/quest/m1/ts-export-byte-schedule.md b/quest/m1/ts-export-byte-schedule.md index 4eb0f15454..8427325a68 100644 --- a/quest/m1/ts-export-byte-schedule.md +++ b/quest/m1/ts-export-byte-schedule.md @@ -27,7 +27,3 @@ UDP sink is out of scope; delivery stays with an external tool. it in the existing TS test recipe against a CBR fixture. - `doc/bin/cli.md`: say that export pads to `mpegts.muxRate` on a constant-rate schedule and what latency that adds. - -## Closes - -- [#3925](https://github.com/moq-dev/moq/issues/3925) - close this issue when the quest finishes diff --git a/quest/m1/ts-import-shared-shift.md b/quest/m1/ts-import-shared-shift.md index 6dd1fa513a..e5c9190bd3 100644 --- a/quest/m1/ts-import-shared-shift.md +++ b/quest/m1/ts-import-shared-shift.md @@ -25,10 +25,16 @@ Guidance: itself leaves a later-arriving stream below its edge, and growing again then drifts the two. Decided: hold each stream's post-wrap frames until every live stream of the program has shown its new PTS, then take the - maximum growth once. A stream that stays silent is bounded by the - existing liveness timeout (#3489), not waited on forever. The - `Anchor`/`Lane` split behind `live()` in `moq_mux::clock` solves the same - problem for restarts and may be reusable. + maximum growth once. +- The hold needs its own bound: #3489 adds per-PID counters, not a timeout, + and the catalog `stalled` bit (`Stream::tick`, #3630) covers video only and + runs on the catalog's timer. Decided: bound the hold on the program clock + (PCR advance since the first stream's new generation, not wall time), and + commit the shift over the streams seen so far when it expires. A stream + that returns later applies the committed shift, clamped to its edge as + below. The `Anchor`/`Lane` split behind `live()` in `moq_mux::clock` solves + a similar problem for restarts, but the remove-live quest deletes it, so + copy what helps rather than depending on it. - A stream whose own edge is still above the shifted timestamp after the shared growth (its tail ran longer) is the case that forces growing by the maximum. Landing on its edge is accepted today; keep that trade-off. @@ -42,3 +48,4 @@ Guidance: ## Related - [#3489](/quest/m1/3489-ts-import-stream-liveness.md) - per-PID liveness in the same importer; touches `Stream` but not the shift +- [Remove live()](/quest/m1/remove-live.md) - deletes the restart anchor; a wrap shift stays input-derived diff --git a/quest/m1/video-keyframe-flag.md b/quest/m1/video-keyframe-flag.md deleted file mode 100644 index 7c97c29d20..0000000000 --- a/quest/m1/video-keyframe-flag.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] Encoded video knows its keyframes - -## Goal - -`moq_video::encode::Encoded` says whether an access unit is a keyframe, so the -capture `Control::cut()` throttle counts every keyframe, including the -encoder's own GOP cadence. Today it sees only forced and opening keyframes, so -a cut requested just after a cadence keyframe still forces another one. - -## Plan - -- Every backend already knows whether it produced a keyframe; carry it on - `Encoded`. Whether that is additive depends on whether `Encoded` is - `#[non_exhaustive]`; if it is not, this goes to `dev`. -- The throttle in the capture driver treats any keyframe as satisfying a - pending cut and restarting the spacing window, matching what JS already does. -- Test with a backend whose GOP produces a keyframe right before a requested - cut, asserting no extra keyframe. - -Public API: one field or accessor on `Encoded`. Wire: none. - -## Related - -- [#4184](https://github.com/moq-dev/moq/pull/4184) - the `Control::cut()` this completes diff --git a/quest/m1/watch-audio-time-stretch.md b/quest/m1/watch-audio-time-stretch.md index 15e13d7cff..0ceb0850b6 100644 --- a/quest/m1/watch-audio-time-stretch.md +++ b/quest/m1/watch-audio-time-stretch.md @@ -9,8 +9,9 @@ audio or rendering silence. Convergence is inaudible at ordinary drift and burst sizes. Boundaries: no packet loss concealment; an underrun still renders a ramped -gap. The target estimator and the ring's slack and re-stall are #3517 on -dev; the clock the stretch converges toward is +gap. The target estimator and the ring's slack and re-stall are the +[audio jitter target](/quest/m0/audio-jitter-target/README.md) line (#3517 +was closed in favor of it); the clock the stretch converges toward is [Plan: A/V clock](/quest/m0/plan-av-clock.md). ## Plan diff --git a/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md b/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md index ab9b803e0d..f77cafbf26 100644 --- a/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md +++ b/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md @@ -1,95 +1,44 @@ -# [M] TR 101 290 monitoring: requirements (broadcast/contribution health metrics) +# [S] Plan TR 101 290 monitoring ## Goal -Implement and verify the behavior tracked in [#1838](https://github.com/moq-dev/moq/issues/1838) -within the issue's stated scope and boundaries. +The TR 101 290 stream-health requirements in +[#1838](https://github.com/moq-dev/moq/issues/1838) become scoped +implementation quests with the open questions answered. This quest writes +quests, not code. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. +Run `/quest-plan` against the issue and the current tree. The issue is a +requirements list (ETSI P1/P2/P3 checks, per-check counters, a per-stream +health roll-up) and asks for agreement before any skeleton; its parent +proposal #1799 is closed. -### Issue context +Settled by the issue, carry over: -#### Goal +- TS-level checks run at the TS edges (`moq import ts`, `moq export ts`, + `moq-srt`), never in the media-agnostic relay. +- In today's media-aware lane our muxer regenerates PAT, PMT, PCR, and CC, + so egress checks validate our own output and SI checks are not applicable. + Full P1 to P3 conformance only means something for an opaque whole-mux + lane, which nothing plans yet. +- Results surface through the existing stats plumbing (`moq-stats`), not a new + transport. No remediation (FEC, 2022-7) and no GUI. -For MoQ to replace satellite/contribution links and hand off to IRDs, it needs the -stream-health telemetry operators expect from SRT / Zixi / RIST. ETSI TR 101 290 is the -industry yardstick for MPEG-TS integrity. This issue specifies *what* to monitor and -*where* to surface it, so we can agree scope before building a skeleton. Part of #1799. -No implementation here, requirements only. +Questions the planning session must answer: -#### Where monitoring runs (design question) - -The relay core is media-agnostic by design, so TS-level monitoring belongs at the **TS -edges**, not in the relay: - -- **Ingest** (e.g. `moq-srt`, `moq import ts`): validate the incoming contribution - feed and expose its health. -- **Egress** (e.g. `moq-srt` m=request, `moq export ts`): validate the - TS we hand downstream. - Important nuance from the two-lane model (#1799): -- **Media-aware lane** (today): PAT/PMT/PCR/CC are *regenerated* by our muxer, so at - egress TR 101 290 mostly validates *our own output* (still valuable, catches muxer - regressions like the DTS issue #1836). SI tables beyond PAT/PMT don't exist in this - lane, so those checks are N/A. -- **Opaque whole-mux lane** (future, Option B): the original PCR cadence, CC, and SI - survive, so TR 101 290 validates the *real* contribution feed faithfully. This is where - full P1/P2/P3 conformance is meaningful. - Proposed: implement the checks once over a TS byte/packet stream, run them at both edges, - and surface results through the existing stats plumbing (cf. #1783 connection stats, - \#1671 per-track stats; `moq-net`/`moq-relay` stats modules). - -#### Checks to implement (configurable thresholds; ETSI defaults) - -##### Priority 1 (loss of these = not decodable) - -- TS\_sync\_loss (loss of sync after N consecutive bad sync bytes; default 5) -- Sync\_byte\_error (sync byte != 0x47) -- PAT\_error / PAT\_error\_2 (PAT absent, repetition > 0.5 s, PID 0 wrong table\_id, scrambled) -- Continuity\_count\_error (CC discontinuity / wrong increment / illegal duplicate) -- PMT\_error / PMT\_error\_2 (PMT repetition > 0.5 s, scrambled) -- PID\_error (a referenced PID not seen within a user-defined window) - -##### Priority 2 (recommended continuous monitoring) - -- Transport\_error (TEI bit set) -- CRC\_error (PAT/PMT/CAT/NIT/EIT/BAT/SDT CRC) -- PCR\_repetition\_error (PCR interval > 40 ms) -- PCR\_discontinuity\_indicator\_error (> 100 ms jump w/o discontinuity\_indicator) -- PCR\_accuracy\_error (PCR drift outside +/- 500 ns) -- PTS\_error (PTS repetition interval > 700 ms) -- CAT\_error (CAT required when scrambling present) - -##### Priority 3 (application dependent; mostly opaque-lane only) - -- NIT/SDT/EIT/TDT/RST\_error, SI\_repetition\_error, unreferenced\_PID - -#### Surfacing - -- Per-check counters + last-event timestamp, per PID where applicable. -- Aggregate per-stream "health" suitable for an operator dashboard (green/amber/red per - priority, akin to an SRT/Zixi stats panel). -- Exposed through the existing stats API so relay/CLI consumers can read it; no new - bespoke transport. - -#### Out of scope (for now) - -- Remediation (FEC, 2022-7) - separate egress work. -- Full DVB SI semantic validation beyond presence/repetition/CRC. -- A GUI; this is the metrics source, not a dashboard. - -#### Open questions for discussion - -1. Does monitoring live in a dedicated `moq-ts`/`moq-monitor` crate, or inside the - ingest/egress crates that already touch TS? -2. Which subset is MVP, P1 + the PCR/CC/PTS parts of P2? -3. Configuration surface (thresholds, which PIDs, sampling) - CLI flags vs config file. - A private reference implementation of these checks exists and can inform thresholds and - edge cases; happy to share it as background (not as a code drop). +1. Where the checks live: a new crate, or beside the TS container in + `rs/moq-mux/src/container/ts`. +2. The MVP subset: P1 plus the PCR, CC, and PTS parts of P2 is the issue's + suggestion. +3. The configuration surface (thresholds, PIDs, sampling) and how the + counters map onto `moq-stats` tracks. +4. Whether the opaque whole-mux lane is wanted at all; without it, P3 is out. ## Closes - [#1838](https://github.com/moq-dev/moq/issues/1838) - close this issue when the quest finishes + +## Related + +- [TS import liveness](/quest/m1/3489-ts-import-stream-liveness.md) - the stream-stall case this model names `PID_error` diff --git a/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md b/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md index aa313e8dba..d72e102a55 100644 --- a/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md +++ b/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md @@ -1,79 +1,42 @@ -# [XL] moq-video: carry PipeWire DMA-BUFs safely into the Vulkan renderer +# [M] moq-video: validate PipeWire DMA-BUFs into the Vulkan renderer ## Goal -Implement and verify the behavior tracked in [#2819](https://github.com/moq-dev/moq/issues/2819) -within the issue's stated scope and boundaries. +The Linux zero-copy spine from [#2819](https://github.com/moq-dev/moq/issues/2819), +`PipeWire DMA-BUF -> Surface::DmaBuf -> Vulkan import -> render shader`, is +proven on real hardware, and a V4L2 camera feeds `Surface::DmaBuf` too. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -#### Goal - -Complete the Linux zero-copy surface spine from #2481 as one producer-to-consumer series: - -`PipeWire DMA-BUF -> moq_video::Surface::DmaBuf -> Vulkan import -> render shader` - -This is the first Linux producer and consumer for the public DMA-BUF surface contract. VAAPI encode/decode and V4L2 M2M can reuse the same contract afterward. - -#### Required lifetime invariant - -Duplicating a DMA-BUF fd preserves the allocation, not the pixels. Returning a dequeued PipeWire buffer lets the compositor overwrite that allocation while a queued frame or GPU import still reads it. - -The PipeWire producer must therefore retain the dequeued buffer until the last `DmaBuf` clone drops, then return it to the stream on the PipeWire loop thread. The renderer must retain its clone until the GPU submission completes. An fd-only wrapper is racy and is not acceptable. - -#### Phase 1: surface and producer - -- \[x] Add the non-default `dmabuf` feature, enabled by `pipewire`, `vaapi`, and `render`. -- \[x] Add `Surface::DmaBuf` with a concrete public payload, typed DRM format, modifier, dimensions, plane offsets/strides, and mint-on-access `export() -> OwnedFd`. -- \[x] Keep backend export/download behavior private so no public implementable trait freezes backend internals. -- \[x] Negotiate DMA-BUF plus shared-memory fallback in PipeWire, preferring packed RGB for the first complete Vulkan path and retaining NV12 support. -- \[x] Retain the dequeued PipeWire buffer through the surface lifetime and return it on the loop thread. -- \[x] Keep the universal I420 fallback for linear NV12 and RGB DMA-BUFs. Reject non-linear CPU mapping instead of interpreting tiled memory as rows. -- \[x] Add unit coverage for descriptors, NV12 stride removal, buffer negotiation, and return-on-last-drop. - -#### Phase 2: renderer consumer - -- \[x] Add a Linux Vulkan DMA-BUF importer behind the non-default render feature. -- \[x] Import single-plane XRGB/ARGB/XBGR/ABGR through wgpu 30's `VULKAN_EXTERNAL_MEMORY_DMA_BUF` HAL path. -- \[x] Retain the producer surface until the GPU submission completes, so PipeWire cannot overwrite in-flight pixels. -- \[x] Preserve the renderer's CPU fallback and three-strike fast-path retirement. -- \[x] Document the wgpu device feature required for DMA-BUF import. A custom device-creation helper is unnecessary with wgpu 30. -- \[ ] Import multi-plane NV12 with explicit DRM modifier plane layouts. -- \[ ] Copy imported NV12 Y/UV planes into wgpu-sampleable R8/RG8 textures without touching the CPU. -- \[ ] Handle unsupported Intel tiling with a VAAPI VPP re-tile path. `Processor` blits exist (moq-vaapi 0.1.0); this item is the renderer using one when a modifier will not import. - -#### Validation gates - -- \[x] macOS `moq-video --all-features` compile and tests remain green. -- \[x] The wgpu 30 packed DMA-BUF HAL import compiles in an isolated Vulkan-enabled API check. -- \[x] Linux renderer-only cross-build (`x86_64-unknown-linux-gnu`, `--features render`). -- \[ ] Native Linux `--features pipewire,render` compile and tests. -- \[ ] Shared-memory PipeWire fallback still captures. -- \[ ] Intel or AMD desktop: packed DMA-BUF capture renders with zero CPU download. -- \[ ] Modifier mismatch exercises VPP re-tiling on hardware that needs it. -- \[ ] Holding several frames cannot produce torn/reused content or exhaust the pool permanently. -- \[ ] Hardware tests ship ignored with a reason where CI lacks the device. - -The first packed-RGB vertical slice is implemented locally. Native Linux validation is still required before opening a PR, and the zero-copy tracker item stays open until the real hardware gates pass. +Built already: the `dmabuf` feature and `Surface::DmaBuf`, PipeWire DMA-BUF +negotiation with the shared-memory fallback, the dequeued buffer retained +until the last clone drops (#2839), packed RGB import in +`rs/moq-video/src/render/dmabuf.rs`, and NV12 import (#3331), which aliases +the buffer as an `R8` luma and an `RG8` chroma texture with no copy. VA-API +encode takes a `Surface::DmaBuf` directly. + +What remains: + +- Hardware gates, as ignored tests with a reason where CI lacks the device: + native Linux `--features pipewire,render` tests; the shared-memory fallback + still captures; packed and NV12 DMA-BUF capture renders with zero CPU + download on an Intel or AMD desktop; holding several frames never shows + reused content or exhausts the PipeWire pool for good. +- Modifier mismatch: `render/dmabuf.rs` downloads a buffer whose modifier the + driver will not import, and re-tiles nothing. A VA-API VPP re-tile is only + worth adding if a measured capture source lands on such a modifier; record + the modifiers seen and decide. +- V4L2 capture still converts to I420 on the CPU. Export its buffers with + `VIDIOC_EXPBUF` (the ioctl is in `moq-v4l`, unused) as a `Surface::DmaBuf` + so a camera reaches VA-API or NVENC without a copy. Refs #2481, #1837. -This also covers the Linux zero-copy capture input gap: V4L2 and PipeWire -convert to I420 on the CPU today (YUYV, BGRA), so V4L2 `VIDIOC_EXPBUF` export -and PipeWire DMA-BUF negotiation are what feed a `Surface::DmaBuf` straight -into VAAPI or NVENC. - ## Closes - [#2819](https://github.com/moq-dev/moq/issues/2819) - close this issue when the quest finishes ## Related -- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - separate memory blocks from a camera, which is the capture offer rather than this renderer import -- [#2893: video: validate PipeWire DMA-BUF capture on KDE hardware](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - related open work +- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - separate memory blocks from a camera, the capture offer rather than this import +- [#2893: video: validate PipeWire DMA-BUF capture on KDE hardware](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - the KDE portal capture that timed out diff --git a/quest/m2/703-experimental-webgpu-renderer.md b/quest/m2/703-experimental-webgpu-renderer.md deleted file mode 100644 index cf78cdd882..0000000000 --- a/quest/m2/703-experimental-webgpu-renderer.md +++ /dev/null @@ -1,26 +0,0 @@ -# [M] Experimental WebGPU renderer - -## Goal - -Implement and verify the behavior tracked in [#703](https://github.com/moq-dev/moq/issues/703) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -**WARNING** This is a pre-mature optimization or gimmick at best. - -We currently use [Canvas2D](https://github.com/kixelated/moq/blob/ff7cf92679e16c1d18ca5862aa4c7f73417e3c36/js/hang/src/watch/video/renderer.ts#L97) to render individual frames. This is pretty basic but works. - -All major browsers support WebGPU now. It has a [copyExternalImageToTexture](https://developer.mozilla.org/en-US/docs/Web/API/GPUQueue/copyExternalImageToTexture) method that apparently copies a `VideoFrame` to a renderable texture. This apparently avoids a copy so it might be faster than Canvas2D but probably not. - -The main benefit of WebGPU is being able to do *other* stuff, like run AI models on pixel data without copying to the CPU. Or rendering a person's face on a teapot. Or using shaders for gimmicky effects. None of this is really generic enough for a MoQ library but maybe somebody wants to have some fun. - -## Closes - -- [#703](https://github.com/moq-dev/moq/issues/703) - close this issue when the quest finishes diff --git a/quest/m2/823-svc-support.md b/quest/m2/823-svc-support.md deleted file mode 100644 index 7445ef6016..0000000000 --- a/quest/m2/823-svc-support.md +++ /dev/null @@ -1,22 +0,0 @@ -# [M] SVC support? - -## Goal - -Implement and verify the behavior tracked in [#823](https://github.com/moq-dev/moq/issues/823) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -WebCodecs supports SVC, but somebody should actually test it out. - -If it works, we could add a field to the catalog indicating `layer`. The media download logic will get more complicated but it should be possible to support. - -## Closes - -- [#823](https://github.com/moq-dev/moq/issues/823) - close this issue when the quest finishes diff --git a/quest/m2/README.md b/quest/m2/README.md index f042fcf0b9..ff38a25780 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -31,14 +31,12 @@ upstream release waits in [m4](/quest/m4/README.md). - [Audio loss recovery](/quest/m2/audio-loss-recovery.md) - prove a useful Opus recovery policy before exposing another option - [Opus implementation](/quest/m2/audio-opus-backend.md) - compare current codec quality, CPU, and optional build costs - [Latency ledger](/quest/m2/latency-ledger.md) - a session reports where its end-to-end audio delay went, stage by stage -- [JS discontinuity](/quest/m2/js-discontinuity.md) - JS names its timeline break `discontinuity()` like Rust, so `cut` means the same group close in both +- [JS discontinuity](/quest/m2/js-discontinuity.md) - on dev, JS `discontinuity()` without an end writes no cadence-estimated end, like Rust - [Synced data playback](/quest/m2/watch-data-sync.md) - js/watch releases JSON and binary payloads on the media playhead, and a slow data track holds media back - [Media Foundation decode](/quest/m2/audio-decode-mediafoundation.md) - Windows decodes HE-AAC, multichannel AAC, and what else the MFTs offer - [Media Foundation encode](/quest/m2/audio-encode-mediafoundation.md) - Windows encodes AAC-LC - [MediaCodec decode](/quest/m2/audio-decode-mediacodec.md) - Android decodes HE-AAC, multichannel AAC, and what else the device offers - [MediaCodec encode](/quest/m2/audio-encode-mediacodec.md) - Android encodes AAC-LC -- [AAC encode refusal](/quest/m2/aac-encode-refusal.md) - AAC config encode refuses channel counts it cannot name, on dev -- [OBS channel layouts](/quest/m2/obs-wave-layout.md) - the OBS source maps channel counts to the WAVE default layouts, like moq-audio - [Video codec coverage](/quest/m2/video-codec-coverage.md) - prioritize remaining native AV1 and portable decoder gaps - [#2147](/quest/m2/2147-moq-video-10-bit-hevc-and-av1-support-in-the-nvidia-codec.md) - moq-video: 10-bit HEVC and AV1 support in the NVIDIA codec path - [NVENC buffer pool](/quest/m2/nvenc-pool.md) - NVENC reuses input and output buffers instead of allocating per frame, if a benchmark shows it wins @@ -46,13 +44,13 @@ upstream release waits in [m4](/quest/m4/README.md). - [Direct3D11 render import](/quest/m2/render-d3d11.md) - Windows presents without downloading every frame to system memory - [Intra-refresh GOPs](/quest/m2/intra-refresh/README.md) - video with periodic intra refresh publishes, imports, and tunes in cleanly with one group per sweep and a catalog `warmup` - [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - I420 and NV12 cameras that deliver one memory block per plane -- [#2819](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - moq-video: carry PipeWire DMA-BUFs safely into the Vulkan renderer +- [#2819](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - moq-video: validate PipeWire DMA-BUFs into the Vulkan renderer on hardware, and export V4L2 buffers as DMA-BUFs - [Unreal prototype](/quest/m2/unreal.md) - a UE5 module on the C++ package with exceptions disabled, rendering a subscribed broadcast to a texture - [Unity prototype](/quest/m2/unity.md) - the C# package under IL2CPP, playing subscribed audio - [C# through moq-ffi](/quest/m2/cs/README.md) - generated C# over moq-ffi as a NuGet package with native runtimes - [vcpkg registry](/quest/m2/cpp-vcpkg.md) - a registry we own serves the prebuilt package to `vcpkg` manifests - [Conan remote](/quest/m2/cpp-conan.md) - a remote we own serves the same tarball to `conan install` -- [Compressed tracks](/quest/m2/flate/README.md) - any track compresses per group from every language, not only the JSON modes +- [Compressed tracks](/quest/m2/flate/README.md) - the hand-written binding wrappers expose flate tracks - [Binary delta stats](/quest/m2/stats-delta.md) - an on-demand varint delta flavor of every stats track, if relay encode CPU still matters after the JSON fixes - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink: a flushing restart after EOS opens a new publication generation - [Redundant ingest](/quest/m2/redundant-ingest.md) - decide whether two publishers sharing one epoch may splice, and who declares the incumbent dead before the keep-alive does @@ -62,7 +60,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [QUIC FEC](/quest/m2/quic-fec.md) - a measured verdict on transport-level FEC vs retransmission - [Google BBR comparison](/quest/m2/quic-bbr-google.md) - measure growth detection and precautionary probing after the correctness fixes - [WHEP ABR](/quest/m2/whep-abr.md) - a WHEP viewer switches renditions from its own congestion feedback -- [Natural media drains](/quest/m2/quic-bbr-app-limited.md) - whether bounded drain credit avoids ProbeRTT deadline interference +- [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - whether bounded drain credit avoids ProbeRTT deadline interference - [Discover media headroom](/quest/m2/quic-probe.md) - test useful-media pacing before adding redundant probe traffic - [L4S on the backbone](/quest/m2/quic-ecn.md) - an ECT(1) option in the fork, an `ecn` config knob, and a dualpi2 measurement - [Careful resume on reconnect](/quest/m2/quic-careful-resume.md) - a redial starts at the previous connection's rate @@ -75,14 +73,11 @@ upstream release waits in [m4](/quest/m4/README.md). - [Send buffer pools](/quest/m2/quic-buffer-pool.md) - whether pooled send buffers beat Bytes in the stream send path - [AF_XDP UDP path](/quest/m2/af-xdp.md) - the kernel-bypass verdict on today's virtio hosts that gates DPDK - [GOP overhead](/quest/m2/gop-overhead.md) - price the I-frames a short GOP pays for, deciding whether a long GOP plus a keyframe request is worth designing -- [#703](/quest/m2/703-experimental-webgpu-renderer.md) - Experimental WebGPU renderer -- [#823](/quest/m2/823-svc-support.md) - SVC support? -- [#1838](/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md) - TR 101 290 monitoring: requirements (broadcast/contribution health metrics) +- [#1838](/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md) - plan TR 101 290 stream-health monitoring into implementation quests - [Teleoperation](/quest/m2/teleop/README.md) - MoQ carries robot video down and control up on one session as a library capability - [SIP media stack](/quest/m2/sip-stack.md) - terminate one inbound SIP audio call leg and expose it as Opus frames - [Carrier voice](/quest/m2/carrier-voice/README.md) - determine whether MoQ should be the call fabric for programmable carrier voice - [LiveKit WebRTC bridge](/quest/m2/livekit-webrtc-bridge.md) - a go/no-go verdict, backed by a spike, on per-track LiveKit-to-MoQ bridging -- [Expired token error](/quest/m2/auth-expired-error.md) - an expired token reports `Error::Expired`, not `Unauthorized`, in Rust, JS, and the bindings - [Common Access Tokens](/quest/m2/cat/README.md) - a moq-transport client presents a CAT in SETUP and `moq auth serve` admits it with the scope its `moqt` claim names - [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - run exact source snapshots on accessible Linux and device hosts with retrievable debug evidence - [Media QA on other engines](/quest/m2/browser-media-qa-engines.md) - the media harness measures a Firefox or WebKit player over the fallback and names what each engine lacks @@ -90,7 +85,6 @@ upstream release waits in [m4](/quest/m4/README.md). - [Windows capture parity](/quest/m2/capture-windows.md) - system audio and screen cursor capture with a settled app-capture policy - [Linux capture parity](/quest/m2/capture-linux.md) - Wayland window/system-audio capture with explicit display-selection and app-capture limits - [Plan capture ergonomics](/quest/m2/capture-ergonomics.md) - scope independent crop and audio mixing quests -- [Capture clock source](/quest/m2/capture-clock-source.md) - capture publishers stamp on the catalog's clock, with no separate clock to pass, on dev - [Audio capture time](/quest/m2/audio-capture-time.md) - native audio stamps a buffer's capture instant, not when the driver reads it - [X11 capture transport](/quest/m2/x11-capture-shm.md) - move X11 capture to shared memory and RandR events instead of a per-frame socket copy - [Capture frame buffers](/quest/m2/capture-frame-buffers.md) - stop rebuilding a full-frame buffer every tick in the X11 and Windows backends diff --git a/quest/m2/aac-encode-refusal.md b/quest/m2/aac-encode-refusal.md deleted file mode 100644 index 4f7a7371bf..0000000000 --- a/quest/m2/aac-encode-refusal.md +++ /dev/null @@ -1,22 +0,0 @@ -# [S] AAC encode refuses a channel count it cannot name - -## Goal - -Writing an AudioSpecificConfig for a channel count that no AAC -channelConfiguration names is an error, not a stereo config with a warning, -in `moq_mux::codec::aac::Config::encode`, as `@moq/hang`'s -`audioSpecificConfig` already refuses since #4119. This mirrors the parse -side, which since #4093 refuses reserved values instead of guessing stereo. - -## Plan - -`Config::encode` becomes fallible, a published API break, so this targets -`dev`. Counts with a PCE-free configuration map as today. Refuse the others, -matching JS; writing channelConfiguration 0 with a PCE derived from the layout -is a later additive change in both languages. Test every count from 1 to 8 and -one beyond. - -## Related - -- [AAC PCE](https://github.com/moq-dev/moq/pull/4093) - the parse half -- [Layout](/quest/m1/audio-codecs/layout.md) - the layout a PCE would be derived from diff --git a/quest/m2/capture-clock-source.md b/quest/m2/capture-clock-source.md deleted file mode 100644 index ddd4d10888..0000000000 --- a/quest/m2/capture-clock-source.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Capture publishers read the catalog's clock - -## Goal - -`moq_video::encode::publish_capture` and `moq_audio::encode::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/cat/README.md b/quest/m2/cat/README.md index 689f7da37f..d68c4ec7d5 100644 --- a/quest/m2/cat/README.md +++ b/quest/m2/cat/README.md @@ -44,9 +44,8 @@ Boundaries decided while planning: Order: the SETUP option already reaches the auth server as `moq_auth::Request.token`; verification comes first, then our clients present one. Everything rides `moq_auth::Request` and -`moq auth serve`, which shipped on dev. The JWT types sit at the crate root; -the verify quest moves them under `moq_auth::jwt` so `cat` is a sibling -module rather than a set of prefixed names. +`moq auth serve`. The JWT types stay at the crate root, so the published +`moq-auth` names do not break; `cat` is an additive module beside them. ## Required diff --git a/quest/m2/cat/verify.md b/quest/m2/cat/verify.md index 46df67ba0e..e606294f04 100644 --- a/quest/m2/cat/verify.md +++ b/quest/m2/cat/verify.md @@ -11,14 +11,16 @@ tokens. Every claim we do not evaluate refuses the token naming the claim. ## Plan -The JWT types (`Claims`, `Key`, `Jwk`, `KeyId`, `Algorithm`, the key set, -`authorize`) sit at the `moq_auth` root today. Move them under -`moq_auth::jwt` first, keeping their names, so `cat` is a sibling module -rather than a set of prefixed names; `@moq/auth` stays flat. +The JWT types (`Claims`, `Key`, `Jwk`, `KeyId`, `Algorithm`, `Scope`, the +key set) sit at the root of the published `moq-auth` 0.1.x, and they stay +there: `cat` is an additive module beside them, so this lands on `main`. +Moving them under `moq_auth::jwt` would break every published caller; if it +is still wanted, it is its own `dev` change, not part of this quest. Decided +in the 2026-09-28 quest audit. `@moq/auth` stays flat. - Crates: `coset` for COSE and CWT claims, `ciborium` for CBOR; HMAC through the `aws-lc-rs` the crate already links, ES256 through `p256`. Keys reuse - `moq_auth::jwt::Key` files: a JWK with `alg` maps to the COSE algorithm + `moq_auth::Key` files: a JWK with `alg` maps to the COSE algorithm (`HS256` to HMAC 256/256, `ES256` to -7, `EdDSA` to -8, `RS256` to -257); a JWK whose algorithm has no COSE mapping is refused at load naming it. - `cat::Claims { issuer, audience, subject, expires, not_before, issued, @@ -46,7 +48,7 @@ rather than a set of prefixed names; `@moq/auth` stays flat. than widening a fetch-only token into a live subscription. `ClientSetup` and `ServerSetup` add nothing. The namespace fields become one `moq_pattern::Pattern` segment each, the mapping `rs/moq-pattern` documents under "CAT / C4M", relative - to the dialed path exactly as `jwt::Claims::root` is: `Exact(f)` is the + to the dialed path exactly as the JWT `Claims::root` is: `Exact(f)` is the literal segment, `Prefix(f)` is `f*`, `Suffix(f)` is `*f`, a named namespace without `exact_depth` appends `/**`, and an absent namespace is bare `**` with nothing appended. A field value containing `/` or `*` diff --git a/quest/m2/cpp-conan.md b/quest/m2/cpp-conan.md index a4d1dfe8d4..19e36c2ac2 100644 --- a/quest/m2/cpp-conan.md +++ b/quest/m2/cpp-conan.md @@ -2,16 +2,17 @@ ## Goal -A consumer adds the moq Conan remote, requires `moq/`, and gets the +A consumer adds the moq Conan remote, requires `moq-cpp/`, and gets the prebuilt package for their `os`, `arch`, and `compiler` without a Rust toolchain or the bindgen fork. A fresh consumer project installs it in CI on Windows, macOS, and Linux. ## Plan -- A `moq` recipe on a moq-dev remote (Artifactory or a GitHub-hosted `conan` - index) that packages the prebuilt release tarball per setting and exports - the CMake target from `package_info`. +- A `moq-cpp` recipe, named after the package, on a moq-dev remote + (Artifactory or a GitHub-hosted `conan` index) that packages the prebuilt + release tarball per setting and exports the `moq::cpp` CMake target from + `package_info`. - The recipe reads the release manifest the vcpkg quest introduced, so one release bumps both recipes; `release-cpp.yml` publishes to the remote after the tarballs land. diff --git a/quest/m2/cpp-vcpkg.md b/quest/m2/cpp-vcpkg.md index 40b98e73fc..36ffc7427f 100644 --- a/quest/m2/cpp-vcpkg.md +++ b/quest/m2/cpp-vcpkg.md @@ -3,13 +3,14 @@ ## Goal A consumer adds `moq-dev/vcpkg-registry` to `vcpkg-configuration.json`, -depends on `moq`, and gets the prebuilt package for their triple without a +depends on `moq-cpp`, and gets the prebuilt package for their triple without a Rust toolchain or the bindgen fork. A fresh consumer project installs it in CI on Windows, macOS, and Linux. ## Plan -- `moq-dev/vcpkg-registry`: a git registry with a `moq` port whose portfile +- `moq-dev/vcpkg-registry`: a git registry with a `moq-cpp` port, named after + the package (`find_package(moq-cpp)`, target `moq::cpp`), whose portfile downloads the per-target release tarball from `release-cpp.yml` by version and hash, installs headers, the static library, and the CMake config, and declares `supports` for exactly the release matrix. Versioning follows the diff --git a/quest/m2/flate/README.md b/quest/m2/flate/README.md index 2438bf27d7..58936a70b3 100644 --- a/quest/m2/flate/README.md +++ b/quest/m2/flate/README.md @@ -2,30 +2,21 @@ ## Goal -Any track can be DEFLATE-compressed per group from every language, not only -the JSON modes. A native or C caller publishes and consumes a compressed track -of opaque frames the same way a Rust or browser caller does, and the bytes on -the wire are identical across all of them. +Opaque tracks, compressed per group or not, are published and consumed the +same way from every language, not only Rust and JS, and the bytes on the wire +are identical across all of them. ## Plan -`moq-flate` and `@moq/flate` today are a bare codec: `Encoder`/`Decoder` with -`frame(bytes) -> bytes` and a shared window the caller scopes to a group by -hand. Only `moq-json` composes them, so the bindings reach compression through -`compression: bool` on the JSON configs and nothing else. A telemetry, caption, -or sensor track of raw frames has no compressed form outside Rust and JS, and -even there the caller re-derives the group discipline from the crate docs. - -The line adds a track wrapper to the crate first, then binds that wrapper. The -codec objects stay as they are; the wrapper owns the per-group window so a -caller cannot desynchronize it. The wire format does not change: a wrapper -group is the raw sync-flushed stream the codec already emits, so a wrapped -producer interoperates with a hand-composed consumer and with `moq-json`. +`moq-flate` and `@moq/flate` absorb `moq-binary`'s snapshot and stream modes +in [moq-binary folds into moq-flate](/quest/m1/flate-binary.md), so the crate +already owns the per-group window a caller could otherwise desynchronize. The +track wrapper this line once planned was dropped for that reason. What +remains is reaching those tracks from the hand-written binding wrappers. No wire, catalog, or relay impact. Compression stays invisible to `moq-net`; a compressed track is announced, routed, and cached like any other. ## Required -- [Track wrapper](/quest/m2/flate/track.md) - `moq-flate` and `@moq/flate` wrap a track so each group is one compression window without caller bookkeeping -- [Bindings](/quest/m2/flate/bindings.md) - moq-ffi and libmoq publish and subscribe compressed tracks, mirrored through every wrapper +- [Bindings](/quest/m2/flate/bindings.md) - the hand-written wrappers expose flate tracks diff --git a/quest/m2/flate/bindings.md b/quest/m2/flate/bindings.md index c83c3df214..ef28b41d31 100644 --- a/quest/m2/flate/bindings.md +++ b/quest/m2/flate/bindings.md @@ -2,55 +2,35 @@ ## Goal -moq-ffi and libmoq publish and subscribe a compressed track of opaque frames, -and every wrapper (Python, Swift, Kotlin, Go, Dart, C) reaches it. A track -written from C decodes in the browser with `@moq/flate` and vice versa. +The hand-written wrappers (Python, Swift, Kotlin, Go, Dart) expose flate +tracks, the snapshot and stream opaque tracks moq-ffi already generates, in +their own idiom beside the JSON entry. A track published from a wrapper +decodes in the browser with `@moq/flate` and vice versa. ## Plan -Bind the track wrapper, not the codec: the bindings' job is to make the group -discipline unrepresentable to misuse, and a bare `frame()` call across an FFI -boundary invites the desync the wrapper exists to prevent. - -moq-ffi, next to `json.rs` and named the same way: - -- `MoqBroadcastProducer::publish_flate(name, MoqFlateConfig) -> MoqFlateProducer` - with `append_group() -> MoqFlateGroupProducer`, `finish()`, `abort(code)`. -- `MoqFlateGroupProducer::write_frame(MoqFrame)`, `finish()`, `abort(code)`. - A failed write aborts the group and the handle refuses further writes. -- `MoqBroadcastConsumer::subscribe_flate(name, MoqFlateConfig) -> MoqFlateConsumer` - with `next_group() -> Option`, `cancel()`. -- `MoqFlateGroupConsumer::read_frame() -> Option`, `cancel()`. -- `MoqFlateConfig { level = 6, max_frame_size = 64 MiB }` as `#[uniffi(default)]` - literals, with the same drift test `json.rs` keeps against the crate defaults. - -Frames cross as `MoqFrame` so the transport timestamp survives, as on the raw -track API; only the payload is compressed. Explicit groups rather than a flat `append(bytes)` because the window resets -at the boundary and the caller chooses where that is; a helper that rolls -groups on a size or count budget can follow if a consumer asks. - -libmoq mirrors `moq_publish_json_*` and `moq_consume_json_*`: -`moq_publish_flate`, `moq_publish_flate_group`, `moq_publish_flate_frame`, -`moq_publish_flate_group_finish`, `moq_publish_flate_finish`, -`moq_consume_flate`, `moq_consume_flate_group`, `moq_consume_flate_frame`, -`moq_consume_flate_frame_free`, `moq_consume_flate_close`, with a -`moq_flate_config` struct. Follow the terminal-status callback contract for -the consume side and regenerate `moq.h`. - -Wrappers per the Cross-Package Sync table: the uniffi bindings regenerate; -`go/wrapper/moq/json.go`, `py/moq-rs/moq/{publish,subscribe}.py`, -`swift/Sources/Moq/Json.swift`, `kt/moq`'s `Json.kt` with its `Aliases.kt` -re-exports and `Flows.kt` extensions, and `dart/moq` each gain a hand-written -sibling. Document in `doc/lib/{c,py,swift,kt,go,dart}` beside the JSON entry. - -Tests: a moq-ffi round trip next to `json_snapshot_roundtrip`, a libmoq C -round trip in `src/test.rs`, and one cross-language check that a C-published -group decodes with the shared vector from the track quest. Run -`just test interop --all`. - -Public API impact: additive on moq-ffi, libmoq, and every wrapper; `main`. -Wire impact: none. +moq-ffi publishes opaque tracks today (`publish_binary_snapshot` and +`publish_binary_stream`, #4137), renamed after `flate` by +[moq-binary folds into moq-flate](/quest/m1/flate-binary.md). Only the +generated bindings reach them; no wrapper does. This quest binds the existing +track modes, not the bare codec: a `frame()` call across the FFI boundary +invites the window desync the track modes exist to prevent. + +- moq-ffi has no consume side for these tracks. Add it next to the JSON + consumers so each wrapper can read what it writes. +- Wrappers per the Cross-Package Sync table: `go/wrapper/json.go`, + `py/moq-rs/moq/{publish,subscribe}.py`, `swift/Sources/Moq/Json.swift`, + `kt/moq`'s `Json.kt` with its `Aliases.kt` re-exports, and + `dart/moq/lib/src/aliases.dart` each gain a flate sibling. If + [FFI shape](/quest/m1/ffi-shape/README.md) has landed, follow its `flate` + namespace instead. +- Document in `doc/lib/{py,swift,kt,go,dart}` beside the JSON entry. +- Tests: a round trip in each wrapper that has tests, and one cross-language + check that a wrapper-published group decodes with `@moq/flate`. Run + `just test interop --all`. + +Public API: additive on moq-ffi and every wrapper. Wire: none. ## Required -- [Track wrapper](/quest/m2/flate/track.md) - the surface being bound +- [moq-binary folds into moq-flate](/quest/m1/flate-binary.md) - the flate snapshot and stream tracks and their moq-ffi names diff --git a/quest/m2/flate/track.md b/quest/m2/flate/track.md deleted file mode 100644 index cec5104a6b..0000000000 --- a/quest/m2/flate/track.md +++ /dev/null @@ -1,55 +0,0 @@ -# [M] Flate track wrapper - -## Goal - -`moq_flate::track::{Producer, Consumer}` and the matching `@moq/flate` classes -wrap a `moq-net` track so every group is one compression window. A caller -writes and reads plain frames; the wrapper compresses, decompresses, and -resets the window at each group boundary. A Rust producer and a browser -consumer (and the reverse) round-trip byte-identical frames. - -## Plan - -Mirror the `moq-json` layering: the codec layer stays for callers that own -their own groups, and the new track layer owns a `moq_net::track::Producer` -or `Subscriber` plus one codec per live group. - -Shape, matching `moq-net` names so the wrapper reads like the track it wraps: - -- `track::Producer::new(track, Config)`, `append_group() -> group::Producer`, - `create_group(sequence)`, `finish()`, `abort(code)`, `consume()`. -- `group::Producer`: `write_frame(timestamp, &[u8])`, `finish()`, - `abort(code)`. Holds the `Encoder`; dropping it without `finish` aborts, - per the refcount idiom. A write that fails after the encoder advanced (an - oversized frame, a closed group) leaves the window ahead of the consumer, - so it is terminal: the group aborts and the handle refuses further writes. -- `track::Consumer::new(subscriber, Config)`, `next_group() -> group::Consumer` - (plus `recv_group` if the raw consumer distinguishes them), `update(...)`. -- `group::Consumer`: `read_frame() -> Option`, the transport - timestamp intact and the payload inflated. Holds the `Decoder`. -- `Config { level, max_frame_size }` with the crate defaults; one struct shared - by both sides, the consumer reading only `max_frame_size`. A frame past the - cap is an error that aborts the group, never a truncated frame. - -No datagrams: a datagram has no window to share, so `append_datagram` is not -on the wrapper. Timestamps pass through untouched: `moq-net` frames carry one -on both sides, and a timed opaque track (telemetry, captions) must round-trip -as the same track. Only the payload is compressed. - -Decide whether `moq-json`'s `stream` and `window` modes should move onto the -group wrapper. They already keep one encoder per group, so it is likely a -deletion, but `stream::Error::Desync` exists because the codec layer can -encode without writing; the wrapper closes that gap by making a failed write -terminal instead. Take the deletion if it is clean, otherwise leave `moq-json` -alone and note why. - -Verify with a shared test vector: a fixed frame sequence compressed by Rust, -checked into both test suites, and decoded by the JS wrapper, with the reverse -direction generated by JS. The existing codec tests stay. - -Public API impact: additive on `moq-flate` and `@moq/flate`; lands on `main`. -Wire impact: none. - -## Related - -- [Bindings](/quest/m2/flate/bindings.md) - binds this wrapper diff --git a/quest/m2/gop-overhead.md b/quest/m2/gop-overhead.md index 67383329de..9c9a8649b3 100644 --- a/quest/m2/gop-overhead.md +++ b/quest/m2/gop-overhead.md @@ -33,11 +33,6 @@ a verdict on whether a long GOP plus a keyframe request is worth designing. A verdict of "2 seconds is fine" is a valid, expected outcome and completes this quest. -## Related - -- [Keyframe trigger](/quest/m1/keyframe-trigger.md) - the publisher-side half a - keyframe request would drive, useful on its own - ## Closes - [#2284](https://github.com/moq-dev/moq/issues/2284) - close this issue when the quest finishes diff --git a/quest/m2/intra-refresh/README.md b/quest/m2/intra-refresh/README.md index 54744d74f0..3bfb94cd11 100644 --- a/quest/m2/intra-refresh/README.md +++ b/quest/m2/intra-refresh/README.md @@ -44,7 +44,7 @@ Decisions the quests share: - [Encode config](/quest/m2/intra-refresh/encode-config.md) - refresh mode extends the settled GOP contract; the producer cuts groups per sweep and publishes `warmup` - [NVENC refresh](/quest/m2/intra-refresh/nvenc-refresh.md) - the NVENC backend encodes refresh mode for H.264 and HEVC - [V4L2 refresh](/quest/m2/intra-refresh/v4l2-refresh.md) - the V4L2 backend encodes refresh mode -- [Bindings](/quest/m2/intra-refresh/bindings.md) - ffi, libmoq, and every wrapper expose the `Gop` enum +- [Bindings](/quest/m2/intra-refresh/bindings.md) - moq-ffi and every wrapper expose refresh mode, additive on the ffi-shape `Gop` enum - [Export sync flags](/quest/m2/intra-refresh/export-sync-flags.md) - fmp4, MKV, and HLS stop advertising a refresh group start as a sync sample ## Related diff --git a/quest/m2/intra-refresh/bindings.md b/quest/m2/intra-refresh/bindings.md index 880327b1a8..78bbc95863 100644 --- a/quest/m2/intra-refresh/bindings.md +++ b/quest/m2/intra-refresh/bindings.md @@ -1,27 +1,25 @@ -# [M] Bindings expose the Gop enum +# [S] Bindings expose refresh mode ## Goal -Every binding names the group structure the way the core does: the ffi record, -the libmoq C struct, and the Python, Swift, Kotlin, Dart, and Go wrappers take -a keyframe interval or a refresh cycle and nothing else, and `cut()` keeps its -meaning in both modes. This replaces the published `gop` integer in moq-ffi -and changes the libmoq C struct layout, so it targets `dev`. +Every binding names the group structure the way the core does: the moq-ffi +record and the Python, Swift, Kotlin, Dart, and Go wrappers take a keyframe +interval or a refresh cycle and nothing else, and `cut()` keeps its meaning in +both modes. ## Plan -- `rs/moq-ffi/src/video.rs`: `MoqVideoEncoderOutput.gop: Option` becomes - a `MoqVideoGop` enum record mirroring `Gop`, defaulting to keyframes at two - seconds. `MoqVideoProducer::cut()` already has the right name. -- `rs/libmoq/src/video.rs`: `moq_video_encoder_output` gains a - `moq_video_gop` discriminant beside `gop`, zero meaning keyframes, and - `moq.h` is regenerated (build.rs does not do it on source-only changes). - `cpp/obs/src` follows the header. +- [Codecs](/quest/m1/ffi-shape/codec.md) already replaces the `gop` integer + with a `MoqVideoGop` enum mirroring the non-exhaustive `Gop`, so this adds + the refresh variant beside `Keyframe` in `rs/moq-ffi/src/video.rs`, which + is additive and lands on `main`. `MoqVideoProducer::cut()` already has the + right name. +- moq-ffi only: the generated C and C++ bindings inherit the variant. - Hand-written wrappers and docs per the cross-package table: `py/moq-rs`, - `swift/`, `kt/`, `dart/moq`, `go/wrapper/moq`, and `doc/lib/{py,swift,kt,go,dart,c}`. - Go gets no uniffi default, so its zero value must read as keyframe mode. + `swift/`, `kt/`, `dart/moq`, `go/wrapper`, and `doc/lib/{py,swift,kt,go,dart}`. - Run `just test interop --all` for the cross-language check. ## Required -- [Encode config](/quest/m2/intra-refresh/encode-config.md) - the core enum this mirrors +- [Encode config](/quest/m2/intra-refresh/encode-config.md) - the core refresh variant this mirrors +- [Codecs](/quest/m1/ffi-shape/codec.md) - the `MoqVideoGop` enum this extends diff --git a/quest/m2/intra-refresh/consumer-warmup.md b/quest/m2/intra-refresh/consumer-warmup.md index 41326377ff..932c5420d7 100644 --- a/quest/m2/intra-refresh/consumer-warmup.md +++ b/quest/m2/intra-refresh/consumer-warmup.md @@ -22,11 +22,10 @@ replaces both: the rule is timestamp arithmetic on the group start. - Withhold rule, keyed on the same non-continuous signal in both consumers: `js/hang/src/container/consumer.ts` `next()` reports `continuous: false` after a subscribe, a declared discontinuity, or any skip (`#gap`). The Rust - `rs/moq-mux/src/container` `Consumer` has no such signal: `read()` returns a - bare frame and `discontinuity()` covers only empty groups and rewinds, so - this quest adds a per-delivery continuity flag there, set on a sequence gap - and on a latency skip, and `rs/moq-video/src/decode/consumer.rs` propagates - it. For the first + `moq_mux::container::Consumer` gains the equivalent in the open-GOP quest + (today `poll_read` returns a bare frame and only the `discontinuity()` + counter moves); this quest reuses it, and + `rs/moq-video/src/decode/consumer.rs` propagates it. For the first group after that signal, every frame is decoded (the decoder needs them to build reference state) and frames stamped below `group.start + warmup` are not presented; frames stamped at or above that boundary are, so the recovery @@ -41,13 +40,15 @@ replaces both: the rule is timestamp arithmetic on the group start. in `js/hang`. Other codecs never set `warmup`, so no check is needed there. - Join earlier: the subscription's maximum age becomes the latency target plus `warmup`, so the group start lands `warmup` before the target and the first - presented frame is on time. JS sets `Subscription.latencyMax` in `js/net`; - Rust sets `latency_max` on the decode consumer's subscription - (`rs/moq-video/src/decode/consumer.rs`), not `Subscription::group_start`, - which is aggregated across subscribers and rewinds the track for everyone. -- Latency skipping must not shed the warmup span it deliberately joined: - `#checkLatency` in the JS container consumer and `with_latency` in Rust - compare the buffered span against the target, and frames still inside a + presented frame is on time. JS sets the subscription's `maxAge` in `js/net`; + Rust adds it to the decode consumer's `Options::max_age`, which reaches the + subscription through `Subscription::with_max_age` + (`rs/moq-video/src/decode/consumer.rs`), not `Subscription::start`, which + is aggregated across subscribers and rewinds the track for everyone. +- Max-age skipping must not shed the warmup span it deliberately joined: + `#checkMaxAge` in the JS container consumer and the max-age budget in Rust + (`Consumer::poll_read`, set by `set_max_age`) compare the buffered span + against the target, and frames still inside a withheld warmup count as decode-only, not buffered. - Tests in both languages: a synthetic three-group track with `warmup` where a cold join presents nothing before start plus `warmup` and everything after; @@ -59,7 +60,4 @@ replaces both: the rule is timestamp arithmetic on the group start. ## Required - [Catalog warmup](/quest/m1/catalog-warmup.md) - the field this reads - -## Related - -- [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - trims frames stamped before the keyframe; this trims frames after the start, on the same signal +- [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - adds the Rust non-continuous signal this keys on, and trims frames stamped before the keyframe where this trims frames after the start diff --git a/quest/m2/js-discontinuity.md b/quest/m2/js-discontinuity.md index 36e41aada4..0270c141b2 100644 --- a/quest/m2/js-discontinuity.md +++ b/quest/m2/js-discontinuity.md @@ -1,29 +1,22 @@ -# [S] JS names the break discontinuity() +# [XS] JS discontinuity() writes no estimated end ## Goal -`@moq/hang`'s container producer spells its two operations the way Rust -`moq_mux::container::Producer` does: `cut(end?)` closes the current group, -and `discontinuity()` closes it and writes the empty marker group that tells -subscribers to re-anchor. Today JS's public `cut()` writes the marker, so the -same name means a routine group close in Rust and a timeline break in JS. +`@moq/hang`'s `discontinuity()` without an explicit end closes the group with +no cadence-estimated end, as Rust `moq_mux::container::Producer::discontinuity` +does. Whatever resumes can land sooner than one estimated frame later (a +capture swap), and an end past it reads as a rewind to every consumer. ## Plan -[#4045](https://github.com/moq-dev/moq/pull/4045) deletes -`quest/m1/js-publish-discontinuity.md`, since #3982 already put the marker -in `cut()`; this rename is the only remaining JS work. +The rename landed on `dev` in #4141 and kept the old behavior: in +`js/hang/src/container/legacy.ts`, `discontinuity(end?)` calls `#close(end)`, +which fills a missing `end` with `#end + #interval`. Rust's `discontinuity()` +calls `close(None, None)` and clears the cadence first. -In `js/hang/src/container/legacy.ts`, rename the public `cut(end?)` to -`discontinuity()` (taking the same optional end) and keep the routine close -private until a caller needs it public. Move `js/publish/src/video/encoder.ts` -and any other caller over, and keep data tracks skipping a sequence the way -Rust's `discontinuity()` does if a JS data-track caller appears. Rust is -untouched. +Give the break its own close path that passes no estimate when the caller +gave no end, leaving the routine close's estimate alone. Test that a +discontinuity after a steady cadence writes no end past the last frame. -Match Rust's break, too: without an explicit end, `discontinuity()` closes the -group with no cadence-estimated duration marker. Whatever resumes can land -sooner than one estimated frame later (a capture swap), and a marker past it -reads as a rewind to every consumer. - -Public API: breaking in published `@moq/hang`, so it targets `dev`. Wire: none. +Public API: behavior change in `@moq/hang`'s `discontinuity()`, which exists +only on `dev`, so it targets `dev`. Wire: none. diff --git a/quest/m2/latency-ledger.md b/quest/m2/latency-ledger.md index 9c5f1edad4..ed059c529b 100644 --- a/quest/m2/latency-ledger.md +++ b/quest/m2/latency-ledger.md @@ -13,8 +13,12 @@ The audio quality harness lands on ad-hoc debug probes, which is the right trade to get it running. This quest promotes them. - Take the stage schema the harness already defines (capture, encode, publish - flush, network, jitter buffer, decode, render) and expose it the way - `moq-stats` exposes relay traffic: an observable readout, not a callback. + flush, network, jitter buffer, decode, render) and expose it as fields of + `hang::Stats`, the media extension the client stats schema adds to + `moq-stats` (`rs/hang/src/stats.rs`, and its `@moq/stats` mirror), rather + than a second readout: an observable value a `.stats` broadcast already + carries, not a callback. Decided so viewers report latency the same way + they report stalls. - Both languages, matching names, per the repo's cross-language rule. Scrutinise each exported item: a stage nobody outside can act on stays internal. - Switch the harness over, deleting the probes it replaces. A ledger with no @@ -30,6 +34,7 @@ trade to get it running. This quest promotes them. ## Required - [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - defines the stage schema and lands the probes this promotes +- [Schema and library](/quest/m1/qos/stats/schema.md) - adds `hang::Stats`, which this extends ## Related diff --git a/quest/m2/mobile-ownership.md b/quest/m2/mobile-ownership.md index 973b75bf56..14e17f4c94 100644 --- a/quest/m2/mobile-ownership.md +++ b/quest/m2/mobile-ownership.md @@ -24,8 +24,3 @@ capture quests in this questline start only once this is settled. `moq-video`, so replace them with the required platform-owned implementation quests if the answer is option 1. Update [mobile completion](/quest/m2/mobile-completion.md) to require those replacements before abandoning the Rust capture quests. - -## Related - -- [Decoded frame ownership](/quest/m1/decoded-frames.md) - established shared frame lifetime; reuse it for any later native mobile views -- [Decoded frame ownership](/quest/m1/decoded-frames.md) - independently supplies portable pixels from Rust decoding diff --git a/quest/m2/multipath-spike.md b/quest/m2/multipath-spike.md index f26d6804fe..6e7646f68b 100644 --- a/quest/m2/multipath-spike.md +++ b/quest/m2/multipath-spike.md @@ -57,5 +57,8 @@ Naming trap: `Path` in moq-net is the broadcast namespace path, and `Client::with_path` is the MoQ SETUP resource path. A network path needs a different name. -Target `dev`, where the crate is `moq-tokio`; the rename has not reached -`main`. +Target `main`: `apply_transport` lives in `rs/moq-tokio/src/noq.rs` there +too, and the max-paths knob is additive. + +Public API: an additive transport setting in `moq-tokio`. Wire: none; multipath +is negotiated by QUIC transport parameters, below MoQ. diff --git a/quest/m2/obs-wave-layout.md b/quest/m2/obs-wave-layout.md deleted file mode 100644 index b62afa7030..0000000000 --- a/quest/m2/obs-wave-layout.md +++ /dev/null @@ -1,17 +0,0 @@ -# [XS] OBS channel layouts - -## Goal - -The OBS source maps a channel count to the same default layout moq-audio does, -the WAVE convention (3 is 2.1, 4 is quad, 6 is 5.1, 8 is 7.1), so a -multichannel broadcast plays with its speakers where the publisher put them. - -## Plan - -`audio_layout_to_speakers` in `cpp/obs/src/moq-source.cpp` maps from FFmpeg -layouts today. Map each count to the nearest OBS `speaker_layout` and refuse -the ones OBS cannot place rather than guess. Note the mapping in `doc/bin/obs.md`. - -## Related - -- [Audio codecs](/quest/m1/audio-codecs/README.md) - the channel layouts this mirrors diff --git a/quest/m2/pipewire-camera-planes.md b/quest/m2/pipewire-camera-planes.md index d33e8fb114..5cc58a94f8 100644 --- a/quest/m2/pipewire-camera-planes.md +++ b/quest/m2/pipewire-camera-planes.md @@ -2,7 +2,7 @@ ## Goal -A PipeWire camera that delivers I420 or NV12 in separate memory blocks produces frames. Single-block cameras keep working. Other pixel formats stay unsupported. Importing multi-plane NV12 into Vulkan stays with the PipeWire DMA-BUF quest. +A PipeWire camera that delivers I420 or NV12 in separate memory blocks produces frames. Single-block cameras keep working. Other pixel formats stay unsupported. The renderer already imports multi-plane NV12 DMA-BUFs (#3331); this is the shared-memory capture offer. ## Plan @@ -13,4 +13,4 @@ Unit-test the offer, a multi-block NV12 buffer, and a multi-block I420 buffer, w ## Related - [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - the pass that shows whether a real Pi or portal camera delivers separate planes -- [PipeWire DMA-BUFs into Vulkan](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - multi-plane NV12 import in the renderer +- [PipeWire DMA-BUFs into Vulkan](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - the hardware validation of the DMA-BUF path diff --git a/quest/m2/quic-bbr-google.md b/quest/m2/quic-bbr-google.md index 869b3c8fa7..63c90992fe 100644 --- a/quest/m2/quic-bbr-google.md +++ b/quest/m2/quic-bbr-google.md @@ -34,11 +34,7 @@ not an end-to-end network measurement. Persist a reproducible harness in CI separate implementation quest for any adopted change rather than silently expanding this study into a controller rewrite. -## Required - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - measure a baseline without the seven known defects - ## Related -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - reuse media profiles and measurements +- [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - reuse media profiles and measurements - [Upstream the fork](/quest/m1/quic/upstream.md) - share useful findings with upstream diff --git a/quest/m2/quic-bbr-app-limited.md b/quest/m2/quic-bbr-natural-drain.md similarity index 95% rename from quest/m2/quic-bbr-app-limited.md rename to quest/m2/quic-bbr-natural-drain.md index d6b7a29846..561b4bea8b 100644 --- a/quest/m2/quic-bbr-app-limited.md +++ b/quest/m2/quic-bbr-natural-drain.md @@ -40,10 +40,6 @@ harness in CI, with broader network cases at least nightly, and a verdict with pinned sources/configurations. Any adopted production policy gets a separate implementation quest; this study does not silently change defaults. -## Required - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - measure the corrected controller through MoQ's dependency chain - ## Related - [Discover media headroom](/quest/m2/quic-probe.md) - preserving an estimate and discovering spare capacity are separate problems diff --git a/quest/m2/quic-kernel-pacing.md b/quest/m2/quic-kernel-pacing.md index af68254040..f8d3b32915 100644 --- a/quest/m2/quic-kernel-pacing.md +++ b/quest/m2/quic-kernel-pacing.md @@ -3,9 +3,12 @@ ## Goal A measured verdict on handing packet pacing to the kernel. Today noq's pacer -is a userspace token bucket, and the io_uring driver ignores its hint -entirely: a GSO train leaves as one burst, bounded only by the congestion -window. Either the kernel paces each train (`SO_TXTIME` with the `etf` or +is a userspace token bucket on both runtimes: `poll_transmit` holds a train +until the pacing timer fires, which the io_uring driver arms through +`poll_timeout` like the tokio driver does, but a released GSO train still +leaves the NIC as one burst. The `flush_one` comment in +`rs/moq-uring/src/quic/noq/connection.rs` saying the driver ignores the hint +is stale from quiche. Either the kernel paces each train (`SO_TXTIME` with the `etf` or `fq` qdisc, per-packet transmit times in the cmsg the driver already builds) and burstiness at the bottleneck drops without costing CPU, or the burst is shown not to matter on the fleet's paths. diff --git a/quest/m2/quic-probe.md b/quest/m2/quic-probe.md index 5926256767..f1e8ef3e06 100644 --- a/quest/m2/quic-probe.md +++ b/quest/m2/quic-probe.md @@ -44,12 +44,8 @@ is not proof of full path capacity. Persist regressions in CI and broader network scenarios at least nightly. A measured no-go is a valid outcome; retain the baseline and record why before exposing an ineffective option. -## Required - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - exclude known controller defects from the experiment - ## Related -- [Natural media drains](/quest/m2/quic-bbr-app-limited.md) - separate ProbeRTT policy experiment +- [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - separate ProbeRTT policy experiment - [FEC experiment](/quest/m2/quic-fec.md) - repetition competes for the redundancy budget - [GCC egress experiment](/quest/m2/quic-gcc.md) - delay control changes what headroom means diff --git a/quest/m2/redundant-ingest.md b/quest/m2/redundant-ingest.md index 6713d87dd3..96ce3c0644 100644 --- a/quest/m2/redundant-ingest.md +++ b/quest/m2/redundant-ingest.md @@ -4,20 +4,26 @@ Decide whether and how two live publishers of identical content share one broadcast, so viewers survive losing one faster than the QUIC keep-alive. -Epochs let the publishers claim one identity (the same `@`), but -today's #3312 rule splices only across routes with the same first hop, so -two ingest hosts are two identities. The study may end in a no-go. +The study may end in a no-go. ## Plan -Open questions: whether an explicitly shared epoch may splice across first -hops at a group boundary, and what makes that safe (group sequences aligned -across encoders, a matching catalog). Also who declares the incumbent dead -early: a failover service that retracts it, or active-active delivery to the -relay. Weigh them against the moq-transport rule that multiple publishers of -a namespace must each be asked (#3697) and the cluster draft. Output: a +Start from what is documented today (`doc/bin/cli.md` "Redundant +publishers"): two encoders sharing a Hop ID (`--hop 42`) are one first hop, +so relays hold both routes and fail over at a group boundary under the #3312 +same-first-hop rule, provided the tracks are identical with aligned groups. + +Open questions: how that maps onto epochs (the pair claiming one +`@`), what enforces the alignment the docs only ask for (group +sequences, a matching catalog), and who declares the incumbent dead early: a +failover service that retracts it, or active-active delivery to the relay. +[Cluster routing](/quest/m1/cluster-routing.md) drops hop lists inside a +cluster and must decide what replaces this failover; follow its answer. +Weigh them against the moq-transport rule that multiple publishers of a +namespace must each be asked (#3697) and the cluster draft. Output: a decision, with a quest for the chosen mechanism. ## Related +- [Cluster routing](/quest/m1/cluster-routing.md) - decides what replaces first-hop failover inside a cluster - [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - explicit epochs are what a redundant pair would share diff --git a/quest/m2/routing-cost-domains.md b/quest/m2/routing-cost-domains.md index 9e045ed5e4..58e0ab5f1f 100644 --- a/quest/m2/routing-cost-domains.md +++ b/quest/m2/routing-cost-domains.md @@ -3,10 +3,13 @@ ## Goal Settle how independently operated MoQ networks exchange reachability without -adding incomparable costs. Produce a reviewed design and scoped implementation -quests, not a protocol implementation. Cloudflare, moq.pro, and self-hosted -relays can retain their own business policy; no RTT/loss-driven repricing or -automatic performance failover is authorized by this work. +adding incomparable costs, at the cluster boundaries where +[Cluster routing](/quest/m1/cluster-routing.md) keeps path vector with cluster +ids as hops. Cost inside one cluster is cluster-routing's. Produce a +reviewed design and scoped implementation quests, not a protocol +implementation. Cloudflare, moq.pro, and self-hosted relays can retain their +own business policy; no RTT/loss-driven repricing or automatic performance +failover is authorized by this work. ## Plan @@ -46,16 +49,14 @@ Explain how warm-route marginal savings interact with border policy without pretending those savings erase upstream delay. State tradeoffs, migration and mixed-version behavior, and the limits of any convergence claim. -Reconcile the existing directional charged/declared cost plan rather than -creating a second peer-policy mechanism. Completion is a documented decision, +Reconcile with cluster-routing's configured link costs rather than creating +a second peer-policy mechanism. Completion is a documented decision, worked counterexamples or model checks, and independently completable follow-up quests. Open wire/API choices belong to this design exercise. ## Related -- [Peer reconfigure](/quest/m1/pop-skipping/peer-reconfigure.md) - existing - charged versus declared directional policy -- [PoP skipping](/quest/m1/pop-skipping/README.md) - coordinated fleet economics - and warm-route behavior +- [Cluster routing](/quest/m1/cluster-routing.md) - in-cluster topology and + cost; this designs only what crosses its boundaries - [#3769](https://github.com/moq-dev/moq/pull/3769) - measurement-based pricing prompted the separation of measurement, operator policy, and protocol diff --git a/quest/m2/stats-delta.md b/quest/m2/stats-delta.md index b60e1b9afa..475e7c6532 100644 --- a/quest/m2/stats-delta.md +++ b/quest/m2/stats-delta.md @@ -112,4 +112,4 @@ impact: new on-demand tracks; existing tracks unchanged. - [Stats format page](/doc/concept/stats.md) - where the new flavor is documented - [Client stats](/quest/m1/qos/stats/README.md) - the extension and gauges the format must carry or refuse -- [Compressed tracks](/quest/m2/flate/README.md) - the group-window discipline this flavor repeats +- [Compressed tracks](/quest/m2/flate/README.md) - group-scoped DEFLATE tracks, whose group-window discipline this flavor repeats diff --git a/quest/m2/teleop/README.md b/quest/m2/teleop/README.md index 1b4b44e856..fa7e5f5a4f 100644 --- a/quest/m2/teleop/README.md +++ b/quest/m2/teleop/README.md @@ -24,11 +24,10 @@ server walks the announce stream to fan the viewers in demo and private to it. Every integrator rebuilds the announce fan-in, the operator arbitration, and the latency instrumentation from scratch. -Two things are genuinely missing rather than merely undocumented: the hang -catalog is video plus audio only (location tracks arrived in moq#401 and were -dropped when the catalog became generic), and `moq-video`'s V4L2-M2M encoder -backend is compiled into no released `moq-cli`, so the boards that fly have no -native hardware-encode path anyone can install. +The hang catalog is no longer a gap: it advertises data tracks in its `json` +and `binary` sections beside video and audio. What is genuinely missing is +`moq-video`'s V4L2-M2M encoder backend in a released `moq-cli`, so the boards +that fly have no native hardware-encode path anyone can install. ### Two delivery classes, one session @@ -45,8 +44,9 @@ a lossy link produces latency spikes rather than delivery, and an emulation study on a long-RTT profile measured command staleness more than twice as bad for QUIC reliable streams as for DDS best-effort: correct and useless. -The split is a framing decision, not a subscription flag, and `moq-json` -already implements both halves as its snapshot and stream modes. What that +The split is a framing decision, not a subscription flag, and `moq-json` and +`moq-binary` already implement both halves as their snapshot and stream +modes. What that means for the primitive is in [robot](/quest/m2/teleop/robot.md), and what it means for a protocol multiplexing many message rates onto one link is in [mavlink](/quest/m2/teleop/mavlink.md). @@ -98,8 +98,8 @@ stating plainly because it is what a builder is comparing against. - [V4L2-M2M encoding](/quest/m2/teleop/v4l2-encode.md) - a released `moq-cli` reaches `moq-video`'s hardware encoder on the boards that fly, and the boards worth buying are written down -- [Teleoperation use-case docs](/quest/m2/teleop/docs.md) - `doc/concept/use-case/other.md` - becomes the teleoperation page, with a runnable non-media example beside it +- [Teleoperation use-case docs](/quest/m2/teleop/docs.md) - `doc/concept/use-case/` + gains a teleoperation page, with a runnable non-media example beside it - [ROS 2 bridge](/quest/m2/teleop/ros2.md) - a ROS 2 bridge sibling to the MAVLink one, carrying topics over the same two delivery classes - [Cross-track correlation](/quest/m2/teleop/correlation.md) - a command, the diff --git a/quest/m2/teleop/browser-package.md b/quest/m2/teleop/browser-package.md index c447df9a37..56e4c29449 100644 --- a/quest/m2/teleop/browser-package.md +++ b/quest/m2/teleop/browser-package.md @@ -7,14 +7,14 @@ and delivery classes rather than reimplementing them. ## Plan -Follow the existing split: `net`, `hang`, `json` and `token` each have a Rust +Follow the existing split: `net`, `hang`, `json` and `auth` each have a Rust crate and a TypeScript package, with zod schemas mirroring the Rust types. There is no `@moq/mux`, so the catalog extension goes through the same seam `js/hang/src/catalog/root.ts` uses to extend the root schema. A browser observer cannot degrade the operator's classes (`clamp_combined` -bounds the window at the publisher, and `Subscription::default()` is already -`Duration::ZERO`), so this package exists for reuse, not for safety: the +bounds the window at the publisher, and `Subscription::max_age` already +defaults to `Duration::ZERO`), so this package exists for reuse, not for safety: the catalog schema, the snapshot and append-log shapes, and the instrumentation are the same on both sides and should be written once. diff --git a/quest/m2/teleop/docs.md b/quest/m2/teleop/docs.md index 78c95c178b..96266a4044 100644 --- a/quest/m2/teleop/docs.md +++ b/quest/m2/teleop/docs.md @@ -2,12 +2,12 @@ ## Goal -`doc/concept/use-case/other.md` stops being a zero-byte stub and becomes the -teleoperation page, with a runnable non-media example beside it. +`doc/concept/use-case/` gains a teleoperation page, listed in its +`index.md`, with a runnable non-media example beside it. ## Plan -The docs have no non-media tutorial. `rs/moq-native/examples/{chat,clock}.rs` +The docs have no non-media tutorial. `rs/moq-tokio/examples/{chat,clock}.rs` and `rs/moq-json/examples/telemetry.rs` all publish something that is not audio or video, but none is presented as the way to carry application data, and telemetry.rs measures wire savings rather than teaching the shape. diff --git a/quest/m2/teleop/mavlink.md b/quest/m2/teleop/mavlink.md index f13c360ed7..5d545ee4d3 100644 --- a/quest/m2/teleop/mavlink.md +++ b/quest/m2/teleop/mavlink.md @@ -41,25 +41,24 @@ because the newest group wins regardless of which message it holds. Putting them all in one long-lived group has the opposite failure: nothing can be skipped and head-of-line blocking is back. -So the lossy class is a latest-value snapshot, the shape `moq_json::snapshot` -implements, with the frame as an opaque value. Two details decide whether it -actually delivers latest-value: +So the lossy class is a latest-value snapshot of opaque bytes: +`moq_binary::snapshot` (moving to `moq_flate::snapshot` in +[moq-binary folds into moq-flate](/quest/m1/flate-binary.md)), with the raw +frame as the value. Every update is a self-contained group, so a newer value +never waits behind an older one. One detail decides whether it actually +delivers latest-value: - **Key on `(sysid, compid, msgid)`, not msgid alone.** A vehicle is several components, and an autopilot, a gimbal and a camera all emit `HEARTBEAT` under msgid 0; keying on msgid alone lets them overwrite each other. Decide explicitly what to do about instances distinguished only inside a payload, which the msgid-only rule cannot see. -- **Set the encoder's delta ratio to 0.** By default `moq_json::snapshot` - batches up to `MAX_DELTA_FRAMES` merge patches into one ordered group - (`rs/moq-json/src/snapshot/encoder.rs`), so under congestion an earlier 50 Hz - attitude delta head-of-line blocks a later heartbeat inside that group. A - ratio of 0 makes every change a self-contained snapshot, which is the - latest-value contract; anything else has to justify the added delay. -`moq-flate` is the existing answer if the encoding overhead matters. +Not `moq_json::snapshot`: a MAVLink frame is not JSON, and its default batches +up to `MAX_DELTA_FRAMES` merge patches into one ordered group, so an earlier +50 Hz attitude delta would head-of-line block a later heartbeat. -The reliable class is the append-log shape (`moq_json::stream`): commands, +The reliable class is the append-log shape (the binary `stream` mode): commands, ACKs, mission, parameter and file transfer are stop-and-wait exchanges carried in one ordered group. Note the scope in [robot](/quest/m2/teleop/robot.md): that is gap-free for a live reader, not diff --git a/quest/m2/teleop/robot.md b/quest/m2/teleop/robot.md index 42614ceb11..5dd2735138 100644 --- a/quest/m2/teleop/robot.md +++ b/quest/m2/teleop/robot.md @@ -34,41 +34,46 @@ QUIC stream (`open_uni`, `rs/moq-net/src/lite/publisher.rs`), so frames inside it are ordered and delivered exactly once. That guarantee is scoped, and the crate must say so rather than promise -losslessness. A group caches at most `MAX_GROUP_CACHE` bytes and evicts frames -off the front (`rs/moq-net/src/model/group.rs`); a reader that falls behind the -retained window, or joins late, gets `Error::Lagged` and cannot recover the -start of the log. So the contract is ordered, gap-free delivery for a live -reader that keeps up, and recovery after a reconnect belongs to the application +losslessness. A group holds at most `MAX_CACHE_BYTES` and `MAX_GROUP_FRAMES` +(`rs/moq-net/src/model/group.rs`), and a write past either aborts it with +`Error::GroupTooLarge`; `moq_json::stream` never rolls its one group, so that +bound ends the track, and a link drop or reconnect loses what was in flight. +So the contract is ordered, gap-free delivery for a live reader within one +bounded log, and recovery after a reconnect belongs to the application protocol. That is an acceptable division for MAVLink, whose mission, parameter and file-transfer services already carry their own stop-and-wait retransmission, but it must be stated, not assumed. The framing is where the guarantee lives, not the subscription flags: -- `Subscription::ordered` is a scheduling tie-break whose own doc says groups - may arrive out of order or not at all. It aggregates across subscribers by - `&&`, and the IETF transport does not carry it. A class built on it would not - be reliable, which is why the reliable class is a single group instead. +- `track::Subscriber::ordered()` returns an `Ordered` handle that reads the + groups it receives in sequence order. It is a local cursor, not a delivery + guarantee: groups that aged out or were skipped never arrive. A class built + on it would not be reliable, which is why the reliable class is a single + group instead. - What makes the lossy class lossy on the wire is the publisher's - `Info::latency_max`: `commit_group` calls `evict_expired`, which calls - `slot.group.abort(Error::Old)` (`rs/moq-net/src/model/track.rs`), and an abort - resets the QUIC stream, so stale bytes stop being retransmitted. + `Info::max_age`: `evict_expired` aborts an aged-out group with `Error::Old` + (`rs/moq-net/src/model/track.rs`), and an abort resets the QUIC stream, so + stale bytes stop being retransmitted. - A subscriber cannot weaken either class. `clamp_combined` (`rs/moq-net/src/model/track.rs`) clamps the aggregate window down to - `Info::latency_max`, and `Subscription::default()` is already + `Info::max_age`, and `Subscription::max_age` already defaults to `Duration::ZERO`, so a raw observer subscribing through `moq-net` neither widens the window nor has to be prevented from trying. ### Contents -- The catalog section, through `moq-mux`'s `CatalogExt` and - `RenditionConfig`: a namespaced root section whose entries embed a - `JsonConfig` or `BinaryConfig` beside the robot's own fields, published - through the `moq-mux` data producers. +- The catalog entries. The hang catalog already advertises data tracks in its + `json` and `binary` sections (`rs/hang/src/catalog/{json,binary}.rs`), + written by the `moq-mux` data producers, and each entry carries `extra` + fields. Decide whether the robot's fields ride there or in a namespaced + root section through `moq-mux`'s `CatalogExt` and `RenditionConfig`. No hang schema change. - Announce-prefix fan-in, generalised from `rs/moq-boy/src/input.rs`. -- The two delivery classes, as `moq-json`'s snapshot and stream modes with - the group structure and `Info::latency_max` each one needs. +- The two delivery classes, as the snapshot and stream modes with the group + structure and `Info::max_age` each one needs: `moq-json`'s for JSON, and the + opaque-bytes ones for binary frames (`moq-binary`, folding into `moq-flate` + per [moq-binary folds into moq-flate](/quest/m1/flate-binary.md)). - Per-stage timestamp instrumentation, generalised from moq-boy's `status` track. Check it against the publisher-reported stats broadcast ([client stats](/quest/m1/qos/stats/schema.md), moq#2734) before adding a diff --git a/quest/m2/unreal.md b/quest/m2/unreal.md index 7062b81245..64b5912809 100644 --- a/quest/m2/unreal.md +++ b/quest/m2/unreal.md @@ -26,4 +26,3 @@ editor stability across a play-stop-play cycle. ## Required - [Package](/quest/m1/cpp/package.md) - the tarball the module links -- [Decoded frame ownership](/quest/m1/decoded-frames.md) - the decoded frames the texture needs diff --git a/quest/m3/README.md b/quest/m3/README.md index c2e15747b7..ac3b0631f5 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -20,8 +20,5 @@ condition clears, move the quest to the milestone its work belongs in. - [#2893](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - video: validate PipeWire DMA-BUF capture on KDE hardware - [Embedded video](/quest/m3/video-embedded.md) - EGL import in the renderer, so moq-video presents on a Pi - [Vision worker](/quest/m3/processor-vision.md) - a documented customer-run vision worker proves the processor contract -- [libmoq hidden opt-in](/quest/m3/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts -- [libmoq shutdown](/quest/m3/libmoq-shutdown.md) - OBS exits cleanly with the plugin loaded: a C ABI `moq_shutdown` stops the libmoq thread before the module is unloaded -- [libmoq CMake library](/quest/m3/libmoq-cmake-lib.md) - the in-tree CMake build links the `libmoq.a` cargo reports, not a hardcoded `target/` path -- [libmoq fetch](/quest/m3/libmoq-fetch.md) - libmoq gains an additive cached-group fetch entry point +- [Suffix announce](/quest/m3/suffix-announce.md) - moq-lite-only suffix announce and interest, benchmarked over the announce table, once a deployment needs a claim a service prefix cannot express - [Upstream forks](/quest/m3/upstream-forks.md) - offer the uniffi generator fixes our cpp, dart, and Python forks carry upstream, lowest priority diff --git a/quest/m3/dpdk.md b/quest/m3/dpdk.md index c9ec3a466f..efaf48e21c 100644 --- a/quest/m3/dpdk.md +++ b/quest/m3/dpdk.md @@ -22,10 +22,7 @@ a bare-metal tier is worth operating. ## Required +- [AF_XDP UDP path](/quest/m2/af-xdp.md) - the no-hardware verdict this waits + on; a kernel path within reach of line rate abandons this quest - A moq.pro relay provider offers SR-IOV or bare-metal hosts the fleet can run on - -## Related - -- [AF_XDP UDP path](/quest/m2/af-xdp.md) - the no-hardware verdict this waits - on diff --git a/quest/m3/libmoq-cmake-lib.md b/quest/m3/libmoq-cmake-lib.md deleted file mode 100644 index ddd27b83d8..0000000000 --- a/quest/m3/libmoq-cmake-lib.md +++ /dev/null @@ -1,33 +0,0 @@ -# [XS] libmoq CMake finds the library cargo built - -## Goal - -`rs/libmoq/CMakeLists.txt` links the static library cargo actually produced, -whatever `CARGO_TARGET_DIR`, `--target` triple, or profile the build uses. -Today `BUILD_RUST_LIB` assumes `target//libmoq.a`, so any other -layout links a stale library or fails to find one. - -## Plan - -Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. - -- `cmake/cargo-build.cmake` already parses cargo's JSON messages to find - `moq.h` in its hashed `OUT_DIR`. Read the libmoq `compiler-artifact` - message's staticlib filename from the same output and copy it beside the - header in the CMake binary dir, `ONLY_IF_DIFFERENT`. `RUST_LIB` then points - there. Chosen over forcing `--target-dir` into the build dir, which loses - the shared workspace cache, and over `cargo metadata`, which still guesses - the triple and profile subdirectories. -- The `BUILD_RUST_LIB=OFF` prebuilt path is unchanged. -- Regression test in CI: the existing `cpp/obs` build against `MOQ_LOCAL` - runs with `CARGO_TARGET_DIR` outside `target/`, so a hardcoded path fails - the build. Update `rs/libmoq/README.md` and `doc/lib/c` if they describe - the path. - -## Required - -- The maintainer keeps the hand-written libmoq instead of retiring it in the Generated C bindings line - -## Related - -- [libmoq shutdown](/quest/m3/libmoq-shutdown.md) - the other libmoq packaging fix OBS needs diff --git a/quest/m3/libmoq-fetch.md b/quest/m3/libmoq-fetch.md deleted file mode 100644 index 89ae92e986..0000000000 --- a/quest/m3/libmoq-fetch.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] libmoq: fetch one cached group - -## Goal - -A C embedder can fetch one cached group by sequence through the existing frame, -handle, and terminal-status conventions. The new entry point is additive. - -## Plan - -Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. - -Mirror `MoqTrackConsumer::fetch_group` from `rs/moq-ffi/src/consumer.rs` in -`rs/libmoq`, supporting raw and container-decoded delivery as the FFI does. -Specify ownership, cancellation, cache misses, and terminal delivery using the -existing consume contracts. Test a hit, miss, and cancelled fetch from a C caller. - -Regenerate `moq.h` and update `doc/lib/c/index.md`, whose capability list already -claims group fetch. Decoder output configuration landed separately on the dev line. -The `moq_group_request_*` tests in `rs/libmoq/src/test.rs` fetch from Rust for -lack of this entry point; switch them to it. - -## Required - -- The maintainer keeps the hand-written libmoq instead of retiring it in the Generated C bindings line diff --git a/quest/m3/libmoq-hidden.md b/quest/m3/libmoq-hidden.md deleted file mode 100644 index 87be221f7e..0000000000 --- a/quest/m3/libmoq-hidden.md +++ /dev/null @@ -1,20 +0,0 @@ -# [XS] libmoq hidden opt-in - -## Goal - -C callers can list hidden broadcasts (a `.`-prefixed segment below the -prefix) the way every other binding can: `moq_origin_announced` takes a -`hidden` flag, mirroring `MoqAnnounceConfig.hidden` in moq-ffi. Today a C -caller can only list them by naming the dot segment in `prefix`. - -## Plan - -Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. - -- Adding a parameter breaks the C ABI, so this lands on `dev`. -- Update `rs/libmoq/src/{api,origin}.rs`, the libmoq tests, `cpp/obs` if it - calls `moq_origin_announced`, and `doc/lib/c/index.md`. - -## Required - -- The maintainer keeps the hand-written libmoq instead of retiring it in the Generated C bindings line diff --git a/quest/m3/libmoq-shutdown.md b/quest/m3/libmoq-shutdown.md deleted file mode 100644 index 5fa707262b..0000000000 --- a/quest/m3/libmoq-shutdown.md +++ /dev/null @@ -1,58 +0,0 @@ -# [M] libmoq can be stopped before its host unloads it - -## Goal - -OBS exits without a crash whether a moq output is running, was just stopped, -or a source still has terminal callbacks outstanding. libmoq parks a -process-wide `libmoq` thread in `block_on` on first use and has no way to -stop it, while OBS `dlclose`s each plugin at shutdown; the plugin links -libmoq statically, so the thread's code is unmapped under it. Any host that -unloads the library has the same exposure. - -## Plan - -Parked in m3 behind [Generated C bindings](/quest/m1/c/README.md): the hand-written libmoq is being replaced by C generated from moq-ffi, which carries this for free. Do it only if the hand-written crate outlives that line. - -Starts on `main`; libmoq's runtime (`rs/libmoq/src/ffi.rs`) is the same on -both branches and the change is additive. - -- Reproduce first: exit OBS with the plugin loaded and an output running, - then again right after stopping it, and again with a source whose - `moq_source_destroy` hit its two-second backstop. Record which of these - crashes, and how, in the PR before changing anything. -- Add `moq_shutdown()` to the C ABI, mirroring `moq_ffi_shutdown` in - `rs/moq-ffi`: it stops and joins the `libmoq` thread. Pending calls resolve - as cancelled, the terminal callback for every still-open handle fires with - a cancelled status before it returns, and any later call returns an error - code rather than restarting the runtime. It is idempotent and must be - called from a host thread, never from a libmoq callback. Native codec and - capture threads belong to their handles and end with them; this call does - not reach into them. -- The OBS wiring (`obs_module_unload` calling the shutdown after the outputs - and sources are destroyed) belongs to [OBS migration](/quest/m1/cpp/obs.md), - where the plugin reaches moq-ffi through the generated C++ and calls - `moq_ffi_shutdown`; this quest gives the plain-C ABI the same call for the - hosts that stay on libmoq. -- Regression tests: a `rs/libmoq/c-tests` fixture that builds libmoq as a - cdylib, `dlopen`s it, opens a session, and `dlclose`s once without - `moq_shutdown` (must fail, proving the hazard) and once with it (clean - exit, thread gone); a `rs/libmoq` unit test that pending and later calls - resolve cancelled and open handles close without panicking afterwards - (mirror `rs/moq-ffi/src/test.rs::shutdown_cancels_and_drops_cleanly`, - in a child process since the stop is process-wide). Wire the new fixture - into `just rs c-tests`. -- Cross-package sync: `moq.h` is cbindgen-generated from the Rust doc - comment; update `doc/lib/c` (the handle and callback contract) and - the Go bindings regenerate from `moq.h` and need no - wrapper: `os.Exit` ends the process without tearing a host runtime down - under the thread. - -Public API: additive on the libmoq C ABI (`moq_shutdown`). Wire: none. - -## Required - -- The maintainer keeps the hand-written libmoq instead of retiring it in the Generated C bindings line - -## Related - -- [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - the same hazard class for the moq-ffi bindings diff --git a/quest/m3/suffix-announce.md b/quest/m3/suffix-announce.md new file mode 100644 index 0000000000..5e061f3b39 --- /dev/null +++ b/quest/m3/suffix-announce.md @@ -0,0 +1,42 @@ +# [L] Suffix announce and interest in moq-lite + +## Goal + +A moq-lite subscriber can request announcements by suffix (`**/transcode.pro`) +and a publisher can advertise one, so a fleet-wide service claims every +matching path once instead of per prefix. moq-lite only: IETF sessions stay +prefix-only. Routing only: token claim patterns keep their suffix support +unchanged. + +## Plan + +Decided in the 2026-09-28 quest audit: suffix routing is dropped from every +other quest, which are prefix-only, and lives here as a moq-lite-only +extension. IETF MoQT refuses suffix matching, so a session negotiated as IETF +never carries it and there is no draft to converge with. + +Today `AnnounceRequest` (`rs/moq-net/src/lite/announce.rs`) carries only a +`prefix`, and the origin's route table is a trie keyed by path segment +(`rs/moq-net/benches/origin.rs`). A suffix cannot walk that trie, so a naive +match costs the whole announce table on every announcement and every new +cursor. Requests hit the same wall: `request_broadcast` resolves through +`best_route`, which walks the prefix trie for the longest covering claim, so +a suffix advertisement must also be inserted where SUBSCRIBE and FETCH +resolution find it, with the same specificity rules as prefix claims. +Benchmark first: extend `rs/moq-net/benches/origin.rs` with suffix cursors +and suffix route lookups, each swept over publishers and subscribers. The +slopes decide between a reversed-segment index and abandoning the quest. + +The wire field is version-gated like `hidden`. Update `js/net` and +`drafts/draft-lcurley-moq-lite.md` in the same PR. Token patterns +(`moq-pattern`, `moq_auth::Claims`) already match suffixes and do not change. + +## Required + +- A deployment needs a suffix claim that a service prefix (`./...`) + cannot express + +## Related + +- [Wildcard](/quest/m0/wildcard/README.md) - prefix-only advertisements and the service-prefix layout this would extend +- [Path patterns](/quest/m1/path-patterns.md) - owns the pattern dialect a suffix interest would reuse diff --git a/quest/m3/upstream-forks.md b/quest/m3/upstream-forks.md index 9ae49440ac..45856ec20e 100644 --- a/quest/m3/upstream-forks.md +++ b/quest/m3/upstream-forks.md @@ -49,6 +49,10 @@ One outcome per candidate: Public API: none. Wire: none. +## Required + +- The maintainer approves offering the fixes upstream, per post, issue, or PR + ## Related - [Upstream the fork](/quest/m1/quic/upstream.md) - the same practice for the noq fork diff --git a/quest/m3/video-embedded.md b/quest/m3/video-embedded.md index 15fd63162d..a844e2f7df 100644 --- a/quest/m3/video-embedded.md +++ b/quest/m3/video-embedded.md @@ -24,3 +24,8 @@ libcamera source composes with what already exists, because `encode::Producer::publish` is the bring-your-own-Annex-B path and `moq_mux::codec::h264` already handles framing, so shelling out to `rpicam-vid` is an application concern rather than a moq-video source. + +## Required + +- Someone with a Raspberry Pi or similar V4L2 M2M device without a usable + Vulkan driver validates the EGL import on it diff --git a/quest/m3/video-hardware.md b/quest/m3/video-hardware.md index 355e929ae5..c8bf739fab 100644 --- a/quest/m3/video-hardware.md +++ b/quest/m3/video-hardware.md @@ -27,6 +27,12 @@ Precedent for what this catches: NVENC validation on an RTX 3070 Ti found that NVENC rejects stream-ordered pool memory, so buffers registered with it must come from plain `cuMemAlloc`. That is not a bug any amount of review finds. +## Required + +- Someone with the hardware runs it: an Intel GPU exposing the VAAPI low-power + entrypoint, a second render node, a V4L2 capture device with DMA-BUF export, + a Windows machine with MJPEG and YUY2 cameras, and a live camera per platform + ## Related - [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - the camera portal and a Pi CSI node, which are a different machine from this list diff --git a/quest/m4/msfts-convergence.md b/quest/m4/msfts-convergence.md index 45af899df9..fb344dd510 100644 --- a/quest/m4/msfts-convergence.md +++ b/quest/m4/msfts-convergence.md @@ -20,7 +20,3 @@ change on either side. Update `drafts/draft-lcurley-moq-mpegts.md` and ## Required - msfts#33 (https://github.com/mondain/msfts/issues/33) settles the ES-level payload unit - -## Closes - -- [#3731](https://github.com/moq-dev/moq/issues/3731) - close this issue when the quest finishes From 4504163f191ee5198b22da951c16daa596fc8e34 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 13:03:04 -0700 Subject: [PATCH 70/87] ci: compile the Windows, macOS, and OBS plugin code on every PR (#4370) Co-authored-by: Claude Opus 5.5 --- .github/workflows/alert.yml | 14 ++- .github/workflows/nightly.yml | 82 +------------- .github/workflows/platform.yml | 136 +++++++++++++++++++++++ cpp/obs/justfile | 8 +- quest/m1/audio-codecs/README.md | 2 +- quest/m1/tooling/README.md | 1 - quest/m1/tooling/windows-cross-check.md | 36 ------ quest/m2/audio-decode-mediafoundation.md | 2 +- rs/AGENTS.md | 2 +- rs/justfile | 50 ++++----- rs/moq-audio/Cargo.toml | 7 +- rs/moq-video/Cargo.toml | 3 +- 12 files changed, 178 insertions(+), 165 deletions(-) create mode 100644 .github/workflows/platform.yml delete mode 100644 quest/m1/tooling/windows-cross-check.md diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index 4180d6bd84..e478ad3289 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -18,13 +18,14 @@ on: workflow_run: # Matched by workflow `name:`, not filename. Every workflow that can run # outside a pull request belongs here; the pull-request-only ones (Check, - # macOS, Windows) are omitted so they don't spawn a skipped Alert run on - # every push to every PR. `just gh check` enforces both halves of that. + # Android) are omitted so they don't spawn a skipped Alert run on every + # push to every PR. `just gh check` enforces both halves of that. # - # Swift is here despite also running on pull requests: it warms its own - # Rust cache on `main`, and that push run is nobody's check, so a break in - # it would otherwise go unseen. The cost is a skipped Alert run per Swift - # pull request. + # Swift and Platform are here despite also running on pull requests: Swift + # warms its own Rust cache on `main`, and Platform compiles every merge to + # `main` and `dev`. Those push runs are nobody's check, so a break in them + # would otherwise go unseen. The cost is a skipped Alert run per pull + # request. workflows: - apt-repo - Cache @@ -36,6 +37,7 @@ on: - moq-gst - moq-relay - Nightly + - Platform - release-brew - Release Dart - Release Dart FFI diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 02afbb0721..231fa33aec 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -10,10 +10,7 @@ name: Nightly # that a PR gate can't justify for how rarely it catches something. `obs` is # diff-independent because its PR-time counterpart can't be: obs.yml has to # name the paths that can break an external link to libmoq.a, and no list of -# paths covers every one of them (see the comment there). `windows` and `macos` -# are diff-independent because what they catch is a whole target's worth of -# `#[cfg(target_os = ...)]` code that no Linux job compiles at all, and those -# runners are throttled too hard to gate every PR on. +# paths covers every one of them (see the comment there). # # The `release-*` jobs are diff-independent because the release workflows only # ever run on a tag: a change that compiles under plain cargo can still break a @@ -289,80 +286,3 @@ jobs: name: fuzz-${{ matrix.target }} path: rs/moq-net/fuzz/artifacts/ if-no-files-found: ignore - - # `just rs windows` is the only thing that compiles moq-video's Media - # Foundation capture/encode/decode, its D3D11 frames, and every - # `#[cfg(target_os = "windows")]` test module. The Linux gate skips all of it, - # so without this the code compiles for the first time in a tag-triggered - # release build, and the test targets never compile at all. - # - # A separate job rather than a step because it needs a Windows host and the - # runner-native toolchain: Nix doesn't run here. No Swatinem cache either. The - # repository cache budget is 10 GB, cache.yml spends it warming the Linux - # target/ that every pull request restores, and a once-a-day job shouldn't - # evict that to save itself a few minutes. - windows: - name: Windows - runs-on: windows-latest - timeout-minutes: 60 - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. - - name: Install NASM - shell: pwsh - run: | - choco install nasm -y --no-progress - "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - # Pinned: `--locked` fixes just's own dependencies, not which version of - # just cargo selects. - - name: Install just - shell: bash - run: cargo install --locked just@1.52.0 - - - name: Check - shell: bash - run: just rs windows - - # `just rs macos` is the only thing that compiles moq-video's VideoToolbox - # encode/decode and its ScreenCaptureKit / AVFoundation capture, plus - # moq-audio's ScreenCaptureKit system audio and its TCC permission pre-check. - # The Linux gate skips all of it. moq-video has a release-time backstop, since - # libmoq's `libmoq-v*` tag build compiles it on Apple Silicon, but moq-audio - # has none: libmoq and moq-ffi take it codecs-only, so no release build ever - # compiles its capture backend. - # - # Same shape as `windows` and for the same reasons: a separate job because it - # needs an Apple host and the runner-native toolchain, and no Swatinem cache - # because a once-a-day job shouldn't evict the Linux `target/` that every pull - # request restores. swift.yml already compiles moq-ffi here, so this stays - # scoped to the two crates holding the Apple-gated code. - macos: - name: macOS - runs-on: macos-latest - timeout-minutes: 60 - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - # Pinned: `--locked` fixes just's own dependencies, not which version of - # just cargo selects. - - name: Install just - run: cargo install --locked just@1.52.0 - - - name: Check - run: just rs macos diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml new file mode 100644 index 0000000000..f87c21cc70 --- /dev/null +++ b/.github/workflows/platform.yml @@ -0,0 +1,136 @@ +name: Platform + +# Compiles the workspace on Windows and macOS, the `#[cfg(...)]` code no Linux +# job compiles at all: moq-video's Media Foundation and VideoToolbox backends, +# their capture paths, moq-audio's ScreenCaptureKit capture, and the smaller +# platform branches elsewhere. It also builds the OBS plugin against the +# obs-deps bundle, which obs.yml can't do on Linux. Without this, a change that +# breaks any of them (a moq-net type they consume, say) merges green and +# compiles for the first time in a tag-triggered release build. +# +# Unfiltered, unlike android.yml: every job still finishes before check.yml, so +# a path list tracking the dependency graphs of all that code isn't worth it. +# +# Pushes to `main` and `dev` catch the break two individually green PRs make +# once both land, which is what the nightly run of these jobs used to cover. +# `dev` never ran the nightly at all. +# +# Outside Nix, like android.yml: neither runner has it, so each installs the +# runner-native toolchain. rust-toolchain.toml still pins the compiler, and the +# recipes are the same ones a developer runs on that OS. +# +# No Rust cache. The repository's 10 GB budget is spent on the Linux `target/` +# that cache.yml warms for every pull request, and a Windows or macOS store +# would evict it to save these jobs a few minutes. + +permissions: + contents: read + +on: + push: + branches: [main, dev] + pull_request: + # `closed` is here only so merging/closing a PR cancels its in-flight run + # via the concurrency group below; the jobs themselves are skipped on close. + types: [opened, synchronize, reopened, closed] + +concurrency: + # Keyed by event, so a merged pull request's `closed` run can't cancel the push + # run it triggers. Pull requests key by number rather than ref, since a merged + # PR's `closed` run can report the base branch as its ref instead of the PR's + # merge ref, and then wouldn't cancel the PR's in-flight run. + group: platform-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + JUST_VERSION: 1.52.0 + +jobs: + windows: + name: Windows + if: github.event.action != 'closed' + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. + - name: Install NASM + shell: pwsh + run: | + choco install nasm -y --no-progress + "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + # Pinned: `--locked` fixes just's own dependencies, not which version of + # just cargo selects. + - name: Install just + shell: bash + run: cargo install --locked "just@$JUST_VERSION" + + - name: Check + shell: bash + run: just rs windows + + macos: + name: macOS + if: github.event.action != 'closed' + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Install just + run: cargo install --locked "just@$JUST_VERSION" + + - name: Check + run: just rs macos + + # The release build of the OBS plugin, minus the published libmoq: build.sh + # without `--libmoq-release` links the in-tree rs/libmoq instead, so a PR is + # checked against its own C ABI. The obs-deps bundle (libobs, Qt6, ffmpeg) + # comes from buildspec.json at configure time, same as a tag build. + obs: + name: OBS (${{ matrix.name }}) + if: github.event.action != 'closed' + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: macOS + target: aarch64-apple-darwin + os: macos-latest + - name: Windows + target: x86_64-pc-windows-msvc + # The plugin preset uses the Visual Studio 17 2022 generator. + os: windows-2022 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Build + shell: bash + env: + TARGET: ${{ matrix.target }} + run: ./cpp/obs/build.sh --target "$TARGET" --output "$RUNNER_TEMP/obs" diff --git a/cpp/obs/justfile b/cpp/obs/justfile index 1c1f59ed10..c28f0f48de 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -129,7 +129,7 @@ compile: # Compile and link the plugin the way CI does, against the dev shell's own # libobs/Qt6/ffmpeg, then run the unit tests. Linux-only in practice: it's the # one platform where nixpkgs has obs-studio, so nothing has to be downloaded. -# Manual elsewhere, like `just rs macos`. +# Manual elsewhere. # # The tests run here without ThreadSanitizer because this is the only recipe CI # invokes (.github/workflows/obs.yml). `just obs test` is manual, so on its own @@ -223,9 +223,9 @@ _includes: # Unit-test the plugin sources against stubbed libobs/libmoq/ffmpeg, under # ThreadSanitizer. # -# Manual, like `just rs macos` and `just rs windows`, because ThreadSanitizer -# needs its own build. `just obs ci` runs the same tests without it, so a -# regression these assertions catch still turns PR CI red; the sanitizer is what +# Manual because ThreadSanitizer needs its own build. `just obs ci` runs the +# same tests without it, so a regression these assertions catch still turns PR +# CI red; the sanitizer is what # adds the races on top. Run this whenever you touch src/, especially the session # callback plumbing, whose orderings the build can't check. test: diff --git a/quest/m1/audio-codecs/README.md b/quest/m1/audio-codecs/README.md index c91e1854fd..3d3c619be3 100644 --- a/quest/m1/audio-codecs/README.md +++ b/quest/m1/audio-codecs/README.md @@ -56,7 +56,7 @@ ready now. ## Related - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - the OBS source and encoder adapters consume this through moq-ffi; #3498 narrowed OBS to what moq-audio decodes today -- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - Windows and Android verification needs a host; the Windows and macOS CI gates run nightly, not per PR +- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - Windows and Android verification needs a host; the Windows and macOS CI gates only compile - [Dart codec parity](/quest/m1/dart-codecs.md) - Dart gains these once it builds with the `audio` feature - [Media Foundation decode](/quest/m2/audio-decode-mediafoundation.md) - Windows decodes HE-AAC, multichannel AAC, and what else the MFTs offer - [Media Foundation encode](/quest/m2/audio-encode-mediafoundation.md) - Windows encodes AAC-LC diff --git a/quest/m1/tooling/README.md b/quest/m1/tooling/README.md index ee3067b9d8..5f3055bfc0 100644 --- a/quest/m1/tooling/README.md +++ b/quest/m1/tooling/README.md @@ -20,7 +20,6 @@ requires the one before it, so they land as one line of pull requests. ## Required - [Thin justfiles](/quest/m1/tooling/justfiles.md) - recipe bodies move to `sh/`, one impact map scopes check/fix/test, self-tests and guards are deleted -- [Windows cross-check](/quest/m1/tooling/windows-cross-check.md) - a Linux cross-check of moq-video for Windows runs per PR when moq-video is selected - [Workflows call just](/quest/m1/tooling/workflows-call-just.md) - no workflow `run:` step names a `.sh`; every script a workflow needs has a recipe - [Binary release workflow](/quest/m1/tooling/release-binary.md) - moq-cli and moq-relay share one reusable workflow behind two thin callers - [FFI release workflow](/quest/m1/tooling/release-ffi.md) - the five `release-*-ffi.yml` share the moq-ffi target matrix and artifact staging diff --git a/quest/m1/tooling/windows-cross-check.md b/quest/m1/tooling/windows-cross-check.md deleted file mode 100644 index 5bda413332..0000000000 --- a/quest/m1/tooling/windows-cross-check.md +++ /dev/null @@ -1,36 +0,0 @@ -# [XS] Windows cfg breaks in moq-video fail the PR - -## Goal - -A pull request that breaks moq-video's `#[cfg(target_os = "windows")]` code -fails its own checks instead of the next nightly. Today only `just rs -windows` compiles that code, on a Windows runner once a day, so -[#4036](https://github.com/moq-dev/moq/pull/4036) had to repair a Media -Foundation test that had been broken on `main` for a while. That PR showed a -Linux host can catch it: -`cargo check -p moq-video --no-default-features --features capture ---all-targets --target x86_64-pc-windows-msvc` reproduced the errors and -passed with the fix, with no Windows host and no C toolchain. - -## Plan - -- Run that check per PR whenever the impact map selects moq-video, as a - recipe the workflow calls. `openh264` stays off because its vendored C++ - needs MSVC; the Media Foundation, D3D11, and capture code is what the - check is for. -- The dev shell's toolchain carries only `wasm32-unknown-unknown`; add the - `x86_64-pc-windows-msvc` std target to `flake.nix` so the check runs the - same locally and in CI. Measure what it adds to the shell and the check. -- Fix the stale `rs/justfile` comment above `windows`: it says cross-compiling - cannot stand in because openh264 is non-optional, but openh264 is now a - feature. The nightly Windows job still owns linking, the other crates, and - running the tests. -- Prove it by reintroducing one of #4036's errors on a branch and watching - the PR check fail. - -Public API: none. Wire: none. - -## Required - -- [Thin justfiles](/quest/m1/tooling/justfiles.md) - the impact map that - selects moq-video diff --git a/quest/m2/audio-decode-mediafoundation.md b/quest/m2/audio-decode-mediafoundation.md index 20a2518458..6fae6be4fa 100644 --- a/quest/m2/audio-decode-mediafoundation.md +++ b/quest/m2/audio-decode-mediafoundation.md @@ -20,7 +20,7 @@ ones. Behind the decode seam as the first candidate on `target_os = not advertised on that host, not an error at decode time. - Fixtures and layout-order tests as in the AudioToolbox quest. - Verification runs on a Windows host; the per-PR CI only compiles the - platform code, and `just rs windows` runs nightly. + platform code (`just rs windows`). ## Required diff --git a/rs/AGENTS.md b/rs/AGENTS.md index 4d1356c455..510f408bd0 100644 --- a/rs/AGENTS.md +++ b/rs/AGENTS.md @@ -53,4 +53,4 @@ Prefer poll. New logic is a `poll_*` with an `async` helper, not the other way a - Tests are inline `#[cfg(test)] mod tests`. Time-dependent async tests call `tokio::time::pause()` first, unless they cross real networking that can't be mocked (sockets, smoke tests); those run on the wall clock and assert lower bounds. - Run tests through `just` (nextest), not `cargo test`: nextest kills a wedged test as TIMEOUT, cargo hangs forever. A test flagged SLOW is a bug to fix, not a threshold to raise. - `just check` compiles default features only, like CI. `just rs features` (nightly) covers `--all-features` / `--no-default-features`. Keep a feature gate around the dependency, not the logic, so the logic's tests stay in the merge gate. -- Local checks compile only the host platform; `just rs windows` / `macos` must run on that OS and `just rs wasm` covers `moq-wasm`. Say plainly in the PR when platform code is uncompiled. +- Local checks compile only the host platform; PR CI runs `just rs windows` / `macos` on those hosts, and `just rs wasm` covers `moq-wasm`. diff --git a/rs/justfile b/rs/justfile index 9b7a35e0f3..68677b5bef 100644 --- a/rs/justfile +++ b/rs/justfile @@ -578,24 +578,21 @@ fix-changed $FILES: just rs wasm-fix fi -# Runs the `#[cfg(target_os = "windows")]` code past the compiler: moq-video's -# Media Foundation capture/encode/decode and its D3D11 frames, which the Linux -# gate skips entirely. Cross-compiling can't stand in, because openh264-sys2 -# builds vendored C++ that needs an MSVC toolchain and openh264 is a -# non-optional dependency. -# -# Windows runners are throttled too hard for a per-PR gate, so nightly.yml runs -# this once a day instead. A break there lands on main rather than being caught -# in review, which is the same trade the other nightly gates make. +# Runs the `#[cfg(windows)]` code past the compiler, which the Linux gate skips +# entirely: moq-video's Media Foundation capture/encode/decode and its D3D11 +# frames, moq-nvenc's Windows loader, and the Winsock paths in moq-tokio. A +# native host rather than a cross-compile, because openh264-sys2's vendored C++ +# needs an MSVC toolchain. platform.yml runs this on every pull request and +# every push to main and dev. # # Default features rather than `--all-features`: jemalloc doesn't build on -# MSVC, while moq-video's Linux-only `nvidia` default -# are already no-ops off Linux. moq-gst is excluded because it links -# GStreamer via pkg-config, which the Windows runner doesn't have. +# MSVC, and the Linux-only features (vaapi, pipewire, io-uring) have nothing to +# compile here. moq-gst is excluded because it links GStreamer via pkg-config, +# which the runner doesn't have. # # `moq-cli/play` and `moq-cli/capture` are named explicitly because they are -# off by default, and they are the only thing that compiles the cli's own -# device and render code. +# off by default, and they are what turns on the device and render code in +# moq-video, moq-audio, and the cli itself. # Compile the whole workspace on a Windows host. Must run ON Windows. windows *args: @@ -618,25 +615,18 @@ uring *args: uring-check *args: cargo clippy --locked -p moq-uring -p moq-relay --features moq-relay/io-uring --all-targets {{ args }} -- -D warnings -# Runs the `#[cfg(target_os = "macos")]` code past the compiler: moq-video's -# VideoToolbox encode/decode and its ScreenCaptureKit / AVFoundation capture, -# plus moq-audio's ScreenCaptureKit system audio and TCC permission pre-check. -# -# Same trade as `windows`: nightly.yml runs this once a day rather than gating a -# merge on a Mac runner. The moq-video half also has a release-time backstop, -# since libmoq's `libmoq-v*` tag build compiles it on Apple Silicon. The -# moq-audio half has none: libmoq and moq-ffi take it codecs-only, so no release -# build compiles its capture backend and this recipe is the only thing that does. +# Runs the Apple-gated code past the compiler: moq-video's VideoToolbox +# encode/decode and its ScreenCaptureKit / AVFoundation capture, moq-audio's +# ScreenCaptureKit system audio and TCC permission pre-check, and moq-sock's +# BSD socket-buffer sysctl. platform.yml runs this alongside `windows`. # -# Scoped to those two crates instead of the workspace, because they hold all -# the Apple-gated code the Linux gate misses. moq-ffi (and through it the -# Swift/Kotlin/Go wrappers) already compiles on macOS in swift.yml. -# `--all-features` also compiles the off-by-default cpal hosts and PipeWire -# capture, which are Linux-only and no-ops here, so it costs nothing extra. +# The same workspace and features as `windows`, for the same reasons: moq-gst +# needs GStreamer, and the cli's `play` and `capture` features are what turn on +# the device and render code. -# Compile the Apple-only code paths. Must run ON macOS. +# Compile the whole workspace on a macOS host. Must run ON macOS. macos *args: - cargo check --locked -p moq-video -p moq-audio --all-targets --all-features {{ args }} + cargo check --locked --workspace --exclude moq-gst {{ no_fuzz }} --all-targets --features "moq-cli/play moq-cli/capture" {{ args }} # The Linux device code: moq-video's V4L2 and X11 capture and moq-audio's cpal # capture, all behind the off-by-default `capture` feature. `check-changed` runs diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 943fcf16c2..b0bdca9aaf 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -19,9 +19,10 @@ categories = ["multimedia", "multimedia::audio", "multimedia::encoding"] # and Windows would pay nothing, but a default is one setting for every host. # The features exist so a consumer can pick what it wants, not to hide code from # the default compile: `just check` lints default features only, so the device -# code is compiled by nightly's `just rs features` and `just rs macos`. Workspace -# consumers opt in at the root manifest (`default-features = false`) per crate, -# which keeps cpal out of the language bindings. +# code is compiled by nightly's `just rs features` and platform.yml's +# `just rs macos`. Workspace consumers opt in at the root manifest +# (`default-features = false`) per crate, which keeps cpal out of the language +# bindings. default = ["aac"] # AAC-LC decode via symphonia. Pure Rust, so it costs no toolchain: the trade # this crate already refused for Opus. Only a subscriber to an ingest-sourced diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index ffb07201cb..aa8b4f8b9d 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -21,7 +21,8 @@ categories = ["multimedia", "multimedia::video", "multimedia::encoding"] # on. `capture` and `v4l2` no longer cost anything on Linux (moq-v4l checks its # bindings in) and stay opt-in only until every distribution ships them. The features exist so a consumer can pick what it wants, not to hide code # from the default compile (`just check` lints default features only, so the -# opt-in ones are compiled by nightly's `just rs features` and `just rs macos`). +# opt-in ones are compiled by nightly's `just rs features` and platform.yml's +# `just rs macos`). # OpenH264 remains the default software fallback, but is optional so a hardware- # only consumer does not compile its vendored C++. Workspace consumers opt out # at the root manifest (`default-features = false`) and pick per crate, so the From 166f9e87bcbf924f50d06a26cb086622f413d286 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 13:17:03 -0700 Subject: [PATCH 71/87] fix(net): forget spliced tracks unread for the linger, finished ones included (#4361) Co-authored-by: Claude Opus 5.5 --- rs/moq-net/src/model/broadcast.rs | 32 +++- rs/moq-net/src/model/front.rs | 233 +++++++++++++++++++++++++----- rs/moq-net/src/model/origin.rs | 110 ++++++++++++-- rs/moq-net/src/model/resume.rs | 5 + 4 files changed, 325 insertions(+), 55 deletions(-) diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index 3b7f90306e..ef40c6e9d4 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -419,6 +419,29 @@ impl Producer { } } + /// Remove the spliced track `producer` from under `name`, unless it has a reader. + /// + /// Returns false only when a reader holds it: lookups hand out readers under the + /// same lock, so one arriving after the caller decided is never cut off. Anything + /// else (removed, or already replaced under the name) is gone as far as the caller + /// is concerned. + pub(crate) fn forget_spliced(&self, name: &str, producer: &super::resume::Producer) -> bool { + let mut state = self.state.lock(); + let Some(spliced) = state.spliced.as_mut() else { + return true; + }; + match spliced.tracks.get(name) { + Some(current) if current.is_clone(producer) => { + if current.is_used() { + return false; + } + spliced.tracks.remove(name); + true + } + _ => true, + } + } + /// Create a consumer of this one publisher's broadcast. /// /// A view of this broadcast object, not of its path: a new publisher at the same @@ -814,11 +837,12 @@ impl Consumer { // the time, not a property of the name: a publisher that had not yet // created the track may have it now. Drop it so this request reaches a // source again, exactly as the plain lookup below reclaims a closed - // entry. A *finished* one stays, since its cache is still readable. + // entry. A *finished* one stays, since its cache is still readable, + // until the front forgets it after going unread for its linger. // - // So a name, once finished, never comes back here: a publisher that - // finishes a track and publishes it again is serving new content, not - // resuming this one, and a subscriber has to re-read the catalog and + // So a name, once finished, is never spliced onto again: a publisher + // that finishes a track and publishes it again is serving new content, + // not resuming this one, and a subscriber has to re-read the catalog and // re-initialize rather than be spliced onto it. Resuming the same // content across routes is the transparent case, and that is what // `resume::Producer` already does. Publish new content under a new diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index b56136b02a..4f0ed26442 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -53,8 +53,9 @@ pub(super) enum Event { Resolved { route: u64, result: Result }, /// A source closed: it will never serve again. SourceClosed { source: u64 }, - /// The spliced broadcast handed out a new logical track to serve. - TrackAssigned { track: Arc }, + /// The spliced broadcast handed out a new logical track to serve. It has no + /// reader until [`Event::Used`] says so. + TrackAssigned { track: Arc, now: Instant }, /// A source answered a track query: its copy's metadata, or a refusal. /// `closing` is whether the source has begun closing, in which case a /// refusal is not held against it: it is on its way out. @@ -80,6 +81,9 @@ pub(super) enum Event { Unused { track: Arc, now: Instant }, /// The armed deadline passed. Deadline { now: Instant }, + /// The driver let go of a track the machine asked it to [`Action::Forget`]. + /// Not fed when a reader arrived first: [`Event::Used`] follows instead. + Forgotten { track: Arc }, /// The origin is tearing down. Closed, } @@ -105,9 +109,10 @@ pub(super) enum Action { /// Drop the source copy of `track` but keep the delivered groups spliced, /// so resume stays seamless while nobody reads. Park { track: Arc }, - /// Drop the source copy of `track` and every delivered group: the linger expired - /// unread, or the source is local and keeps its own cache. - Release { track: Arc }, + /// Remove `track` from the broadcast and drop everything behind it, unless a reader + /// arrived meanwhile: it went unread for the linger, or the source is local and + /// keeps its own cache. Feed back [`Event::Forgotten`] once it is gone. + Forget { track: Arc }, /// The logical track completed. Finish { track: Arc }, /// The logical track failed for good. @@ -174,8 +179,8 @@ enum TrackState { Querying { source: u64 }, /// A source's copy is spliced in and being served. Spliced { source: u64 }, - /// Nobody reads it: the copy was dropped, the delivered groups stay - /// spliced until the linger expires. + /// Nobody reads it: the copy was dropped, and whatever the track delivered + /// stays spliced until the linger expires and the track is forgotten. Parked { since: Instant }, } @@ -189,6 +194,9 @@ struct Track { refusal: Option, /// Whether anyone reads the track: a copy is only spliced in for a reader. used: bool, + /// The track finished or aborted: nothing is spliced again, and it stays only + /// until it goes unread for the linger. + ended: bool, } /// One front's state; see the module docs. @@ -213,7 +221,7 @@ pub(super) struct Front { /// Immutable track metadata, fixed by the first copy of each track. Every /// source of the broadcast must serve the same content. info: BTreeMap, track::Info>, - /// How long an unread track keeps its delivered groups spliced. + /// How long an unread track stays before it is forgotten. linger: Duration, /// The deadline last armed, so a step only re-arms on change. armed: Option, @@ -222,7 +230,7 @@ pub(super) struct Front { impl Front { /// A front with no source and no tracks; `linger` is how long an unread - /// track keeps its delivered groups spliced. + /// track stays before it is forgotten. pub(super) fn new(linger: Duration) -> Self { Self { identity: Identity::Undetermined, @@ -278,15 +286,17 @@ impl Front { Event::SourceClosed { source } => self.source_closed(source, &mut actions), // A fresh logical track, even under a name served before: an earlier // verdict belonged to that request, and a later one asks afresh. The - // broadcast's track metadata is what persists. - Event::TrackAssigned { track } => { + // broadcast's track metadata is what persists. Unread until a reader + // shows up, so a lookup whose reader left early is forgotten too. + Event::TrackAssigned { track, now } => { self.tracks.insert( track, Track { - state: TrackState::Idle, + state: TrackState::Parked { since: now }, refused: HashSet::new(), refusal: None, used: false, + ended: false, }, ); } @@ -306,6 +316,9 @@ impl Front { Event::Used { track } => self.used(track, &mut actions), Event::Unused { track, now } => self.unused(track, now, &mut actions), Event::Deadline { now } => self.deadline(now, &mut actions), + Event::Forgotten { track } => { + self.tracks.remove(&track); + } Event::Closed => self.end(Error::Dropped, &mut actions), } if !self.ended { @@ -406,7 +419,7 @@ impl Front { } // A read track is never parked, so idle is the only state to re-query. for (name, track) in &mut self.tracks { - if track.used && track.state == TrackState::Idle { + if track.used && !track.ended && track.state == TrackState::Idle { track.state = TrackState::Querying { source }; actions.push(Action::Query { track: name.clone(), @@ -519,11 +532,11 @@ impl Front { return; } match result { - // Over for good: the track leaves the machine, so a name served again - // later starts afresh and a long-lived front does not keep a stub per - // name it ever served. + // Over for good: readers drain what it delivered, and it is forgotten + // once unread for the linger, like any other track. Ok(()) => { - self.tracks.remove(&name); + track.state = TrackState::Idle; + track.ended = true; actions.push(Action::Finish { track: name }); } // Died mid-serve after delivering: normal failover, re-splice from @@ -560,12 +573,12 @@ impl Front { return; }; let track = self.tracks.get_mut(&name).expect("dispatching a known track"); - if !track.used || track.state != TrackState::Idle { + if !track.used || track.ended || track.state != TrackState::Idle { return; } if track.refused.contains(&source) { let err = track.refusal.clone().unwrap_or(Error::NotFound); - self.tracks.remove(&name); + track.ended = true; actions.push(Action::Abort { track: name, err }); return; } @@ -594,11 +607,11 @@ impl Front { track.used = false; match track.state { // A local source keeps its own cache, so a warm copy would only be a staler - // duplicate of it: drop the copy outright, and a returning reader re-splices - // the source and reads its cache against the real live edge. + // duplicate of it: forget the track outright, and a returning reader + // re-splices the source and reads its cache against the real live edge. TrackState::Spliced { .. } if self.identity == Identity::Local => { track.state = TrackState::Idle; - actions.push(Action::Release { track: name }); + actions.push(Action::Forget { track: name }); } // Drop the copy so the source goes idle at once; the delivered // groups stay spliced for the linger. @@ -606,21 +619,22 @@ impl Front { track.state = TrackState::Parked { since: now }; actions.push(Action::Park { track: name }); } - TrackState::Querying { .. } => track.state = TrackState::Idle, - _ => {} + TrackState::Parked { .. } => {} + TrackState::Idle | TrackState::Querying { .. } => track.state = TrackState::Parked { since: now }, } } fn deadline(&mut self, now: Instant, actions: &mut Vec) { // The driver's deadline fired and cleared itself: re-arm whatever is still - // parked, even if nothing was due yet, or it would never be released. + // parked, even if nothing was due yet, or it would never be forgotten. + // Idle until the driver confirms: a reader that arrived first keeps it. self.armed = None; for (name, track) in &mut self.tracks { if let TrackState::Parked { since } = track.state && since + self.linger <= now { track.state = TrackState::Idle; - actions.push(Action::Release { track: name.clone() }); + actions.push(Action::Forget { track: name.clone() }); } } } @@ -705,13 +719,23 @@ mod tests { }), &[Action::Resolve], ); - assert_actions(front.step(Event::TrackAssigned { track: name("video") }), &[]); + let t0 = Instant::now(); assert_actions( - front.step(Event::Used { track: name("video") }), - &[Action::Query { + front.step(Event::TrackAssigned { track: name("video"), - source, - }], + now: t0, + }), + &[Action::Arm { at: Some(t0 + LINGER) }], + ); + assert_actions( + front.step(Event::Used { track: name("video") }), + &[ + Action::Query { + track: name("video"), + source, + }, + Action::Arm { at: None }, + ], ); assert_actions( front.step(Event::TrackInfo { @@ -970,7 +994,10 @@ mod tests { #[test] fn a_source_refusing_a_track_aborts_it_and_nothing_else() { let mut front = serving(remote(1, 10), 100); - front.step(Event::TrackAssigned { track: name("audio") }); + front.step(Event::TrackAssigned { + track: name("audio"), + now: Instant::now(), + }); front.step(Event::Used { track: name("audio") }); assert_actions( front.step(Event::TrackInfo { @@ -990,7 +1017,10 @@ mod tests { #[test] fn a_closing_source_refusal_is_not_a_verdict() { let mut front = serving(remote(1, 10), 100); - front.step(Event::TrackAssigned { track: name("audio") }); + front.step(Event::TrackAssigned { + track: name("audio"), + now: Instant::now(), + }); front.step(Event::Used { track: name("audio") }); assert_actions( front.step(Event::TrackInfo { @@ -1100,7 +1130,7 @@ mod tests { } #[test] - fn unread_track_parks_then_releases_after_the_linger() { + fn unread_track_parks_then_is_forgotten_after_the_linger() { let mut front = serving(remote(1, 10), 100); let t0 = Instant::now(); assert_actions( @@ -1123,9 +1153,26 @@ mod tests { // nothing re-arms. assert_actions( front.step(Event::Deadline { now: t0 + LINGER }), - &[Action::Release { track: name("video") }], + &[Action::Forget { track: name("video") }], + ); + assert_actions(front.step(Event::Forgotten { track: name("video") }), &[]); + assert!(!front.tracks.contains_key(&name("video"))); + } + + /// A reader that looked the track up before the driver could forget it keeps + /// it: the driver feeds `Used` instead of `Forgotten`, and the track re-splices. + #[test] + fn a_reader_racing_the_forget_keeps_the_track() { + let mut front = serving(remote(1, 10), 100); + let t0 = Instant::now(); + front.step(Event::Unused { + track: name("video"), + now: t0, + }); + assert_actions( + front.step(Event::Deadline { now: t0 + LINGER }), + &[Action::Forget { track: name("video") }], ); - // A returning reader re-splices from the serving source. assert_actions( front.step(Event::Used { track: name("video") }), &[Action::Query { @@ -1135,6 +1182,100 @@ mod tests { ); } + /// A finished track stays for readers still draining it and for the linger + /// after the last one, is never spliced again, and is then forgotten. + #[test] + fn a_finished_track_lingers_then_is_forgotten() { + let mut front = serving(remote(1, 10), 100); + assert_actions( + front.step(Event::TrackEnded { + track: name("video"), + source: 100, + closing: false, + result: Ok(()), + delivered: true, + }), + &[Action::Finish { track: name("video") }], + ); + let t0 = Instant::now(); + assert_actions( + front.step(Event::Unused { + track: name("video"), + now: t0, + }), + &[Action::Arm { at: Some(t0 + LINGER) }], + ); + // A returning reader reads what it holds: no query, and the linger waits. + assert_actions( + front.step(Event::Used { track: name("video") }), + &[Action::Arm { at: None }], + ); + // A replacement source does not re-splice it either. + front.step(Event::Selected { + best: Some(remote(2, 10)), + serving_closing: false, + }); + assert_actions( + front.step(Event::Resolved { + route: 2, + result: Ok(200), + }), + &[Action::Detach { source: 100 }], + ); + let t1 = t0 + LINGER; + front.step(Event::Unused { + track: name("video"), + now: t1, + }); + assert_actions( + front.step(Event::Deadline { now: t1 + LINGER }), + &[Action::Forget { track: name("video") }], + ); + front.step(Event::Forgotten { track: name("video") }); + assert!(!front.tracks.contains_key(&name("video"))); + } + + /// An aborted track lingers the same way rather than staying for the life of + /// the front. + #[test] + fn an_aborted_track_lingers_then_is_forgotten() { + let mut front = serving(remote(1, 10), 100); + front.step(Event::TrackEnded { + track: name("video"), + source: 100, + closing: false, + result: Err(Error::Dropped), + delivered: false, + }); + let t0 = Instant::now(); + assert_actions( + front.step(Event::Unused { + track: name("video"), + now: t0, + }), + &[Action::Arm { at: Some(t0 + LINGER) }], + ); + assert_actions( + front.step(Event::Deadline { now: t0 + LINGER }), + &[Action::Forget { track: name("video") }], + ); + front.step(Event::Forgotten { track: name("video") }); + assert!(!front.tracks.contains_key(&name("video"))); + } + + /// A local source keeps its own cache, so an unread track is forgotten at once. + #[test] + fn an_unread_local_track_is_forgotten_at_once() { + let mut front = serving(local(1), 100); + assert_actions( + front.step(Event::Unused { + track: name("video"), + now: Instant::now(), + }), + &[Action::Forget { track: name("video") }], + ); + } + #[test] fn a_returning_reader_cancels_the_linger() { let mut front = serving(remote(1, 10), 100); @@ -1155,11 +1296,21 @@ mod tests { ); } + /// A track whose reader left before the front saw it is never spliced, and is + /// forgotten after the linger rather than kept as a stub. #[test] fn unread_track_is_never_spliced() { let mut front = serving(remote(1, 10), 100); - assert_actions(front.step(Event::TrackAssigned { track: name("audio") }), &[]); - assert_eq!(front.tracks[&name("audio")].state, TrackState::Idle); + let t0 = Instant::now(); + front.step(Event::TrackAssigned { + track: name("audio"), + now: t0, + }); + assert_eq!(front.tracks[&name("audio")].state, TrackState::Parked { since: t0 }); + assert_actions( + front.step(Event::Deadline { now: t0 + LINGER }), + &[Action::Forget { track: name("audio") }], + ); } #[test] @@ -1237,7 +1388,10 @@ mod tests { }), }, Event::SourceClosed { source: 100 }, - Event::TrackAssigned { track: name("v") }, + Event::TrackAssigned { + track: name("v"), + now: t0, + }, Event::Used { track: name("v") }, Event::Unused { track: name("v"), @@ -1263,6 +1417,7 @@ mod tests { delivered: false, }, Event::Deadline { now: t0 + LINGER }, + Event::Forgotten { track: name("v") }, ]; fn walk(front: &Front, alphabet: &[Event], depth: usize, sequences: &mut usize) { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a93764e92e..2f8a2a944d 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1996,7 +1996,8 @@ impl Drop for DriverState { /// Within the window a returning viewer, or the next of a run of back-to-back /// fetches, reads the groups the front already cached: no second round trip for /// `TRACK_INFO`. Groups past that cached edge cost a fresh source splice. After -/// the window, the cached segment is released. +/// the window the track is forgotten, finished and aborted ones included, so a +/// long-lived broadcast only holds the tracks read recently. /// /// Sized above the fetch cadence of a segmented consumer: HLS polls every /// `TARGETDURATION` seconds, commonly 6 or 10, so a shorter window would drop the @@ -2203,6 +2204,18 @@ struct TrackIo { used: bool, } +impl TrackIo { + /// Let go of every source handle once the logical track ended: its segments keep + /// what readers drain, and only its demand is still watched, until it is forgotten. + fn end(&mut self) { + self.staged = None; + self.query = None; + self.copy = None; + self.warm = None; + self.head = None; + } +} + /// Drives one front: feeds the world's events to a [`Front`] and performs the /// actions it returns, until the front ends. The decisions live in the machine; /// this only waits and executes, so nothing here decides anything twice. @@ -2451,24 +2464,33 @@ async fn run_front(task: FrontTask) { } io.warm = warm; } - Action::Release { track: name } => { - let Some(io) = tracks.get_mut(&name) else { continue }; - // A local source releases straight from the spliced copy. - io.copy = None; - io.warm = None; - io.head = None; - if io.resume.release().is_err() { - tracks.remove(&name); + Action::Forget { track: name } => { + // A reader that looked the track up since the machine decided keeps + // it. Feed its `Used` edge here: the demand poll only sees the + // current level, so a reader gone before the next poll would + // otherwise leave the track unread with no linger armed. + if let Some(io) = tracks.get_mut(&name) + && !broadcast.forget_spliced(&name, &io.resume) + { + if !io.used { + io.used = true; + events.push_back(Event::Used { track: name }); + } + continue; } + tracks.remove(&name); + events.push_back(Event::Forgotten { track: name }); } Action::Finish { track: name } => { - if let Some(mut io) = tracks.remove(&name) { + if let Some(io) = tracks.get_mut(&name) { + io.end(); let _ = io.resume.finish(); } } Action::Abort { track: name, err } => { - if let Some(mut io) = tracks.remove(&name) { + if let Some(io) = tracks.get_mut(&name) { tracing::debug!(name = %name, %err, "aborting track"); + io.end(); let _ = io.resume.abort(err); } } @@ -2577,7 +2599,10 @@ async fn run_front(task: FrontTask) { used: false, }, ); - Event::TrackAssigned { track: name } + Event::TrackAssigned { + track: name, + now: timers.now(), + } } Step::Resolved(route, result) => { upstream = None; @@ -5407,6 +5432,67 @@ mod tests { assert_eq!(&payload[..], b"snapshot"); } + /// A finished track stays readable from the front while it is read and for the + /// linger after, then leaves the broadcast so it stops pinning its cache: the next + /// reader asks the source afresh. + #[tokio::test(start_paused = true)] + async fn finished_track_is_forgotten_after_the_linger() { + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + + let track = resolved.track("catalog").unwrap(); + let subscribing = tokio::spawn(async move { track.subscribe(None).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source") + .expect("request"); + let source = request.resolving_start().accept(None); + let mut group = source.create_group(0u64.into()).unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"snapshot".as_ref()).unwrap(); + group.finish().unwrap(); + source.finish().unwrap(); + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + assert_eq!( + next_group(&mut subscription) + .await + .unwrap() + .expect("the catalog") + .sequence, + 0 + ); + assert!(next_group(&mut subscription).await.unwrap().is_none()); + drop(subscription); + drop(source); + + // Within the linger, a returning reader gets the finished track from the front. + let mut subscription = resolved.track("catalog").unwrap().subscribe(None).await.unwrap(); + assert_eq!( + next_group(&mut subscription) + .await + .unwrap() + .expect("the catalog") + .sequence, + 0 + ); + assert!(next_group(&mut subscription).await.unwrap().is_none()); + drop(subscription); + assert!( + tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .is_err(), + "a finished track within the linger asked the source again" + ); + + // Paused time runs the front's earlier deadline before this sleep returns. + tokio::time::sleep(TRACK_IDLE_LINGER).await; + + let track = resolved.track("catalog").unwrap(); + let _subscribing = tokio::spawn(async move { track.subscribe(None).await }); + tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the finished track outlived the linger") + .expect("request"); + } + /// A group that stays open for good (a JSON log in group 0) survives a park: the /// returning reader gets the frames delivered before it from the warm cache, and the /// re-splice asks the source only for the frames after them, across repeated parks. diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 0d9b8ba038..c7dd92e803 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -518,6 +518,11 @@ impl Producer { self.state.read().abort.is_some() } + /// Whether `other` produces the same logical track. + pub(crate) fn is_clone(&self, other: &Self) -> bool { + self.state.same_channel(&other.state) + } + /// Create a read handle for the logical track. pub fn consume(&self) -> Consumer { Consumer { From e9180387a273bbe34a83364244db56045b218f93 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 13:24:49 -0700 Subject: [PATCH 72/87] fix(auth): make grant expiry exact, dropping the clock-skew grace (#4368) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/auth.md | 6 ++--- doc/lib/rs/moq-auth.md | 4 ++-- rs/moq-auth/src/client.rs | 33 ++++++++++++++++++++------ rs/moq-auth/src/grant.rs | 50 +++++++++++++++++++++++---------------- rs/moq-auth/src/key.rs | 49 ++++++++++++++++++++++++++++---------- rs/moq-relay/src/auth.rs | 24 +++++++------------ 6 files changed, 105 insertions(+), 61 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 096a5159c8..e507a47e84 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -58,8 +58,8 @@ ingest here). A 2xx with a grant admits. A 401 or 403 refuses. Anything else at connect, a timeout, a 5xx, or an unparseable body, refuses and logs an error; nothing is admitted because the server was down. A grant that names nothing refuses, and one with `revalidate` but no `expires` is refused -as invalid. A grant already less than five seconds past `expires` is accepted -for the remainder of that clock-skew window; future expiries are unchanged. +as invalid, as is one whose `expires` is already at or before now: expiry is +exact, with no grace for clock skew, so keep the auth server's clock in sync. The client and relay snapshot each accepted grant's deadline on a monotonic clock, so later polls, outages, and wall-clock adjustments do not restart it. @@ -165,7 +165,7 @@ after which it can never sign a broader token. | `root` | Base path. Optional. | | `publish` | Patterns the bearer may publish under `root`. `**` means everything; omitted means no publishing. | | `subscribe` | Patterns the bearer may subscribe to under `root`. Same rules. | -| `exp`, `iat`, `nbf` | Expiry, issue time, and not-before. `exp` is enforced for the whole session, not just at connect, and a token is refused before its `nbf`. | +| `exp`, `iat`, `nbf` | Expiry, issue time, and not-before. `exp` is enforced for the whole session, not just at connect, and a token is refused from its `exp` on and before its `nbf`. | | `iss`, `sub`, `jti` | Read and ignored. | Any other claim refuses the token with its name, `aud` included: an unknown diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index 5f0de2a969..862868356c 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -13,8 +13,8 @@ Everything a party needs to ask for or answer an authorization on answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. -- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `mounts` that read a subtree from elsewhere on the origin, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. -- **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline: future expiries are unchanged, and one already less than five seconds late gets the remainder of that skew window. +- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `mounts` that read a subtree from elsewhere on the origin, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired; expiry is exact, with no grace for clock skew. +- **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline; an expiry already past is now. - **Client**: `Client::new(url, tls)` and `Client::connect(request)` drive a lease against an auth server over `https://`, `unix://`, or loopback `http://`: revalidate on cadence with jittered backoff through an outage until `expires`, revoke on a 401/403 or an invalid grant, and POST `end` with the reason, duration, and byte totals the session reported through `lease::Consumer::close` when it ended. Dropping the consumer reports zero bytes. `end.reason` is `dropped`, `expired`, `refused`, `invalid`, 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 from the `jwt` query or a type-0 SETUP token, verified against a key file or a `{kid}.jwk` directory (a token of another type, or both at once, is refused), an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. - **Keys**: generate HS256/384/512, RS256/384/512, PS256/384/512, ES256/384, or EdDSA keys as JWKs, with a `kid` for rotation and an optional immutable scope that caps every token the key signs. diff --git a/rs/moq-auth/src/client.rs b/rs/moq-auth/src/client.rs index 30dc8e991e..935e22c784 100644 --- a/rs/moq-auth/src/client.rs +++ b/rs/moq-auth/src/client.rs @@ -493,24 +493,43 @@ mod tests { } #[tokio::test] - async fn a_grant_within_clock_skew_stays_live() { + async fn an_expired_grant_is_refused() { tokio::time::pause(); let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); let client = clock_server(Log::default(), grant, false).await; + assert!(matches!(client.connect(request()).await, Err(Error::GrantExpired))); + } + + #[tokio::test] + async fn a_grant_closes_at_its_expiry() { + tokio::time::pause(); + // Whole seconds, as the grant crosses the wire, so the client sees this exact instant. + // An hour out, so a slow runner cannot expire it before `connect` answers. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let expires = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() + 3600); + let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); + grant.expires = Some(expires); + let client = clock_server(Log::default(), grant, false).await; + + // The client reads both clocks somewhere inside `connect`, so bracket it: the + // bounds hold however long it takes. + let (wall, tick) = (SystemTime::now(), tokio::time::Instant::now()); let consumer = client.connect(request()).await.unwrap(); + let earliest = tick + expires.duration_since(SystemTime::now()).unwrap(); + let latest = tokio::time::Instant::now() + expires.duration_since(wall).unwrap(); - tokio::time::sleep(Duration::from_millis(500)).await; + // A millisecond either side for Tokio's timer resolution. + let tolerance = Duration::from_millis(1); assert!( - tokio::time::timeout(Duration::from_millis(100), consumer.closed()) + tokio::time::timeout_at(earliest - tolerance, consumer.closed()) .await .is_err(), - "still live inside the skew window" + "live until its expiry" ); - - let reason = tokio::time::timeout(crate::grant::CLOCK_SKEW + Duration::from_secs(1), consumer.closed()) + let reason = tokio::time::timeout_at(latest + tolerance, consumer.closed()) .await - .expect("expired once the skew window ended"); + .expect("closed at its expiry, not later"); assert_eq!(reason, Reason::Expired); } diff --git a/rs/moq-auth/src/grant.rs b/rs/moq-auth/src/grant.rs index 0558b4e6dd..dd774fd52e 100644 --- a/rs/moq-auth/src/grant.rs +++ b/rs/moq-auth/src/grant.rs @@ -4,19 +4,6 @@ use serde_with::{DurationSeconds, TimestampSeconds, serde_as}; use std::collections::BTreeMap; use std::time::{Duration, SystemTime}; -/// A grant that expired this recently still stands: the auth server's clock may run behind. -pub(crate) const CLOCK_SKEW: Duration = Duration::from_secs(5); - -/// How long until `at`. A deadline up to [`CLOCK_SKEW`] in the past still has the -/// remaining window; anything older is zero. Future deadlines are unchanged, so a -/// grant that expires in ten seconds still expires in ten seconds. -fn until(at: SystemTime) -> Duration { - match at.duration_since(SystemTime::now()) { - Ok(remaining) => remaining, - Err(late) => CLOCK_SKEW.saturating_sub(late.duration()), - } -} - /// What a session may do, as the auth server answered. /// /// A 2xx carrying one of these admits; anything else refuses. A grant that names @@ -74,15 +61,15 @@ impl Grant { } } - /// Snapshot the expiry on Tokio's clock, allowing five seconds of past clock skew. + /// Snapshot the expiry on Tokio's clock; one already past is now. #[cfg(feature = "tokio")] pub fn deadline(&self) -> Option { - self.expires.map(|at| tokio::time::Instant::now() + until(at)) + let remaining = self.expires?.duration_since(SystemTime::now()).unwrap_or_default(); + Some(tokio::time::Instant::now() + remaining) } /// Refuse a grant that admits nothing, asks to be revalidated without a bound or - /// at no interval, or has already expired. A few seconds of clock skew are - /// tolerated so an auth server whose clock runs behind still admits. + /// at no interval, or has already expired. pub fn validate(&self) -> crate::Result<()> { if self.publish.is_empty() && self.subscribe.is_empty() { return Err(crate::Error::UselessGrant); @@ -94,7 +81,7 @@ impl Grant { if self.revalidate.is_some_and(|cadence| cadence.is_zero()) { return Err(crate::Error::ZeroRevalidate); } - if self.expires.is_some_and(|expires| until(expires).is_zero()) { + if self.expires.is_some_and(|expires| expires <= SystemTime::now()) { return Err(crate::Error::GrantExpired); } Ok(()) @@ -175,10 +162,11 @@ mod tests { grant.revalidate = Some(Duration::from_secs(1)); assert!(matches!(grant.validate(), Err(crate::Error::UnboundedRevalidate))); - grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); - grant.validate().unwrap(); + // Exact: no grace for an auth server whose clock runs behind. + grant.expires = Some(SystemTime::now()); + assert!(matches!(grant.validate(), Err(crate::Error::GrantExpired))); - grant.expires = Some(SystemTime::now() - CLOCK_SKEW - Duration::from_secs(1)); + grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); assert!(matches!(grant.validate(), Err(crate::Error::GrantExpired))); grant.expires = Some(SystemTime::now() + Duration::from_secs(60)); @@ -187,4 +175,24 @@ mod tests { grant.revalidate = Some(Duration::ZERO); assert!(matches!(grant.validate(), Err(crate::Error::ZeroRevalidate))); } + + #[cfg(feature = "tokio")] + #[tokio::test(start_paused = true)] + async fn deadline_is_the_exact_expiry() { + let start = tokio::time::Instant::now(); + let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); + assert_eq!(grant.deadline(), None); + + grant.expires = Some(SystemTime::now() + Duration::from_secs(10)); + let deadline = grant.deadline().unwrap(); + assert!(deadline <= start + Duration::from_secs(10), "not later than the expiry"); + assert!(deadline > start + Duration::from_secs(9)); + + grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); + assert_eq!( + grant.deadline(), + Some(start), + "a past expiry is now, not a grace window" + ); + } } diff --git a/rs/moq-auth/src/key.rs b/rs/moq-auth/src/key.rs index 903e32e427..1d9f766cb9 100644 --- a/rs/moq-auth/src/key.rs +++ b/rs/moq-auth/src/key.rs @@ -544,18 +544,7 @@ impl Key { let token = jsonwebtoken::decode::(token, decode, &validation)?; - let now = std::time::SystemTime::now(); - if let Some(exp) = token.claims.expires - && exp < now - { - return Err(crate::Error::TokenExpired); - } - if let Some(nbf) = token.claims.not_before - && nbf > now - { - return Err(crate::Error::TokenNotYetValid); - } - + validate_times(&token.claims, std::time::SystemTime::now())?; token.claims.validate()?; self.validate_scope(&token.claims)?; @@ -611,6 +600,17 @@ impl Key { } } +/// Refuse claims expired at `now` (`exp <= now`) or not yet valid (`nbf > now`), as `jose` does. +fn validate_times(claims: &Claims, now: std::time::SystemTime) -> crate::Result<()> { + if claims.expires.is_some_and(|exp| exp <= now) { + return Err(crate::Error::TokenExpired); + } + if claims.not_before.is_some_and(|nbf| nbf > now) { + return Err(crate::Error::TokenNotYetValid); + } + Ok(()) +} + /// Serialize bytes as base64url without padding fn serialize_base64url(bytes: &[u8], serializer: S) -> Result where @@ -1034,6 +1034,31 @@ mod tests { assert!(matches!(key.verify(&at(3600)), Err(crate::Error::TokenNotYetValid))); } + /// `exp` is refused at the instant itself and `nbf` accepted at it, matching `jose`. + #[test] + fn validate_times_at_the_boundary() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000); + let second = Duration::from_secs(1); + + let at = |expires: Option, not_before: Option| { + let mut claims = create_test_claims(); + claims.expires = expires; + claims.not_before = not_before; + validate_times(&claims, now) + }; + + assert!(at(Some(now + second), None).is_ok()); + assert!(matches!(at(Some(now), None), Err(crate::Error::TokenExpired))); + assert!(matches!(at(Some(now - second), None), Err(crate::Error::TokenExpired))); + + assert!(at(None, Some(now)).is_ok()); + assert!(at(None, Some(now - second)).is_ok()); + assert!(matches!( + at(None, Some(now + second)), + Err(crate::Error::TokenNotYetValid) + )); + } + #[test] fn test_key_verify_expired_token() { let key = create_test_key(); diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 018086750c..c124948c87 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -969,27 +969,19 @@ mod tests { assert_eq!(reason, lease::Reason::Expired); } - #[tokio::test] - async fn a_grant_within_clock_skew_stays_live() { - tokio::time::pause(); + #[tokio::test(start_paused = true)] + async fn an_expired_grant_ends_at_once() { use std::time::Duration; let mut grant = Grant::new(patterns(&["**"]), patterns(&["**"])); grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); - grant.validate().expect("accepted inside the skew window"); + assert!(matches!(grant.validate(), Err(moq_auth::Error::GrantExpired))); + + let start = tokio::time::Instant::now(); let mut lease = Lease::new("/room", lease::Consumer::fixed(grant)); - assert!( - tokio::time::timeout(Duration::from_secs(1), lease.ended()) - .await - .is_err(), - "still live inside the skew window" - ); - assert_eq!( - tokio::time::timeout(Duration::from_secs(4), lease.ended()) - .await - .expect("expired once the skew window ended"), - lease::Reason::Expired - ); + assert_eq!(lease.ended().await, lease::Reason::Expired); + // A millisecond for Tokio's timer resolution. + assert!(start.elapsed() <= Duration::from_millis(1), "no grace after expiry"); } #[test] From b43958c8568b678809c9fdde574304e0c29408f1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 13:33:02 -0700 Subject: [PATCH 73/87] fix(net): age out an ended JS track's groups, match Rust abort semantics (#4385) Co-authored-by: Claude Opus 5.5 --- js/net/src/broadcast.test.ts | 26 +++++++++ js/net/src/track.test.ts | 110 +++++++++++++++++++++++++++++++++++ js/net/src/track.ts | 71 +++++++++++++++++----- 3 files changed, 193 insertions(+), 14 deletions(-) diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 1a0c019c42..28d202c48c 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -109,6 +109,32 @@ test("concurrent dynamic producers share a sequence namespace", async () => { broadcast.close(); }); +test("a sibling producer's groups do not settle an aborted end", async () => { + const broadcast = new BroadcastProducer(); + const firstSubscriber = broadcast.track("media").subscribe(); + const secondSubscriber = broadcast.track("media").subscribe(); + const firstRequest = await wireOf(broadcast).requested(); + const secondRequest = await wireOf(broadcast).requested(); + if (!firstRequest || !secondRequest) throw new Error("expected requests"); + const firstProducer = firstRequest.accept(); + const secondProducer = secondRequest.accept(); + + const reader = firstProducer.subscribe(); + firstProducer.finishAt(3); + for (let i = 0; i < 3; i++) secondProducer.appendGroup().close(); + const boom = new Error("boom"); + firstProducer.close(boom); + + // The first producer never received the groups its end promised, so it was cut off. + expect(firstProducer.closed.peek()).toBe(boom); + await expect(reader.recvGroup()).rejects.toBe(boom); + + firstSubscriber.close(); + secondSubscriber.close(); + secondProducer.close(); + broadcast.close(); +}); + test("closing a broadcast rejects a dequeued request", async () => { const broadcast = new BroadcastProducer(); const subscriber = broadcast.track("media").subscribe(); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index e3ca4517ff..c8c516b67e 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1687,3 +1687,113 @@ test("finished rejects when the track aborts or closes without an end", async () clean.close(); expect(await reader.finished()).toBe(1); }); + +test("an abort keeps finished groups for a slow reader, then reports it", async () => { + const producer = new TrackProducer("test").accept({ maxAge: Milli(10_000) }); + const arrival = producer.subscribe({ maxAge: Milli(10_000) }); + const ordered = producer.subscribe({ maxAge: Milli(10_000) }).ordered(); + for (let i = 0; i < 2; i++) { + const group = producer.appendGroup(); + group.writeString(`g${i}`); + group.close(); + } + producer.appendGroup().writeString("open"); + const boom = new Error("boom"); + producer.close(boom); + + for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { + for (let i = 0; i < 2; i++) { + const group = await next(); + expect(group?.sequence).toBe(i); + expect(await group?.readString()).toBe(`g${i}`); + } + // The open group nobody will finish is not handed out. + await expect(next()).rejects.toBe(boom); + } +}); + +test("an abort after the declared end settles ends clean", async () => { + const producer = new TrackProducer("test").accept({ maxAge: Milli(10_000) }); + const arrival = producer.subscribe({ maxAge: Milli(10_000) }); + const ordered = producer.subscribe({ maxAge: Milli(10_000) }).ordered(); + for (let i = 0; i < 2; i++) { + const group = producer.appendGroup(); + group.writeString(`g${i}`); + group.close(); + } + producer.finishAt(2); + producer.close(new Error("boom")); + + for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { + expect((await next())?.sequence).toBe(0); + expect((await next())?.sequence).toBe(1); + expect(await next()).toBeUndefined(); + } +}); + +test("an abort after the declared end reached by a datagram ends clean", () => { + const producer = new TrackProducer("test").accept(); + producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("d")); + producer.finishAt(1); + producer.close(new Error("boom")); + expect(producer.closed.peek()).toBeNull(); +}); + +test("an ended track's buffered groups age out for a stale subscriber", () => { + // Nothing writes after the close, so only the prune wakeup can reclaim them. Stub the + // timers so the test fires exactly the wakeups still armed, at a mocked time. + const clock = mockMonotonicTime(10_000); + const realSet = globalThis.setTimeout; + const realClear = globalThis.clearTimeout; + const armed = new Map void>(); + // @ts-expect-error a stub, not a full setTimeout + globalThis.setTimeout = (fn: () => void) => { + const handle = { unref: () => {} }; + armed.set(handle, fn); + return handle; + }; + // @ts-expect-error a stub, not a full clearTimeout + globalThis.clearTimeout = (handle: object) => armed.delete(handle); + + try { + for (const abort of [undefined, new Error("boom")]) { + clock.set(10_000); + armed.clear(); + const producer = new TrackProducer("test").accept({ maxAge: Milli(30) }); + const stale = producer.subscribe({ maxAge: Milli(30) }); + const group = producer.appendGroup(); + group.writeString("x"); + group.close(); + producer.close(abort); + + clock.set(10_100); + for (const [handle, fire] of [...armed]) { + armed.delete(handle); + fire(); + } + // Nothing buffered is left: the subscriber sees only how the track ended. + if (abort) expect(() => stale.tryRecvGroup()).toThrow(abort); + else expect(stale.tryRecvGroup()).toBeUndefined(); + } + } finally { + globalThis.setTimeout = realSet; + globalThis.clearTimeout = realClear; + clock.restore(); + } +}); + +test("an abort leaves a group already taken readable, then reports the abort", async () => { + const producer = new TrackProducer("test").accept({ maxAge: Milli(10_000) }); + const arrival = producer.subscribe({ maxAge: Milli(10_000) }); + const ordered = producer.subscribe({ maxAge: Milli(10_000) }).ordered(); + producer.appendGroup().writeString("held"); + const held = [await arrival.recvGroup(), await ordered.nextGroup()]; + const boom = new Error("boom"); + producer.close(boom); + + for (const group of held) { + expect(group?.sequence).toBe(0); + expect(await group?.readString()).toBe("held"); + await expect(group?.readFrame()).rejects.toBe(boom); + } +}); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index dc6e8747ec..9f406cd55d 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -426,6 +426,10 @@ export class Producer { // read mirrored sinks, never this state directly. #state = new TrackState(); #sequence: TrackSequence = { next: 0 }; + // One past the highest group or datagram this producer received, like the Rust + // `max_sequence`. The shared counter above can run ahead of it: sibling producers of + // the same track advance it too. + #received = 0; // Recently written source groups, retained for replay to late subscribers and // pruned once idle for longer than the cache window. Each entry tracks the mirror @@ -565,6 +569,15 @@ export class Producer { forward(); this.#sinks.delete(sink); this.#updateSubscription(); + // Update demand: once the last subscriber leaves, the consumer wire (watching + // {@link unused}) tears the upstream down instead of downloading to nobody. + this.#used.set(this.#sinks.size > 0); + // The producer closing every sink keeps its mirrors tracked, so what the sink + // still buffers ages out with the cache instead of staying pinned. + if (this.#state.closed.peek() !== undefined) { + dispose(); + return; + } for (const entry of this.#cache) { const mirror = entry.mirrors.get(sink); if (mirror) { @@ -574,10 +587,6 @@ export class Producer { } for (const group of sink.groups.peek()) group.close(abort); dispose(); - - // Update demand: once the last subscriber leaves, the consumer wire (watching - // {@link unused}) tears the upstream down instead of downloading to nobody. - this.#used.set(this.#sinks.size > 0); }); } @@ -619,19 +628,32 @@ export class Producer { // drained it. The usual case, an already-closed group aging out, keeps its own // terminal state. if (!entry.group.isClosed) entry.group.close(new TooFarBehind()); + const mirrors = [...entry.mirrors.values()]; + for (const mirror of mirrors) hooks.evictGroup(mirror); + this.#unlink(entry); + for (const mirror of mirrors) mirror.close(); + } + + // Take a cached group's mirrors out of every sink, so a subscriber can no longer + // receive them. A reader already holding one keeps it as is. + #unlink(entry: CachedGroup): void { for (const [sink, mirror] of entry.mirrors) { - hooks.evictGroup(mirror); sink.groups.mutate((groups) => { const i = groups.indexOf(mirror); if (i >= 0) groups.splice(i, 1); }); const index = timelineIndex(sink.timeline, mirror.sequence); if (sink.timeline[index] === mirror) sink.timeline.splice(index, 1); - mirror.close(); } entry.mirrors.clear(); } + // Take a cached group out of the cache lookups. + #uncache(entry: CachedGroup): void { + this.#cache.splice(this.#cache.indexOf(entry), 1); + this.#cached.delete(entry.group.sequence); + } + // The one group retention never takes: the newest, while it is still open. That is // the live edge a publisher is appending to, and a track may legitimately keep it // open across a long quiet stretch (a catalog snapshot, a JSON stream). Every other @@ -685,9 +707,9 @@ export class Producer { if (oldest !== undefined) this.#wake(Math.max(oldest + maxAgeMs, now + slice)); } - // Arm the prune wakeup for `at`, unless one is already armed sooner. + // Arm the prune wakeup for `at`, unless one is already armed sooner. Kept after a close, + // until the cache empties, so what the track left behind still ages out. #wake(at: number): void { - if (this.#state.closed.peek() !== undefined) return; if (this.#pruneTimer !== undefined && this.#pruneTimerAt <= at) return; clearTimeout(this.#pruneTimer); @@ -710,6 +732,7 @@ export class Producer { const entry: CachedGroup = { group, mirrors: new Map() }; this.#cache.push(entry); this.#cached.set(group.sequence, entry); + this.#received = Math.max(this.#received, group.sequence + 1); for (const sink of this.#sinks) this.#mirror(entry, sink); // Give held mirrors the new live edge before pruning their timeline entry, // so their latency guard can preserve a terminal expiry verdict. @@ -753,8 +776,7 @@ export class Producer { throw new Error(`duplicate group: sequence=${group.sequence}`); } this.#evict(existing); - this.#cache.splice(this.#cache.indexOf(existing), 1); - this.#cached.delete(group.sequence); + this.#uncache(existing); } // Only advance the shared counter upward (for appendGroup auto-increment). @@ -769,6 +791,7 @@ export class Producer { // Fan a datagram out to every live subscriber, dropping the oldest once the ring is full. // Late subscribers do NOT replay old datagrams (best-effort, unlike the group cache). #publishDatagram(datagram: Datagram): void { + this.#received = Math.max(this.#received, datagram.sequence + 1); for (const sink of this.#sinks) { sink.datagrams.mutate((list) => { if (list.length === MAX_DATAGRAMS) list.shift(); @@ -848,21 +871,41 @@ export class Producer { * Close the track and every subscriber, mirroring the abort to their groups. Idempotent. * * A clean close keeps the end {@link finishAt} declared, or declares one past the highest - * sequence produced; an abort ends without one. + * sequence produced; an abort ends without one. Subscribers still draining get the + * finished groups first, then the end or the abort. An abort after the declared end + * settled (reached, with every group below it finished) is a clean close. The groups + * left behind still age out after the track's `maxAge`, so a stale subscriber can't pin + * them. */ close(abort?: Error) { - if (abort === undefined && this.#state.closed.peek() === undefined && this.#state.final.peek() === undefined) { + if (this.#state.closed.peek() !== undefined) return; + if (abort && this.#settled()) abort = undefined; + if (abort === undefined && this.#state.final.peek() === undefined) { this.#declareFinal(this.#sequence.next); } + // Nobody will finish these, so a subscriber that has not taken one yet never sees it. + // Not evicted: a reader already holding one keeps its frames and sees the abort. + const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : []; closeTrackState(this.#state, abort); - clearTimeout(this.#pruneTimer); - this.#pruneTimer = undefined; for (const { group } of this.#cache) group.close(abort); + for (const entry of open) { + this.#unlink(entry); + this.#uncache(entry); + } for (const sink of this.#sinks) { for (const group of sink.groups.peek()) group.close(abort); closeTrackState(sink, abort); } this.#sinks.clear(); + this.#prune(); + } + + // Whether the declared end was reached and every cached group below it finished, so + // the track already holds everything it promised. Mirrors the Rust `is_settled`. + #settled(): boolean { + const final = this.#state.final.peek(); + if (final === undefined || this.#received < final) return false; + return this.#cache.every(({ group }) => group.sequence >= final || group.closed.peek() === null); } /** Append a frame as its own single-frame group. */ From 3ea70022f8a19ba6db5ecd997d6a3c6fe2de8041 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 14:08:41 -0700 Subject: [PATCH 74/87] quest: watch refusal, auth outage clock, relay auth client CA (#4367) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 3 ++ quest/m1/auth-outage-clock.md | 63 ++++++++++++++++++++++++++++++++ quest/m1/relay-auth-client-ca.md | 37 +++++++++++++++++++ quest/m1/watch-refusal.md | 38 +++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 quest/m1/auth-outage-clock.md create mode 100644 quest/m1/relay-auth-client-ca.md create mode 100644 quest/m1/watch-refusal.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 93cdfc7aad..d0d397df16 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -31,6 +31,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport - [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one - [Live in apps](/quest/m1/announce-live-apps.md) - the demo and `@moq/room` show "no broadcasts" from the `live` marker, which waits for the first session on page load +- [Watch refusal](/quest/m1/watch-refusal.md) - `` shows an origin refusal as an error instead of sitting offline - [kio waiter overflow](/quest/m1/kio-waiter-lost.md) - a retained `Waiter` past 8 lists stops adding a duplicate entry to lists it already recorded - [Capture re-anchor](/quest/m1/capture-reanchor.md) - a repeating or restarting device clock never rewinds native capture during a fast backlog drain - [Splice edge cases](/quest/m1/splice-edges.md) - an unstamped successor, a pruned segment's boundary group, and a warm head during a takeover are each handled correctly @@ -38,6 +39,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Session death parity](/quest/m1/session-death.md) - a local close ends tracks cleanly in both languages, and JS group readers see the session's error on session death - [moqsrc stop](/quest/m1/moqsrc-stop.md) - moqsrc's stop blocks until its session ends, without deadlocking on a blocked pad push - [More tests under load](/quest/m1/test-flakes-2.md) - the second round of load-only failures, fixed at the cause +- [Auth outage clock](/quest/m1/auth-outage-clock.md) - the relay and moq-auth outage tests run on a paused clock again and assert both bounds of `expires` - [Interop contention](/quest/m1/interop-contention.md) - two `just test interop --all` matrices pass side by side, and `just test harness` runs from a clean checkout - [UnknownSession log flood](/quest/m1/unknown-session-logs.md) - streams reset before their WebTransport header stop being reported as UnknownSession at WARN - [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main @@ -54,6 +56,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Capture control](/quest/m1/capture-control.md) - on dev, `encode::Capture` replaces `CaptureOptions` without a `clock` field (it reads the catalog's), an unsupported `cut()` errors, and dropping the last `Control` cancels in-flight opens - [Video surface](/quest/m1/video-surface.md) - on dev, moq-ffi's `native` becomes `surface`, refused on platforms with no surface - [HLS discontinuity sequence](/quest/m1/hls-discontinuity-sequence.md) - on dev, `Segment::discontinuity` is the absolute sequence, so every cursor agrees +- [Auth client CA](/quest/m1/relay-auth-client-ca.md) - on dev, `auth::Config::validate` and `init` take the client-CA flag, so no caller can skip the check - [RTMP TLS only](/quest/m1/rtmp-tls-only.md) - an RTMP listener configured for TLS can refuse plaintext instead of sniffing and serving it - [HLS linger](/quest/m1/hls-linger.md) - `moq_hls::Server` serves an ended broadcast for its playlist window plus grace, so the moq.pro edge drops its own pool - [Remove live()](/quest/m1/remove-live.md) - on dev, importers publish stream timestamps verbatim, the catalog clock maps them to wall time, and an encoder restart becomes a new epoch diff --git a/quest/m1/auth-outage-clock.md b/quest/m1/auth-outage-clock.md new file mode 100644 index 0000000000..43c923a8b3 --- /dev/null +++ b/quest/m1/auth-outage-clock.md @@ -0,0 +1,63 @@ +# [M] Auth outage tests on a paused clock + +## Goal + +The moq-relay and moq-auth outage tests run on tokio's paused clock again and +assert both bounds: a session (or grant) survives an auth outage until its +`expires`, and closes at `expires`, not later. No wall-clock sleeps, no +widened timeouts, and no dependence on how fast the OS delivers loopback. + +## Plan + +- The tests: `an_outage_keeps_the_session_until_expires` in + `rs/moq-relay/tests/auth_lifetime.rs` + ([#4244](https://github.com/moq-dev/moq/pull/4244)) and + `an_outage_keeps_the_grant_until_expires` in `rs/moq-auth/src/client.rs` + ([#4291](https://github.com/moq-dev/moq/pull/4291)). Both moved to the real + clock because a paused clock auto-advances while the runtime waits on a + real socket, so a virtual timer fired before macOS delivered loopback. That + swapped one violation of "unit tests mock time" for another, and #4244 + dropped the upper bound. Read both PR descriptions: they list what was + tried and why it failed (restoring a listener probe, pausing after setup, + waiting on the log). +- The race is real sockets under virtual time, so fix it by taking the + sockets out of these tests. Look at what the codebase already offers + before building anything: `rs/moq-net/tests/support/mock.rs` (an in-memory + session pair), `moq_relay::auth::Auth::embedded` with its `Admissions` + (decides leases in-process), and the lease driver in moq-auth, which could + be exercised against an in-process answer source instead of HTTP. If the + relay's `Connection` or moq-auth's `Client` cannot take such a transport, + prefer the small seam that lets them over a test-only shim. +- Decide where each assertion belongs. The outage semantics (a 503 keeps the + grant until `expires`) are moq-auth's; the relay test may only need to show + that a lease reaching `expires` closes the session as `Expired` and reports + `end`. Don't keep two tests proving the same thing. +- Measure against tokio's clock, not `SystemTime`: the grant still carries a + wall-clock `expires`, so pin how it maps onto the paused clock. +- Other paused-clock tests touch real sockets and would share the hazard + once a timeout lands on their path. #4291's audit named + `a_grant_within_clock_skew_stays_live` (moq-auth) and + `fixed_addresses_keep_tls_name_and_request_host` (moq-tokio websocket); + moq-auth's `clock_server` helper exists only to keep axum on the paused + clock. Move those onto the same seam if it is cheap. +- Prove it: loop the tests with every core loaded, on macOS if available, + and mutate the deadline both ways (close early, close late) to see each + bound fail. + +Public API: none unless a transport seam is needed; report it if so. Wire: +none. + +## Related + +- [More tests under load](/quest/m1/test-flakes-2.md) - the same rule + applied to other load-only failures +- [moq-shaper virtual time](/quest/m1/shaper-virtual-time.md) - the same + paused-clock-versus-real-socket fight in moq-shaper +- [#4280](https://github.com/moq-dev/moq/pull/4280) - moq-archive and + moq-hls tests poll with real-clock sleeps, on the archive track-timeline + line +- [#4281](https://github.com/moq-dev/moq/pull/4281) - OBS `WaitFor` polling, + on the C++ line +- [Nightly 2026-09-26](https://github.com/moq-dev/moq/actions/runs/36240326747/job/108399481809) - + the macOS relay tarball job failed this test with "publisher connect + timeout", before #4244 landed diff --git a/quest/m1/relay-auth-client-ca.md b/quest/m1/relay-auth-client-ca.md new file mode 100644 index 0000000000..5393c341df --- /dev/null +++ b/quest/m1/relay-auth-client-ca.md @@ -0,0 +1,37 @@ +# [S] moq-relay auth validate takes the client-CA flag + +## Goal + +On dev, no caller of `moq_relay::auth::Config` can start with `--auth-public` +rules alongside a listener TLS client CA by forgetting a check. +`Config::validate` takes the client-CA flag, `init` requires it too, and +`validate_client_ca` is gone. The `moq` CLI fails loud on an invalid auth +config instead of quietly refusing every session. + +## Plan + +- [#4364](https://github.com/moq-dev/moq/pull/4364) adds an additive + `Config::validate_client_ca(&self, client_ca: bool)` that `Relay::load` and + the CLI must each remember to call; the CLI missing the original check is + the bug it fixes. Fold it into `validate(&self, client_ca: bool)` so every + caller has to answer, and have `init` take the same answer so a caller that + skips `validate` still cannot start. `init` today only receives the + outbound auth TLS, so the listener's client-CA answer is a new input; its + shape (a bool, or the listener TLS config) is open. Prefer whatever makes + the wrong call unrepresentable. +- `spawn_server` in `rs/moq-cli/src/main.rs` maps any `auth.validate()` error + to `Auth::refuse`. That fallback is only right for a LAN-only mesh with no + auth configured, which `MoqSide::validate` permits and whose peers admit + through the cluster. Make that case explicit and let any other error stop + startup. +- Update every caller, the tests #4364 added in both `moq-cli` and + `moq-relay`, and `doc/bin/relay/auth.md` or `doc/lib/rs` wherever they name + the methods. + +Public API: breaks `moq_relay::auth::Config::validate` and `init`, removes +`validate_client_ca`, so this targets `dev`. Wire: none. + +## Required + +- #4364 merged to `main` +- `dev` has merged `main` after #4364 lands diff --git a/quest/m1/watch-refusal.md b/quest/m1/watch-refusal.md new file mode 100644 index 0000000000..4412cf6113 --- /dev/null +++ b/quest/m1/watch-refusal.md @@ -0,0 +1,38 @@ +# [S] Watch shows a refusal + +## Goal + +When the origin refuses the broadcast `` asks for, the player shows +that refusal as an error instead of sitting offline as if nothing was +published yet. Refusal stays terminal, as +[#4230](https://github.com/moq-dev/moq/pull/4230) made it in `@moq/net` to +match Rust moq-net: the player never re-asks a handler that already said no. + +## Plan + +- The gap is Codex's P1 on #4230 + ([r4109909244](https://github.com/moq-dev/moq/pull/4230#discussion_r4109909244)): + `js/watch/src/broadcast.ts` only watches `request.active`, so after a + `dynamic()` handler refuses, the request closes with an error that nobody + reads and `active` stays `undefined` forever. +- Observe `Requesting.closed` and carry the error into the broadcast's + state. Whether that is a new `"error"` status, a separate error signal, or + both is open; mirror how the element already surfaces other terminal + states, such as the unsupported indicator. Keep the error's message so the + UI can say why. `unroutable` is also true for a path nothing serves yet, so + it cannot tell a refusal from offline. +- What clears the error is part of the design: a fresh request (a new + `name` or origin, or re-enabling) should be the only way back. No retry + loop. +- Cover both the announced and unannounced paths in `#runBroadcast`. +- Show it in the UI, and update `demo/web` if it consumes the status. Add a + test in `js/watch` where a `dynamic()` handler refuses and the broadcast + reports the error. +- Update `doc/` wherever the watch status values are documented. + +Public API: likely additive (a new status value or error signal on the watch +broadcast and element). Wire: none. + +## Related + +- [#4230](https://github.com/moq-dev/moq/pull/4230) - made JS refusals terminal From dd8940698b3e50765404f24e52a34631c1d48e53 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 14:48:12 -0700 Subject: [PATCH 75/87] chore(quest): move the local origin to moq.pro (#4396) Co-authored-by: Claude Opus 5.5 --- quest/README.md | 6 +++--- quest/m0/README.md | 9 ++++----- quest/m0/local-origin.md | 36 ------------------------------------ quest/m1/cluster-routing.md | 2 +- 4 files changed, 8 insertions(+), 45 deletions(-) delete mode 100644 quest/m0/local-origin.md diff --git a/quest/README.md b/quest/README.md index 3a687ff8d5..dcc3e3b52d 100644 --- a/quest/README.md +++ b/quest/README.md @@ -7,8 +7,8 @@ grouped into milestones ordered by priority. ## Plan -m0 is everything in flight now: announce and wildcard routing, the local -origin, and audio playout (jitter target, quality harness, A/V clock). m1 is the next wave across reliability, features, +m0 is everything in flight now: announce and wildcard routing, and audio +playout (jitter target, quality harness, A/V clock). m1 is the next wave across reliability, features, performance, and planning. m2 holds later features, design studies, and experiments. m3 is deferred: work whose first step is outside this repository. m4 waits on an upstream release or external dependency to ship. Priority is @@ -17,7 +17,7 @@ under the repository rules. ## Required -- [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: announce and wildcard routing, the local origin, and audio playout +- [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: announce and wildcard routing, and audio playout - [m1: next wave](/quest/m1/README.md) - reliability, capabilities, performance, and the planning that settles their contracts - [m2: later work](/quest/m2/README.md) - deferred features, design studies, and experiments - [m3: deferred](/quest/m3/README.md) - gated on the outside world: hardware nobody has, a partner, or a provider's offer diff --git a/quest/m0/README.md b/quest/m0/README.md index 9aa54fa283..0f104ffe7e 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -4,8 +4,7 @@ The work in flight now, in two independent tracks. Routing: a publisher stops sending announce updates the wire cannot tell apart, a service claims the -prefix it could serve instead of enumerating broadcasts, and localhost workers -read only what their relay ingested. Audio playout: the target is a measured +prefix it could serve instead of enumerating broadcasts. Audio playout: the target is a measured estimate of arrival timing in both languages, a browser regression fails a nightly run, and the audio playhead becomes the clock video follows. @@ -18,8 +17,9 @@ moq.pro. Routing: announce-update dedupe is a wire-compatible fix on every version. The wildcard line is prefix-only on the wire; its resolve and demand work is done -on the line branch and waits to land. Local origin serves the relay's -ingested-only view on the internal listener. +on the line branch and waits to land. Serving the relay's ingested-only +view (`origin::Consumer::local()`) to localhost workers belongs to moq.pro's +edge, which embeds moq-relay; it moved there on 2026-09-28. Audio playout: the jitter target replaces the round-trip guess. The harness's browser lane grades it nightly and records the traces it replays; the native @@ -32,7 +32,6 @@ Published API or wire breaks still land on dev; each quest's Plan says so. - [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - a publisher sends an announce update only when the wire route changed - [Wildcard](/quest/m0/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability -- [Local origin](/quest/m0/local-origin.md) - localhost workers read only the broadcasts their relay ingested, from the internal listener - [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a browser playout latency regression fails a nightly run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess - [A/V clock](/quest/m0/plan-av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles diff --git a/quest/m0/local-origin.md b/quest/m0/local-origin.md deleted file mode 100644 index 60937e64ac..0000000000 --- a/quest/m0/local-origin.md +++ /dev/null @@ -1,36 +0,0 @@ -# [M] Local origin - -## Goal - -A localhost worker (a Voice agent, a recorder) connects to the relay's -internal listener and gets a moq session whose origin holds only the -broadcasts this relay ingested from its own customer sessions, never those -learned from cluster peers. Workers stop electing on hop chains ("the first -internal hop is me"), which [Cluster routing](/quest/m1/cluster-routing.md) -removes inside a cluster. - -## Plan - -- The view exists: `origin::Consumer::local()` (`rs/moq-net/src/model/origin.rs`) - hides every route a `Producer::peer` handle announced, which is how cluster - peers attach (`rs/moq-relay/src/cluster.rs`), and - `local_view_hides_peer_routes` covers it. The work is serving that view. -- The internal listener (`rs/moq-relay/src/internal.rs`) is plain HTTP - today (`/metrics`, `/health`, `/nodes`, `/sessions`). Serve a moq session on - it over the WebSocket transport the relay already accepts. It is - unauthenticated, so serve it only when the internal listener binds a - loopback address. The listener may also bind a private-overlay address, - which would expose customer media to the overlay; config load fails if the - local origin is enabled there. -- The session is read-only. Open question: Voice publishes responses. Decide - whether this session may publish into the relay origin, and under which - grant, before it accepts a publish. -- Document it in `doc/bin/relay/`. - -Tests: a two-relay cluster where each relay's local session lists only its own -publishers, a publisher moving relays moves between the two views, and a -peer-learned route never appears. - -## Related - -- [Voice on the local origin](https://github.com/moq-dev/moq.pro/blob/main/quest/m0/voice-local-origin.md) - moq.pro's Voice and recorder switch to this diff --git a/quest/m1/cluster-routing.md b/quest/m1/cluster-routing.md index 4c82131756..983b880eb6 100644 --- a/quest/m1/cluster-routing.md +++ b/quest/m1/cluster-routing.md @@ -113,7 +113,7 @@ make MoQ's common case. ## Required -- [Local origin](/quest/m0/local-origin.md) - workers stop reading hop chains before they go +- moq.pro workers stop electing on hop chains, reading the relay's local origin instead ([moq.pro voice-local-origin](https://github.com/moq-dev/moq.pro/blob/main/quest/m0/voice-local-origin.md)) - [Wildcard](/quest/m0/wildcard/README.md) - the specificity, pool spread, and reply identity this selection builds on - moq.pro's routing simulator reports ([quest](https://github.com/moq-dev/moq.pro/blob/main/quest/m1/routing-simulator.md)) From c18781179220c353794d1d91bf5c3658f758a4fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 14:51:52 -0700 Subject: [PATCH 76/87] docs: merge-commit PRs into non-main branches, keep scratch in .scratch/ (#4397) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 1 + CONTRIBUTING.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e2b594df12..3618479e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ Wire changes should be backwards compatible for any *published* drafts/versions. Before starting, `git fetch origin` and set the upstream to the base branch. If a published API break requires `dev`, retarget the PR to `dev`, set the upstream to `origin/dev`, then rebase onto it. +Write scratch files (PR bodies, logs, notes) to the worktree's gitignored `.scratch/`, never a directory other agents share. Use the Nix dev shell so tooling matches CI. direnv loads it automatically, but if not: `nix develop --command ...`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57498db7be..9bf948cca4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Commits -PRs are squash-merged, so the PR title becomes the commit subject and the PR description becomes the body in `git log`. +PRs into `main` are squash-merged, so the PR title becomes the commit subject and the PR description becomes the body in `git log`. +PRs into any other branch (`dev`, a questline) use a merge commit, so their history survives until they land. - Use conventional-commit subjects (`feat(watch): ...`, `fix: ...`, `chore: ...`, `docs: ...`) - AI commit attribution goes in a `Co-Authored-By:` trailer, not the commit body. From c9028b02b8c54b6b568da6d748b898cad6ef12b5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 14:59:30 -0700 Subject: [PATCH 77/87] ci: skip platform builds and CI setup for quest and docs-only diffs (#4407) Co-authored-by: Claude Opus 5.5 --- .github/workflows/android.yml | 11 ++++++-- .github/workflows/check.yml | 51 ++++++++++++++++++++++++++-------- .github/workflows/platform.yml | 36 +++++++++++++++++++----- 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 9b8e9b4d85..881d127686 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -74,8 +74,15 @@ jobs: # No Rust cache: this workflow only runs on pull requests, which may not # save one, so it would have nothing to restore. - - name: Install just and cargo-ndk - run: cargo install --locked "just@$JUST_VERSION" "cargo-ndk@$CARGO_NDK_VERSION" + - name: Install cargo-ndk + run: cargo install --locked "cargo-ndk@$CARGO_NDK_VERSION" + + # A checksummed release binary, where `cargo install` would spend ~2 minutes + # building just from source. + - name: Install just + uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: just@${{ env.JUST_VERSION }} - name: Check run: just rs android diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e3183cfdc6..8fa0ed62ea 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -32,11 +32,6 @@ jobs: timeout-minutes: 60 steps: - - name: Free disk space - uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main - with: - tool-cache: false - - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -44,6 +39,23 @@ jobs: # Full history so `just ci check` can diff against origin/$GITHUB_BASE_REF. fetch-depth: 0 + # Quests, agent config, and root Markdown compile nothing, so a diff of only + # those skips the disk cleanup and Rust cache that exist for the build. + - name: Scope + id: scope + run: | + base=$(git merge-base "origin/$GITHUB_BASE_REF" HEAD) + files=$(git diff --name-only "$base") + if grep -qvE '^(quest/|\.claude/|[^/]+\.md$)' <<< "$files"; then + echo build=true >> "$GITHUB_OUTPUT" + fi + + - name: Free disk space + if: steps.scope.outputs.build + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main + with: + tool-cache: false + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: determinate: false @@ -63,6 +75,7 @@ jobs: # scope such an entry to the PR's own branch anyway, where no later PR # could read it while it ate the repository's 10 GB budget. - name: Rust cache + if: steps.scope.outputs.build uses: ./.github/actions/rust-cache # The same recipe a developer runs locally. It diffs against @@ -88,11 +101,6 @@ jobs: timeout-minutes: 60 steps: - - name: Free disk space - uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main - with: - tool-cache: false - - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -100,7 +108,26 @@ jobs: # Full history so `just ci test` can diff against origin/$GITHUB_BASE_REF. fetch-depth: 0 - - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + # Same scope as the `check` job. No test covers those files either, so the + # job skips everything and reports green instead of spending a minute of + # setup to run nothing. + - name: Scope + id: scope + run: | + base=$(git merge-base "origin/$GITHUB_BASE_REF" HEAD) + files=$(git diff --name-only "$base") + if grep -qvE '^(quest/|\.claude/|[^/]+\.md$)' <<< "$files"; then + echo build=true >> "$GITHUB_OUTPUT" + fi + + - name: Free disk space + if: steps.scope.outputs.build + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main + with: + tool-cache: false + + - if: steps.scope.outputs.build + uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: determinate: false # Trust the flake's cachix substituter. `nixConfig` in flake.nix is @@ -115,11 +142,13 @@ jobs: # Restore only, same as the `check` job above. - name: Rust cache + if: steps.scope.outputs.build uses: ./.github/actions/rust-cache # NEXTEST_PROFILE picks up the longer hang timeout in .config/nextest.toml; # without it a runner under load could trip the local one. - name: Test + if: steps.scope.outputs.build run: nix develop --command just ci test env: MOQ_STRICT: 1 diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index f87c21cc70..d7e3534240 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -8,8 +8,9 @@ name: Platform # breaks any of them (a moq-net type they consume, say) merges green and # compiles for the first time in a tag-triggered release build. # -# Unfiltered, unlike android.yml: every job still finishes before check.yml, so -# a path list tracking the dependency graphs of all that code isn't worth it. +# Filtered to the Rust workspace and the OBS plugin, rather than to each job's +# exact dependency graph like android.yml: these are the slowest and most +# expensive runners in CI, and a quest or docs PR has nothing for them to build. # # Pushes to `main` and `dev` catch the break two individually green PRs make # once both land, which is what the nightly run of these jobs used to cover. @@ -29,10 +30,28 @@ permissions: on: push: branches: [main, dev] + paths: + - "rs/**" + - "cpp/**" + - ".cargo/**" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "justfile" + - ".github/workflows/platform.yml" pull_request: # `closed` is here only so merging/closing a PR cancels its in-flight run # via the concurrency group below; the jobs themselves are skipped on close. types: [opened, synchronize, reopened, closed] + paths: + - "rs/**" + - "cpp/**" + - ".cargo/**" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "justfile" + - ".github/workflows/platform.yml" concurrency: # Keyed by event, so a merged pull request's `closed` run can't cancel the push @@ -68,11 +87,12 @@ jobs: choco install nasm -y --no-progress "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - # Pinned: `--locked` fixes just's own dependencies, not which version of - # just cargo selects. + # A checksummed release binary, where `cargo install` would spend ~2 minutes + # building just from source. - name: Install just - shell: bash - run: cargo install --locked "just@$JUST_VERSION" + uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: just@${{ env.JUST_VERSION }} - name: Check shell: bash @@ -94,7 +114,9 @@ jobs: uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - name: Install just - run: cargo install --locked "just@$JUST_VERSION" + uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: just@${{ env.JUST_VERSION }} - name: Check run: just rs macos From 7b81c83119ad0ce8a6da8f941dc3d62bdfb34e8a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 15:14:14 -0700 Subject: [PATCH 78/87] fix(go): keep test origins alive until the test ends (#4402) Co-authored-by: Claude Opus 5.5 --- doc/lib/go/index.md | 6 ++++++ go/wrapper/moq_test.go | 27 ++++++++++++++++-------- go/wrapper/origin.go | 4 ++++ go/wrapper/origin_internal_test.go | 34 ++++++++++++++++++++++++++++++ quest/m1/README.md | 1 - quest/m1/go-origin-gc.md | 32 ---------------------------- 6 files changed, 62 insertions(+), 42 deletions(-) create mode 100644 go/wrapper/origin_internal_test.go delete mode 100644 quest/m1/go-origin-gc.md diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 7b36cf9a8c..3d72c03e8e 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -86,6 +86,12 @@ relative to the origin and `ann.Captures()` reports the wildcard matches. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless `Hidden: true`. +An `OriginProducer` from `moq.NewOriginProducer` has no `Close`: its origin +ends when the garbage collector reaches the last producer, and every consumer +and `OriginDynamic` made from it then fails with `moq.ErrClosed`. Keep the +producer reachable (a field on a long-lived struct, or `runtime.KeepAlive`) for +as long as the origin should serve. + Every call that can block takes a `context.Context` first. Cancelling it returns `ctx.Err()` promptly and tears the in-flight native work down, so a per-call deadline bounds resource use rather than just your wait. What it tears diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 936fd7e5df..06fef334ef 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -18,6 +18,15 @@ import ( // job instead of hanging it. const testTimeout = 10 * time.Second +// newOrigin returns an origin that lasts the whole test. An OriginProducer has +// no Close: the collector ends its origin once nothing reaches the producer, +// even while consumers and dynamic handles made from it are still in use. +func newOrigin(t *testing.T) *moq.OriginProducer { + origin := moq.NewOriginProducer() + t.Cleanup(func() { runtime.KeepAlive(origin) }) + return origin +} + // opusHead builds a valid OpusHead init buffer (RFC 7845): 48 kHz, 2 channels. func opusHead() []byte { buf := []byte("OpusHead") @@ -30,7 +39,7 @@ func opusHead() []byte { } func TestOriginLifecycle(t *testing.T) { - origin := moq.NewOriginProducer() + origin := newOrigin(t) _ = origin.Consume() dynamic, err := origin.Dynamic("", moq.Route{}) if err != nil { @@ -43,7 +52,7 @@ func TestDynamicBroadcastRequest(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) dynamic, err := origin.Dynamic("", moq.Route{}) if err != nil { t.Fatal(err) @@ -263,7 +272,7 @@ func TestDecodeVideoFormat(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) broadcast, err := origin.CreateBroadcast("video-decode-format") if err != nil { t.Fatal(err) @@ -465,7 +474,7 @@ func TestLocalPublishConsumeAudio(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) broadcast, err := origin.CreateBroadcast("live") if err != nil { t.Fatal(err) @@ -1049,7 +1058,7 @@ func TestConsumerCancelConcurrent(t *testing.T) { // dynamic handler, then proves the origin still resolves: the cancel has to abort // that one request rather than the consumer it was made on. func TestRequestBroadcastCancelKeepsTheOrigin(t *testing.T) { - origin := moq.NewOriginProducer() + origin := newOrigin(t) dynamic, err := origin.Dynamic("", moq.Route{}) if err != nil { t.Fatal(err) @@ -1306,7 +1315,7 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) broadcast, err := origin.CreateBroadcast("live") if err != nil { t.Fatal(err) @@ -1363,7 +1372,7 @@ func TestAnnouncedPatternCaptures(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) filter := "*/chat" announced, err := origin.Consume().Announced(moq.AnnounceOptions{Prefix: "room", Filter: &filter}) if err != nil { @@ -1405,7 +1414,7 @@ func TestAnnouncedExactFilterCapturesEmpty(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) filter := "" announced, err := origin.Consume().Announced(moq.AnnounceOptions{Prefix: "room/alice/chat", Filter: &filter}) if err != nil { @@ -1437,7 +1446,7 @@ func TestDynamicServesARequestUnderAPrefix(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) dynamic, err := origin.Dynamic("live", moq.Route{}) if err != nil { t.Fatal(err) diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index 78642147c1..938bbbbda4 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -11,6 +11,10 @@ import ( // OriginProducer publishes broadcasts under paths and hands out consumers that // discover them. Wire one as both a client's/server's publish source and // consume sink for a full-duplex peer. +// +// There is no Close: the origin ends once the collector reaches every +// producer, and its consumers and dynamic handles then fail with [ErrClosed]. +// Keep a producer reachable for as long as the origin should live. type OriginProducer struct { inner *ffi.MoqOriginProducer } diff --git a/go/wrapper/origin_internal_test.go b/go/wrapper/origin_internal_test.go new file mode 100644 index 0000000000..bd46a0914c --- /dev/null +++ b/go/wrapper/origin_internal_test.go @@ -0,0 +1,34 @@ +package moq + +import ( + "context" + "errors" + "testing" + "time" +) + +// An OriginProducer has no Close: the collector ends its origin by finalizing +// the last producer, even while a consumer or dynamic handle made from it is in +// use. Destroying the handle is what that finalizer does, so this pins the +// behavior the doc comment warns about without waiting on the collector. +func TestOriginEndsWithItsProducer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + origin := NewOriginProducer() + dynamic, err := origin.Dynamic("", Route{}) + if err != nil { + t.Fatal(err) + } + defer dynamic.Cancel() + consumer := origin.Consume() + + origin.inner.Destroy() + + if _, err := dynamic.RequestedBroadcast(ctx); !errors.Is(err, ErrClosed) { + t.Fatalf("RequestedBroadcast err = %v, want ErrClosed", err) + } + if _, err := consumer.RequestBroadcast(ctx, "live"); !errors.Is(err, ErrClosed) { + t.Fatalf("RequestBroadcast err = %v, want ErrClosed", err) + } +} diff --git a/quest/m1/README.md b/quest/m1/README.md index d0d397df16..b5c8573a69 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -19,7 +19,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Cluster routing](/quest/m1/cluster-routing.md) - an announcement says where a broadcast originates, not how to reach it, and a relay hears only the prefixes its clients asked for - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` -- [Go cancel test](/quest/m1/go-origin-gc.md) - the Go request-cancel test keeps its origin alive, so the collector can't close it mid-test - [Worker socket count](/quest/m1/worker-socket-count.md) - the moq-tokio worker test counts only its own listener's sockets - [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi and every wrapper configure and observe audio playout delay - [moq-binary folds into moq-flate](/quest/m1/flate-binary.md) - on dev, moq-flate and @moq/flate own the opaque snapshot and stream tracks and moq-binary is deleted diff --git a/quest/m1/go-origin-gc.md b/quest/m1/go-origin-gc.md deleted file mode 100644 index 25bc6aae9e..0000000000 --- a/quest/m1/go-origin-gc.md +++ /dev/null @@ -1,32 +0,0 @@ -# [XS] Go cancel test keeps its origin - -## Goal - -`TestRequestBroadcastCancelKeepsTheOrigin` in `go/wrapper/moq_test.go` passes -under load, not only alone (30/30 in isolation, `MoqError: Closed` under a -loaded run). Fixed at the cause, with no retry or longer timeout. - -## Plan - -Likely cause, found by reading and not yet reproduced: the test never touches -`origin` after `origin.Dynamic(...)` and `origin.Consume()`, so Go may collect -it mid-test. The generated finalizer then drops the only `MoqOriginProducer`, -the origin's driver finishes once every producer handle is gone -(`origin::Driver::poll` in `rs/moq-net/src/model/origin.rs`), and the next call -on the consumer or the dynamic handle gets `Error::Closed`. The collector runs -more often under load, which fits. - -- Reproduce first. `runtime.GC()` after `cancel()` or `GOGC=1` only makes the - collection likely: finalizers run later on their own goroutine. For a - deterministic failure, do what the finalizer does: an internal test calls - the inner handle's `Destroy` before the next call. -- Keep the producer reachable to the end of the test (`runtime.KeepAlive`, as - the file already does for `pending`), and audit the other `go/wrapper` tests - that hold an `OriginProducer` only for setup. -- Users hit the same trap: a Go `OriginProducer` has no `Close`, so its origin - ends whenever the collector reaches it. Say so in its doc comment and - `doc/lib/go`. Whether consumers should keep their producer alive, or the - wrapper should gain an explicit `Close`, is an API call: propose it to the - maintainer rather than ship it here. - -Public API: none unless the maintainer picks an API change. Wire: none. From 2d4ee777a63b58d78fac4ac821e4ca63dde19f13 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 15:44:24 -0700 Subject: [PATCH 79/87] feat(hang): reject catalog broadcast references escaping the consumer's path (#4406) Co-authored-by: Claude Opus 5.5 --- doc/lib/js/hang.md | 4 ++- doc/lib/js/net.md | 2 +- js/hang/src/catalog/consumer.test.ts | 52 ++++++++++++++++++++++++++- js/hang/src/catalog/root.ts | 43 ++++++++++++++++++++-- js/net/src/broadcast.ts | 54 +++++++++++++++++++++------- js/net/src/internal.ts | 7 +++- js/net/src/origin.test.ts | 34 ++++++++++++++++++ js/net/src/origin.ts | 2 ++ quest/m1/README.md | 1 - quest/m1/js-catalog-path.md | 27 -------------- quest/m1/processor/media-contract.md | 4 --- 11 files changed, 180 insertions(+), 50 deletions(-) delete mode 100644 quest/m1/js-catalog-path.md diff --git a/doc/lib/js/hang.md b/doc/lib/js/hang.md index e7f14a5b5a..07d9325f5b 100644 --- a/doc/lib/js/hang.md +++ b/doc/lib/js/hang.md @@ -20,7 +20,9 @@ import * as Container from "@moq/hang/container"; ``` `Catalog.watch(broadcast)` iterates validated catalog roots. It throws -`Catalog.TooManyRenditions` for an update above the 64 rendition limit. +`Catalog.TooManyRenditions` for an update above the 64 rendition limit, and +`Catalog.EscapingBroadcast` for a `broadcast` reference that walks above the +handle's `path`. `Hang.Timeline.Consumer.subscribe(broadcast, root.archive)` reads segment `push`, `pop`, and `skip` events when a root advertises an archive. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index daef5e58f3..b0d4fda217 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -55,7 +55,7 @@ for (;;) { - **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. `track.fetchGroup(sequence)` on moq-lite resolves when the publisher sends the first response byte or finishes an empty group. A missing group rejects the fetch with `StreamCode.NotFound`, including every concurrent caller sharing that fetch. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. -- **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. +- **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. A `Broadcast.Consumer` names its broadcast by `path`: the path it was requested at relative to the origin handle's root, or empty for a standalone broadcast. Those references resolve against it. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. The [path pattern](/concept/moq-lite#path-patterns) grammar lives on the concept page. diff --git a/js/hang/src/catalog/consumer.test.ts b/js/hang/src/catalog/consumer.test.ts index 8fd1c8caf7..6b0e328aae 100644 --- a/js/hang/src/catalog/consumer.test.ts +++ b/js/hang/src/catalog/consumer.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import * as Json from "@moq/json"; import * as Moq from "@moq/net"; import { TRACK } from "./format"; -import { checkRenditions, MAX_RENDITIONS, type Root, TooManyRenditions, watch } from "./root"; +import { checkRenditions, EscapingBroadcast, MAX_RENDITIONS, type Root, TooManyRenditions, watch } from "./root"; function catalog(count: number): Root { return { @@ -40,6 +40,56 @@ test("watch refuses an oversized catalog update", async () => { broadcast.close(); }); +// A catalog whose one audio rendition references `broadcast`. +function referencing(broadcast: string): Root { + const root = catalog(1); + const [config] = Object.values(root.audio?.renditions ?? {}); + if (config) config.broadcast = Moq.Path.normalizeRelative(broadcast); + return root; +} + +// Watch the catalog of `room/alice`, published through an origin and requested from it. +async function watchRequested(root: Root) { + const origin = new Moq.Origin.Producer(); + const broadcast = origin.createBroadcast(Moq.Path.from("room/alice")); + const track = broadcast.createTrack(TRACK); + const producer = new Json.Snapshot.Producer({ track, deltaRatio: 0 }); + broadcast.announce(); + const request = origin.request(Moq.Path.from("room/alice")); + const active = request.active.peek(); + if (!active) throw new Error("request did not resolve"); + const consumer = watch(active)[Symbol.asyncIterator](); + producer.update(root); + try { + return await consumer.next(); + } finally { + await consumer.return?.(); + request.close(); + broadcast.close(); + origin.close(); + } +} + +test("watch refuses a broadcast reference escaping the requested path", async () => { + await expect(watchRequested(referencing("../../../other"))).rejects.toBeInstanceOf(EscapingBroadcast); +}); + +test("watch accepts a sibling reference under the same root and yields it unresolved", async () => { + const root = referencing("./bob"); + expect(await watchRequested(root)).toMatchObject({ value: root }); +}); + +test("watch on a standalone broadcast refuses any parent reference", async () => { + const broadcast = new Moq.Broadcast.Producer(); + const track = broadcast.createTrack(TRACK); + const producer = new Json.Snapshot.Producer({ track, deltaRatio: 0 }); + const consumer = watch(broadcast.consume())[Symbol.asyncIterator](); + producer.update(referencing("../bob")); + await expect(consumer.next()).rejects.toBeInstanceOf(EscapingBroadcast); + producer.finish(); + broadcast.close(); +}); + test("watch subscribes and yields typed catalog updates", async () => { const broadcast = new Moq.Broadcast.Producer(); const track = broadcast.createTrack(TRACK); diff --git a/js/hang/src/catalog/root.ts b/js/hang/src/catalog/root.ts index 5ea8453ded..97b1689600 100644 --- a/js/hang/src/catalog/root.ts +++ b/js/hang/src/catalog/root.ts @@ -1,5 +1,6 @@ import * as Json from "@moq/json"; import type * as Moq from "@moq/net"; +import { Path } from "@moq/net"; import * as z from "@zod/mini"; import { ArchiveSchema } from "./archive"; import { AudioSchema } from "./audio"; @@ -7,6 +8,7 @@ import { BinarySchema } from "./binary"; import { ClockSchema } from "./clock"; import { TRACK } from "./format"; import { JsonSchema } from "./json"; +import type { RelativeBroadcast } from "./path"; import { PRIORITY } from "./priority"; import { section } from "./section"; import { TextSchema } from "./text"; @@ -70,12 +72,49 @@ export function checkRenditions(root: Root): Root { return root; } -/** Subscribe to a broadcast's catalog and iterate validated root updates. */ +/** A catalog update carried a `broadcast` reference that walks above its reader's root. */ +export class EscapingBroadcast extends Error { + /** The offending reference, normalized. */ + readonly broadcast: RelativeBroadcast; + + constructor(broadcast: RelativeBroadcast) { + super(`catalog broadcast reference escapes the root: ${broadcast}`); + this.name = "EscapingBroadcast"; + this.broadcast = broadcast; + } +} + +// Refuse an update with a `broadcast` reference that walks above the root from `base`. +function checkResolvable(root: Root, base: Moq.Path.Valid): Root { + // Every section carrying a `broadcast` reference must be listed here; one left out + // silently exempts its tracks from the check. + const sections = [ + root.video?.renditions, + root.audio?.renditions, + root.text?.renditions, + root.json?.tracks, + root.binary?.tracks, + ]; + for (const section of sections) { + for (const { broadcast } of Object.values(section ?? {})) { + if (broadcast && Path.tryResolve(base, broadcast) === undefined) throw new EscapingBroadcast(broadcast); + } + } + return root; +} + +/** + * Subscribe to a broadcast's catalog and iterate validated root updates. + * + * Throws {@link TooManyRenditions} or {@link EscapingBroadcast} on an update that fails + * validation. `broadcast` references are checked against the handle's `path` and yielded + * unresolved. + */ export async function* watch(broadcast: Moq.Broadcast.Consumer): AsyncIterable { const track = broadcast.track(TRACK).subscribe({ priority: PRIORITY.catalog }); try { const consumer = new Json.Snapshot.Consumer({ track, schema: RootSchema }); - for await (const root of consumer) yield checkRenditions(root); + for await (const root of consumer) yield checkResolvable(checkRenditions(root), broadcast.path); } finally { track.close(); } diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index b744b042bb..cf1a099510 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -8,6 +8,7 @@ import { NotFound } from "./error.ts"; import type { Consumer as GroupConsumer } from "./group.ts"; import { Route } from "./hop.ts"; import { hooks, type TrackSequence } from "./internal.ts"; +import * as Path from "./path.ts"; import * as track from "./track.ts"; import { registerWire, trackOf, type Broadcast as Wire } from "./wire.ts"; @@ -20,6 +21,7 @@ export interface Announcer { } let attachAnnouncer: (producer: Producer, announcer: Announcer) => void; +let stampProducer: (producer: Producer, path: Path.Valid) => void; /** Reactive backing state shared by broadcast producers and consumers. */ class BroadcastState { @@ -158,6 +160,7 @@ async function fetchGroup( export class Producer { #state = new BroadcastState(); #announcer?: Announcer; + #path = Path.empty(); constructor() { registerWire(this, this.#wire(false)); @@ -168,6 +171,9 @@ export class Producer { producer.#announcer = announcer; }; hooks.attachAnnouncer = attachAnnouncer; + stampProducer = (producer, path) => { + producer.#path = path; + }; } /** @@ -178,9 +184,9 @@ export class Producer { return this.#state.closed; } - /** A read handle for this broadcast. */ + /** A read handle for this broadcast, named by the path the origin created it at. */ consume(): Consumer { - return makeConsumer(this.#state); + return makeConsumer({ state: this.#state, path: this.#path }); } async #requested(): Promise { @@ -277,9 +283,15 @@ export class Producer { } } +// What a new consumer handle inherits: the shared broadcast plus the path naming it. +interface Shared { + state: BroadcastState; + path: Path.Valid; +} + // Constructs a Consumer from within this module without exposing a public constructor // that would leak the unexported BroadcastState. Assigned in the class's static block. -let makeConsumer: (state: BroadcastState) => Consumer; +let makeConsumer: (shared: Shared) => Consumer; /** * The read side of a broadcast. @@ -291,13 +303,15 @@ let makeConsumer: (state: BroadcastState) => Consumer; */ export class Consumer { #state: BroadcastState; + #path: Path.Valid; // Guards against a double close() on this handle over-decrementing the consumer count. #closed = false; - protected constructor(state?: never); - protected constructor(state?: BroadcastState) { - this.#state = state ?? new BroadcastState(); + protected constructor(shared?: never); + protected constructor(shared?: Shared) { + this.#state = shared?.state ?? new BroadcastState(); + this.#path = shared?.path ?? Path.empty(); this.#state.consumers++; registerWire(this, { subscribe: (name, options) => subscribe(this.#state, name, options, true), @@ -308,7 +322,23 @@ export class Consumer { } static { - makeConsumer = (state) => new Consumer(state as never); + makeConsumer = (shared) => new Consumer(shared as never); + hooks.stampPath = (target, path) => { + if (target instanceof Consumer) target.#path = path; + else stampProducer(target, path); + }; + } + + /** + * The path this handle names the broadcast by, which relative references in its catalog + * (hang's `broadcast` field) resolve against. + * + * An origin stamps each handle it hands out with the path it was requested at, relative to + * that origin handle's scope root, and a broadcast it created with the path it was created at. + * Empty for a standalone broadcast, which is then its own root: any `..` reference escapes. + */ + get path(): Path.Valid { + return this.#path; } /** @@ -325,8 +355,8 @@ export class Consumer { /** * Return another handle to the same broadcast, reference-counted with this one. * - * Both handles read the same tracks and share one {@link closed} state; the broadcast - * closes only once *every* handle has {@link close}d. Used by the connection's per-path + * Both handles read the same tracks, carry the same {@link path}, and share one {@link closed} + * state; the broadcast closes only once *every* handle has {@link close}d. Used by the connection's per-path * consume cache to share one subscription across callers. Subclasses that resolve info over * the wire override this to preserve their type (see the wire layer's consumed broadcast). */ @@ -334,10 +364,10 @@ export class Consumer { return new Consumer(this.shareState()); } - // Hand this consumer's backing state to a clone. Opaque (`never`) so the state type stays - // unexported; a subclass passes it straight back into its own `super(...)`. + // Hand this consumer's backing state and path to a clone. Opaque (`never`) so the state type + // stays unexported; a subclass passes it straight back into its own `super(...)`. protected shareState(): never { - return this.#state as never; + return { state: this.#state, path: this.#path } satisfies Shared as never; } /** Get a lazy handle for a track on this broadcast. Repeat subscriptions dedupe onto one upstream subscription. */ diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 0f00c90848..db88b3b90d 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -6,7 +6,7 @@ * @module */ import type { Dispose, Getter } from "@moq/signals"; -import type { Producer as BroadcastProducer } from "./broadcast.ts"; +import type { Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import type { Frame, Consumer as GroupConsumer } from "./group.ts"; import type { Route } from "./hop.ts"; import * as Path from "./path.ts"; @@ -179,6 +179,8 @@ export const hooks: { producer: BroadcastProducer, announcer: { announce(route: Route): void; unannounce(): void }, ) => void; + /** Name a broadcast handle by the path an origin created or resolved it at. */ + stampPath: (target: BroadcastProducer | BroadcastConsumer, path: Path.Valid) => void; } = { makeRequest: () => { throw new Error("track.ts not loaded"); @@ -219,4 +221,7 @@ export const hooks: { attachAnnouncer: () => { throw new Error("broadcast.ts not loaded"); }, + stampPath: () => { + throw new Error("broadcast.ts not loaded"); + }, }; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 5ed445a314..635b4fb2fb 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1558,3 +1558,37 @@ test("a scoped reader sees a local broadcast that only loses to a route outside chat.close(); origin.close(); }); + +test("broadcast handles carry the path they were created or requested at", async () => { + expect(new BroadcastProducer().consume().path).toBe(Path.empty()); + + const origin = new Producer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*")])); + const broadcast = publish(scoped, Path.from("room/alice")); + expect(broadcast.consume().path).toBe(Path.from("tenant/room/alice")); + + // Relative to each cursor's root, and kept by a clone. + const whole = origin.request(Path.from("tenant/room/alice")); + const rooted = scoped.consume().request(Path.from("room/alice")); + expect(whole.active.peek()?.path).toBe(Path.from("tenant/room/alice")); + expect(rooted.active.peek()?.path).toBe(Path.from("room/alice")); + const clone = rooted.active.peek()?.clone(); + expect(clone?.path).toBe(Path.from("room/alice")); + + // A dynamic handler's standalone broadcast is named by the request, too. + const dynamic = origin.dynamic(Path.from("live")); + const request = origin.request(Path.from("live/bob")); + const pending = await dynamic.requested().next(); + const served = new BroadcastProducer(); + pending.value?.accept(served); + expect(request.active.peek()?.path).toBe(Path.from("live/bob")); + + clone?.close(); + whole.close(); + rooted.close(); + request.close(); + served.close(); + dynamic.close(); + broadcast.close(); + origin.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index f5b9438238..9b72b472ac 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -732,6 +732,7 @@ export class Producer implements Table { if (!created) throw new Error("origin is closed"); const producer = new broadcast.Producer(); + hooks.stampPath(producer, path); const front = producer.consume(); hooks.attachAnnouncer(producer, { @@ -1277,6 +1278,7 @@ export class Consumer { const previous = handle; source = front; handle = front?.clone(); + if (handle) hooks.stampPath(handle, relative); previous?.close(); } return handle; diff --git a/quest/m1/README.md b/quest/m1/README.md index b5c8573a69..5eceb31859 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -44,7 +44,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main - [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container - [Accept-side flags](/quest/m1/cli-given-flags.md) - dial-only and local verbs refuse every `--listen-*` flag instead of ignoring it -- [JS catalog path](/quest/m1/js-catalog-path.md) - `@moq/net` broadcast consumers expose their path and `Catalog.watch` rejects escaping references, like Rust - [#2075](/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - @moq/publish gates the first catalog snapshot until every reserved track is described - [Full codec string](/quest/m1/publish-codec-string.md) - browser-published video carries the encoder's full RFC 6381 codec string, so native players decode it - [TS export jitter](/quest/m1/ts-export-jitter.md) - the video reorder bound follows later catalogs and the declared reorder depth, so a late B-frame never reorders TS output; an undeclared stream can reorder once per new maximum depth diff --git a/quest/m1/js-catalog-path.md b/quest/m1/js-catalog-path.md deleted file mode 100644 index 230a37e60d..0000000000 --- a/quest/m1/js-catalog-path.md +++ /dev/null @@ -1,27 +0,0 @@ -# [XS] JS catalog watch rejects escaping broadcast references - -## Goal - -`Catalog.watch` in `@moq/hang` rejects a catalog whose rendition or track -`broadcast` reference escapes the broadcast, as Rust `Catalog::subscribe` -does with `EscapingBroadcast` ([#3935](https://github.com/moq-dev/moq/pull/3935)). -JS cannot check today: `watch` takes a `Broadcast.Consumer`, which has no -path to resolve the reference against, so `../../../other` passes in JS and -fails in Rust. - -## Plan - -Decided: make the path public on `@moq/net`'s `Broadcast.Consumer` and have -`Catalog.watch` read it, so the `watch()` call shape does not change. - -Guidance: - -- Mirror Rust's `broadcast::Info::path`: the origin stamps each handle with - the path it was requested or announced at, relative to the cursor's root, - and a standalone broadcast has an empty path, so any `..` escapes. -- Resolve every video, audio, text, JSON, and binary reference with - `Path.tryResolve` against it and reject the update when it returns - `undefined`, as `js/hang/src/catalog/path.ts` already describes. Keep the - relative values in the yielded catalog unchanged. -- Tests: an escaping reference is rejected, a sibling reference under the - same root passes, and a standalone broadcast rejects `..`. diff --git a/quest/m1/processor/media-contract.md b/quest/m1/processor/media-contract.md index f2f042408f..1bcf95eb5f 100644 --- a/quest/m1/processor/media-contract.md +++ b/quest/m1/processor/media-contract.md @@ -31,7 +31,3 @@ Land Rust and JavaScript catalog bindings, resolver behavior, and fixtures for video, audio, text, missing output, malformed relations, lazy resolution, and relative-path escape. The release and the moq.pro (downstream) pin rollout stay out of this quest. - -## Related - -- [JS catalog path](/quest/m1/js-catalog-path.md) - JS rejects an escaping `broadcast` reference like Rust From 9e2b686d0fb77c4a73610f84c9547416eae88e68 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 15:45:35 -0700 Subject: [PATCH 80/87] build(nix): move obs-studio to a .#obs shell, drop rust-docs (#4410) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 2 +- .github/workflows/obs.yml | 6 +++--- cpp/obs/justfile | 4 ++-- doc/bin/obs.md | 2 +- flake.nix | 19 ++++++++++++++++--- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 231fa33aec..90002d9552 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -125,7 +125,7 @@ jobs: # cost of surfacing a day late rather than in review. - name: OBS if: ${{ !cancelled() }} - run: nix develop --command just obs ci + run: nix develop .#obs --command just obs ci - name: libmoq C fixtures if: ${{ !cancelled() }} diff --git a/.github/workflows/obs.yml b/.github/workflows/obs.yml index 8b4c03420c..05e78169b9 100644 --- a/.github/workflows/obs.yml +++ b/.github/workflows/obs.yml @@ -5,7 +5,7 @@ name: OBS # multi-hundred-MB obs-deps bundle on macOS and Windows. # # Linux is the platform that needs no bundle at all -- nixpkgs has obs-studio, -# Qt6 and ffmpeg, so the dev shell is the whole dependency set. The plugin is +# Qt6 and ffmpeg, so the `.#obs` shell is the whole dependency set. The plugin is # platform-independent C++ over libmoq's C ABI, so a Linux compile catches the # breakage a macOS developer would otherwise ship uncompiled. # @@ -102,9 +102,9 @@ jobs: # The same recipe a developer runs on Linux. Warnings are errors here, via # the platform's CI preset. - name: Build - run: nix develop --command just obs ci + run: nix develop .#obs --command just obs ci # The plugin build above already produced the debug libmoq.a, so the # cargo step inside is a no-op and this is just a cc and a run. - name: libmoq C fixtures - run: nix develop --command just rs c-tests + run: nix develop .#obs --command just rs c-tests diff --git a/cpp/obs/justfile b/cpp/obs/justfile index c28f0f48de..8a2778d6cf 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -4,7 +4,7 @@ # `just obs compile` type-checks the sources on any platform with nothing to # download. The recipes below build a loadable plugin, which is a bigger ask: # -# Linux: `nix develop` provides obs-studio/qt6/ffmpeg/cmake, then `just obs build`. +# Linux: `nix develop .#obs` provides obs-studio/qt6/ffmpeg/cmake, then `just obs build`. # macOS: native, NOT nix. Needs full Xcode; run outside `nix develop` (its # toolchain breaks the Xcode build). `just obs setup` downloads # libobs/Qt6/ffmpeg via buildspec.json (obs-deps). @@ -126,7 +126,7 @@ compile: done exit $status -# Compile and link the plugin the way CI does, against the dev shell's own +# Compile and link the plugin the way CI does, against the `.#obs` shell's own # libobs/Qt6/ffmpeg, then run the unit tests. Linux-only in practice: it's the # one platform where nixpkgs has obs-studio, so nothing has to be downloaded. # Manual elsewhere. diff --git a/doc/bin/obs.md b/doc/bin/obs.md index a9733f27d1..2cec6544eb 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -67,7 +67,7 @@ Extract into your OBS plugins directory. The archives are unsigned, so Gatekeeper and SmartScreen warn on first load. Linux builds from source: ```bash -nix develop +nix develop .#obs just obs build ``` diff --git a/flake.nix b/flake.nix index d9d90e1b5d..7c0920487e 100644 --- a/flake.nix +++ b/flake.nix @@ -74,8 +74,12 @@ # string pool 4-byte aligned, which macOS 27's dyld refuses to load, so # release-profile proc macros and cdylibs are a coin flip there. The # crates still declare their own lower floors (Cargo.toml rust-version). - rust-toolchain = pkgs.rust-bin.stable."1.98.1".default.override { + rust-toolchain = pkgs.rust-bin.stable."1.98.1".minimal.override { + # `minimal` rather than `default`, which adds 740 MB of offline HTML + # docs to a closure every CI job downloads. extensions = [ + "rustfmt" + "clippy" "rust-src" "rust-analyzer" ]; @@ -392,7 +396,8 @@ # Type-checking needs headers rather than libraries, and those are # cross-platform -- obs-headers above, plus qt6.qtbase, which does build # on Darwin. So `just obs compile` and the lints run everywhere while - # `just obs build` stays native. + # `just obs build` stays native. On Linux it links nixpkgs' obs-studio, + # which only the `.#obs` shell below carries. obsDeps = with pkgs; [ @@ -409,7 +414,6 @@ gersemi ] ++ lib.optionals (!stdenv.hostPlatform.isDarwin) [ - obs-studio ninja ]; @@ -475,6 +479,15 @@ # are disallowed in the flake `packages` schema. legacyPackages = { inherit (pkgs) gst_all_1; + + # `nix develop .#obs`: the default shell plus obs-studio, which linking + # the plugin on Linux needs (`just obs build`, `just obs ci`). Kept out + # of the default shell because it pulls in ~3 GB (CEF, mostly) that + # every other CI job would download. Under legacyPackages rather than + # devShells so `nix flake check` doesn't build it on every Rust PR. + obs = self.devShells.${system}.default.overrideAttrs (old: { + nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.obs-studio ]; + }); }; devShells.default = pkgs.mkShell { From b611b0c46c06318e08a2bc3f88a1747645d006bd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 15:50:51 -0700 Subject: [PATCH 81/87] fix(moq-net): hide routes through a peer that withdrew the prefix (#4399) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6 --- doc/bin/relay/cluster.md | 6 + rs/moq-net/benches/session.rs | 57 +++++++- rs/moq-net/src/lite/subscriber.rs | 15 ++- rs/moq-net/src/model/origin.rs | 217 +++++++++++++++++++++++++++++- rs/moq-net/tests/mesh_withdraw.rs | 139 +++++++++++++++++++ 5 files changed, 420 insertions(+), 14 deletions(-) create mode 100644 rs/moq-net/tests/mesh_withdraw.rs diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index b1eb6ddbe7..f4c3466a5d 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -16,6 +16,12 @@ same way so the cluster converges instead of flapping. Both wire protocols carry it: natively on moq-lite, and via the [cluster extension](/draft/moq-cluster) on moq-transport 17+. +When a moq-lite-04 or later peer withdraws its last advertisement for a broadcast, a relay +drops every other route to it that passed through that peer, since each was +relayed from what the peer just withdrew, rather than falling back to them one +by one. During reconnect, another session from that peer can still advertise +the broadcast; an old session's withdrawal does not invalidate that route. + Failover routes must carry copies of the same broadcast. For each track, the relay requires matching timescale, retention window, publisher priority, and group ordering. A source with different properties is refused before its groups diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index 62485be85b..4aaf8765d9 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -637,6 +637,61 @@ fn allocations() { } } +/// Withdraw a batch across a full mesh, sweeping both peers and prefixes. +/// Paused protocol time lets the quiet-point timeout drain pending work without +/// including a real wait in the measured duration. +fn withdrawal(c: &mut Criterion) { + let mut group = c.benchmark_group("session_withdrawal"); + group.sample_size(10); + for relays in [4, 8, 16] { + for broadcasts in [1, 32] { + group.bench_function(format!("{relays}r_{broadcasts}b"), |b| { + b.iter_custom(|iters| { + let mut elapsed = Duration::ZERO; + for _ in 0..iters { + let rt = runtime(); + elapsed += rt.block_on(async { + tokio::time::pause(); + let shape = Shape { + relays, + publishers: 0, + ..Shape::BASE + }; + let cluster = Cluster::new("moq-lite-06", shape).await; + let mut announced = cluster.relays.last().unwrap().consume().announced(); + let live: Vec<_> = (0..broadcasts) + .map(|i| { + cluster.relays[i % (relays - 1)] + .publish(path(i), Default::default()) + .unwrap() + }) + .collect(); + while tokio::time::timeout(Duration::from_secs(1), announced.next()) + .await + .is_ok() + {} + let start = Instant::now(); + drop(live); + let mut retracted = 0; + while let Ok(Some(update)) = + tokio::time::timeout(Duration::from_secs(1), announced.next()).await + { + assert_eq!(update.kind, moq_net::announce::Kind::Retracted); + retracted += 1; + } + let elapsed = start.elapsed(); + assert_eq!(retracted, broadcasts); + elapsed + }); + } + elapsed + }); + }); + } + } + group.finish(); +} + fn session(c: &mut Criterion) { if std::env::var_os("SESSION_ALLOCS").is_some() { allocations(); @@ -754,5 +809,5 @@ fn session(c: &mut Criterion) { ); } -criterion_group!(benches, session); +criterion_group!(benches, session, withdrawal); criterion_main!(benches); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 8a0259fec9..68b2e34e2f 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -219,14 +219,14 @@ impl Subscriber { lite::AnnounceBroadcast::Ended { suffix, .. } => { let path = prefix.join(&suffix); tracing::debug!(broadcast = %self.log_path(&path), "unannounced"); - run.announced.retire(&path); + run.announced.withdraw(&path); } lite::AnnounceBroadcast::EndedId { id } => { // Resolve and retire the id; an unknown or already-retired id is a // protocol violation. let path = prefix.join(&run.decoder.end(id)?); tracing::debug!(broadcast = %self.log_path(&path), "unannounced"); - run.announced.retire(&path); + run.announced.withdraw(&path); } lite::AnnounceBroadcast::Restart { id, hops, cost } => { // Resolve the id; it stays live (the replacement reuses it). An unknown @@ -2649,7 +2649,7 @@ mod tests { .unwrap(); cursor.assert_next_active("room/host"); assert!(announced.contains(&path.clone()), "the announce was not recorded"); - announced.retire(&path.clone()); + announced.withdraw(&path); cursor.assert_next_ended("room/host"); } } @@ -2789,9 +2789,12 @@ impl Announced { self.routes.get_mut(path)?.as_mut() } - fn retire(&mut self, path: &PathOwned) { - // Dropping the route closes its sources. - self.routes.remove(path); + /// Retire this session's advertisement without invalidating another live + /// session from the same peer. Dropping its sources closes their requests. + fn withdraw(&mut self, path: &PathOwned) { + if let Some(Some(entry)) = self.routes.remove(path) { + entry.dynamic.withdrawn(); + } } /// Serve queued requests on every ready route: mint a source per requested diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 2f8a2a944d..c9592defec 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -737,6 +737,10 @@ struct RouteEntry { /// through it. A broadcast is in the table from creation but serves nobody, /// locally or remotely, until it announces. advertised: bool, + /// Whether a peer the chain passes through has since withdrawn this prefix. + /// The route was derived from that peer's advertisement, so it serves nobody + /// until the peer announces again. See [`Dynamic::withdrawn`]. + stale: bool, /// [`prefix_claim`] of [`Self::prefix`], built once at announce time. /// /// The announce sync evaluates a route's claim once per (cursor, route) pair, @@ -746,6 +750,11 @@ struct RouteEntry { } impl RouteEntry { + /// Whether cursors see the entry and requests resolve through it. + fn live(&self) -> bool { + self.advertised && !self.stale + } + fn is_anonymous(&self) -> bool { self.hops.iter().any(|hop| *hop == Hop::UNKNOWN) } @@ -953,7 +962,7 @@ impl TableCursor { /// the excluded peer (split horizon), within the cursor's patterns, and /// nameable through its mount. fn visible(&self, entry: &RouteEntry) -> bool { - entry.advertised + entry.live() && self.horizon.admits(entry) && entry.overlaps(&self.allowed) && self.discovers(&entry.prefix) @@ -1694,8 +1703,10 @@ impl Announcing { let mut entries = Vec::with_capacity(self.prefixes.len()); for (prefix, claim) in &self.prefixes { + shared.reannounced(prefix, &route.hops); let id = shared.next_route; shared.next_route += 1; + let stale = shared.withdrawn_through(prefix, &route.hops); shared.routes.insert(RouteEntry { id, prefix: prefix.clone(), @@ -1708,6 +1719,7 @@ impl Announcing { server: serving.server.clone(), source: serving.source.clone(), advertised: serving.advertised, + stale, claim: claim.clone(), }); shared.sync_route(prefix, claim); @@ -1798,16 +1810,20 @@ impl AnnounceProducer { return Err(Error::Closed); } for (prefix, id) in &self.entries { + shared.reannounced(prefix, &route.hops); + let stale = shared.withdrawn_through(prefix, &route.hops); // Each entry keeps its advertised prefix; only the metadata moves. let Some(entry) = shared.routes.entry_mut(prefix, *id) else { return Err(Error::Closed); }; entry.hops = route.hops.clone(); + entry.stale = stale; entry.cost = route.cost; entry.via = route.via; entry.advertised = true; let claim = entry.claim.clone(); shared.sync_route(prefix, &claim); + shared.prune_withdrawn(prefix); } Ok(()) } @@ -1833,7 +1849,7 @@ impl AnnounceProducer { /// Retract the route now: remove its table entries and reject anything still /// waiting on its queue. Idempotent, and what dropping the advertisement does. - fn retract(&self) { + fn retract(&self, withdrawn: bool) { let mut shared = self.shared.lock(); for (prefix, id) in &self.entries { let Some(entry) = shared.routes.remove(prefix, *id) else { @@ -1850,14 +1866,30 @@ impl AnnounceProducer { } } } + // A peer remains reachable while any of its sessions advertises this + // prefix. Remove this session's route before testing the remaining + // claims, under the same lock, so overlapping withdrawals cannot + // invalidate a newer advertisement or miss the last withdrawal. + if withdrawn + && let Some(&peer) = entry.hops.iter().last() + && peer != Hop::UNKNOWN + && !shared + .routes + .at(prefix) + .any(|other| other.live() && other.hops.iter().last() == Some(&peer)) + { + shared.withdrawn.entry(prefix.clone()).or_default().insert(peer); + shared.restale(prefix); + } shared.sync_route(&entry.prefix, &entry.claim); + shared.prune_withdrawn(&entry.prefix); } } } impl Drop for AnnounceProducer { fn drop(&mut self) { - self.retract(); + self.retract(false); } } @@ -2292,7 +2324,7 @@ async fn run_front(task: FrontTask) { table .routes .covering(&path.as_path()) - .find(|entry| entry.id == route) + .find(|entry| entry.id == route && entry.live()) .map(|entry| { ( Candidate { @@ -2893,9 +2925,9 @@ impl RouteTable { above.into_iter().chain(at).flat_map(|node| node.entries.iter()) } - /// Whether the route `id` still covers `path`. + /// Whether the live route `id` still covers `path`. fn covers(&self, path: &Path, id: u64) -> bool { - self.covering(path).any(|entry| entry.id == id) + self.covering(path).any(|entry| entry.id == id && entry.live()) } /// The routes announced exactly at `prefix`. @@ -2906,6 +2938,15 @@ impl RouteTable { .flat_map(|node| node.entries.iter()) } + /// The routes announced exactly at `prefix`, for a change in place. + fn at_mut(&mut self, prefix: &Path) -> impl Iterator { + let mut node = Some(&mut self.root); + for part in prefix.parts() { + node = node.and_then(|node| node.children.get_mut(part)); + } + node.into_iter().flat_map(|node| node.entries.iter_mut()) + } + /// Every route in the table, for the teardown. fn entries(&self) -> impl Iterator { let mut nodes = Vec::new(); @@ -3023,12 +3064,82 @@ struct OriginState { // a front dies with its watcher and a later request re-creates it. fronts: WeakCache, + // Peers that withdrew a prefix while routes there still passed through them, + // which are stale. See [`Dynamic::withdrawn`]. + withdrawn: HashMap>, + // Set when the origin's driver dropped: new requests fail with `Closed` // immediately and handlers observe the end instead of parking forever. closed: bool, } impl OriginState { + /// Whether a peer in `hops` withdrew `prefix`. + fn withdrawn_through(&self, prefix: &Path, hops: &Hops) -> bool { + if self.withdrawn.is_empty() { + return false; + } + self.withdrawn + .get(prefix) + .is_some_and(|peers| hops.iter().any(|hop| peers.contains(hop))) + } + + /// Recompute which entries at `prefix` are stale after its withdrawals changed. + fn restale(&mut self, prefix: &Path) { + let peers = self.withdrawn.get(prefix); + let mut changed = None; + for entry in self.routes.at_mut(prefix) { + let stale = peers.is_some_and(|peers| entry.hops.iter().any(|hop| peers.contains(hop))); + if entry.stale != stale { + entry.stale = stale; + changed = Some(entry.claim.clone()); + } + } + if let Some(claim) = changed { + self.sync_route(prefix, &claim); + } + } + + /// A route at `prefix` was announced or restarted with `hops`. When its sender, + /// the chain's last hop, had withdrawn the prefix, routes through it are live + /// once more. + fn reannounced(&mut self, prefix: &PathOwned, hops: &Hops) { + if let Some(sender) = hops.iter().last() + && self.withdrawn.get_mut(prefix).is_some_and(|peers| peers.remove(sender)) + { + self.restale(prefix); + self.prune_withdrawn(prefix); + } + } + + /// Forget the withdrawals at `prefix` that no longer hide a route. + fn prune_withdrawn(&mut self, prefix: &PathOwned) { + if self.withdrawn.is_empty() { + return; + } + let Some(peers) = self.withdrawn.get_mut(prefix) else { + return; + }; + if peers.len() <= 1 { + // The common case is already linear and needs no temporary allocation. + peers.retain(|peer| self.routes.at(prefix).any(|entry| entry.hops.contains(peer))); + } else { + let mut unreferenced = peers.clone(); + for entry in self.routes.at(prefix) { + if unreferenced.is_empty() { + break; + } + for hop in entry.hops.iter() { + unreferenced.remove(hop); + } + } + peers.retain(|peer| !unreferenced.contains(peer)); + } + if peers.is_empty() { + self.withdrawn.remove(prefix); + } + } + /// Re-deliver the best route at every presented prefix `prefix` maps to, on /// every cursor it can present on. Called after an entry covering `prefix` /// was added, updated, or removed. `claim` is `prefix`'s [`prefix_claim`], @@ -3179,7 +3290,7 @@ impl OriginState { let mut candidates = node .entries .iter() - .filter(|entry| entry.advertised) + .filter(|entry| entry.live()) .filter(|entry| entry.scope.matches(path.as_str())) .filter(|entry| horizon.admits(entry)) .filter(|entry| entry.qualifies(pin)) @@ -3225,6 +3336,12 @@ pub struct Dynamic { } impl Dynamic { + /// Retire an explicit peer withdrawal, hiding derived paths only once its + /// last live advertisement at the prefix is gone. A lost session just drops. + pub(crate) fn withdrawn(self) { + self.announcement.retract(true); + } + /// Re-price the route in place: replace its hops and cost. /// /// Consumers observe another active update for the same prefix; sessions @@ -6015,6 +6132,92 @@ mod tests { assert_eq!(announced.assert_next_active("room/x").source(), Source::Peer(origin(8))); } + /// A peer withdrawing a prefix hides the routes there through it, so the next + /// best is never a path derived from the one withdrawn. Announcing again + /// revives them, and nothing is remembered once no route passes through it. + #[tokio::test] + async fn withdrawn_peer_hides_routes_through_it() { + let producer = origin(1).produce(); + let peer = producer.clone().peer(); + let mut announced = producer.consume().announced(); + + // Publisher 9 ingests at relay 2; relay 3 relays 2's route. + let direct = || Route::default().with_hops(hops(&[9, 2])).with_via(origin(2)); + let first = peer.dynamic("room", direct()).unwrap(); + let relayed = peer + .dynamic("room", Route::default().with_hops(hops(&[9, 2, 3])).with_via(origin(3))) + .unwrap(); + announced.assert_next_active("room"); + announced.assert_next_wait(); + + // Relay 2 withdraws: the relayed copy goes with it rather than taking over. + first.withdrawn(); + announced.assert_next_ended("room"); + announced.assert_next_wait(); + + // Relay 2 announces again, and the relayed copy is live with it. + let second = peer.dynamic("room", direct()).unwrap(); + announced.assert_next_active("room"); + assert!(producer.shared.lock().withdrawn.is_empty()); + + // Nothing passes through relay 2 once both routes are gone. + second.withdrawn(); + drop(relayed); + announced.assert_next_ended("room"); + assert!(producer.shared.lock().withdrawn.is_empty()); + } + + /// Overlapping sessions are independent claims, regardless of announcement order. + #[tokio::test] + async fn old_session_withdrawal_keeps_newer_route() { + for restart in [false, true] { + let producer = origin(1).produce(); + let peer = producer.clone().peer(); + let mut announced = producer.consume().announced(); + let route = Route::default().with_hops(hops(&[9, 2])); + let first = peer.dynamic("room", route.clone()).unwrap(); + let second = peer.dynamic("room", route.clone()).unwrap(); + announced.assert_next_active("room"); + announced.assert_next_wait(); + let remaining = if restart { + // The older table entry has the newest advertisement after a restart. + first.update(route).unwrap(); + second.withdrawn(); + first + } else { + first.withdrawn(); + second + }; + announced.assert_next_wait(); + assert!(producer.shared.lock().withdrawn.is_empty()); + remaining.withdrawn(); + announced.assert_next_ended("room"); + assert!(producer.shared.lock().withdrawn.is_empty()); + } + } + + #[tokio::test] + async fn withdrawn_route_no_longer_covers_requests() { + let producer = origin(1).produce(); + let peer = producer.clone().peer(); + let direct = peer.dynamic("room", Route::default().with_hops(hops(&[9, 2]))).unwrap(); + let _relayed = peer + .dynamic("room", Route::default().with_hops(hops(&[9, 2, 3]))) + .unwrap(); + let path = Path::new("room/video"); + let id = producer + .shared + .read() + .routes + .covering(&path) + .find(|entry| entry.hops.iter().last() == Some(&origin(3))) + .unwrap() + .id; + assert!(producer.shared.read().routes.covers(&path, id)); + direct.withdrawn(); + assert!(!producer.shared.read().routes.covers(&path, id)); + } + /// A change of source alone is delivered: the same chain and cost arriving /// from a peer instead of a client is a different fact for the consumer. #[tokio::test] diff --git a/rs/moq-net/tests/mesh_withdraw.rs b/rs/moq-net/tests/mesh_withdraw.rs new file mode 100644 index 0000000000..1ce9930142 --- /dev/null +++ b/rs/moq-net/tests/mesh_withdraw.rs @@ -0,0 +1,139 @@ +//! Broadcasts withdrawn from a meshed cluster retract everywhere, once. +//! +//! Every relay holds a route through each peer that re-advertised a broadcast. +//! When the publisher's relay withdraws it, those routes all derive from the one +//! withdrawn; a relay that selects them in turn re-advertises each stale path, and +//! the cluster walks all of them before it converges (path hunting). + +mod support; + +use std::{collections::HashMap, time::Duration}; + +use moq_net::{Hop, Version, announce, broadcast, origin}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer +} + +/// Peer two relays the way `moq-relay`'s cluster does: one session, both directions. +async fn peer(version: Version, a: &origin::Producer, b: &origin::Producer) -> MockPair { + let a = a.clone().peer(); + let b = b.clone().peer(); + let mut options = MockConnectOptions::new(version); + options.client_publish = Some(a.consume().with_hidden(true)); + options.client_subscribe = Some(a); + options.server_publish = Some(b.consume().with_hidden(true)); + options.server_subscribe = Some(b); + connect_mock(options).await +} + +/// Every update per prefix until the cursor goes quiet. Time is paused, so the +/// timeout fires only once every task is idle. +async fn drain(announced: &mut announce::Consumer) -> HashMap> { + let mut updates = HashMap::>::new(); + while let Ok(Some(update)) = tokio::time::timeout(Duration::from_secs(1), announced.next()).await { + updates.entry(update.prefix.to_string()).or_default().push(update.kind); + } + updates +} + +/// `n` relays meshed over `edges`, watched from the last one. +struct Mesh { + nodes: Vec, + _pairs: Vec, + announced: announce::Consumer, +} + +impl Mesh { + async fn new(version: &str, n: u64, edges: &[(usize, usize)]) -> Self { + let version: Version = version.parse().unwrap(); + let nodes: Vec<_> = (1..=n).map(produce_origin).collect(); + let mut pairs = Vec::new(); + for &(a, b) in edges { + pairs.push(peer(version, &nodes[a], &nodes[b]).await); + } + let announced = nodes.last().unwrap().consume().announced(); + Self { + nodes, + _pairs: pairs, + announced, + } + } + + /// Publish `count` broadcasts spread over every relay but the watcher, starting + /// at relay `offset`, and require the watcher to see each announced. + async fn publish(&mut self, count: usize, offset: usize) -> Vec { + let relays = self.nodes.len() - 1; + let broadcasts = (0..count) + .map(|i| { + let broadcast = self.nodes[(i + offset) % relays] + .create_broadcast(format!("room/{i}")) + .unwrap(); + broadcast.announce(Default::default()).unwrap(); + broadcast + }) + .collect(); + let updates = drain(&mut self.announced).await; + assert_eq!(updates.len(), count); + for (prefix, kinds) in updates { + assert_eq!(kinds[0], announce::Kind::Announced, "{prefix}: {kinds:?}"); + assert!(kinds.last().unwrap().is_active(), "{prefix}: {kinds:?}"); + } + broadcasts + } +} + +fn full_mesh(n: usize) -> Vec<(usize, usize)> { + (0..n).flat_map(|a| (a + 1..n).map(move |b| (a, b))).collect() +} + +/// A ring with chords: most relays reach a publisher's relay only through others. +fn ring_with_chords(n: usize) -> Vec<(usize, usize)> { + (0..n).flat_map(|a| [(a, (a + 1) % n), (a, (a + 3) % n)]).collect() +} + +/// Every relay neighbors the publisher's, so each hears the withdrawal first-hand +/// and drops every path derived from it at once. Lite04 names the peer only in +/// the chain, later versions in the announce handshake too. +#[tokio::test(start_paused = true)] +async fn full_mesh_withdraw_retracts_once_lite04() { + full_mesh_withdraw_retracts_once("moq-lite-04").await; +} + +#[tokio::test(start_paused = true)] +async fn full_mesh_withdraw_retracts_once_lite06() { + full_mesh_withdraw_retracts_once("moq-lite-06").await; +} + +async fn full_mesh_withdraw_retracts_once(version: &str) { + let mut mesh = Mesh::new(version, 8, &full_mesh(8)).await; + let broadcasts = mesh.publish(100, 0).await; + drop(broadcasts); + let updates = drain(&mut mesh.announced).await; + assert_eq!(updates.len(), 100); + for (prefix, kinds) in updates { + assert_eq!(kinds, [announce::Kind::Retracted], "{prefix}"); + } + // The same relays publish again, clearing their own withdrawals. + let _broadcasts = mesh.publish(100, 0).await; +} + +/// A relay two hops from the publisher's hears only that its neighbor withdrew, not +/// why, so it can still pass through a stale path or two. Every broadcast must still +/// end retracted, and republishing from other relays must reach the watcher again: +/// no withdrawal outlives the peer announcing the path again. +#[tokio::test(start_paused = true)] +async fn partial_mesh_withdraw_then_republish() { + let mut mesh = Mesh::new("moq-lite-06", 12, &ring_with_chords(12)).await; + let broadcasts = mesh.publish(100, 0).await; + drop(broadcasts); + let updates = drain(&mut mesh.announced).await; + assert_eq!(updates.len(), 100); + for (prefix, kinds) in updates { + assert!(!kinds.last().unwrap().is_active(), "{prefix}: {kinds:?}"); + } + let _broadcasts = mesh.publish(100, 5).await; +} From 5800ff21bbec8a0af99eab0bce5f0f0f9f15741d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 15:52:16 -0700 Subject: [PATCH 82/87] fix(test): keep concurrent interop runs from rebuilding each other's clients (#4411) Co-authored-by: Claude Opus 5.5 --- py/moq-ffi/pyproject.toml | 3 +++ quest/m1/README.md | 3 ++- quest/m1/interop-contention.md | 29 ------------------------- quest/m1/legacy-end-overshoot.md | 34 ++++++++++++++++++++++++++++++ quest/m1/slow-group-log.md | 24 +++++++++++++++++++++ test/README.md | 2 ++ test/interop/README.md | 2 +- test/interop/clients/js/driver.ts | 2 +- test/interop/clients/js/harness.ts | 10 +++++++-- test/interop/interop.sh | 34 +++++++++++++++++++----------- test/justfile | 7 ++++-- test/lib/harness.sh | 19 ++++++++++++----- 12 files changed, 116 insertions(+), 53 deletions(-) delete mode 100644 quest/m1/interop-contention.md create mode 100644 quest/m1/legacy-end-overshoot.md create mode 100644 quest/m1/slow-group-log.md diff --git a/py/moq-ffi/pyproject.toml b/py/moq-ffi/pyproject.toml index 93262be8b2..b0df391092 100644 --- a/py/moq-ffi/pyproject.toml +++ b/py/moq-ffi/pyproject.toml @@ -36,3 +36,6 @@ bindings = "uniffi" manifest-path = "../../rs/moq-ffi/Cargo.toml" python-source = "." module-name = "moq_ffi._uniffi" +# `maturin develop` writes the bindings into the source tree, where a later wheel build +# would pick them up again and fail on the duplicate. The build regenerates them anyway. +exclude = ["moq_ffi/_uniffi/**", "**/__pycache__/**"] diff --git a/quest/m1/README.md b/quest/m1/README.md index 5eceb31859..59d3e53852 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -39,7 +39,8 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [moqsrc stop](/quest/m1/moqsrc-stop.md) - moqsrc's stop blocks until its session ends, without deadlocking on a blocked pad push - [More tests under load](/quest/m1/test-flakes-2.md) - the second round of load-only failures, fixed at the cause - [Auth outage clock](/quest/m1/auth-outage-clock.md) - the relay and moq-auth outage tests run on a paused clock again and assert both bounds of `expires` -- [Interop contention](/quest/m1/interop-contention.md) - two `just test interop --all` matrices pass side by side, and `just test harness` runs from a clean checkout +- [Legacy end overshoot](/quest/m1/legacy-end-overshoot.md) - browser playback survives a group that starts inside the previous group's estimated end +- [Slow group log](/quest/m1/slow-group-log.md) - a starved viewer reports skipped groups once per catch-up, not once per group - [UnknownSession log flood](/quest/m1/unknown-session-logs.md) - streams reset before their WebTransport header stop being reported as UnknownSession at WARN - [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main - [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container diff --git a/quest/m1/interop-contention.md b/quest/m1/interop-contention.md deleted file mode 100644 index f1bb5fe6e3..0000000000 --- a/quest/m1/interop-contention.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] Interop matrices run side by side - -## Goal - -Two `just test interop --all` matrices can run at once on one machine, even -from one checkout, and both pass. `just test harness` runs from a clean -checkout. - -## Plan - -[#4228](https://github.com/moq-dev/moq/pull/4228) fixed the port -reservations and the canvas-blocked pause click, and stages Go per run. What -remains: - -- Under two concurrent matrices, `python -> js` and `go -> js` sometimes stall - the browser subscriber: the Pause control never appears within 30 s. It - was not seen in a single run. Find the cause (CPU starvation of headless - Chromium, a shared resource, or a real stall) before touching timeouts. -- `prepare_python` runs `just py build` in the workspace, so concurrent runs - rewrite the same `.venv` and maturin output (Codex on #4228). Build into the - run directory, as Go now does, or serialize the build. -- `just test harness` runs `harness.browser.ts` without installing workspace - dependencies or Playwright Chromium, so it only passes where CI's earlier - step prepared them. Provision them in the recipe, or reuse the path - `interop.sh` already takes. -- Prove it by running two `--all` matrices at once, several times, from one - checkout. - -Public API: none. Wire: none. diff --git a/quest/m1/legacy-end-overshoot.md b/quest/m1/legacy-end-overshoot.md new file mode 100644 index 0000000000..77cff3dc58 --- /dev/null +++ b/quest/m1/legacy-end-overshoot.md @@ -0,0 +1,34 @@ +# [S] Browser playback survives an estimated group end + +## Goal + +A `@moq/watch` subscriber never aborts a browser-published track with "group +timestamp is below the live edge" when the publisher's next group starts +inside the previous group's estimated end. + +## Plan + +Seen once in `js -> js` while two interop matrices ran side by side: the +subscriber logged `skipping covered group: 0 -> 1`, then aborted the video +track with that error, one second into the cell. + +Likely cause, not yet reproduced in a unit test: the Legacy producer in +`js/hang/src/container/legacy.ts` closes a group without an explicit end (for +example `cut()` when the encoder pauses for lack of demand) by estimating +`end = last frame + interval` and writing that as the group's end marker. Its +own live edge stays at the last frame, so it accepts a resuming keyframe that +lands before the estimate. The consumer (`js/hang/src/container/consumer.ts`) +takes the live edge from the end marker, so that keyframe's group reads as +below it and the track aborts. The consumer's covered-group skip tolerates the +same overlap the live-edge check rejects. + +- Reproduce with a mocked clock: cut a group, then resume with a keyframe + between the last frame and the estimated end. +- Decide which side is wrong: the producer's estimate reaching past what it + will refuse, or the consumer treating an estimated end as a hard edge. + Fix it there and keep the other side's rule consistent with it. +- Check the Rust `moq-mux` producer for the same estimate. + +## Related + +- [More tests under load](/quest/m1/test-flakes-2.md) - other load-only failures, fixed at the cause diff --git a/quest/m1/slow-group-log.md b/quest/m1/slow-group-log.md new file mode 100644 index 0000000000..91aa5d576f --- /dev/null +++ b/quest/m1/slow-group-log.md @@ -0,0 +1,24 @@ +# [XS] A starved viewer reports skipped groups once + +## Goal + +When `@moq/watch` falls behind and skips groups past the max age, it logs one +summary per catch-up instead of one warning per skipped group, so a starved +page does not spend its remaining CPU on console traffic. + +## Plan + +`#checkMaxAge` in `js/hang/src/container/consumer.ts` calls `console.warn` +for every group it drops. A 2.5 ms Opus track is one group per frame, so a +page behind on it warns hundreds of times a second. In an interop `go -> js` +cell at load average 182, the subscriber logged 7151 `skipping slow group: +track=tone` lines in 149 s, and Playwright could not resolve the Pause button +for 30 s. Starvation was the cause; the warnings, each forwarded over CDP, +made it deeper. + +Report the skipped range and count once per `#checkMaxAge` call, the way +`watch/src/sync.ts` summarizes late frames. + +## Related + +- [More tests under load](/quest/m1/test-flakes-2.md) - other load-only failures, fixed at the cause diff --git a/test/README.md b/test/README.md index 0e9ad969f7..ae54517fa2 100644 --- a/test/README.md +++ b/test/README.md @@ -19,6 +19,8 @@ Every run owns three things and touches nothing else. **A private run directory.** `mktemp -d` under `$TMPDIR/moq-test-`, mode 700, holding every log, generated config, and capture. `MOQ_TEST_RUNS` moves the root. +Client builds go here too (the Python venv, the staged Go modules, the browser +page), so two runs from one checkout never rebuild a client the other is running. **Reserved ports.** A port is claimed by creating a directory under `/tmp/moq-test-ports-` (`MOQ_TEST_PORTS`), held for the whole run, and diff --git a/test/interop/README.md b/test/interop/README.md index ab969fc1a0..956eaa42af 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -28,7 +28,7 @@ player survive the publication lifecycle. See [Media QA](#media-qa). | Client | Source under test | Built with | Roles | |---|---|---|---| | Rust | `rs/moq-relay` + `rs/moq-cli` | `cargo build` | publish (video) + subscribe | -| Python | `py/moq-rs` (+ `rs/moq-ffi`, import `moq`) | `just py build` (maturin editable into `.venv`) | publish (video + audio) + subscribe | +| Python | `py/moq-rs` (+ `rs/moq-ffi`, import `moq`) | `uv build` wheels (maturin + hatchling), installed into a venv in the run directory | publish (video + audio) + subscribe | | Go | `go/wrapper` (+ `rs/moq-ffi`, import `moq-go/moq`) | `go/scripts/stage.sh` (uniffi-bindgen-go) + `go build` | publish (video + audio) + subscribe | | Browser | `js/watch` + `js/publish` | `vite build` + headless Chromium (Playwright) | publish (video + audio) + rendered playback | | Native JS | `js/net` + `js/hang` + the npm `@moq/web-transport` polyfill | `node` (tsx) and `bun` | subscribe | diff --git a/test/interop/clients/js/driver.ts b/test/interop/clients/js/driver.ts index b04fceca21..508cf30f47 100644 --- a/test/interop/clients/js/driver.ts +++ b/test/interop/clients/js/driver.ts @@ -1,5 +1,5 @@ /** - * Drives a headless Chromium against the vite-built page (dist/) for the interop matrix. publish + * Drives a headless Chromium against the vite-built page (see `serve`) for the interop matrix. publish * streams fake camera/microphone input until killed; subscribe verifies rendered playback, * pause/resume, and optionally browser-to-browser audio. * diff --git a/test/interop/clients/js/harness.ts b/test/interop/clients/js/harness.ts index 4a7dd6ed16..bd59af1602 100644 --- a/test/interop/clients/js/harness.ts +++ b/test/interop/clients/js/harness.ts @@ -75,9 +75,15 @@ export const POLL_INTERVAL_MS = 100; /** Sleep for `ms`. */ export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -/** Serve the prebuilt page on localhost, a secure context so WebTransport and WebCodecs are enabled. */ +/** + * Serve the prebuilt page on localhost, a secure context so WebTransport and WebCodecs are enabled. + * + * `interop.sh` builds the page into its run directory, so a concurrent run's rebuild cannot empty + * it mid-load; outside a harness run, it is vite's default `dist/`. + */ export function serve(): { origin: string; stop: () => void } { - const root = join(new URL(".", import.meta.url).pathname, "dist"); + const run = process.env.MOQ_TEST_RUN; + const root = run ? join(run, "js-dist") : join(new URL(".", import.meta.url).pathname, "dist"); const server = Bun.serve({ port: 0, async fetch(req) { diff --git a/test/interop/interop.sh b/test/interop/interop.sh index e2c8ecd884..0b7a45a1e6 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -188,23 +188,31 @@ build_relay_cli() { [[ -n "$MOQ" ]] || MOQ="$TARGET_BASE/$PROFILE/moq" } -# Editable-install the workspace Python build (maturin builds rs/moq-ffi, then -# the moq-rs wrapper installs on top) into the repo-root .venv. `import moq` -# then resolves to this checkout, not a PyPI wheel. +# Build the workspace Python packages as wheels (maturin builds rs/moq-ffi, hatchling +# the moq-rs wrapper) and install them into a venv in the run directory, so `import moq` +# resolves to this checkout rather than a PyPI wheel. The shared .venv is off limits: a +# concurrent run's `just py build` uninstalls the package there while this run's +# clients are importing it. prepare_python() { have uv || { mark_broken python "uv not found" return } - echo "building python client (workspace moq via maturin)..." - if (cd "$WORKSPACE" && just py build) >"$HARNESS_RUN/py-build.log" 2>&1; then - PY="$WORKSPACE/.venv/bin/python" - [[ -x "$PY" ]] || { - mark_broken python "workspace .venv python not found after build" - sed 's/^/ /' "$HARNESS_RUN/py-build.log" >&2 || true - } + echo "building python client (workspace moq wheels)..." + local venv="$HARNESS_RUN/py-venv" wheels="$HARNESS_RUN/py-wheels" + # maturin stages the bindings at a fixed path under the cargo target dir, so one + # build at a time per target. Debug like the other clients; its build backend + # defaults to release. + if (cd "$WORKSPACE" && + harness_locked "$TARGET_BASE/.moq-test-maturin.lock" \ + env MATURIN_PEP517_ARGS="--profile dev --locked" \ + uv build --wheel --package moq-ffi --out-dir "$wheels" && + uv build --wheel --package moq-rs --out-dir "$wheels" && + uv venv "$venv" && + uv pip install --python "$venv/bin/python" --no-deps "$wheels"/*.whl) >"$HARNESS_RUN/py-build.log" 2>&1; then + PY="$venv/bin/python" else - mark_broken python "just py build failed" + mark_broken python "wheel build failed" sed 's/^/ /' "$HARNESS_RUN/py-build.log" >&2 || true fi } @@ -230,7 +238,9 @@ prepare_js() { elif ! (cd "$CLIENTS/js" && bun run check) >"$HARNESS_RUN/js-check.log" 2>&1; then mark_broken js "type check failed" sed 's/^/ /' "$HARNESS_RUN/js-check.log" >&2 || true - elif ! (cd "$CLIENTS/js" && bunx vite build) >"$HARNESS_RUN/js-vite.log" 2>&1; then + # Into the run directory, where harness.ts serves it from: vite empties its output + # first, so a shared dist/ vanishes under a concurrent run's page loads. + elif ! (cd "$CLIENTS/js" && bunx vite build --outDir "$HARNESS_RUN/js-dist" --emptyOutDir) >"$HARNESS_RUN/js-vite.log" 2>&1; then mark_broken js "vite build failed" sed 's/^/ /' "$HARNESS_RUN/js-vite.log" >&2 || true fi diff --git a/test/justfile b/test/justfile index a9a777d330..1c88dcb677 100644 --- a/test/justfile +++ b/test/justfile @@ -23,10 +23,13 @@ default: @just --justfile {{ justfile() }} --list test # Port isolation across private temp roots and keyboard activation under a canvas. -# The browser check is not `*.test.ts`: plain `bun test` runs lack Playwright Chromium. +# The browser check is not `*.test.ts`: plain `bun test` runs lack Playwright Chromium, +# which this recipe installs, as the interop and wasm harnesses do. harness: ./lib/harness.test.sh - bun test ./interop/clients/js/harness.browser.ts + cd .. && bun install --frozen-lockfile + cd interop/clients/js && bunx playwright install chromium + cd interop/clients/js && bun test ./harness.browser.ts # Cross-language media interop test, built from this checkout. Stands up a # relay and runs the publisher x subscriber matrix. Default: rust only (fast diff --git a/test/lib/harness.sh b/test/lib/harness.sh index 7cd50c85e9..de6c3e729a 100644 --- a/test/lib/harness.sh +++ b/test/lib/harness.sh @@ -195,16 +195,25 @@ harness_port() { # Claim one port. Private; `harness_port` is the entry point. harness_port_take() { - local root="$1" port="$2" lock="$1/.lock-$2" + local root="$1" port="$2" + harness_locked "$root/.lock-$port" "$HARNESS_LIB/reserve.sh" "$root" "$port" "$$" "$HARNESS_RUN" || return $? + HARNESS_PORTS+=("$root/$port") +} + +# Run CMD holding an exclusive advisory lock on FILE: `harness_locked `. +# CMD is an executable, not a shell function. For a step that writes somewhere every +# run shares, so concurrent runs take turns instead of clobbering each other. +harness_locked() { + local lock="$1" + shift if command -v flock >/dev/null 2>&1; then - flock "$lock" "$HARNESS_LIB/reserve.sh" "$root" "$port" "$$" "$HARNESS_RUN" || return $? + flock "$lock" "$@" elif command -v lockf >/dev/null 2>&1; then - lockf -k "$lock" "$HARNESS_LIB/reserve.sh" "$root" "$port" "$$" "$HARNESS_RUN" || return $? + lockf -k "$lock" "$@" else - echo "error: port reservations require flock or lockf" >&2 + echo "error: the test harness requires flock or lockf" >&2 return 2 fi - HARNESS_PORTS+=("$root/$port") } # Record an endpoint this run stood up: `harness_endpoint