From 0aff4ac8eb631f79788738b0cb17ee742137e5ec Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 10:30:52 -0700 Subject: [PATCH 01/40] quest: open the cli-inspect line Co-Authored-By: Claude Opus 5.5 From 25bd0673ae0c284626e119b6e1b8969e2bd87dad Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 18:55:33 -0700 Subject: [PATCH 02/40] feat(moq-net)!: announce consumers yield a Live marker once caught up (#4059) Co-authored-by: Claude Opus 5.5 --- doc/concept/moq-lite.md | 17 +- doc/lib/rs/index.md | 5 +- doc/lib/rs/moq-net.md | 18 +- quest/m1/cli-inspect/README.md | 1 - quest/m1/cli-inspect/caught-up.md | 41 --- quest/m1/cli-inspect/ls.md | 7 +- quest/m1/ietf-announce-count.md | 4 - quest/m1/js-announce-caught-up.md | 14 +- rs/hang/examples/subscribe.rs | 11 +- rs/libmoq/src/origin.rs | 18 +- rs/moq-bench/src/connection.rs | 18 +- rs/moq-boy/src/input.rs | 11 +- rs/moq-cli/src/complete.rs | 32 +- rs/moq-ffi/src/origin.rs | 17 +- rs/moq-net/benches/origin.rs | 2 +- rs/moq-net/src/client.rs | 31 ++ rs/moq-net/src/ietf/publisher.rs | 25 +- rs/moq-net/src/ietf/session.rs | 21 +- rs/moq-net/src/ietf/subscriber.rs | 81 +++-- rs/moq-net/src/lite/publisher.rs | 43 ++- rs/moq-net/src/lite/subscriber.rs | 104 +++++- rs/moq-net/src/model/mod.rs | 5 +- rs/moq-net/src/model/origin.rs | 506 ++++++++++++++++++++------ rs/moq-net/tests/caught_up.rs | 76 ++++ rs/moq-net/tests/goaway.rs | 11 +- rs/moq-relay/src/cluster.rs | 37 +- rs/moq-relay/src/internal.rs | 17 +- rs/moq-relay/src/nodes.rs | 11 +- rs/moq-relay/src/web.rs | 4 +- rs/moq-relay/tests/auth_lifetime.rs | 21 +- rs/moq-relay/tests/cluster_unknown.rs | 17 +- rs/moq-relay/tests/drills.rs | 21 +- rs/moq-relay/tests/embed.rs | 17 +- rs/moq-relay/tests/goaway_cluster.rs | 32 +- rs/moq-relay/tests/runtime_uring.rs | 25 +- rs/moq-relay/tests/runtime_workers.rs | 17 +- rs/moq-relay/tests/smoke.rs | 35 +- rs/moq-room/src/room.rs | 9 +- rs/moq-rtc/src/lib.rs | 17 +- rs/moq-stats/src/aggregate.rs | 29 +- rs/moq-stats/src/consume.rs | 21 +- rs/moq-stats/src/produce.rs | 21 +- rs/moq-tokio/examples/clock.rs | 7 +- rs/moq-tokio/src/origin.rs | 21 +- rs/moq-tokio/src/server.rs | 17 +- rs/moq-tokio/tests/backend.rs | 21 +- rs/moq-tokio/tests/broadcast.rs | 138 +++---- 47 files changed, 1243 insertions(+), 431 deletions(-) delete mode 100644 quest/m1/cli-inspect/caught-up.md create mode 100644 rs/moq-net/tests/caught_up.rs diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 6c1fd65b96..e8ef1a555a 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -88,12 +88,17 @@ 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 -happened to it: Rust -`announce::Update { path, captures: Option>, route, kind }` and -TypeScript `Announce.Update { path, captures, route, kind }`, where the kind is -announced, updated (a reprice in place), or retracted. Captures are present when -the announced prefix pins every wildcard in the most-specific matching scope -member. The Rust consumer is a `Stream` and the TypeScript one an async iterable. +happened to it: Rust `announce::Event::{Announced, Updated, Retracted}`, each +holding an `announce::Announce { prefix, captures: Option>, route }`, +and TypeScript `Announce.Update { path, captures, route, kind }`, where the kind +is announced, updated (a reprice in place), or retracted. Captures are present +when the announced prefix pins every wildcard in the most-specific matching +scope member. The Rust consumer is a `Stream` and the TypeScript one an async +iterable. The Rust consumer also yields one `announce::Event::Live` once the +routes live at subscribe time have all been delivered, including those a peer +session was still sending: moq-lite-05+ counts them in `ANNOUNCE_OK`, +moq-lite-01/02 send them in `ANNOUNCE_INIT`, and older or IETF sessions wait +for the stream to go quiet. Announcements are hints; requests are the authority. When a subscriber asks for a covered path the advertiser will not serve, the advertiser refuses that diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 17cd466441..4ab44798c9 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -50,8 +50,9 @@ let session = client.with_origin(origin.clone()).connect(url); // Subscribe: wait for a route, resolve the broadcast at its path, read the catalog. let consumer = origin.consume(); let mut announced = consumer.announced(); -while let Some(update) = announced.next().await { - if !update.kind.is_active() { continue } +while let Some(event) = announced.next().await { + // Skip retractions, and `Live`, which marks the end of what was already live. + let moq_net::announce::Event::Announced(update) = event else { continue }; let broadcast = consumer.request_broadcast(&update.prefix).await?; let catalog = broadcast .track(hang::Catalog::DEFAULT_NAME)? diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 33f67336aa..0f30796e69 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -126,14 +126,16 @@ overlaps that scope; exact creates and requests must match it, so a broad route can advertise the wire-compatible prefix while excluded requests are refused locally. A disjoint route is `Unauthorized`. -`origin.consume().announced()` yields `announce::Update` values: `path` is the -covered prefix relative to the consumer's root, `kind` is `Announced`, -`Updated` (a reprice in place), or `Retracted`, `captures` reports what the -most specific matching scope member's wildcards stood for when the prefix -pins them, and `route` carries hops and cost (on a retraction, its last -values). The consumer is also a `futures::Stream`. A prefix is not a -broadcast name; sessions request each scope member's literal head and filter -locally. +`origin.consume().announced()` yields `announce::Event`s: `Announced`, +`Updated` (a reprice in place), or `Retracted`, each holding an +`announce::Announce` with `prefix`, the covered prefix relative to the +consumer's root; `captures`, what the most specific matching scope member's +wildcards stood for when the prefix pins them; and `route`, its hops and cost +(on a retraction, its last values). A single `Event::Live` follows +the routes live at subscribe time, including every route a connected peer +was still sending, so a caller listing what is live stops there. The +consumer is also a `futures::Stream`. A prefix is not a broadcast name; +sessions request each scope member's literal head and filter locally. ## Limiting reads diff --git a/quest/m1/cli-inspect/README.md b/quest/m1/cli-inspect/README.md index 0ff452df40..35315ac840 100644 --- a/quest/m1/cli-inspect/README.md +++ b/quest/m1/cli-inspect/README.md @@ -20,6 +20,5 @@ This README owns the guide, written once both verbs land: a new ## Quests -- [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 - [Fetch](/quest/m1/cli-inspect/fetch.md) - `moq fetch` writes one group of a track to stdout, raw or as 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 index a5791a8d6a..67307711f7 100644 --- a/quest/m1/cli-inspect/ls.md +++ b/quest/m1/cli-inspect/ls.md @@ -14,6 +14,9 @@ it exits non-zero. ## Plan +- Snapshot mode reads the announce cursor up to its `announce::Event::Live` + marker, which follows the relay's whole initial set; `--follow` prints + through it. - 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. @@ -23,7 +26,3 @@ it exits non-zero. - 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/ietf-announce-count.md b/quest/m1/ietf-announce-count.md index 5c5b92695f..c077ad10bc 100644 --- a/quest/m1/ietf-announce-count.md +++ b/quest/m1/ietf-announce-count.md @@ -18,7 +18,3 @@ instead of the settle timer. Without the extension the timer stays. 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/js-announce-caught-up.md b/quest/m1/js-announce-caught-up.md index 46785d11e4..b537acba01 100644 --- a/quest/m1/js-announce-caught-up.md +++ b/quest/m1/js-announce-caught-up.md @@ -2,17 +2,15 @@ ## 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 +`@moq/net`'s announce consumer yields the same `Live` marker as the Rust +`announce::Consumer`, 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. Test the same cases +Mirror the Rust shape (one flat `announce::Event`: `Announced`, `Updated`, +`Retracted`, each holding an `Announce`, or `Live`), its +per-source guards (a session holds one per announce stream until the count, +ANNOUNCE_INIT, or a quiet stream lands it), and the marker-less fallback. 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/rs/hang/examples/subscribe.rs b/rs/hang/examples/subscribe.rs index eb1614df05..a832f96f96 100644 --- a/rs/hang/examples/subscribe.rs +++ b/rs/hang/examples/subscribe.rs @@ -43,9 +43,14 @@ async fn run_session(origin: moq_net::origin::Producer) -> anyhow::Result<()> { async fn run_subscribe(consumer: moq_net::origin::Consumer) -> anyhow::Result<()> { // Wait for a route to be announced, then resolve the broadcast at its path. // The convention is that a publisher announces each broadcast's exact path. - let update = consumer.announced().next().await.context("origin closed")?; - anyhow::ensure!(update.kind.is_active(), "route retracted: {}", update.prefix); - let path = update.prefix; + let mut announced = consumer.announced(); + let path = loop { + match announced.next().await.context("origin closed")? { + moq_net::announce::Event::Announced(announce) => break announce.prefix, + // Nothing else can come before the first announcement. + event => anyhow::ensure!(matches!(event, moq_net::announce::Event::Live), "unexpected {event:?}"), + } + }; tracing::info!(%path, "broadcast announced"); let broadcast = consumer.request_broadcast(&path).await?; diff --git a/rs/libmoq/src/origin.rs b/rs/libmoq/src/origin.rs index 23451023a3..04a77321bd 100644 --- a/rs/libmoq/src/origin.rs +++ b/rs/libmoq/src/origin.rs @@ -54,7 +54,7 @@ struct AnnouncedRecord { unsafe impl Send for AnnouncedRecord {} impl AnnouncedRecord { - fn new(update: moq_net::announce::Update) -> Self { + fn new(update: moq_net::announce::Announce, active: bool) -> Self { let captures = update.captures.map(|captures| { captures .into_iter() @@ -75,7 +75,7 @@ impl AnnouncedRecord { prefix: update.prefix.to_string(), captures, capture_views, - active: update.kind.is_active(), + active, } } } @@ -143,17 +143,25 @@ impl Origin { ) -> Result<(), Error> { loop { // `biased` so a pending close always wins over a ready announcement. - let update = tokio::select! { + let (update, active) = tokio::select! { biased; _ = &mut close => return Ok(()), next = consumer.next() => match next { - Some(announced) => announced, + Some(moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update)) => { + (update, true) + } + Some(moq_net::announce::Event::Retracted(update)) => (update, false), + // The C API has no caught-up callback yet. + Some(moq_net::announce::Event::Live) => continue, None => return Ok(()), }, }; // Hold the lock only to buffer the announcement; release it before the callback. - let announced_id = State::lock().origin.announced.insert(AnnouncedRecord::new(update))?; + let announced_id = State::lock() + .origin + .announced + .insert(AnnouncedRecord::new(update, active))?; callback.call(announced_id); } } diff --git a/rs/moq-bench/src/connection.rs b/rs/moq-bench/src/connection.rs index 1502ab5c87..9dcc527ec8 100644 --- a/rs/moq-bench/src/connection.rs +++ b/rs/moq-bench/src/connection.rs @@ -335,10 +335,11 @@ async fn subscribe( biased; _ = &mut deadline => break, update = announced.next() => { - let Some(update) = update else { break }; - if !update.kind.is_active() { - continue; - } + let update = match update { + Some(moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update)) => update, + Some(moq_net::announce::Event::Retracted(_) | moq_net::announce::Event::Live) => continue, + None => break, + }; let path = update.prefix.to_string(); if own.contains(&path) || !seen.insert(path.clone()) { continue; @@ -360,12 +361,11 @@ async fn subscribe( // Top up from late announcements, first-come: the pool was too small, so // there is nothing to spread over. while selected < want { - let Some(update) = announced.next().await else { - break; + let update = match announced.next().await { + Some(moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update)) => update, + Some(moq_net::announce::Event::Retracted(_) | moq_net::announce::Event::Live) => continue, + None => break, }; - if !update.kind.is_active() { - continue; - } let path = update.prefix.to_string(); if own.contains(&path) || !seen.insert(path.clone()) { continue; diff --git a/rs/moq-boy/src/input.rs b/rs/moq-boy/src/input.rs index cf6e658ea7..d8e48b6432 100644 --- a/rs/moq-boy/src/input.rs +++ b/rs/moq-boy/src/input.rs @@ -66,13 +66,18 @@ pub async fn handle_viewers( ) -> anyhow::Result<()> { let mut announced = viewer_origin.announced(); loop { - let Some(update) = announced.next().await else { - break; + let (update, active) = match announced.next().await { + Some(moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update)) => { + (update, true) + } + Some(moq_net::announce::Event::Retracted(update)) => (update, false), + Some(moq_net::announce::Event::Live) => continue, + None => break, }; let viewer_id = update.prefix.to_string(); - if update.kind.is_active() { + if active { let Ok(broadcast) = viewer_origin.request_broadcast(&update.prefix).await else { continue; }; diff --git a/rs/moq-cli/src/complete.rs b/rs/moq-cli/src/complete.rs index 104f64fac2..d495eedd08 100644 --- a/rs/moq-cli/src/complete.rs +++ b/rs/moq-cli/src/complete.rs @@ -47,13 +47,6 @@ const BUDGET: Duration = Duration::from_millis(500); /// (a blocking device enumeration) still cannot hold the answer back. const CEILING: Duration = Duration::from_millis(1_500); -/// How long the announce sweep waits for a sibling after an announcement lands. -/// -/// A relay sends its whole announced set back to back, so the gap between the -/// first and the last is a round trip, not a budget. Without this the sweep would -/// always cost [`BUDGET`], even when the answer arrived in a few milliseconds. -const SETTLE: Duration = Duration::from_millis(30); - /// The completers every build has, keyed by the *value* name they answer for. /// /// The value name, not the flag: that is what Usage matches an overlay on, and the @@ -481,21 +474,22 @@ fn broadcasts(_ctx: CompleteCtx<'_>) -> CompletionFuture<'static> { // sweep is running is one the user cannot name by the time they press enter. let mut announced = origin.consume().announced(); let mut live = BTreeSet::new(); - // The first announcement gets the whole remaining budget; each one after it - // only has to beat its siblings, which are already on the wire. - let mut until = deadline; - while let Ok(Some(update)) = timeout_at(until, announced.next()).await { - until = deadline.min(Instant::now() + SETTLE); - let path = update.prefix.to_string(); + // The marker says the relay's whole set has arrived; the budget only bounds + // a relay that is slow to send it. + while let Ok(Some(event)) = timeout_at(deadline, announced.next()).await { // The root broadcast is the connection path itself, which an unset // `--broadcast` already names; there is no word to insert for it. - if path.is_empty() { - continue; + match event { + moq_net::announce::Event::Announced(announce) | moq_net::announce::Event::Updated(announce) => { + if !announce.prefix.is_empty() { + live.insert(announce.prefix.to_string()); + } + } + moq_net::announce::Event::Retracted(announce) => { + live.remove(announce.prefix.as_str()); + } + moq_net::announce::Event::Live => break, } - match update.kind.is_active() { - true => live.insert(path), - false => live.remove(&path), - }; } drop(connection); diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index 0169459ed9..250b2d5929 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -143,14 +143,25 @@ impl OriginDynamic { impl Announced { async fn next(&mut self) -> Result>, MoqError> { - match self.inner.next().await { - Some(update) => Ok(Some(Arc::new(MoqAnnounceUpdate { + // The bindings have no caught-up marker yet. + let update = loop { + match self.inner.next().await { + Some(moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update)) => { + break Some((update, true)); + } + Some(moq_net::announce::Event::Retracted(update)) => break Some((update, false)), + Some(moq_net::announce::Event::Live) => continue, + None => break None, + } + }; + match update { + Some((update, active)) => Ok(Some(Arc::new(MoqAnnounceUpdate { prefix: update.prefix.to_string(), captures: update .captures .map(|captures| captures.into_iter().map(|capture| capture.to_string()).collect()), route: update.route.into(), - active: update.kind.is_active(), + active, }))), None => Ok(None), } diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 04e1be686e..58478c3021 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -267,7 +267,7 @@ fn bench_subscribe(c: &mut Criterion) { b.iter(|| { let mut cursor = fleet.consumer.announced(); let mut replayed = 0; - while cursor.next().now_or_never().flatten().is_some() { + while let Some(announce::Event::Announced(_)) = cursor.next().now_or_never().flatten() { replayed += 1; } assert_eq!(replayed, publishers); diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 4f567d715b..c96cf6659a 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -735,6 +735,37 @@ mod tests { .expect("connect failed"); } + /// A peer that never delivers its announce count cannot stall the live + /// marker past its session: dropping the session lands the source. + #[tokio::test(start_paused = true)] + async fn a_dead_session_does_not_hold_the_live_marker() { + let gate = kio::Producer::new(true); + let transport = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()) + .with_protocol(crate::version::ALPN_LITE_05); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let client = Client::new() + .with_versions([Version::Lite(lite::Version::Lite05)].into()) + .with_subscriber(origin.clone()); + let (session, driver) = client + .connect(tokio::time::Instant::now().into_std(), transport) + .await + .expect("connect failed"); + + let mut announced = origin.consume().announced(); + let mut next = std::pin::pin!(announced.next()); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(5), next.as_mut()) + .await + .is_err(), + "live before the peer answered" + ); + + drop(driver); + drop(session); + assert!(matches!(next.await, Some(crate::announce::Event::Live))); + } + #[tokio::test(start_paused = true)] async fn alpn_lite_falls_back_to_draft14_and_switches_version_post_setup() { run_alpn_lite_fallback_case(Some(ALPN_LITE)).await; diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index ec5332f378..c40bdeebe6 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -214,8 +214,9 @@ impl Namespaces { enum NamespaceEvent { /// The session or stream ended, with the result to surface. Closed(Result<(), Error>), - /// An origin-level route (un)announce, `None` once the announce stream ends. - Update(Option), + /// An origin-level route (un)announce and whether it is active, `None` once + /// the announce stream ends. + Update(Option<(crate::announce::Announce, bool)>), /// The retry sleep fired: re-offer whatever the peer should be holding and isn't. Retry, } @@ -1777,8 +1778,20 @@ where if let Poll::Ready(res) = target.poll_closed(&mut cx) { return Poll::Ready(NamespaceEvent::Closed(res)); } - if let Poll::Ready(update) = announced.poll_next(waiter) { - return Poll::Ready(NamespaceEvent::Update(update)); + // The origin's live marker means nothing to the peer here. + while let Poll::Ready(next) = announced.poll_next(waiter) { + match next { + Some(crate::announce::Event::Live) => continue, + Some( + crate::announce::Event::Announced(update) | crate::announce::Event::Updated(update), + ) => { + return Poll::Ready(NamespaceEvent::Update(Some((update, true)))); + } + Some(crate::announce::Event::Retracted(update)) => { + return Poll::Ready(NamespaceEvent::Update(Some((update, false)))); + } + None => return Poll::Ready(NamespaceEvent::Update(None)), + } } if retry.poll(waiter).is_ready() { return Poll::Ready(NamespaceEvent::Retry); @@ -1819,14 +1832,14 @@ where stream.writer.finish()?; return stream.writer.closed().await; } - NamespaceEvent::Update(Some(update)) => { + NamespaceEvent::Update(Some((update, active))) => { let path = update.prefix; let suffix = path .strip_prefix(&prefix) .expect("origin returned invalid prefix") .to_owned(); - if update.kind.is_active() { + if active { // A repeat for a live suffix is a metadata update: keep the // peer's refusal state and re-run the selection. match ns.watched.get_mut(&suffix) { diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 92093e65ef..f12351c168 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -8,8 +8,10 @@ use crate::{ }; use super::{ - Control, Message, Publisher, Subscriber, Version, adapter::ControlStreamAdapter, cluster, peer, solicit, - subscriber::is_protocol_violation, + Control, Message, Publisher, Subscriber, Version, + adapter::ControlStreamAdapter, + cluster, peer, solicit, + subscriber::{is_protocol_violation, subscribe_prefixes}, }; /// Everything one moq-transport session needs to start. @@ -90,6 +92,10 @@ where // server to open connections (draft-19 sect 10.4). let (goaway_handle, goaway) = crate::goaway::Handle::new(!client); + // One SUBSCRIBE_NAMESPACE per permitted prefix, like `lite::Subscriber`: the + // scope is what we may ask for, and it is not the origin's root. + let namespaces = subscribe.as_ref().map(subscribe_prefixes).unwrap_or_default(); + let driver = async move { // Our own Hop ID, taken from whichever origin the caller actually supplied so // every session out of this process stamps the same one and cross-session loop @@ -203,11 +209,9 @@ where // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; // see `Publisher::run_publish_namespaces`. let mut pub_ns_run = std::pin::pin!(err_only(publisher.clone().run_publish_namespaces())); - // One SUBSCRIBE_NAMESPACE per permitted prefix, like `lite::Subscriber`: - // the scope is what we may ask for, and it is not the origin's root. let mut sub_ns_run = std::pin::pin!(err_only(async { let mut prefixes = futures::stream::FuturesUnordered::new(); - for prefix in sub_ns.subscribe_prefixes() { + for (prefix, replaying) in namespaces { let mut sub_ns = sub_ns.clone(); let sub_ns_adapter = sub_ns_adapter.clone(); prefixes.push(async move { @@ -222,7 +226,7 @@ where } _ => Stream::open(&mut sub_ns_adapter.clone(), version).await?, }; - if let Err(err) = sub_ns.run_subscribe_namespace(stream, prefix).await { + if let Err(err) = sub_ns.run_subscribe_namespace(stream, prefix, replaying).await { // The peer breaking the protocol is fatal, and the driver // below turns this into the session close the draft wants. if is_protocol_violation(&err) { @@ -340,16 +344,15 @@ where // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; // see `Publisher::run_publish_namespaces`. let mut pub_ns_run = std::pin::pin!(err_only(publisher.clone().run_publish_namespaces())); - // One SUBSCRIBE_NAMESPACE per permitted prefix; see the draft-16 arm. let mut sub_ns_run = std::pin::pin!(err_only(async { let mut prefixes = futures::stream::FuturesUnordered::new(); - for prefix in sub_ns.subscribe_prefixes() { + for (prefix, replaying) in namespaces { let mut sub_ns = sub_ns.clone(); let sub_ns_session = sub_ns_session.clone(); prefixes.push(async move { let mut sub_ns_session = sub_ns_session; let stream = Stream::open(&mut sub_ns_session, version).await?; - if let Err(err) = sub_ns.run_subscribe_namespace(stream, prefix).await { + if let Err(err) = sub_ns.run_subscribe_namespace(stream, prefix, replaying).await { // The peer breaking the protocol is fatal, and the driver // below turns this into the session close the draft wants. if is_protocol_violation(&err) { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 1b2fab1598..b973b0033f 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -415,6 +415,28 @@ pub(super) struct Subscriber { going_away: crate::goaway::GoingAway, } +/// The prefixes to issue SUBSCRIBE_NAMESPACE for: `origin`'s permitted scope, +/// relative to its root. +/// +/// The scope is what we may ASK the peer for; the root is where what comes back +/// MOUNTS locally. Those are independent, and only coincide when the peer shares +/// our namespace -- a peer outside it has never heard of our root, so a rooted +/// subscriber asks for its scope and mounts the replies under the root. +/// +/// Asked unconditionally, without waiting on the peer's SETUP: a peer with nothing to +/// advertise answers with an empty set, which costs one stream, while waiting to find +/// out costs a round trip on every session. +/// +/// Each comes with the guard its stream holds until the peer's initial set has +/// landed. Taken when the session starts, before it is handed out, so no announce +/// cursor misses the replay. +pub(super) fn subscribe_prefixes(origin: &origin::Producer) -> Vec<(PathOwned, crate::model::Replaying)> { + crate::model::interest_prefixes(&origin.allowed()) + .into_iter() + .map(|prefix| (prefix.clone(), origin.replaying(prefix))) + .collect() +} + /// Resolve the subscription a data stream belongs to. /// /// SUBSCRIBE_OK can be reordered behind the stream it describes, so an alias we have not @@ -626,21 +648,6 @@ where Some(track) } - /// The prefixes to issue SUBSCRIBE_NAMESPACE for: this handle's permitted scope, - /// relative to its root. - /// - /// The scope is what we may ASK the peer for; the root is where what comes back - /// MOUNTS locally. Those are independent, and only coincide when the peer shares - /// our namespace -- a peer outside it has never heard of our root, so a rooted - /// subscriber asks for its scope and mounts the replies under the root. - /// - /// Asked unconditionally, without waiting on the peer's SETUP: a peer with nothing to - /// advertise answers with an empty set, which costs one stream, while waiting to find - /// out costs a round trip on every session. - pub fn subscribe_prefixes(&self) -> Vec { - crate::model::interest_prefixes(&self.origin.allowed()) - } - /// Send SUBSCRIBE_NAMESPACE for one prefix on a bidi stream. /// The caller is responsible for opening the appropriate stream type /// (virtual for v14/v15, real bidi for v16+), one per prefix. @@ -652,6 +659,7 @@ where &mut self, mut stream: Stream, prefix: PathOwned, + replaying: crate::model::Replaying, ) -> Result<(), Error> { // A peer that sent GOAWAY told us to stop opening requests on this session, // announce-interest included (draft-19 sect 10.4). @@ -714,6 +722,10 @@ where tracing::debug!(%prefix, "subscribe_namespace ok"); + // Nothing on this wire says where the initial set ends, so it has landed once + // the stream goes quiet. + let mut landing = Some((replaying, crate::model::Quiet::new(&self.runtime))); + // The extension changes the NAMESPACE encoding, so we can't parse one until // the peer's SETUP says whether it negotiated. let peer = self.peer().await; @@ -731,7 +743,9 @@ where // its channel without being withdrawn, so hold the front open for a reconnect. // This is what moq-lite already does, where the equivalent map is a local whose // guards drop. - let res = self.run_namespace_entries(&mut stream, &prefix, &peer, &mut live).await; + let res = self + .run_namespace_entries(&mut stream, &prefix, &peer, &mut live, &mut landing) + .await; for path in live { let _ = self.stop_announce(path, Detach::Abrupt); } @@ -749,12 +763,29 @@ where prefix: &PathOwned, peer: &cluster::Peer, live: &mut std::collections::HashSet, + // The replay guard and its quiet timer, until the initial set has landed. + landing: &mut Option<(crate::model::Replaying, crate::model::Quiet)>, ) -> Result<(), Error> { loop { - let type_id: u64 = match stream.reader.decode_maybe().await? { + let next = { + let mut decode = std::pin::pin!(stream.reader.decode_maybe::()); + kio::wait(|waiter| { + if let Some((_, quiet)) = landing + && quiet.poll(waiter).is_ready() + { + *landing = None; + } + waiter.poll_future(decode.as_mut()) + }) + .await + }; + let type_id: u64 = match next? { Some(id) => id, None => break, // Stream closed }; + if let Some((_, quiet)) = landing { + quiet.heard(); + } let size: u16 = stream.reader.decode().await?; let mut data = stream.reader.read_exact(size as usize).await?; @@ -2894,14 +2925,16 @@ mod tests { Default::default(), ); + let mut namespaces = subscribe_prefixes(&subscriber.origin); assert_eq!( - subscriber.subscribe_prefixes(), + namespaces.iter().map(|(prefix, _)| prefix.clone()).collect::>(), vec![crate::Path::new("cam").to_owned()], "one SUBSCRIBE_NAMESPACE per permitted prefix, relative to the root", ); + let (prefix, replaying) = namespaces.pop().unwrap(); let stream = Stream::open(&mut session.clone(), Version::Draft16).await.unwrap(); - let mut run = std::pin::pin!(subscriber.run_subscribe_namespace(stream, crate::Path::new("cam").to_owned())); + let mut run = std::pin::pin!(subscriber.run_subscribe_namespace(stream, prefix, replaying)); // Parks awaiting the peer's response; the request is already on the wire. assert!(futures::poll!(run.as_mut()).is_pending()); @@ -2966,10 +2999,10 @@ mod tests { Default::default(), ); - let prefix = subscriber.subscribe_prefixes().pop().expect("one prefix"); + let (prefix, replaying) = subscribe_prefixes(&subscriber.origin).pop().expect("one prefix"); let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); // Parks on the read after the scripted NAMESPACE is consumed. - let mut run = std::pin::pin!(subscriber.run_subscribe_namespace(stream, prefix)); + let mut run = std::pin::pin!(subscriber.run_subscribe_namespace(stream, prefix, replaying)); for _ in 0..100 { // The result is deliberately ignored: a regressed mount lands out of scope // and errors here, which the assertions below name far better than a poll @@ -3999,7 +4032,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); subscriber - .run_subscribe_namespace(stream, crate::Path::new("").to_owned()) + .run_subscribe_namespace(stream, crate::Path::new("").to_owned(), subscriber.origin.replaying("")) .await .expect("a clean FIN is not an error"); settle().await; @@ -4069,7 +4102,9 @@ mod tests { ); let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); - let mut run = std::pin::pin!(subscriber.run_subscribe_namespace(stream, crate::Path::new("").to_owned())); + let replaying = subscriber.origin.replaying(""); + let mut run = + std::pin::pin!(subscriber.run_subscribe_namespace(stream, crate::Path::new("").to_owned(), replaying)); for _ in 0..100 { assert!( futures::poll!(run.as_mut()).is_pending(), diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index e618843c2d..cb7274fd10 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -601,7 +601,7 @@ 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 { + fn suffix(&self, update: &announce::Announce) -> crate::PathOwned { update .prefix .strip_prefix(&self.prefix) @@ -685,11 +685,18 @@ impl AnnounceRun { // Send ANNOUNCE_INIT as the first message with all currently active routes. // We use `try_next()` to synchronously get the initial updates. - while let Some(update) = announced.try_next() { + while let Some(event) = announced.try_next() { + let (update, active) = match event { + announce::Event::Announced(update) | announce::Event::Updated(update) => (update, true), + announce::Event::Retracted(update) => (update, false), + // The marker only says the origin caught up; the peer learns the + // initial set's end from the version's own framing. + announce::Event::Live => continue, + }; let absolute = origin.absolute(&update.prefix); let suffix = self.suffix(&update); - if update.kind.is_active() { + if active { if self.outgoing(&update.route, &absolute).is_none() { continue; } @@ -713,11 +720,18 @@ impl AnnounceRun { // them afterward. The receiver stamps our origin onto each hop chain, so we // forward the stored chain as-is (no self push here). let mut initial: Vec<(crate::PathOwned, Hops, crate::origin::Cost)> = Vec::new(); - while let Some(update) = announced.try_next() { + while let Some(event) = announced.try_next() { + let (update, active) = match event { + announce::Event::Announced(update) | announce::Event::Updated(update) => (update, true), + announce::Event::Retracted(update) => (update, false), + // The marker only says the origin caught up; the peer learns the + // initial set's end from the version's own framing. + announce::Event::Live => continue, + }; let absolute = origin.absolute(&update.prefix); let suffix = self.suffix(&update); - if update.kind.is_active() { + if active { let Some((hops, cost)) = self.outgoing(&update.route, &absolute) else { continue; }; @@ -785,18 +799,23 @@ impl AnnounceRun { return Poll::Pending; }; - let Some(update) = next else { - // The buffer is empty (flushed at the loop top), so FIN now and - // wait for the acknowledgement. - stream.writer.finish()?; - self.phase = AnnouncePhase::Closing; - continue; + let (update, active) = match next { + Some(announce::Event::Announced(update) | announce::Event::Updated(update)) => (update, true), + Some(announce::Event::Retracted(update)) => (update, false), + Some(announce::Event::Live) => continue, + None => { + // The buffer is empty (flushed at the loop top), so FIN now and + // wait for the acknowledgement. + stream.writer.finish()?; + self.phase = AnnouncePhase::Closing; + continue; + } }; let absolute = origin.absolute(&update.prefix); let suffix = self.suffix(&update); - if !update.kind.is_active() { + if !active { self.retract(stream, suffix, &absolute)?; continue; } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 729a23e4bc..93d22504da 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1060,6 +1060,20 @@ struct AnnouncePrefix { subscriber: Subscriber, prefix: PathOwned, state: PrefixState, + /// Held from the request until the peer's initial set has landed in the + /// origin, so announce cursors know when they have caught up. Taken at + /// construction, before the session is handed out, so no cursor misses it. + replaying: Option, +} + +/// How the peer's initial set ends on this version. +enum Landing { + /// Lite05+: after this many more announces, the count in ANNOUNCE_OK. + Count(u64), + /// Lite03/04 carry no boundary: once the stream goes quiet. + Quiet(crate::model::Quiet), + /// It has landed. Lite01/02 land it in ANNOUNCE_INIT, before the run. + Landed, } enum PrefixState { @@ -1073,6 +1087,8 @@ enum PrefixState { Cost { stream: Stream, responder_origin: Option, + /// Lite05+: the initial set's size, from ANNOUNCE_OK. + active: Option, }, /// Lite01/02: reading the ANNOUNCE_INIT set. ReadInit { stream: Stream, run: PrefixRun }, @@ -1095,17 +1111,32 @@ struct PrefixRun { // but a peer may. next_announce_id: u64, announced_by_id: HashMap, + landing: Landing, } impl AnnouncePrefix { fn new(subscriber: Subscriber, prefix: PathOwned) -> Self { Self { + replaying: Some(subscriber.origin.replaying(&prefix)), subscriber, prefix, state: PrefixState::Open, } } + /// Drop the guard once the initial set has landed. + fn poll_landing(replaying: &mut Option, landing: &mut Landing, waiter: &kio::Waiter) { + let landed = match landing { + Landing::Count(remaining) => *remaining == 0, + Landing::Quiet(quiet) => quiet.poll(waiter).is_ready(), + Landing::Landed => return, + }; + if landed { + *landing = Landing::Landed; + *replaying = None; + } + } + fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let mut cx = std::task::Context::from_waker(waiter.waker()); loop { @@ -1140,16 +1171,14 @@ impl AnnouncePrefix { false => PrefixState::Cost { stream, responder_origin: None, + active: None, }, }; } PrefixState::ReadOk { stream } => { // Lite05+: the publisher reports its own origin id, which we stamp onto // every received Announce's hop chain since it no longer does so itself. - // Its `active` count marks where the initial set ends; nothing here needs - // that boundary, so it is read and dropped. Callers that must not race an - // announcement use `origin::Consumer::announced_broadcast`, which waits - // for the path itself. + // Its `active` count marks where the initial set ends. let ok = ready!(stream.reader.poll_decode::(&mut cx))?; // A peer may legally report id 0 (no identity). Keep it: the assigned // identity stays on `via` and is never forwarded as a hop. @@ -1160,6 +1189,7 @@ impl AnnouncePrefix { self.state = PrefixState::Cost { stream, responder_origin: Some(origin), + active: Some(ok.active), }; } PrefixState::Cost { .. } => { @@ -1167,17 +1197,24 @@ impl AnnouncePrefix { let PrefixState::Cost { stream, responder_origin, + active, } = std::mem::replace(&mut self.state, PrefixState::Open) else { unreachable!() }; + let landing = match (active, self.subscriber.version) { + (Some(active), _) => Landing::Count(active), + (None, Version::Lite01 | Version::Lite02) => Landing::Landed, + (None, _) => Landing::Quiet(crate::model::Quiet::new(&self.subscriber.runtime)), + }; let run = PrefixRun { responder_origin, link_cost, announced: Announced::default(), next_announce_id: 0, announced_by_id: HashMap::new(), + landing, }; // Lite01/02 send the initial set as one ANNOUNCE_INIT message, so they @@ -1208,6 +1245,7 @@ impl AnnouncePrefix { else { unreachable!() }; + self.replaying = None; self.state = PrefixState::Run { stream, run }; } PrefixState::Run { stream, run } => { @@ -1225,9 +1263,19 @@ impl AnnouncePrefix { // request that arrives in between: its wake finds no waiter, and // nothing else re-polls this machine. loop { + // Land before decoding past the boundary, so no live update + // enters the origin ahead of the marker. + Self::poll_landing(&mut self.replaying, &mut run.landing, waiter); match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(announce))) => { + // The count is of ANNOUNCE_STARTs, not every message. + let start = matches!(announce, lite::AnnounceBroadcast::Active { .. }); self.subscriber.handle_announce(&self.prefix, announce, run)?; + match &mut run.landing { + Landing::Count(remaining) if start => *remaining = remaining.saturating_sub(1), + Landing::Quiet(quiet) => quiet.heard(), + Landing::Count(_) | Landing::Landed => {} + } } Poll::Ready(Ok(None)) => { // The publisher FINed: it has nothing (more) to announce for this @@ -2413,6 +2461,54 @@ mod tests { announced.retire(&path.clone()); cursor.assert_next_ended("room/host"); } + + /// ANNOUNCE_OK's count lands the initial set at exactly that many + /// ANNOUNCE_STARTs: an unknown message does not count toward it, and a live + /// update buffered right behind the set does not enter the origin before the + /// marker. + #[tokio::test(start_paused = true)] + async fn the_count_lands_at_the_last_initial_start() { + const VERSION: Version = Version::Lite06; + let start = |suffix| lite::AnnounceBroadcast::Active { + suffix: Path::new(suffix), + hops: crate::Hops::new(), + cost: crate::origin::Cost::default(), + }; + let mut script = Vec::new(); + lite::AnnounceOk { + origin: crate::Hop::new(9).unwrap(), + active: 1, + } + .encode(&mut script, VERSION) + .unwrap(); + // An unknown announce type with an empty body, which decodes as `Skipped`. + script.extend([0x3f, 0x00]); + start("a").encode(&mut script, VERSION).unwrap(); + start("b").encode(&mut script, VERSION).unwrap(); + + let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session: crate::lite::test_transport::ScriptedSession::new(script), + origin, + recv_bandwidth: None, + version: VERSION, + peer_setup: Default::default(), + cost: Some(1), + peer_hop: None, + going_away: Default::default(), + }); + let mut prefix = AnnouncePrefix::new(subscriber, Path::new("").to_owned()); + let mut cursor = consumer.announced(); + + let mut run = std::pin::pin!(kio::wait(|waiter| prefix.poll(waiter))); + assert!(futures::poll!(run.as_mut()).is_pending()); + + cursor.assert_next_active("a"); + cursor.assert_next_live(); + cursor.assert_next_active("b"); + } } /// The four wire fields a subscription's half-open range encodes to. diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index 3b259d1540..8f0a7c7f39 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -43,7 +43,7 @@ pub mod origin { /// Subscribing to route (un)announcements from an origin. pub mod announce { - pub use super::origin_impl::{AnnounceConsumer as Consumer, AnnounceKind as Kind, AnnounceUpdate as Update}; + pub use super::origin_impl::{Announce, AnnounceConsumer as Consumer, AnnounceEvent as Event}; } // Hop identity and the `Consume` conversion trait aren't part of a role @@ -53,6 +53,9 @@ pub use origin_impl::{Consume, Hop, Hops, InvalidHop}; // The announce-interest prefixes a scope needs on a prefix-shaped wire. pub(crate) use origin_impl::interest_prefixes; +// Held by a session until the peer's initial announce set has landed. +pub(crate) use origin_impl::{Quiet, Replaying}; + // The advertise-only route guard, for tests shaping the route table. #[cfg(test)] pub(crate) use origin_impl::AnnounceProducer; diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 2f7ab0f77b..89aaca5096 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -602,9 +602,59 @@ struct OriginConsumerState { /// Set by the origin's teardown: the cursor drains `pending`, then reports /// the end instead of parking forever on a table that can never fire again. ended: bool, + /// Progress toward the one [`AnnounceEvent::Live`] marker. + live: LiveState, +} + +/// Where a cursor stands on delivering its initial set. +enum LiveState { + /// This many remote sources pending at subscribe time are still replaying. + Waiting(usize), + /// Every source has landed: the marker follows once these prefixes, pending + /// when the last one landed, have been delivered (or cancelled). + Owed(BTreeSet), + /// The marker was delivered. + Done, +} + +impl Default for LiveState { + fn default() -> Self { + Self::Waiting(0) + } } impl OriginConsumerState { + /// One source the cursor was waiting on has landed its initial set. + fn landed(&mut self) { + if let LiveState::Waiting(count) = &mut self.live { + *count = count.saturating_sub(1); + if *count == 0 { + self.live = LiveState::Owed(self.pending.keys().cloned().collect()); + } + } + } + + /// `prefix` left the pending set (delivered or cancelled), so the marker no + /// longer waits on it. A prefix still pending (the announce trailing a + /// retraction) stays owed. + fn settled(&mut self, prefix: &PathOwned) { + if let LiveState::Owed(owed) = &mut self.live + && !self.pending.contains_key(prefix) + { + owed.remove(prefix); + } + } + + /// Whether the marker is due now. + fn live_due(&self) -> bool { + matches!(&self.live, LiveState::Owed(owed) if owed.is_empty()) + } + + /// Whether [`Self::take`] has something to hand out, or the cursor ended. + fn ready(&self) -> bool { + !self.pending.is_empty() || self.ended || self.live_due() + } + fn apply_announce(&mut self, prefix: PathOwned, meta: RouteMeta, captures: Option>) { let meta = (meta, captures); let new = match self.pending.remove(&prefix) { @@ -623,7 +673,7 @@ impl OriginConsumerState { match self.pending.remove(&prefix) { // The pending announce was never delivered and neither was any earlier // one, so the pair cancels entirely. - Some(PendingUpdate::Announce(_)) if !self.delivered.contains(&prefix) => {} + Some(PendingUpdate::Announce(_)) if !self.delivered.contains(&prefix) => self.settled(&prefix), // Either nothing is pending or the pending announce was a metadata // update on a delivered route; the consumer still owes a retraction. None | Some(PendingUpdate::Announce(_) | PendingUpdate::Unannounce(_)) => { @@ -637,31 +687,37 @@ impl OriginConsumerState { } } - /// Take one update to deliver to the consumer, if any. - fn take(&mut self) -> Option { + /// Take one event to deliver to the consumer, if any: the marker as soon as + /// it is due, otherwise the next update. + fn take(&mut self) -> Option { + if self.live_due() { + self.live = LiveState::Done; + return Some(AnnounceEvent::Live); + } let prefix = self.pending.keys().next()?.clone(); - let ((meta, captures), kind) = match self.pending.remove(&prefix).unwrap() { + let ((meta, captures), kind): (_, fn(Announce) -> AnnounceEvent) = match self.pending.remove(&prefix).unwrap() { PendingUpdate::Announce(meta) => { // The consumer has seen this prefix before, so it is a metadata update. let kind = match self.delivered.insert(prefix.clone()) { - true => AnnounceKind::Announced, - false => AnnounceKind::Updated, + true => AnnounceEvent::Announced, + false => AnnounceEvent::Updated, }; (meta, kind) } PendingUpdate::Unannounce(meta) => { self.delivered.remove(&prefix); - (meta, AnnounceKind::Retracted) + (meta, AnnounceEvent::Retracted) } PendingUpdate::UnannounceAnnounce { old, new } => { // Deliver the retraction now; leave the trailing announce pending so // the next take returns it for the same prefix. self.delivered.remove(&prefix); self.pending.insert(prefix.clone(), PendingUpdate::Announce(new)); - (old, AnnounceKind::Retracted) + (old, AnnounceEvent::Retracted) } }; - Some(AnnounceUpdate { + self.settled(&prefix); + Some(kind(Announce { prefix, captures, route: Route { @@ -669,8 +725,7 @@ impl OriginConsumerState { cost: meta.1, via: Hop::UNKNOWN, }, - kind, - }) + })) } } @@ -938,25 +993,22 @@ pub(crate) fn interest_prefixes(allowed: &Patterns) -> Vec { heads } -/// What an [`AnnounceUpdate`] reports about its path. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum AnnounceKind { - /// A route now covers the path; the cursor had none there. - Announced, - /// The route covering the path changed hops or cost; it is delivered in place. - Updated, - /// No route covers the path any more. - Retracted, -} - -impl AnnounceKind { - /// Whether a route covers the path after this update. - pub fn is_active(self) -> bool { - !matches!(self, Self::Retracted) - } +/// What an [`AnnounceConsumer`] yields. +#[derive(Clone, Debug)] +pub enum AnnounceEvent { + /// A route now covers the prefix; the cursor had none there. + Announced(Announce), + /// The route covering the prefix changed hops or cost; it is delivered in place. + Updated(Announce), + /// No route covers the prefix any more. Carries its last advertised route. + Retracted(Announce), + /// Every route live at subscribe time has been delivered; what follows is + /// live changes. Yielded once, and only after each remote source feeding the + /// origin at subscribe time has landed its initial set. + Live, } -/// A route announcement, update, or retraction, delivered by [`AnnounceConsumer`]. +/// A route over a prefix, delivered by [`AnnounceConsumer`] inside an [`AnnounceEvent`]. /// /// An announcement is always a prefix, never a broadcast: it advertises that /// [`prefix`](Self::prefix) and every path beneath it are servable. A broadcast @@ -964,7 +1016,7 @@ impl AnnounceKind { /// [`Consumer::request_broadcast`]; the application decides which paths name /// broadcasts, and filters with a [`Pattern`] locally when it wants a subset. #[derive(Clone, Debug)] -pub struct AnnounceUpdate { +pub struct Announce { /// The prefix the route covers, relative to the consuming cursor's root. pub prefix: PathOwned, /// What the scope's wildcards stood for when the announced prefix pins all of @@ -973,8 +1025,6 @@ pub struct AnnounceUpdate { /// The route serving the prefix. On a retraction this carries its last /// advertised metadata. pub route: Route, - /// Whether the prefix was announced, re-priced, or retracted. - pub kind: AnnounceKind, } /// Publishes broadcasts and announces routes into an origin. @@ -1236,6 +1286,35 @@ impl Producer { ) } + /// Register a remote announce source replaying its initial set under + /// `prefix`, relative to this producer's root. + /// + /// Every announce cursor overlapping that prefix and registered while the + /// guard lives withholds its [`AnnounceEvent::Live`] marker until the guard + /// drops, so a session drops it once the peer's initial routes are in the + /// table (or the session dies). + pub(crate) fn replaying(&self, prefix: impl AsPath) -> Replaying { + // A subtree too complex to intersect waits on the whole scope instead. + let scope = Pattern::subtree(self.absolute(prefix).as_str()) + .ok() + .and_then(|subtree| self.scope.allowed.intersect(&subtree.into()).ok()) + .unwrap_or_else(|| self.scope.allowed.clone()); + let mut state = self.shared.lock(); + let id = state.next_replaying; + state.next_replaying += 1; + state.replaying.insert( + id, + ReplayingSource { + scope, + waiting: Vec::new(), + }, + ); + Replaying { + shared: self.shared.clone(), + id, + } + } + /// Advertise a route over `prefix` and serve the requests beneath it. /// /// A route is always a prefix: it claims `prefix` and every path beneath it @@ -2533,11 +2612,24 @@ struct OriginState { // a front dies with its watcher and a later request re-creates it. fronts: WeakCache, + // Remote announce sources still replaying their initial set, keyed by + // [`Replaying`] id. Empty outside a session's first round trip. + replaying: HashMap, + next_replaying: u64, + // Set when the origin's driver dropped: new requests fail with `Closed` // immediately and handlers observe the end instead of parking forever. closed: bool, } +/// A remote announce source still replaying its initial set. +struct ReplayingSource { + /// The absolute patterns the source may announce under. + scope: Patterns, + /// The cursors registered while it replayed, each owed its landing. + waiting: Vec, +} + impl OriginState { /// 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` @@ -2659,6 +2751,19 @@ impl OriginState { for p in &presented { Self::sync_cursor(&self.routes, &mut cursor, p); } + // The marker also waits on every source still replaying into this + // cursor's scope; with none, it follows the replay above. + let mut waiting = 0; + for source in self.replaying.values_mut() { + if source.scope.iter().any(|pattern| cursor.allowed.overlaps(pattern)) { + source.waiting.push(id); + waiting += 1; + } + } + if let Ok(mut state) = cursor.state.write() { + state.live = LiveState::Waiting(waiting + 1); + state.landed(); + } for head in &cursor.heads { self.routes.add_cursor(head, id); } @@ -2702,6 +2807,66 @@ impl OriginState { } } +/// A remote announce source still replaying its initial set, from +/// [`Producer::replaying`]. Drop it once the set has landed. +pub(crate) struct Replaying { + shared: kio::Shared, + id: u64, +} + +impl Drop for Replaying { + fn drop(&mut self) { + let mut state = self.shared.lock(); + let Some(source) = state.replaying.remove(&self.id) else { + return; + }; + for id in source.waiting { + // A cursor dropped since registering is simply gone. + if let Some(cursor) = state.cursors.get(&id) + && let Ok(mut cursor) = cursor.state.write() + { + cursor.landed(); + } + } + } +} + +/// When a [`Replaying`] source has landed, for wires that never say where the +/// initial set ends (lite-03/04, moq-transport): once its stream goes quiet. +/// +/// A peer writes its whole set back to back, so the first announcement gets a +/// round trip's grace and each one after it only has to beat its siblings. +pub(crate) struct Quiet { + deadline: crate::runtime::Deadline, + clock: Clock, +} + +impl Quiet { + /// How long the stream may stay silent before its first announcement. + const FIRST: Duration = Duration::from_millis(500); + /// How long the stream may stay silent between announcements. + const GAP: Duration = Duration::from_millis(30); + + /// Start counting from now. + pub(crate) fn new(clock: &Clock) -> Self { + Self { + deadline: crate::runtime::Deadline::after(clock, Self::FIRST), + clock: clock.clone(), + } + } + + /// An announcement arrived: the set is still landing. + pub(crate) fn heard(&mut self) { + let now = crate::runtime::Timers::now(&self.clock); + self.deadline.set(now.checked_add(Self::GAP)); + } + + /// Ready once the stream has gone quiet. + pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + self.deadline.poll(waiter) + } +} + /// One-shot result of a dynamic broadcast request. /// /// Stays `None` until a handler [`accept`](Request::accept)s (yielding the served @@ -3217,9 +3382,10 @@ impl Consumer { // forwarding, so it must not drive the announce guards. let mut announced = consumer.untagged().announced(); loop { - let update = announced.next().await?; - if update.kind.is_active() && path.has_prefix(&update.prefix) { - return Some(update.route); + if let AnnounceEvent::Announced(announce) | AnnounceEvent::Updated(announce) = announced.next().await? + && path.has_prefix(&announce.prefix) + { + return Some(announce.route); } } } @@ -3481,50 +3647,52 @@ impl AnnounceConsumer { } /// Drive the egress announce guards for one update. - fn hand_out(&mut self, update: AnnounceUpdate) -> AnnounceUpdate { - let absolute = self.root.join(&update.prefix).to_owned(); - if update.kind.is_active() { - let scope = self.stats.egress(&absolute); - self.guards - .entry(update.prefix.clone()) - .or_insert_with(|| scope.announce()); - } else { - self.guards.remove(&update.prefix); + fn hand_out(&mut self, event: AnnounceEvent) -> AnnounceEvent { + match &event { + AnnounceEvent::Announced(announce) | AnnounceEvent::Updated(announce) => { + let scope = self.stats.egress(self.root.join(&announce.prefix)); + self.guards + .entry(announce.prefix.clone()) + .or_insert_with(|| scope.announce()); + } + AnnounceEvent::Retracted(announce) => { + self.guards.remove(&announce.prefix); + } + AnnounceEvent::Live => {} } - update + event } /// Returns the next route announcement, update, or retraction, its prefix - /// relative to this cursor's root. + /// relative to this cursor's root, or the one [`AnnounceEvent::Live`] marker. /// - /// A retraction is only delivered for a previously announced prefix, and a + /// The routes live at subscribe time come first, then the marker, then live + /// changes, so a caller listing what is live stops at the marker. A + /// retraction is only delivered for a previously announced prefix, and a /// repeated announcement for the same prefix is a metadata update. Returns /// None if the cursor is closed. The consumer is also a [`futures::Stream`] - /// of the same updates. - pub async fn next(&mut self) -> Option { + /// of the same events. + pub async fn next(&mut self) -> Option { kio::wait(|waiter| self.poll_next(waiter)).await } - /// Poll for the next update, without blocking. + /// Poll for the next event, without blocking. /// - /// Returns `Poll::Ready(Some(_))` for an update, `Poll::Ready(None)` if the + /// Returns `Poll::Ready(Some(_))` for an event, `Poll::Ready(None)` if the /// cursor is closed, or `Poll::Pending` after registering `waiter` to be - /// notified when the next update arrives. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { - let update = { - let mut state = match ready!(self.state.poll(waiter, |state| { - if state.pending.is_empty() && !state.ended { - Poll::Pending - } else { - Poll::Ready(()) - } + /// notified when the next event arrives. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { + let event = { + let mut state = match ready!(self.state.poll(waiter, |state| match state.ready() { + true => Poll::Ready(()), + false => Poll::Pending, })) { Ok(state) => state, // Closed: discard the Ref so its MutexGuard doesn't escape this call. Err(_) => return Poll::Ready(None), }; match state.take() { - Some(update) => update, + Some(event) => event, None => { // Ended by the origin's teardown, pending updates already // drained; close the channel so every closure signal agrees. @@ -3533,16 +3701,16 @@ impl AnnounceConsumer { } } }; - Poll::Ready(Some(self.hand_out(update))) + Poll::Ready(Some(self.hand_out(event))) } - /// Returns the next update without blocking. + /// Returns the next event without blocking. /// - /// Returns None if there is no update available; NOT because the cursor is closed. + /// Returns None if there is no event available; NOT because the cursor is closed. /// Use [`Self::is_closed`] to check if the cursor is closed. - pub fn try_next(&mut self) -> Option { - let update = self.state.write().ok()?.take()?; - Some(self.hand_out(update)) + pub fn try_next(&mut self) -> Option { + let event = self.state.write().ok()?.take()?; + Some(self.hand_out(event)) } /// Returns true if the cursor is closed (no more updates will arrive). @@ -3563,7 +3731,7 @@ impl AnnounceConsumer { } impl futures::Stream for AnnounceConsumer { - type Item = AnnounceUpdate; + type Item = AnnounceEvent; fn poll_next(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll> { let this = self.get_mut(); @@ -3589,35 +3757,70 @@ use futures::FutureExt; #[cfg(test)] #[allow(missing_docs)] // test-only assertion helpers impl AnnounceConsumer { + /// The next route event without blocking, skipping the + /// [`AnnounceEvent::Live`] marker: these helpers pin routes, and the marker + /// has its own tests. + fn next_route_now(&mut self) -> Option> { + loop { + match self.next().now_or_never()? { + Some(AnnounceEvent::Live) => continue, + event => return Some(event), + } + } + } + + /// The `try_next` counterpart of [`Self::next_route_now`]. + fn try_next_route(&mut self) -> Option { + loop { + match self.try_next()? { + AnnounceEvent::Live => continue, + event => return Some(event), + } + } + } + + /// The route of an active event at `expected`. + fn active(event: AnnounceEvent, expected: &Path) -> Route { + match event { + AnnounceEvent::Announced(announce) | AnnounceEvent::Updated(announce) => { + assert_eq!(announce.prefix, *expected, "wrong prefix"); + announce.route + } + other => panic!("should be an active route: got {other:?}"), + } + } + /// The next update must be an active route at `expected`; returns it. pub fn assert_next_active(&mut self, expected: impl AsPath) -> Route { - let expected = expected.as_path(); - let update = self.next().now_or_never().expect("next blocked").expect("no next"); - assert_eq!(update.prefix, expected, "wrong prefix"); - assert!(update.kind.is_active(), "should be an active route"); - update.route + let event = self.next_route_now().expect("next blocked").expect("no next"); + Self::active(event, &expected.as_path()) } /// The `try_next` counterpart of [`Self::assert_next_active`]. pub fn assert_try_next_active(&mut self, expected: impl AsPath) -> Route { - let expected = expected.as_path(); - let update = self.try_next().expect("no next"); - assert_eq!(update.prefix, expected, "wrong prefix"); - assert!(update.kind.is_active(), "should be an active route"); - update.route + let event = self.try_next_route().expect("no next"); + Self::active(event, &expected.as_path()) } /// The next update must be a retraction at `expected`. pub fn assert_next_ended(&mut self, expected: impl AsPath) { - let expected = expected.as_path(); - let update = self.next().now_or_never().expect("next blocked").expect("no next"); - assert_eq!(update.prefix, expected, "wrong prefix"); - assert_eq!(update.kind, AnnounceKind::Retracted, "should be a retraction"); + match self.next_route_now().expect("next blocked").expect("no next") { + AnnounceEvent::Retracted(announce) => assert_eq!(announce.prefix, expected.as_path(), "wrong prefix"), + other => panic!("should be a retraction: got {other:?}"), + } } pub fn assert_next_wait(&mut self) { - if let Some(res) = self.next().now_or_never() { - panic!("next should block: got {:?}", res.map(|u| u.prefix)); + if let Some(event) = self.next_route_now() { + panic!("next should block: got {event:?}"); + } + } + + /// The next event must be the [`AnnounceEvent::Live`] marker. + pub fn assert_next_live(&mut self) { + match self.next().now_or_never() { + Some(Some(AnnounceEvent::Live)) => {} + other => panic!("expected the live marker: got {other:?}"), } } } @@ -3751,6 +3954,88 @@ mod tests { announced.assert_next_wait(); } + #[tokio::test] + async fn an_empty_origin_is_live_at_once() { + let producer = origin(1).produce(); + let mut announced = producer.consume().announced(); + announced.assert_next_live(); + announced.assert_next_wait(); + + // Later routes are live changes; the marker never repeats. + let _alice = producer.announce("alice", Route::default()).unwrap(); + announced.assert_next_active("alice"); + assert!(announced.next().now_or_never().is_none()); + } + + #[tokio::test] + async fn live_follows_the_replayed_routes() { + let producer = origin(1).produce(); + let _b = producer.announce("b", Route::default()).unwrap(); + let _c = producer.announce("c", Route::default()).unwrap(); + let mut announced = producer.consume().announced(); + + // A route arriving before the replay drains may come either side of the + // marker, but never pushes it past the replay. + let _a = producer.announce("a", Route::default()).unwrap(); + let mut seen = Vec::new(); + loop { + match announced.next().now_or_never().expect("blocked").expect("closed") { + AnnounceEvent::Live => break, + AnnounceEvent::Announced(announce) => seen.push(announce.prefix.to_string()), + other => panic!("only announcements before the marker: got {other:?}"), + } + } + assert!( + seen.contains(&"b".to_string()) && seen.contains(&"c".to_string()), + "{seen:?}" + ); + } + + #[tokio::test] + async fn a_replayed_route_retracted_before_delivery_does_not_hold_live() { + let producer = origin(1).produce(); + let alice = producer.announce("alice", Route::default()).unwrap(); + let mut announced = producer.consume().announced(); + drop(alice); + announced.assert_next_live(); + announced.assert_next_wait(); + } + + #[tokio::test] + async fn a_replaying_source_withholds_live_until_it_lands() { + let producer = origin(1).produce(); + let room = producer.scope("", &scopes(&["room"])).unwrap(); + let replaying = room.replaying("room/alice"); + + let mut announced = producer.consume().announced(); + // Out of the source's scope: nothing to wait for. + let mut elsewhere = producer.consume().scope("", &scopes(&["other"])).unwrap().announced(); + elsewhere.assert_next_live(); + // In its scope but beside the prefix it replays: nothing to wait for either. + let mut bob = producer + .consume() + .scope("", &scopes(&["room/bob"])) + .unwrap() + .announced(); + bob.assert_next_live(); + + // Routes the source lands arrive before the marker. + let _alice = room.announce("room/alice", Route::default()).unwrap(); + announced.assert_next_active("room/alice"); + assert!( + announced.next().now_or_never().is_none(), + "live before the source landed" + ); + + drop(replaying); + announced.assert_next_live(); + + // A cursor registered after the source landed does not wait on it. + let mut later = producer.consume().announced(); + later.assert_next_active("room/alice"); + later.assert_next_live(); + } + #[tokio::test] async fn broadcast_announces_its_own_path() { let producer = origin(1).produce(); @@ -4113,19 +4398,22 @@ mod tests { .unwrap(); let mut announced = consumer.announced(); - let first = announced.next().now_or_never().expect("next").expect("announce"); + let Some(Some(AnnounceEvent::Announced(first))) = announced.next_route_now() else { + panic!("expected the announcement"); + }; assert_eq!(first.prefix.as_str(), ""); - assert_eq!(first.kind, AnnounceKind::Announced); assert_eq!(first.captures, Some(Vec::new())); drop(exact); - let retracted = announced.next().now_or_never().expect("next").expect("retract"); + let Some(Some(AnnounceEvent::Retracted(retracted))) = announced.next_route_now() else { + panic!("expected the retraction"); + }; assert_eq!(retracted.prefix.as_str(), ""); - assert_eq!(retracted.kind, AnnounceKind::Retracted); assert_eq!(retracted.captures, Some(Vec::new())); - let replacement = announced.next().now_or_never().expect("next").expect("announce"); + let Some(Some(AnnounceEvent::Announced(replacement))) = announced.next_route_now() else { + panic!("expected the replacement"); + }; assert_eq!(replacement.prefix.as_str(), ""); - assert_eq!(replacement.kind, AnnounceKind::Announced); assert_eq!(replacement.captures, None); } @@ -4183,16 +4471,18 @@ mod tests { let second = producer.dynamic("live", Route::default().with_cost(1)).unwrap(); let mut announced = producer.consume().announced(); - let update = announced.next().now_or_never().expect("next").expect("no next"); + let Some(Some(AnnounceEvent::Announced(update))) = announced.next_route_now() else { + panic!("expected the announcement"); + }; assert_eq!(update.prefix.as_str(), "live"); - assert_eq!(update.kind, AnnounceKind::Announced); assert_eq!(update.route.cost, Cost::new(1)); announced.assert_next_wait(); drop(second); - let update = announced.next().now_or_never().expect("next").expect("no next"); + let Some(Some(AnnounceEvent::Updated(update))) = announced.next_route_now() else { + panic!("expected the reprice"); + }; assert_eq!(update.prefix.as_str(), "live"); - assert_eq!(update.kind, AnnounceKind::Updated); assert_eq!(update.route.cost, Cost::new(3)); drop(first); @@ -4288,19 +4578,15 @@ mod tests { let producer = origin(1).produce(); let server = producer.dynamic("live", Route::default()).unwrap(); let mut announced = producer.consume().announced(); - let update = StreamExt::next(&mut announced) - .now_or_never() - .expect("next") - .expect("no next"); + let mut next = || StreamExt::next(&mut announced).now_or_never(); + let Some(Some(AnnounceEvent::Announced(update))) = next() else { + panic!("expected the replayed route"); + }; assert_eq!(update.prefix.as_str(), "live"); - assert_eq!(update.kind, AnnounceKind::Announced); - assert!(StreamExt::next(&mut announced).now_or_never().is_none()); + assert!(matches!(next(), Some(Some(AnnounceEvent::Live)))); + assert!(next().is_none()); drop(server); - let update = StreamExt::next(&mut announced) - .now_or_never() - .expect("next") - .expect("no next"); - assert_eq!(update.kind, AnnounceKind::Retracted); + assert!(matches!(next(), Some(Some(AnnounceEvent::Retracted(_))))); } #[tokio::test] @@ -5703,7 +5989,9 @@ mod tests { let alice = producer.create_broadcast("room/alice/chat").unwrap(); alice.announce(Route::default()).unwrap(); - let update = announced.try_next().expect("alice's chat"); + let Some(AnnounceEvent::Announced(update)) = announced.try_next_route() else { + panic!("expected alice's chat"); + }; assert_eq!(update.prefix.as_str(), "room/alice/chat"); assert_eq!(update.captures, Some(vec!["alice".parse::().unwrap()])); @@ -5712,7 +6000,9 @@ mod tests { announced.assert_next_wait(); let broad = producer.announce("room", Route::default()).unwrap(); - let update = announced.try_next().expect("overlapping broad route"); + let Some(AnnounceEvent::Announced(update)) = announced.try_next_route() else { + panic!("expected the overlapping broad route"); + }; assert_eq!(update.prefix.as_str(), "room"); assert_eq!(update.captures, None, "an overlap does not pin the wildcard"); @@ -5729,7 +6019,9 @@ mod tests { local.announce(Route::default()).unwrap(); let mut announced = producer.consume().announced(); - let update = announced.try_next().expect("one winning route"); + let Some(AnnounceEvent::Announced(update)) = announced.try_next_route() else { + panic!("expected one winning route"); + }; assert_eq!(update.prefix.as_str(), "room/alice"); assert_eq!(update.route.cost, Cost::default()); announced.assert_next_wait(); diff --git a/rs/moq-net/tests/caught_up.rs b/rs/moq-net/tests/caught_up.rs new file mode 100644 index 0000000000..bbf55344c0 --- /dev/null +++ b/rs/moq-net/tests/caught_up.rs @@ -0,0 +1,76 @@ +//! An announce cursor on a session's origin yields `Live` once the peer's +//! initial set has landed, on every version. + +mod support; + +use std::time::Duration; + +use moq_net::{Hop, Version, announce, origin}; +use support::harness::{MockConnectOptions, connect_mock}; + +fn produce_origin(hop: Hop) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(hop)); + tokio::spawn(support::harness::run(driver)); + producer +} + +/// Versions whose wire says where the initial set ends: ANNOUNCE_INIT or +/// ANNOUNCE_OK's count. +const COUNTED: &[&str] = &["moq-lite-01", "moq-lite-02", "moq-lite-05", "moq-lite-06"]; + +/// Versions that land the initial set once the stream goes quiet instead. +const QUIET: &[&str] = &["moq-lite-03", "moq-lite-04", "moq-transport-14", "moq-transport-19"]; + +/// Connect a subscriber to a peer publishing `paths`, then read its cursor up to +/// the marker, returning the paths delivered before it. +async fn caught_up(version: &str, paths: &[&str]) -> Vec { + let version: Version = version.parse().unwrap(); + let published = produce_origin(Hop::new(1).unwrap()); + let broadcasts: Vec<_> = paths + .iter() + .map(|path| { + let broadcast = published.create_broadcast(*path).unwrap(); + broadcast.announce(Default::default()).unwrap(); + broadcast + }) + .collect(); + + let subscribed = produce_origin(Hop::new(2).unwrap()); + let mut options = MockConnectOptions::new(version); + options.server_publish = Some(published.clone()); + options.client_subscribe = Some(subscribed.clone()); + let _pair = connect_mock(options).await; + + let mut announced = subscribed.consume().announced(); + let mut live = Vec::new(); + let read = async { + loop { + match announced.next().await.expect("cursor closed") { + announce::Event::Live => break, + announce::Event::Announced(update) => live.push(update.prefix.to_string()), + other => panic!("{version}: only announcements before the marker: got {other:?}"), + } + } + }; + tokio::time::timeout(Duration::from_secs(10), read) + .await + .unwrap_or_else(|_| panic!("{version}: never caught up")); + + drop(broadcasts); + live.sort(); + live +} + +#[tokio::test] +async fn the_marker_follows_the_whole_initial_set() { + for version in COUNTED.iter().chain(QUIET) { + assert_eq!(caught_up(version, &["a", "b", "c"]).await, ["a", "b", "c"], "{version}"); + } +} + +#[tokio::test] +async fn an_empty_peer_is_caught_up() { + for version in COUNTED.iter().chain(QUIET) { + assert!(caught_up(version, &[]).await.is_empty(), "{version}"); + } +} diff --git a/rs/moq-net/tests/goaway.rs b/rs/moq-net/tests/goaway.rs index 2eefac0f0a..69e8c3198e 100644 --- a/rs/moq-net/tests/goaway.rs +++ b/rs/moq-net/tests/goaway.rs @@ -443,11 +443,12 @@ async fn goaway_drains_routes(version: Version) { // re-prices the route in place, which arrives as another active update. let mut announced = sub.announced(); loop { - let update = announced.next().await.expect("update"); - if update.kind.is_active() - && update.prefix.as_str() == "test" - && update.route.cost == moq_net::origin::Cost::DRAIN - { + let (moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update)) = + announced.next().await.expect("update") + else { + continue; + }; + if update.prefix.as_str() == "test" && update.route.cost == moq_net::origin::Cost::DRAIN { break; } } diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index e274431079..4bab2fd09c 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1585,7 +1585,12 @@ impl Cluster { loop { tokio::select! { ann = announced.next() => { - let Some(update) = ann else { return; }; + let (update, active) = match ann { + Some(moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update)) => (update, true), + Some(moq_net::announce::Event::Retracted(update)) => (update, false), + Some(moq_net::announce::Event::Live) => continue, + None => return, + }; let relative = update.prefix; // The address to dial, which keeps its query: `run_remote` reads // `?cost=` and `?jwt=` off it. The key is only its identity. @@ -1603,7 +1608,7 @@ impl Cluster { continue; } let advertisement = relative.as_str().to_owned(); - match update.kind.is_active() { + match active { true => { let target = live.announce(advertisement, target); let mut spawn = |target: DialTarget| { @@ -2217,6 +2222,19 @@ mod tests { use super::*; use crate::Config as RelayConfig; + /// The next route and whether it is active, skipping the caught-up marker. + async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } + } + fn new_cluster(config: Config) -> anyhow::Result { Cluster::new(Options::new(config)) } @@ -2951,11 +2969,11 @@ mod tests { .announced(); let registration = origin.create_broadcast(&path).expect("node advertise"); registration.announce(Default::default()).expect("announce node"); - let update = tokio::time::timeout(Duration::from_secs(2), announced.next()) + let (_, active) = tokio::time::timeout(Duration::from_secs(2), next_update(&mut announced)) .await .expect("node advertised") .expect("announce"); - assert!(update.kind.is_active()); + assert!(active); let snapshot = cluster.nodes.snapshot(); assert!( snapshot.nodes.iter().any(|node| node.node.contains("peer.example")), @@ -3008,9 +3026,12 @@ mod tests { tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; // The self-registration route must be visible on the origin. - let update = watcher.try_next().expect("self-registration must be published"); + // The watcher subscribed to an empty origin, so its marker comes first. + assert!(matches!(watcher.try_next(), Some(moq_net::announce::Event::Live))); + let Some(moq_net::announce::Event::Announced(update)) = watcher.try_next() else { + panic!("self-registration must be published"); + }; assert_eq!(update.prefix.as_str(), ".internal/origins/rendezvous.example.com:4443"); - assert!(update.kind.is_active()); // run() must NOT have returned: dropping the broadcast (via run returning) // would unannounce the registration immediately. Use a short timeout to @@ -3589,7 +3610,7 @@ mod tests { let _dial = fingerprint.dial_lan_target(&target).expect("dial"); let mut announced = fingerprint.origin.consume().announced(); - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (update, _) = tokio::time::timeout(TIMEOUT, next_update(&mut announced)) .await .expect("timed out waiting for from-node") .expect("origin closed"); @@ -3599,7 +3620,7 @@ mod tests { _from_fp.announce(Default::default()).expect("announce"); let mut announced = node.origin.consume().announced(); loop { - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (update, _) = tokio::time::timeout(TIMEOUT, next_update(&mut announced)) .await .expect("timed out waiting for from-fingerprint") .expect("origin closed"); diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index c3e576a8a8..514dbae25c 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -626,6 +626,19 @@ fn render_uring(_out: &mut String, _workers: &[UringWorker]) {} mod tests { use super::*; + /// The next route and whether it is active, skipping the caught-up marker. + async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } + } + /// An `Config` whose listener is enabled, so `Internal` registers its own. fn listening() -> Config { Config { @@ -919,8 +932,8 @@ mod tests { // Leave 46 bytes across two frames behind the live edge, then read 1234 // egress bytes out of the default-tier broadcast. - let update = announced.next().await.unwrap(); - assert!(update.kind.is_active()); + let (update, active) = next_update(&mut announced).await.unwrap(); + assert!(active); let bc = egress .request_broadcast(moq_net::Path::new(update.prefix.as_str())) .await diff --git a/rs/moq-relay/src/nodes.rs b/rs/moq-relay/src/nodes.rs index 28bf31ab77..35be6a55a9 100644 --- a/rs/moq-relay/src/nodes.rs +++ b/rs/moq-relay/src/nodes.rs @@ -167,13 +167,14 @@ impl Nodes { fn scan_announced(&self, announced: &mut moq_net::announce::Consumer) -> Announced { let mut scanned = Announced::default(); - while let Some(update) = announced.try_next() { + while let Some(event) = announced.try_next() { // A retraction can land mid-drain (a re-announce is a metadata update, // not a retract-and-announce). Skip it rather than end the scan, which // would drop every node still queued behind it. - if !update.kind.is_active() { + let (moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update)) = event + else { continue; - } + }; let key = canonical_announced_node(update.prefix.as_str()); let route = update.route; @@ -384,7 +385,9 @@ mod tests { // Take relay-a's replayed announce, then retire it so the cursor queues a // bare unannounce ahead of relay-b's still-pending announce. - let first_update = announced.try_next().expect("replayed announce"); + let Some(moq_net::announce::Event::Announced(first_update)) = announced.try_next() else { + panic!("replayed announce"); + }; assert_eq!( canonical_announced_node(first_update.prefix.as_str()), "https://relay-a.example/" diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 8b89b5ff87..15e6bba4ff 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -865,8 +865,8 @@ async fn serve_announced( let mut announced = origin.consume().announced(); let mut broadcasts = Vec::new(); - while let Some(update) = announced.try_next() { - if update.kind.is_active() { + while let Some(event) = announced.try_next() { + if let moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update) = event { broadcasts.push(update.prefix); } } diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 7c4afbfe1e..d74b42b0bd 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -253,12 +253,12 @@ async fn connect_and_round_trip(url: &url::Url) -> (moq_tokio::Connection, moq_t .expect("subscriber connect timeout") .expect("subscriber connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announcement timeout") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = sub_consumer .request_broadcast("test") .await @@ -511,12 +511,12 @@ async fn http_routes_hold_a_lease() { .await .expect("subscriber connect timeout") .expect("subscriber connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announcement timeout") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let http = reqwest::Client::new(); let announced = http @@ -940,3 +940,16 @@ async fn a_relay_without_an_auth_source_is_decided_by_the_embedder() { .expect("relay task panicked") .expect("relay exited with an error"); } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-relay/tests/cluster_unknown.rs b/rs/moq-relay/tests/cluster_unknown.rs index 9ad71cdde3..69a230d2a3 100644 --- a/rs/moq-relay/tests/cluster_unknown.rs +++ b/rs/moq-relay/tests/cluster_unknown.rs @@ -190,8 +190,8 @@ async fn watch_announces(port: u16, window: Duration) -> Vec<(String, bool)> { let mut updates = Vec::new(); let deadline = tokio::time::Instant::now() + window; - while let Ok(Some(update)) = tokio::time::timeout_at(deadline, announced.next()).await { - updates.push((update.prefix.as_str().to_string(), update.kind.is_active())); + while let Ok(Some((update, active))) = tokio::time::timeout_at(deadline, next_update(&mut announced)).await { + updates.push((update.prefix.as_str().to_string(), active)); } updates } @@ -380,3 +380,16 @@ async fn unknown_publisher_does_not_flap_across_a_lite04_cluster_triangle() { let version = "moq-lite-04".parse().expect("parse version"); assert_unknown_publisher_stays_announced(Some(version), true).await; } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index 3aea1c105b..cede1d8754 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -575,11 +575,11 @@ async fn interrupted_publisher_republishes_new_content() { /// Wait for `path` to be announced (`want`) or unannounced (`!want`). async fn expect_announce(announced: &mut moq_net::announce::Consumer, path: &str, want: bool, who: &str) { loop { - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(announced)) .await .unwrap_or_else(|_| panic!("{who}: no announcement change within {TIMEOUT:?}")) .unwrap_or_else(|| panic!("{who}: the announcement stream closed")); - if update.prefix.as_str() == path && update.kind.is_active() == want { + if update.prefix.as_str() == path && active == want { return; } } @@ -615,8 +615,8 @@ async fn no_publisher_never_delivers() { let quiet = Duration::from_secs(2); let mut announced = subscribed.announced(); - if let Ok(update) = tokio::time::timeout(quiet, announced.next()).await { - let path = update.map(|update| update.prefix.to_string()); + if let Ok(update) = tokio::time::timeout(quiet, next_update(&mut announced)).await { + let path = update.map(|(update, _)| update.prefix.to_string()); panic!("the announcement stream reported {path:?} with no publisher"); } @@ -630,3 +630,16 @@ async fn no_publisher_never_delivers() { drop(session); drop(relay); } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-relay/tests/embed.rs b/rs/moq-relay/tests/embed.rs index e65b5577e9..bc2738f632 100644 --- a/rs/moq-relay/tests/embed.rs +++ b/rs/moq-relay/tests/embed.rs @@ -247,12 +247,12 @@ async fn embed_and_stop(mut config: Config) { .expect("connect timeout") .expect("connect failed"); - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announced)) .await .expect("announcement timeout") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let announced = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("test")) .await .expect("request timeout") @@ -465,3 +465,16 @@ fn embedded_cli_merges_only_relay_settings() { assert_eq!(parsed.worker_name.as_deref(), Some("recorder")); assert_eq!(parsed.relay.cluster.id, Some(9)); } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-relay/tests/goaway_cluster.rs b/rs/moq-relay/tests/goaway_cluster.rs index c2b6a061c5..c0afc746ab 100644 --- a/rs/moq-relay/tests/goaway_cluster.rs +++ b/rs/moq-relay/tests/goaway_cluster.rs @@ -245,7 +245,7 @@ async fn cluster_migrates_on_upstream_goaway_inner() { // unannounce the path (metadata updates are expected: the drain // re-prices the old route and the sibling announces its own). let mut announcements = cluster.origin.consume().announced(); - let first = announcements.next().await.expect("initial announce"); + let (first, _) = next_update(&mut announcements).await.expect("initial announce"); assert_eq!(first.prefix.as_str(), "cam"); // ── sibling A drains with a redirect to sibling B ──────────────── @@ -283,9 +283,9 @@ async fn cluster_migrates_on_upstream_goaway_inner() { // updates (the drain re-pricing, the sibling's route) are expected and // harmless; an inactive event means the path flapped. loop { - match tokio::time::timeout(Duration::from_millis(500), announcements.next()).await { + match tokio::time::timeout(Duration::from_millis(500), next_update(&mut announcements)).await { Err(_) => break, - Ok(Some(update)) if update.kind.is_active() => continue, + Ok(Some((_, true))) => continue, Ok(event) => panic!("migration must not retract the path on the cluster origin: {event:?}"), } } @@ -452,9 +452,12 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { // Watch announcements for the whole test: the failover must never // unannounce the broadcast under the subscriber. let mut announcements = sub_origin.consume().announced(); - let first = within("broadcast announced through the MID-A leg", announcements.next()) - .await - .expect("origin closed before the announce"); + let (first, _) = within( + "broadcast announced through the MID-A leg", + next_update(&mut announcements), + ) + .await + .expect("origin closed before the announce"); assert_eq!(first.prefix.as_str(), "diamond"); let bc = within("broadcast resolves on the subscriber origin", async { @@ -603,9 +606,9 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { // ── announcement stability: the path never retracted under the swap ── // Metadata updates (route re-pricing, the new leg's hops) are expected. loop { - match tokio::time::timeout(Duration::from_millis(500), announcements.next()).await { + match tokio::time::timeout(Duration::from_millis(500), next_update(&mut announcements)).await { Err(_) => break, - Ok(Some(update)) if update.kind.is_active() => continue, + Ok(Some((_, true))) => continue, Ok(event) => panic!("failover must not retract the path under the subscriber: {event:?}"), } } @@ -836,3 +839,16 @@ async fn goaway_handover_is_enforced_while_the_replacement_dial_hangs_inner() { drop(connection); } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-relay/tests/runtime_uring.rs b/rs/moq-relay/tests/runtime_uring.rs index 8a5dab09a8..24f9b08bc6 100644 --- a/rs/moq-relay/tests/runtime_uring.rs +++ b/rs/moq-relay/tests/runtime_uring.rs @@ -156,12 +156,12 @@ async fn uring_workers_serve_webtransport_and_raw_quic() { } for (index, (_connection, consumer, announced)) in subscribers.iter_mut().enumerate() { - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(announced)) .await .unwrap_or_else(|_| panic!("subscriber {index} announcement timeout")) .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let broadcast = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("test")) .await .unwrap_or_else(|_| panic!("subscriber {index} request timeout")) @@ -312,12 +312,12 @@ async fn an_mtls_client_authenticates_without_a_token() { let mut announced = consumer.announced(); let subscriber = connect(client().with_subscriber(subscriber_origin), url).await; - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announced)) .await .expect("announcement timeout") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let announced = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("test")) .await .expect("request timeout") @@ -391,11 +391,11 @@ async fn uring_workers_write_qlog_traces() { let consumer = subscriber_origin.consume(); let mut announced = consumer.announced(); let subscriber = connect(client().with_subscriber(subscriber_origin), url).await; - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (_, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announced)) .await .expect("announcement timeout") .expect("origin closed"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); assert!(!running.is_finished(), "the relay stopped while serving"); drop(track); @@ -462,3 +462,16 @@ async fn spawn_auth_server(policy: moq_auth::serve::Policy) -> url::Url { tokio::spawn(async move { server.serve(listener).await }); url } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-relay/tests/runtime_workers.rs b/rs/moq-relay/tests/runtime_workers.rs index 7f6040ec1d..c1961c5e97 100644 --- a/rs/moq-relay/tests/runtime_workers.rs +++ b/rs/moq-relay/tests/runtime_workers.rs @@ -119,12 +119,12 @@ async fn workers_serve_quic_and_share_one_origin() { } for (index, (_connection, consumer, announced)) in subscribers.iter_mut().enumerate() { - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(announced)) .await .unwrap_or_else(|_| panic!("subscriber {index} announcement timeout")) .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let broadcast = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("test")) .await .unwrap_or_else(|_| panic!("subscriber {index} request timeout")) @@ -158,3 +158,16 @@ async fn workers_serve_quic_and_share_one_origin() { running.abort(); let _ = running.await; } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 77a5ef78ec..5b99363890 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -206,12 +206,12 @@ async fn relay_websocket_round_trip_uses_newest_version() { ); // ── data path ─────────────────────────────────────────────────── - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announcement timeout") .expect("origin closed"); let path = moq_net::Path::new(update.prefix.as_str()).to_owned(); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); // Auth root for `/smoke` is "smoke"; the broadcast "test" announces underneath. assert_eq!(path.as_str(), "test"); let bc = sub_consumer @@ -397,12 +397,12 @@ async fn relay_websocket_root_path_upgrades() { // ── data path ─────────────────────────────────────────────────── // The root auth scope is the empty path, so the broadcast announces at its // own name with no prefix. - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announcement timeout") .expect("origin closed"); let path = moq_net::Path::new(update.prefix.as_str()).to_owned(); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); assert_eq!(path.as_str(), "test"); let bc = sub_consumer .request_broadcast(&path) @@ -485,11 +485,11 @@ async fn two_publish_only_clients_coexist() { let mut seen = std::collections::HashSet::new(); while seen.len() < 2 { - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announcement timeout") .expect("origin closed"); - if update.kind.is_active() { + if active { seen.insert(update.prefix.as_str().to_owned()); } } @@ -628,12 +628,12 @@ async fn internal_tcp_round_trip() { // ── data path ─────────────────────────────────────────────────── // The internal listener grants the empty root, so the broadcast announces // at its own name with no path prefix. - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announcement timeout") .expect("origin closed"); let path = moq_net::Path::new(update.prefix.as_str()).to_owned(); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); assert_eq!(path.as_str(), "test"); let bc = sub_consumer .request_broadcast(&path) @@ -742,12 +742,12 @@ async fn internal_unix_round_trip() { .expect("subscriber connect failed"); // ── data path ─────────────────────────────────────────────────── - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announcement timeout") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timeout") @@ -825,7 +825,7 @@ async fn path_round_trip(version: moq_net::Version, pub_url: url::Url, sub_url: .expect("subscriber connect timeout") .expect("subscriber connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, _) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announcement timeout") .expect("origin closed"); @@ -1100,3 +1100,16 @@ async fn connect_once( let connection = client.clone().with_reconnect(false).connect(url).established().await?; Ok((client, connection)) } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-room/src/room.rs b/rs/moq-room/src/room.rs index 22666ca7a4..19e9d28c05 100644 --- a/rs/moq-room/src/room.rs +++ b/rs/moq-room/src/room.rs @@ -78,8 +78,11 @@ impl Room { return Poll::Ready(Some(event)); } - let Some(update) = ready!(self.announced.poll_next(waiter)) else { - return Poll::Ready(None); + let (update, active) = match ready!(self.announced.poll_next(waiter)) { + Some(announce::Event::Announced(update) | announce::Event::Updated(update)) => (update, true), + Some(announce::Event::Retracted(update)) => (update, false), + Some(announce::Event::Live) => continue, + None => return Poll::Ready(None), }; let path = update.prefix; let Some(parsed) = parse(&path) else { @@ -88,7 +91,7 @@ impl Room { if self.local.as_ref().is_some_and(|id| *id == parsed.identity) { continue; } - if !update.kind.is_active() { + if !active { return Poll::Ready(Some(Event { identity: parsed.identity, kind: parsed.kind, diff --git a/rs/moq-rtc/src/lib.rs b/rs/moq-rtc/src/lib.rs index 7217a4c8f6..bf7c9360e4 100644 --- a/rs/moq-rtc/src/lib.rs +++ b/rs/moq-rtc/src/lib.rs @@ -72,6 +72,19 @@ pub use server::{Response, Server, whep, whip}; mod tests { use std::time::Duration; + /// The next route and whether it is active, skipping the caught-up marker. + async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } + } + use axum::Router; use bytes::Bytes; @@ -103,12 +116,12 @@ mod tests { }, ) .expect("publish source packet"); - let announcement = tokio::time::timeout(TIMEOUT, announcements.next()) + let (announcement, announcement_active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("source announcement timed out") .expect("source origin closed"); assert_eq!(announcement.prefix.as_str(), "source"); - assert!(announcement.kind.is_active(), "source was unannounced"); + assert!(announcement_active, "source was unannounced"); drop(announcements); let server_origin = moq_tokio::origin::spawn(); diff --git a/rs/moq-stats/src/aggregate.rs b/rs/moq-stats/src/aggregate.rs index c494def001..b15c5da7c6 100644 --- a/rs/moq-stats/src/aggregate.rs +++ b/rs/moq-stats/src/aggregate.rs @@ -295,7 +295,13 @@ impl Merged { // unannounce. A closed stream ends the merged view. loop { match self.announce.poll_next(waiter) { - Poll::Ready(Some(update)) => changed |= self.apply_announce(update), + Poll::Ready(Some( + moq_net::announce::Event::Announced(update) | moq_net::announce::Event::Updated(update), + )) => changed |= self.apply_announce(update, true), + Poll::Ready(Some(moq_net::announce::Event::Retracted(update))) => { + changed |= self.apply_announce(update, false) + } + Poll::Ready(Some(moq_net::announce::Event::Live)) => {} Poll::Ready(None) => return Poll::Ready(Ok(None)), Poll::Pending => break, } @@ -319,7 +325,7 @@ impl Merged { /// Apply one announce update to the node set. Returns whether the merged view /// changed (only a non-sticky contribution leaving does; a sticky one is /// kept). - fn apply_announce(&mut self, update: moq_net::announce::Update) -> bool { + fn apply_announce(&mut self, update: moq_net::announce::Announce, active: bool) -> bool { let path = update.prefix; let absolute = self.announce.absolute(&path).to_owned(); @@ -330,7 +336,7 @@ impl Merged { return false; } - if update.kind.is_active() { + if active { // A route update on a node already tracked (a reprice, or a takeover // with different metadata) keeps the live reader: existing // subscriptions survive a takeover, and the reader re-resolves through @@ -489,6 +495,19 @@ mod tests { producer } + /// The next route and whether it is active, skipping the caught-up marker. + async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } + } + use std::time::Duration; use moq_net::{PathOwned, Timestamp, announce, broadcast, origin, track}; @@ -533,8 +552,8 @@ mod tests { source.announce(origin::Route::default()).expect("announce"); let track = source.create_track("video", None).expect("create_track"); - let update = announced.next().await.expect("announce"); - assert!(update.kind.is_active()); + let (_, active) = next_update(&mut announced).await.expect("announce"); + assert!(active); let consumer = egress.request_broadcast(path).await.expect("resolve"); let mut sub = consumer.track("video").unwrap().subscribe(None).await.unwrap(); diff --git a/rs/moq-stats/src/consume.rs b/rs/moq-stats/src/consume.rs index bc02b53f89..9ce49003d2 100644 --- a/rs/moq-stats/src/consume.rs +++ b/rs/moq-stats/src/consume.rs @@ -117,6 +117,19 @@ mod tests { producer } + /// The next route and whether it is active, skipping the caught-up marker. + async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } + } + use std::time::Duration; use moq_net::{Consume, PathOwned, Timestamp, announce, broadcast, origin, track}; @@ -167,8 +180,8 @@ mod tests { source.announce(origin::Route::default()).unwrap(); let track = source.clone().create_track("video", None).unwrap(); - let update = announced.next().await.expect("announce"); - assert!(update.kind.is_active()); + let (_, active) = next_update(&mut announced).await.expect("announce"); + assert!(active); let consumer = egress.request_broadcast(path).await.expect("resolve"); let sub = consumer.track("video").unwrap().subscribe(None).await.unwrap(); @@ -184,8 +197,8 @@ mod tests { async fn announced(origin: &origin::Producer) -> moq_net::broadcast::Consumer { let mut consumer = origin.consume().announced(); tokio::time::advance(Duration::from_millis(1)).await; - let update = consumer.next().await.expect("expected announce"); - assert!(update.kind.is_active()); + let (update, active) = next_update(&mut consumer).await.expect("expected announce"); + assert!(active); origin .consume() .request_broadcast(moq_net::Path::new(update.prefix.as_str())) diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index ce706424d4..0a8c40d1e4 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -1017,6 +1017,19 @@ mod tests { producer } + /// The next route and whether it is active, skipping the caught-up marker. + async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } + } + use std::collections::BTreeMap; use moq_net::stats::{Registry, Tier}; @@ -1068,8 +1081,8 @@ mod tests { source.announce(origin::Route::default()).expect("announce"); let producer = source.create_track("video", None).expect("create_track"); - let update = announced.next().await.expect("announce"); - assert!(update.kind.is_active()); + let (_, active) = next_update(&mut announced).await.expect("announce"); + assert!(active); let consumer = egress.request_broadcast(path).await.expect("resolve"); let sub = if subscribe { @@ -1109,8 +1122,8 @@ mod tests { async fn announced(origin: &origin::Producer) -> (String, moq_net::broadcast::Consumer) { let mut consumer = origin.consume().announced(); tokio::time::advance(Duration::from_millis(1)).await; - let update = consumer.next().await.expect("expected announce"); - assert!(update.kind.is_active()); + let (update, active) = next_update(&mut consumer).await.expect("expected announce"); + assert!(active); let broadcast = origin .consume() .request_broadcast(moq_net::Path::new(update.prefix.as_str())) diff --git a/rs/moq-tokio/examples/clock.rs b/rs/moq-tokio/examples/clock.rs index 42912d89c1..195ccead8a 100644 --- a/rs/moq-tokio/examples/clock.rs +++ b/rs/moq-tokio/examples/clock.rs @@ -110,17 +110,18 @@ async fn main() -> anyhow::Result<()> { loop { tokio::select! { - Some(update) = announced.next() => match update.kind.is_active() { - true => { + Some(event) = announced.next() => match event { + announce::Event::Announced(update) | announce::Event::Updated(update) => { tracing::info!(broadcast = %update.prefix, "broadcast is online, subscribing to track"); let broadcast = consumer.request_broadcast(&update.prefix).await?; let track = broadcast .track(&track)?.subscribe(None).await?; clock = Some(Subscriber::new(track)); } - false => { + announce::Event::Retracted(update) => { tracing::warn!(broadcast = %update.prefix, "broadcast is offline, waiting..."); } + announce::Event::Live => {} }, res = reconnect.closed() => return Ok(res?), // Drops the previous subscriber on each new announce. diff --git a/rs/moq-tokio/src/origin.rs b/rs/moq-tokio/src/origin.rs index b2911dfba5..51de8f3801 100644 --- a/rs/moq-tokio/src/origin.rs +++ b/rs/moq-tokio/src/origin.rs @@ -25,6 +25,19 @@ pub fn spawn_config(config: moq_net::origin::Config) -> moq_net::origin::Produce mod tests { use super::*; + /// The next route and whether it is active, skipping the caught-up marker. + async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } + } + /// The spawned driver runs the origin's lifecycle: an announced route /// reaches a consumer, and dropping the announcement retracts it. #[tokio::test] @@ -35,13 +48,13 @@ mod tests { let broadcast = origin.create_broadcast("cam").expect("create broadcast"); broadcast.announce(Default::default()).expect("create broadcast"); - let update = announced.next().await.expect("announce"); + let (update, active) = next_update(&mut announced).await.expect("announce"); assert_eq!(update.prefix.as_str(), "cam"); - assert!(update.kind.is_active()); + assert!(active); broadcast.finish(); - let update = announced.next().await.expect("retraction"); - assert!(!update.kind.is_active()); + let (_, active) = next_update(&mut announced).await.expect("retraction"); + assert!(!active); } /// Outside a runtime the spawn has nowhere to run the driver: it panics diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index ba6ccd00a1..9f39d8911c 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1419,6 +1419,19 @@ impl Request { mod tests { use super::*; + /// The next route and whether it is active, skipping the caught-up marker. + async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } + } + #[test] fn version_help_lists_every_parseable_name() { #[derive(usage::Cli)] @@ -1582,12 +1595,12 @@ mod tests { // Without the server's publisher the session announces nothing, so this is // where the regression shows up. - let update = tokio::time::timeout(TIMEOUT, announced.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announced)) .await .expect("announce timeout") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active()); + assert!(active); let broadcast = consumer.request_broadcast("test").await.expect("resolve"); let mut track = broadcast diff --git a/rs/moq-tokio/tests/backend.rs b/rs/moq-tokio/tests/backend.rs index 15d850c24b..06871ac776 100644 --- a/rs/moq-tokio/tests/backend.rs +++ b/rs/moq-tokio/tests/backend.rs @@ -176,12 +176,12 @@ async fn connect_test(config: ConnectTest<'_>) { .expect("client connect timed out") .expect("client connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -629,12 +629,12 @@ async fn iroh_connect() { .expect("client connect timed out") .expect("client connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -859,3 +859,16 @@ async fn window_test(scheme: &str) { async fn noq_windows() { window_test("moqt").await; } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index ddc1db8b78..65378a8040 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -83,13 +83,13 @@ async fn broadcast_test(scheme: &str, client_version: Option<&str>, server_versi .expect("client connect failed"); // Wait for the broadcast announcement. - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -197,12 +197,12 @@ async fn lite05_timestamp_roundtrip(scheme: &str) { .expect("client connect timed out") .expect("client connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -320,12 +320,12 @@ async fn lite05_fetch_roundtrip(scheme: &str) { .expect("client connect timed out") .expect("client connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -450,12 +450,12 @@ async fn lite05_fetch_during_subscribe(scheme: &str) { .expect("client connect timed out") .expect("client connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -561,11 +561,11 @@ async fn broadcast_moq_lite_05_default_timescale() { .expect("connect timeout") .expect("connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timeout") .expect("origin closed"); - assert!(update.kind.is_active(), "expected announce"); + assert!(active, "expected announce"); let bc = tokio::time::timeout( TIMEOUT, sub_consumer.request_broadcast(moq_net::Path::new(update.prefix.as_str())), @@ -661,9 +661,9 @@ async fn broadcast_moq_transport_20_current_group_join() { .expect("connect timed out") .expect("connect failed"); - let announced = next_announce(&mut announcements).await; + let (announced, announced_active) = next_announce(&mut announcements).await; assert_eq!(announced.prefix.as_str(), "test"); - assert!(announced.kind.is_active(), "expected an announce"); + assert!(announced_active, "expected an announce"); let remote = tokio::time::timeout( TIMEOUT, sub_consumer.request_broadcast(moq_net::Path::new(announced.prefix.as_str())), @@ -710,8 +710,8 @@ async fn broadcast_moq_transport_20_current_group_join() { } /// Wait for the next announce event, failing the test on a timeout or a closed origin. -async fn next_announce(announcements: &mut moq_net::announce::Consumer) -> moq_net::announce::Update { - tokio::time::timeout(TIMEOUT, announcements.next()) +async fn next_announce(announcements: &mut moq_net::announce::Consumer) -> (moq_net::announce::Announce, bool) { + tokio::time::timeout(TIMEOUT, next_update(announcements)) .await .expect("announce timeout") .expect("origin closed") @@ -763,50 +763,50 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { .expect("connect failed"); // The initial set: "first" was announced before the session existed. - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "first"); - assert!(update.kind.is_active(), "expected initial announce"); + assert!(active, "expected initial announce"); // A live announce after the initial set. let second = pub_origin.create_broadcast("second").expect("create broadcast"); second.announce(Default::default()).expect("create broadcast"); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "second"); - assert!(update.kind.is_active(), "expected live announce"); + assert!(active, "expected live announce"); // Unannounce: retracted by announce id on the wire. Dropping the announcement // retracts the route; the broadcast's own end is independent. second.finish(); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "second"); - assert!(!update.kind.is_active(), "expected retraction"); + assert!(!active, "expected retraction"); // Re-announce the same path: a fresh announce assigning a fresh id. let _second = pub_origin.create_broadcast("second").expect("create broadcast"); _second.announce(Default::default()).expect("create broadcast"); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "second"); - assert!(update.kind.is_active(), "expected re-announce"); + assert!(active, "expected re-announce"); // Replace the route at "first": retract the original (retiring its announce // id on the wire), then announce the same path again (assigning a fresh id). // Await the retraction first so the events cannot coalesce away. first.finish(); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "first"); - assert!(!update.kind.is_active(), "expected the replaced retraction"); + assert!(!active, "expected the replaced retraction"); let _replacement = pub_origin.create_broadcast("first").expect("create replacement"); _replacement.announce(Default::default()).expect("create replacement"); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "first"); - assert!(update.kind.is_active(), "expected the replacement announce"); + assert!(active, "expected the replacement announce"); // A sentinel proves no stray event for "first" snuck in behind the replacement. let _sentinel = pub_origin.create_broadcast("sentinel").expect("create broadcast"); _sentinel.announce(Default::default()).expect("create broadcast"); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "sentinel"); - assert!(update.kind.is_active(), "expected sentinel announce"); + assert!(active, "expected sentinel announce"); drop(connection); server_handle @@ -947,9 +947,9 @@ async fn broadcast_route_migration() { // One path, announced once even though two sessions route it (the cheaper // route wins the advertisement). - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce"); + assert!(active, "expected announce"); // Resolve and subscribe: the cheaper route (A) serves the track. let subscription = moq_net::track::Subscription::default() @@ -985,8 +985,11 @@ async fn broadcast_route_migration() { // The path was never retracted across the swap: any events delivered are // active metadata updates (the standby's chain taking over). - while let Some(update) = announcements.try_next() { - assert!(update.kind.is_active(), "failover must not retract the route"); + while let Some(event) = announcements.try_next() { + assert!( + !matches!(event, moq_net::announce::Event::Retracted(_)), + "failover must not retract the route" + ); } handle_a.await.expect("server a panicked").expect("server a failed"); @@ -1062,9 +1065,9 @@ async fn route_reannounce_test(version: Option<&str>) { .expect("connect timeout") .expect("connect failed"); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce"); + assert!(active, "expected announce"); let initial = update.route; let broadcast = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) @@ -1089,9 +1092,9 @@ async fn route_reannounce_test(version: Option<&str>) { .expect("update route"); // The subscriber sees the new chain as another active update for the prefix... - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "a restart must not retract the route"); + assert!(active, "a restart must not retract the route"); assert_ne!(initial.hops, update.route.hops, "route must change"); assert!( update.route.hops.iter().any(|h| h.id() == 0x5555), @@ -1387,9 +1390,9 @@ async fn max_age_test(version: &str) -> Duration { .expect("connect timeout") .expect("connect failed"); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce"); + assert!(active, "expected announce"); let broadcast = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -1702,13 +1705,13 @@ async fn broadcast_websocket() { .expect("client connect failed"); // Wait for the broadcast announcement. - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -1824,13 +1827,13 @@ async fn broadcast_websocket_fallback() { .expect("client connect failed"); // Wait for the broadcast announcement. - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -2091,11 +2094,11 @@ async fn quic_driver_task_inherits_connection_span() { .expect("client connect timed out") .expect("client connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout( TIMEOUT, sub_consumer.request_broadcast(moq_net::Path::new(update.prefix.as_str())), @@ -2209,12 +2212,12 @@ async fn resubscribe_keeps_flowing_moq_lite_03() { .expect("connect timeout") .expect("connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timeout") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce"); + assert!(active, "expected announce"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -2348,11 +2351,11 @@ async fn idle_subscription_releases_the_viewer_count() { .expect("connect timeout") .expect("connect failed"); - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timeout") .expect("origin closed"); - assert!(update.kind.is_active(), "expected announce"); + assert!(active, "expected announce"); let bc = tokio::time::timeout( TIMEOUT, sub_consumer.request_broadcast(moq_net::Path::new(update.prefix.as_str())), @@ -2673,9 +2676,9 @@ async fn a_dead_session_unannounces_while_the_reconnect_retries() { .expect("connect timed out") .expect("connect failed"); - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "live"); - assert!(update.kind.is_active(), "expected the initial announce"); + assert!(active, "expected the initial announce"); // The server kills the session; the loop starts redialing into the void. let session = tokio::time::timeout(TIMEOUT, session_rx) @@ -2685,9 +2688,9 @@ async fn a_dead_session_unannounces_while_the_reconnect_retries() { session.abort(moq_net::Error::Cancel); // The retraction lands promptly, while the connection is still retrying. - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "live"); - assert!(!update.kind.is_active(), "a dead session must retract, not linger"); + assert!(!active, "a dead session must retract, not linger"); drop(connection); server_handle.abort(); @@ -2758,12 +2761,12 @@ async fn announce_interest_unauthorized_keeps_session_alive() { .expect("client connect failed"); // The "allowed" announce stream still delivers even though "denied" was FINed. - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "allowed/test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); // The unauthorized "denied" interest must not have torn down the session. assert!( @@ -2850,15 +2853,15 @@ async fn wildcard_scope_test(version: &str, server_scope: &str) { // Exactly alice's chat: bob's is outside the server's grant, alice's audio and the // lobby are outside the client's. The match pins the room. - let update = next_announce(&mut announcements).await; + let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "room/alice/chat"); assert_eq!( update.captures, Some(vec!["alice".parse::().unwrap()]) ); - assert!(update.kind.is_active()); + assert!(active); assert!( - tokio::time::timeout(Duration::from_millis(200), announcements.next()) + tokio::time::timeout(Duration::from_millis(200), next_update(&mut announcements)) .await .is_err(), "a path outside one of the grants was announced" @@ -2961,12 +2964,12 @@ async fn publish_only_client_to_subscribe_only_server() { // The client serves "allowed/test"; the "denied" interest is FINed but must not // tear down the session. - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "allowed/test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("allowed/test")) .await .expect("request timed out") @@ -3142,12 +3145,12 @@ async fn goaway_test(scheme: &str, version: &str, expect_wire_timeout: bool) { .expect("client connect failed"); // Subscribe and read the pre-GOAWAY group. - let update = tokio::time::timeout(TIMEOUT, announcements.next()) + let (update, active) = tokio::time::timeout(TIMEOUT, next_update(&mut announcements)) .await .expect("announce timed out") .expect("origin closed"); assert_eq!(update.prefix.as_str(), "test"); - assert!(update.kind.is_active(), "expected announce, got retraction"); + assert!(active, "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timed out") @@ -3471,3 +3474,16 @@ async fn abort_carries_its_code_to_the_peer() { server_handle.abort(); } + +/// The next route and whether it is active, skipping the caught-up marker. +async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq_net::announce::Announce, bool)> { + loop { + return match announced.next().await? { + moq_net::announce::Event::Announced(route) | moq_net::announce::Event::Updated(route) => { + Some((route, true)) + } + moq_net::announce::Event::Retracted(route) => Some((route, false)), + moq_net::announce::Event::Live => continue, + }; + } +} From 961822d7ff2724805a8b4c13e829e40af5476680 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 05:49:04 -0700 Subject: [PATCH 03/40] feat(cli): add `moq ls` to list what is live on a relay (#4121) Co-authored-by: Claude Opus 5.5 --- doc/bin/cli.md | 26 ++- quest/m1/cli-inspect/README.md | 6 +- quest/m1/cli-inspect/ls.md | 28 --- rs/moq-cli/src/args.rs | 18 +- rs/moq-cli/src/fetch.rs | 4 +- rs/moq-cli/src/ls.rs | 338 +++++++++++++++++++++++++++++++++ rs/moq-cli/src/main.rs | 10 +- 7 files changed, 383 insertions(+), 47 deletions(-) delete mode 100644 quest/m1/cli-inspect/ls.md create mode 100644 rs/moq-cli/src/ls.rs diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 1376686f9d..038f2d2101 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -23,6 +23,7 @@ or Docker; see [Install](/setup/install). | `export` | `rtmp`, `srt`, `rtc` | Serve plays (`--listen`) or push to a remote (`--connect`). | | `play` | | Decode and play in a native window with sound. | | `transcode` | | Publish a just-in-time rendition ladder next to a broadcast. | +| `ls` | `[prefix]` | List the broadcasts live on a relay. | | `fetch` | `` | Write one group of a track to stdout. | | `auth` | | Generate, sign, and verify relay JWTs. | | `devices` | | List capture sources and their ids. | @@ -33,6 +34,7 @@ or Docker; see [Install](/setup/install). moq import [options] moq export [options] moq play [options] +moq ls [prefix] [options] moq fetch [options] ``` @@ -157,6 +159,26 @@ heights and bitrates must then increase strictly together. Duplicate heights or bitrates, inverted rankings, and zero-sized or zero-bitrate rungs are rejected before connecting. +## List + +```bash +moq --connect https://relay.example.com/anon ls +moq ... ls room --follow --json +``` + +Lists the broadcasts live on a relay over MoQ, with the session's own auth: the +counterpart of the relay's HTTP `/announced/`. It prints one path per +line, relative to the `--connect` path, and exits once the relay has sent +everything live under `prefix`. `--follow` keeps running: it prints `+ path` +for each live broadcast, then `+ path` and `- path` as broadcasts come and go, +and exits non-zero if the session ends. `--json` prints +`{"path": "room/alice", "active": true}` per line instead, in either mode. + +Like `/announced`, it lists announced prefixes, which by convention are +broadcast paths. A new route to a path already live prints nothing. A name +starting with `.` stays hidden unless `prefix` names it. `ls` only dials +`--connect`, and refuses any other MoQ-side flag. + ## Fetch ```bash @@ -279,7 +301,7 @@ track quiet for longer is muxed around until it catches up; a sparse track ## Debugging `RUST_LOG=debug` prints the negotiated version and every subscription. -`curl http://relay:4443/announced/` confirms the relay is reachable and shows -what it holds. Connection refused means UDP isn't getting through; certificate +`moq --connect ls`, or `curl http://relay:4443/announced/`, confirms the +relay is reachable and shows what it holds. Connection refused means UDP isn't getting through; certificate errors on a dev relay want `--connect-tls-insecure` or the `http://` fingerprint flow. diff --git a/quest/m1/cli-inspect/README.md b/quest/m1/cli-inspect/README.md index 084294bbf7..dfa8994a05 100644 --- a/quest/m1/cli-inspect/README.md +++ b/quest/m1/cli-inspect/README.md @@ -1,4 +1,4 @@ -# CLI inspection +# [S] CLI inspection ## Goal @@ -17,7 +17,3 @@ 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. - -## Quests - -- [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/ls.md b/quest/m1/cli-inspect/ls.md deleted file mode 100644 index 67307711f7..0000000000 --- a/quest/m1/cli-inspect/ls.md +++ /dev/null @@ -1,28 +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 - -- Snapshot mode reads the announce cursor up to its `announce::Event::Live` - marker, which follows the relay's whole initial set; `--follow` prints - through it. -- 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. diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index 2e5cff514d..4fc22ac33b 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -2,7 +2,8 @@ //! //! Grammar: `moq [-- ]...`, where a stage is //! ` [endpoint opts]`, plus `moq play` for -//! native playback and `moq fetch ` to read one group. +//! native playback, `moq ls [prefix]` to list what is live, and +//! `moq fetch ` to read one group. //! //! - The MoQ side (`--connect`, the `--listen*` transport binds, `--cluster-lan`, //! and `--cluster-connect` / `--cluster-connect-api`; all optional, at least @@ -181,10 +182,10 @@ impl Invocation { self.typed.reject(command) } - /// Refuse every MoQ-side flag but `--connect` and `--broadcast`, on a verb that + /// Refuse every MoQ-side flag but `--connect` and those in `allow`, on a verb that /// only reads from a relay. Answered from the command line, like [`Self::reject`]. - pub fn dial_only(&self, command: &str) -> anyhow::Result<()> { - self.typed.dial_only(command) + pub fn dial_only(&self, command: &str, allow: &[&str]) -> anyhow::Result<()> { + self.typed.dial_only(command, allow) } /// Split `argv` on `--` and run each chunk through a real parser. @@ -255,7 +256,7 @@ impl Invocation { // Only `import` and `export` share an Origin. The rest own the process: `play` // drives a window on the main thread, `transcode` builds its own Origin, `fetch` - // opens its own session, and `auth` / `devices` never touch the network at all. + // and `ls` open their own session, and `auth` / `devices` never touch the network at all. if let Some(command) = self.stages.iter().find(|command| !command.is_stageable()) { anyhow::bail!( "`{}` must be the only verb; it can't share a process with another `--` stage", @@ -521,8 +522,8 @@ impl MoqSide { /// relay: a listener or cluster it would never serve is not silently ignored. /// Private for the same reason as [`Self::reject`], and reached through /// [`Invocation::dial_only`]. - fn dial_only(&self, command: &str) -> anyhow::Result<()> { - if let Some(flag) = self.given().find(|flag| !matches!(*flag, "--connect" | "--broadcast")) { + fn dial_only(&self, command: &str, allow: &[&str]) -> anyhow::Result<()> { + if let Some(flag) = self.given().find(|flag| *flag != "--connect" && !allow.contains(flag)) { anyhow::bail!("`{command}` only dials a relay with --connect; drop {flag}"); } Ok(()) @@ -595,6 +596,8 @@ pub enum Command { /// The released spelling of [`Self::Export`]. #[usage(hide = true)] Subscribe(Export), + /// List the broadcasts live on a relay. + Ls(crate::ls::Args), /// Write one group of a track to stdout. Fetch(crate::fetch::Args), /// Play a broadcast in a native window and speaker. @@ -654,6 +657,7 @@ impl Command { match self { Self::Import(_) | Self::Publish(_) => "import", Self::Export(_) | Self::Subscribe(_) => "export", + Self::Ls(_) => "ls", Self::Fetch(_) => "fetch", #[cfg(feature = "play")] Self::Play(_) => "play", diff --git a/rs/moq-cli/src/fetch.rs b/rs/moq-cli/src/fetch.rs index 11cfc90258..90531e5ace 100644 --- a/rs/moq-cli/src/fetch.rs +++ b/rs/moq-cli/src/fetch.rs @@ -228,7 +228,7 @@ mod tests { .chain(args.iter().copied()) .chain(args.is_empty().then_some("data")); let mut cli = Invocation::try_parse_from(argv).expect("parse"); - cli.dial_only("fetch").expect("only the dial"); + cli.dial_only("fetch", &["--broadcast"]).expect("only the dial"); match cli.stages.remove(0) { Command::Fetch(args) => (cli.moq, args), _ => unreachable!("parsed a fetch"), @@ -354,7 +354,7 @@ mod tests { "data", ]) .expect("parse"); - let err = cli.dial_only("fetch").unwrap_err().to_string(); + let err = cli.dial_only("fetch", &["--broadcast"]).unwrap_err().to_string(); assert!(err.contains("--listen-tcp-bind"), "{err}"); } } diff --git a/rs/moq-cli/src/ls.rs b/rs/moq-cli/src/ls.rs new file mode 100644 index 0000000000..0d9f6abf8b --- /dev/null +++ b/rs/moq-cli/src/ls.rs @@ -0,0 +1,338 @@ +//! `moq ls`: list the broadcasts live on a relay, the MoQ counterpart of the +//! relay's HTTP `/announced/`. + +use std::collections::BTreeSet; + +use anyhow::Context; +use hang::moq_net::{self, announce::Event}; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +use crate::args::MoqSide; + +/// List the broadcasts live on a relay. +#[derive(usage::Args, Clone)] +#[usage(unknown_flags = "error", args_override_self = false)] +pub struct Args { + /// Only list paths under this prefix. + pub prefix: Option, + + /// Keep running, printing `+ path` and `- path` as broadcasts come and go. + #[usage(long)] + pub follow: bool, + + /// Print one JSON object per line instead of the bare paths. + #[usage(long)] + pub json: bool, +} + +/// One `--json` line. +#[derive(serde::Serialize)] +struct Line<'a> { + path: &'a str, + active: bool, +} + +/// List what is live under the prefix `args` names and write it to stdout. +pub async fn run(moq: MoqSide, args: Args, net: crate::Net) -> anyhow::Result<()> { + list(&moq, &args, &net, &mut tokio::io::stdout()).await +} + +async fn list(moq: &MoqSide, args: &Args, net: &crate::Net, out: &mut (impl AsyncWrite + Unpin)) -> anyhow::Result<()> { + let url = moq + .client + .url + .clone() + .context("`ls` dials a relay: pass --connect ")?; + let prefix = args.prefix.as_deref().unwrap_or_default(); + + // Scoped to the prefix, so the session only asks the relay for what is listed. + let pattern = moq_net::Pattern::subtree(prefix).with_context(|| format!("invalid prefix `{prefix}`"))?; + let origin = moq_tokio::origin::spawn() + .scope("", &moq_net::Patterns::from(pattern)) + .with_context(|| format!("failed to scope to `{prefix}`"))?; + + // Subscribe-only: this session reads and never publishes. + let client = net + .client(moq.client.clone())? + .with_subscriber(origin.clone()) + .with_reconnect(false); + let connection = client.connect(url).established().await.context("failed to connect")?; + + // Registered once the session has started, so `Live` waits for the relay's initial set. + let mut announced = origin.consume().announced(); + let mut live = BTreeSet::new(); + + loop { + // Checked first: a closing session retracts every route, which is not news. + let event = tokio::select! { + biased; + closed = connection.closed() => { + closed?; + anyhow::bail!("connection closed"); + } + event = announced.next() => event.context("announcements ended")?, + }; + + let (announce, active) = match event { + Event::Announced(announce) => (announce, true), + Event::Retracted(announce) => (announce, false), + // A new route for a path already live changes nothing listed. + Event::Updated(_) => continue, + Event::Live if args.follow => continue, + Event::Live => break, + }; + + let path = announce.prefix.as_str(); + if args.follow { + let line = match args.json { + true => json(path, active)?, + false => format!("{} {path}", if active { '+' } else { '-' }), + }; + out.write_all(format!("{line}\n").as_bytes()).await?; + out.flush().await?; + } else if active { + live.insert(path.to_owned()); + } else { + live.remove(path); + } + } + + for path in &live { + let line = match args.json { + true => json(path, true)?, + false => path.clone(), + }; + out.write_all(format!("{line}\n").as_bytes()).await?; + } + out.flush().await?; + Ok(()) +} + +fn json(path: &str, active: bool) -> serde_json::Result { + serde_json::to_string(&Line { path, active }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::args::{Command, Invocation}; + use crate::test_env::EnvGuard; + use std::time::Duration; + use tokio::io::{AsyncBufReadExt, BufReader, DuplexStream, Lines}; + + const TIMEOUT: Duration = Duration::from_secs(10); + const ENV: &[&str] = &["MOQ_CONNECT", "MOQ_HOP", "MOQ_BROADCAST"]; + + /// A running relay holding `demo/a`, `demo/b`, and `other/c` from one publisher. + struct Fixture { + /// `--connect` and the TLS pin for the relay's generated certificate. + connect: [String; 4], + /// Stops the relay, which ends every session on it. + shutdown: moq_relay::shutdown::Trigger, + /// The published broadcasts by path; dropping one retracts it. + broadcasts: Vec<(&'static str, moq_net::broadcast::Producer)>, + _publisher: moq_tokio::Connection, + } + + impl Fixture { + async fn new() -> Self { + let _ = moq_tokio::crypto::install_default(); + let fixture = moq_relay::test_relay().await.expect("test relay"); + let ready = fixture.relay.ready(); + let shutdown = fixture.relay.shutdown_trigger().clone(); + let relay = fixture.relay.cluster().origin.consume(); + tokio::spawn(fixture.relay.run()); + ready.wait().await.expect("relay ready"); + + let connect = [ + "--connect".to_string(), + fixture.url.to_string(), + "--connect-tls-fingerprint".to_string(), + fixture.fingerprint.clone(), + ]; + + let origin = moq_tokio::origin::spawn(); + let broadcasts = ["demo/a", "demo/b", "other/c"] + .into_iter() + .map(|path| { + let broadcast = origin.create_broadcast(path).expect("broadcast"); + broadcast.announce(Default::default()).expect("announce"); + (path, broadcast) + }) + .collect::>(); + + let (moq, _) = parse(&connect, &[]); + let publisher = net() + .client(moq.client.clone()) + .expect("client") + .with_publisher(origin.consume()) + .with_reconnect(false) + .connect(fixture.url.clone()) + .established() + .await + .expect("publisher connects"); + + // Every broadcast is on the relay before anything lists it. + for (path, _) in &broadcasts { + tokio::time::timeout(TIMEOUT, relay.routed_broadcast(*path)) + .await + .expect("relay routes the broadcast") + .expect("broadcast"); + } + + Self { + connect, + shutdown, + broadcasts, + _publisher: publisher, + } + } + + /// Run `moq ls ` to completion, returning what it wrote. + async fn ls(&self, args: &[&str]) -> String { + let (moq, args) = parse(&self.connect, args); + let mut out = Vec::new(); + tokio::time::timeout(TIMEOUT, list(&moq, &args, &net(), &mut out)) + .await + .expect("ls exits once caught up") + .expect("ls"); + String::from_utf8(out).expect("UTF-8") + } + + /// Start `moq ls --follow `, returning its lines as they come + /// and the running task. + fn follow( + &self, + args: &[&str], + ) -> ( + Lines>, + tokio::task::JoinHandle>, + ) { + let args = ["--follow"].iter().chain(args).copied().collect::>(); + let (moq, args) = parse(&self.connect, &args); + let (mut writer, reader) = tokio::io::duplex(4096); + let task = tokio::spawn(async move { list(&moq, &args, &net(), &mut writer).await }); + (BufReader::new(reader).lines(), task) + } + + /// Unpublish `path`. + fn retract(&mut self, path: &str) { + self.broadcasts.retain(|(held, _)| *held != path); + } + } + + /// The next line `--follow` prints. + async fn next(lines: &mut Lines>) -> String { + tokio::time::timeout(TIMEOUT, lines.next_line()) + .await + .expect("a line in time") + .expect("read") + .expect("a line") + } + + /// Parse an ls invocation the way `main` does. + fn parse(connect: &[String], args: &[&str]) -> (MoqSide, Args) { + let argv = ["moq"] + .into_iter() + .chain(connect.iter().map(String::as_str)) + .chain(["ls"]) + .chain(args.iter().copied()); + let mut cli = Invocation::try_parse_from(argv).expect("parse"); + cli.dial_only("ls", &[]).expect("only the dial"); + match cli.stages.remove(0) { + Command::Ls(args) => (cli.moq, args), + _ => unreachable!("parsed an ls"), + } + } + + fn net() -> crate::Net { + crate::Net { + quic: Default::default(), + #[cfg(feature = "iroh")] + iroh: None, + } + } + + /// Exactly the announced set under the prefix, one path per line. + #[tokio::test] + async fn prints_the_live_set_and_exits() { + let _env = EnvGuard::clear(ENV); + let fixture = Fixture::new().await; + + assert_eq!(fixture.ls(&[]).await, "demo/a\ndemo/b\nother/c\n"); + assert_eq!(fixture.ls(&["demo"]).await, "demo/a\ndemo/b\n"); + assert_eq!(fixture.ls(&["nothing"]).await, ""); + } + + /// The initial replay as `+` lines, then a `-` when a publisher leaves. + #[tokio::test] + async fn follow_prints_the_replay_then_changes() { + let _env = EnvGuard::clear(ENV); + let mut fixture = Fixture::new().await; + + let (mut lines, task) = fixture.follow(&["demo"]); + let mut replay = vec![next(&mut lines).await, next(&mut lines).await]; + replay.sort(); + assert_eq!(replay, ["+ demo/a", "+ demo/b"]); + + fixture.retract("demo/a"); + assert_eq!(next(&mut lines).await, "- demo/a"); + assert!(!task.is_finished(), "follow runs until interrupted"); + task.abort(); + } + + /// Each `--json` line is `{"path", "active"}`, in either mode. + #[tokio::test] + async fn json_lines_parse() { + let _env = EnvGuard::clear(ENV); + let mut fixture = Fixture::new().await; + + let lines = fixture.ls(&["demo", "--json"]).await; + let lines: Vec = lines + .lines() + .map(|line| serde_json::from_str(line).expect("a JSON line")) + .collect(); + assert_eq!( + lines, + [ + serde_json::json!({"path": "demo/a", "active": true}), + serde_json::json!({"path": "demo/b", "active": true}), + ] + ); + + let (mut lines, task) = fixture.follow(&["other", "--json"]); + let line: serde_json::Value = serde_json::from_str(&next(&mut lines).await).expect("a JSON line"); + assert_eq!(line, serde_json::json!({"path": "other/c", "active": true})); + fixture.retract("other/c"); + let line: serde_json::Value = serde_json::from_str(&next(&mut lines).await).expect("a JSON line"); + assert_eq!(line, serde_json::json!({"path": "other/c", "active": false})); + task.abort(); + } + + /// `--follow` fails once the relay goes away rather than waiting forever. + #[tokio::test] + async fn follow_fails_when_the_session_ends() { + let _env = EnvGuard::clear(ENV); + let fixture = Fixture::new().await; + + let (mut lines, task) = fixture.follow(&["other"]); + assert_eq!(next(&mut lines).await, "+ other/c"); + + fixture.shutdown.start(); + let result = tokio::time::timeout(TIMEOUT, task) + .await + .expect("follow exits") + .expect("task"); + result.expect_err("a lost session is an error"); + } + + /// `ls` lists a prefix, so a `--broadcast` is refused rather than ignored. + #[test] + fn only_connect_is_accepted() { + let _env = EnvGuard::clear(ENV); + let cli = Invocation::try_parse_from(["moq", "--connect", "http://relay", "--broadcast", "demo", "ls"]) + .expect("parse"); + let err = cli.dial_only("ls", &[]).unwrap_err().to_string(); + assert!(err.contains("--broadcast"), "{err}"); + } +} diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 27ee260451..fa43d4587f 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -12,6 +12,7 @@ mod devices; mod duration; mod fetch; mod hls; +mod ls; mod moq; mod play; mod publish; @@ -317,10 +318,12 @@ async fn main() -> anyhow::Result<()> { } } - // `fetch` only dials, so an ambient listener or cluster setting it never uses - // is not validated either. + // `fetch` and `ls` only dial, so an ambient listener or cluster setting they + // never use is not validated either. if let [Command::Fetch(_)] = stages.as_slice() { - cli.dial_only("fetch")?; + cli.dial_only("fetch", &["--broadcast"])?; + } else if let [Command::Ls(_)] = stages.as_slice() { + cli.dial_only("ls", &[])?; } else { cli.moq.validate()?; } @@ -342,6 +345,7 @@ async fn main() -> anyhow::Result<()> { if stages.len() == 1 && !stages[0].is_stageable() { match stages.remove(0) { Command::Fetch(args) => return fetch::run(cli.moq, args, net).await, + Command::Ls(args) => return ls::run(cli.moq, args, net).await, #[cfg(feature = "play")] Command::Play(args) => return run_play(cli.moq, args, net).await, #[cfg(feature = "transcode")] From eacf376c1ce80884d30bfcfd5f9e752e70853144 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 10:43:28 -0700 Subject: [PATCH 04/40] test(moq-tokio): read the active flag from announce tuples after merging main Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/tests/broadcast.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index c4435649db..6d9f707cef 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -1058,7 +1058,7 @@ async fn broadcast_rejoin_skips_a_stale_warm_cache() { .expect("connect timeout") .expect("connect failed"); - assert!(next_announce(&mut announcements).await.kind.is_active()); + assert!(next_announce(&mut announcements).await.1); let remote = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timeout") @@ -1175,7 +1175,7 @@ async fn rejoin_replays_a_current_warm_cache(version: &str, open: bool) { .expect("connect timeout") .expect("connect failed"); - assert!(next_announce(&mut announcements).await.kind.is_active()); + assert!(next_announce(&mut announcements).await.1); let remote = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await .expect("request timeout") From 801711489b5262d288d7c07594107ae40ce71b09 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 10:43:28 -0700 Subject: [PATCH 05/40] fix(cli): fetch a hidden broadcast such as .stats by name The subscriber origin asked the relay for every announcement, which hides dot-prefixed segments, so `moq fetch` never resolved `.stats/...` and timed out while HTTP `/fetch` served it. Scope the session to the broadcast, as `moq ls` scopes to its prefix. Co-Authored-By: Claude Opus 5.5 --- rs/moq-cli/src/fetch.rs | 54 +++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/rs/moq-cli/src/fetch.rs b/rs/moq-cli/src/fetch.rs index 90531e5ace..698e221c6d 100644 --- a/rs/moq-cli/src/fetch.rs +++ b/rs/moq-cli/src/fetch.rs @@ -5,6 +5,7 @@ use std::time::Duration; use anyhow::Context; use base64::Engine; +use hang::moq_net; use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::time::{Instant, timeout_at}; @@ -60,8 +61,15 @@ async fn fetch( .context("`fetch` dials a relay: pass --connect ")?; let broadcast = moq.broadcast.clone().unwrap_or_default(); + // Scoped to the broadcast, so the relay announces it by name even when a hidden + // segment such as `.stats` would keep it out of an unscoped listing. + let pattern = + moq_net::Pattern::subtree(&broadcast).with_context(|| format!("invalid broadcast `{broadcast}`"))?; + let origin = moq_tokio::origin::spawn() + .scope("", &moq_net::Patterns::from(pattern)) + .with_context(|| format!("failed to scope to `{broadcast}`"))?; + // Subscribe-only: this session reads and never publishes. - let origin = moq_tokio::origin::spawn(); let client = net .client(moq.client.clone())? .with_subscriber(origin.clone()) @@ -122,7 +130,6 @@ mod tests { use super::*; use crate::args::{Command, Invocation}; use crate::test_env::EnvGuard; - use hang::moq_net; /// The frames of each finished group on the `data` track. fn frames(sequence: u64) -> [Vec; 2] { @@ -141,7 +148,7 @@ mod tests { /// Kept so the published broadcast, its tracks, and the open group stay live. _publisher: ( moq_tokio::Connection, - moq_net::broadcast::Producer, + Vec, Vec, moq_net::group::Producer, ), @@ -153,6 +160,21 @@ mod tests { async fn new() -> Self { let _ = moq_tokio::crypto::install_default(); let fixture = moq_relay::test_relay().await.expect("test relay"); + // A dot-prefixed broadcast on the relay itself, like its `.stats`, which + // announce listings hide unless asked for by name. + let hidden = fixture + .relay + .cluster() + .origin + .create_broadcast(".hidden") + .expect("hidden broadcast"); + hidden.announce(Default::default()).expect("announce hidden"); + let secret = hidden.create_track("data", None).expect("hidden track"); + let mut group = secret.append_group().expect("group"); + group.write_frame(moq_net::Timestamp::ZERO, b"secret".as_ref()) + .expect("frame"); + group.finish().expect("finish"); + let ready = fixture.relay.ready(); tokio::spawn(fixture.relay.run()); ready.wait().await.expect("relay ready"); @@ -182,7 +204,7 @@ mod tests { open.write_frame(moq_net::Timestamp::ZERO, b"first".as_ref()) .expect("frame"); - let (moq, _) = parse(&connect, &[]); + let (moq, _) = parse(&connect, "demo", &[]); let connection = net() .client(moq.client.clone()) .expect("client") @@ -196,14 +218,19 @@ mod tests { Self { connect, http: fixture.http, - _publisher: (connection, broadcast, vec![data, empty, live], open), + _publisher: (connection, vec![broadcast, hidden], vec![data, empty, live, secret], open), } } /// Run `moq --broadcast demo fetch ` with `timeout`, returning /// the outcome and what it wrote. async fn fetch(&self, args: &[&str], timeout: Duration) -> (anyhow::Result<()>, Vec) { - let (moq, args) = parse(&self.connect, args); + self.fetch_from("demo", args, timeout).await + } + + /// [`Self::fetch`] from another broadcast. + async fn fetch_from(&self, broadcast: &str, args: &[&str], timeout: Duration) -> (anyhow::Result<()>, Vec) { + let (moq, args) = parse(&self.connect, broadcast, args); let mut out = Vec::new(); let result = super::fetch(&moq, &args, &net(), Instant::now() + timeout, &mut out).await; (result, out) @@ -220,11 +247,11 @@ mod tests { } /// Parse a fetch invocation the way `main` does. - fn parse(connect: &[String], args: &[&str]) -> (MoqSide, Args) { + fn parse(connect: &[String], broadcast: &str, args: &[&str]) -> (MoqSide, Args) { let argv = ["moq"] .into_iter() .chain(connect.iter().map(String::as_str)) - .chain(["--broadcast", "demo", "fetch"]) + .chain(["--broadcast", broadcast, "fetch"]) .chain(args.iter().copied()) .chain(args.is_empty().then_some("data")); let mut cli = Invocation::try_parse_from(argv).expect("parse"); @@ -269,6 +296,17 @@ mod tests { assert_eq!(out, fixture.curl("").await); } + /// A hidden broadcast such as `.stats` is fetched by name, as `/fetch` serves it. + #[tokio::test] + async fn a_hidden_broadcast_is_fetched_by_name() { + let _env = EnvGuard::clear(ENV); + let fixture = Fixture::new().await; + + let (result, out) = fixture.fetch_from(".hidden", &["data"], Duration::from_secs(5)).await; + result.expect("fetch"); + assert_eq!(out, b"secret"); + } + #[tokio::test] async fn a_missing_sequence_fails() { let _env = EnvGuard::clear(ENV); From bd6f5a0b2ff92b37ffa8faca7509f2c4ef478d3b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 10:43:28 -0700 Subject: [PATCH 06/40] docs: add the Inspect a relay guide and complete the cli-inspect line Co-Authored-By: Claude Opus 5.5 --- doc/.vitepress/config.ts | 6 ++- doc/bin/cli.md | 7 ++- doc/bin/inspect.md | 79 ++++++++++++++++++++++++++++++++++ doc/bin/relay/http.md | 3 ++ quest/m1/README.md | 1 - quest/m1/cli-inspect/README.md | 19 -------- 6 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 doc/bin/inspect.md delete mode 100644 quest/m1/cli-inspect/README.md diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 5b6eb122e7..9c736e282c 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -132,7 +132,11 @@ export default defineConfig({ { text: "Deployment", link: "/setup/prod" }, ], }, - { text: "moq-cli", link: "/bin/cli" }, + { + text: "moq-cli", + link: "/bin/cli", + items: [{ text: "Inspect a relay", link: "/bin/inspect" }], + }, { text: "Gateways", items: [ diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 038f2d2101..6de2b370a1 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -179,6 +179,9 @@ broadcast paths. A new route to a path already live prints nothing. A name starting with `.` stays hidden unless `prefix` names it. `ls` only dials `--connect`, and refuses any other MoQ-side flag. +[Inspect a relay](/bin/inspect) walks through `ls` and `fetch` next to their +`curl` equivalents, including reading the relay's stats. + ## Fetch ```bash @@ -301,7 +304,7 @@ track quiet for longer is muxed around until it catches up; a sparse track ## Debugging `RUST_LOG=debug` prints the negotiated version and every subscription. -`moq --connect ls`, or `curl http://relay:4443/announced/`, confirms the -relay is reachable and shows what it holds. Connection refused means UDP isn't getting through; certificate +`moq --connect ls`, or `curl http://relay:4443/announced`, confirms the +relay is reachable and shows what it holds; see [Inspect a relay](/bin/inspect). Connection refused means UDP isn't getting through; certificate errors on a dev relay want `--connect-tls-insecure` or the `http://` fingerprint flow. diff --git a/doc/bin/inspect.md b/doc/bin/inspect.md new file mode 100644 index 0000000000..f83a55b4a3 --- /dev/null +++ b/doc/bin/inspect.md @@ -0,0 +1,79 @@ +--- +title: Inspect a relay +description: See what is live on a relay, watch it change, read a group, and read its stats +--- + +# Inspect a relay + +Every question here has two answers: a [`moq`](/bin/cli) verb that speaks MoQ +with the session's own auth, and a `curl` against the relay's +[HTTP endpoints](/bin/relay/http). The examples use the local dev relay from +`just dev`; in production, swap in your relay's URL and add a `?jwt=` token. + +| Question | MoQ | HTTP | +| --- | --- | --- | +| What is live? | `moq --connect / ls [prefix]` | `GET /announced/` | +| What changes? | `moq ... ls --follow` | (none) | +| What is in a group? | `moq ... --broadcast fetch ` | `GET /fetch///` | + +## What is live + +```bash +moq --connect http://localhost:4443/anon ls +curl http://localhost:4443/announced/anon +``` + +Both print one broadcast path per line, relative to the path in the URL, and +both use that path for auth, so a token rooted at `rooms/123` lists only its +room. `moq ls room` narrows the listing to one prefix, and `--json` prints +`{"path": "room/alice", "active": true}` per line instead. + +Both list announced prefixes, which by convention are broadcast paths. A +segment starting with `.` stays hidden unless the URL path or `prefix` names +it, which keeps the relay's own `.stats` out of the listing. + +## What changes + +```bash +moq --connect http://localhost:4443/anon ls --follow +``` + +`--follow` prints `+ path` for each live broadcast, then `+ path` and `- path` +as broadcasts come and go, until you stop it. It exits non-zero if the session +ends, so a script can tell a lost relay from a quiet one. `curl` has no +equivalent: `/announced` is a snapshot. + +## What is in a group + +```bash +moq --connect http://localhost:4443/anon --broadcast demo/bbb.hang fetch catalog.json | jq +curl http://localhost:4443/fetch/anon/demo/bbb.hang/catalog.json | jq +``` + +Both write the frame payloads of one group back to back: the newest group, or +the one `--group 42` (`?group=42`) names. `moq fetch --json` prints one line +per frame instead, `{"group": 42, "frame": 0, "size": 1234, "payload": ""}`, +which shows where the frames split. + +`fetch` takes the track name literally. `/fetch` splits its path on the last +`/`, so it cannot reach a track whose name contains one. Both give up after 30 +seconds, and `moq fetch` exits non-zero when the broadcast or group is missing. + +## Stats + +A relay with [`[stats]`](/bin/relay/config#stats) enabled publishes its +counters as ordinary broadcasts under `.stats`, so the same tools read them. +Dial the `.stats` path, which the token must cover: + +```bash +moq --connect http://localhost:4443/.stats ls +moq --connect http://localhost:4443/.stats --broadcast node/local/host fetch publisher.json | jq +curl http://localhost:4443/fetch/.stats/node/local/host/publisher.json | jq +``` + +The node name (`local/host` here) is `stats.node`. Each fetch returns the +newest snapshot: a JSON object of cumulative counters keyed by broadcast path. +`publisher.json` is egress, `subscriber.json` is ingress, and `sessions.json` +counts sessions per auth root. Fetch twice and divide by the interval for a +rate. The `.json.z` twins carry compressed patches, which these tools print as +raw bytes. [Stats](/concept/stats) describes every field. diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md index 28cad8de5c..c4460ccb22 100644 --- a/doc/bin/relay/http.md +++ b/doc/bin/relay/http.md @@ -26,6 +26,9 @@ The announcement listing names each announced route by the prefix it covers; by convention a publisher announces each broadcast's exact path, so the list reads as broadcast names. +`moq ls` and `moq fetch` answer the same questions over MoQ; see +[Inspect a relay](/bin/inspect). + A relay configured with more than one certificate has no single fingerprint to publish, and this endpoint answers for the first. The others are reachable over `https://`, which selects a certificate by SNI at the handshake. diff --git a/quest/m1/README.md b/quest/m1/README.md index 93cb6d3458..98f5fb893b 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -34,7 +34,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer - [CLI import clock](/quest/m1/cli-import-clock.md) - fMP4, TS, and FLV imports publish on the shared broadcast clock across restarts - [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - CI drives native capture through clock edge cases and asserts the published timestamps -- [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included - [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 diff --git a/quest/m1/cli-inspect/README.md b/quest/m1/cli-inspect/README.md deleted file mode 100644 index dfa8994a05..0000000000 --- a/quest/m1/cli-inspect/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# [S] 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. From b00dfffd17c6d2f770edf415cf4c95a34c71c368 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 16:35:36 -0700 Subject: [PATCH 07/40] quest: scope JS announce interest and close sessions gracefully (#4209) Co-authored-by: Grok 4.7 --- quest/m1/README.md | 2 ++ quest/m1/js-origin-scope.md | 27 +++++++++++++++++++++++++++ quest/m1/session-close.md | 29 +++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 quest/m1/js-origin-scope.md create mode 100644 quest/m1/session-close.md diff --git a/quest/m1/README.md b/quest/m1/README.md index fee11fb135..f3b9d5c9da 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -34,6 +34,8 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay +- [JS origin scope](/quest/m1/js-origin-scope.md) - a scoped `@moq/net` origin subscribes only to its allowed prefixes +- [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included - [Optional max age](/quest/m1/ietf-max-age.md) - max age is optional, set only by the publisher, and crosses moq-transport as MAX_CACHE_DURATION diff --git a/quest/m1/js-origin-scope.md b/quest/m1/js-origin-scope.md new file mode 100644 index 0000000000..9bc8530c60 --- /dev/null +++ b/quest/m1/js-origin-scope.md @@ -0,0 +1,27 @@ +# [M] JS origin scope drives announce interest + +## Goal + +A `@moq/net` origin scoped to a prefix asks the peer only for that prefix. +An origin that was never scoped still asks for every namespace. A relay that +ignores an empty `SUBSCRIBE_NAMESPACE` then delivers a namespace the caller +actually named. + +## Plan + +`Producer.scope(root, patterns)` matches Rust `origin::Producer::scope`: the +root is stripped from paths, and the patterns are what the handle may publish +and subscribe under. `forwardAnnounced` sends one `SUBSCRIBE_NAMESPACE`, and +the moq-lite equivalent, per literal head of those patterns, the same rule as +Rust `interest_prefixes`. Hidden routes stay opted in on those subscriptions. + +An unscoped origin still allows `**`. Its head is the empty prefix, so the +session sends one subscription for everything, which is today's behavior. + +`doc/concept/moq-lite.md` says the prefixes a session asks for are the +origin's scope. No new page. + +## Related + +- [JS caught up](/quest/m1/js-announce-caught-up.md) - when the replay of those prefixes has landed +- [IETF announce count](/quest/m1/ietf-announce-count.md) - how an IETF consumer knows that replay is finished diff --git a/quest/m1/session-close.md b/quest/m1/session-close.md new file mode 100644 index 0000000000..a846d5e977 --- /dev/null +++ b/quest/m1/session-close.md @@ -0,0 +1,29 @@ +# [M] Graceful session close withdraws announces + +## Goal + +Ending a session on purpose withdraws the namespaces it published and waits +for the peer to acknowledge that, up to one second. `abort`, and dropping the +last handle, still end the session immediately and do not wait. + +## Plan + +Rust has `abort` and drop, and both end the session now. Drop sends a bare +`Cancel`, so `PUBLISH_NAMESPACE_DONE` never goes out. Add +`moq_tokio::Connection::close()`. It unannounces what this session published, +waits up to one second for the acknowledgements, then ends the session. If +the peer has not answered by then, the session still ends and `close()` +returns that timeout. There is no existing request-ack timer to reuse; this +one second is its own constant. + +JS `Established.close()` is synchronous today. It becomes the same graceful +end and returns a promise, which is a published break, so that change targets +`dev`. `abort` stays immediate in both languages. + +`doc/concept/moq-lite.md` says a graceful close withdraws announces and an +abort does not. No new page. + +## Related + +- [Session death error](/quest/m1/session-death-error.md) - a session that dies still ends its tracks with its own error +- [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` on a broadcast, which is not this session end From 3e52c7c0c2fb7573df827e9227cb7826a18f05af Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 16:43:40 -0700 Subject: [PATCH 08/40] fix(nvenc): commit rate changes and cleanup only once the driver accepts (#4146) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- quest/m1/README.md | 2 +- quest/m1/nvdec-teardown.md | 21 ++++ quest/m1/nvenc-recovery.md | 25 ---- rs/moq-nvenc/src/safe/buffer.rs | 77 ++++++++++-- rs/moq-nvenc/src/safe/encoder.rs | 102 +++++++++++++-- rs/moq-nvenc/src/safe/session.rs | 152 +++++++++++++++++++---- rs/moq-video/src/encode/backend/nvenc.rs | 29 +++++ 7 files changed, 344 insertions(+), 64 deletions(-) create mode 100644 quest/m1/nvdec-teardown.md delete mode 100644 quest/m1/nvenc-recovery.md diff --git a/quest/m1/README.md b/quest/m1/README.md index f3b9d5c9da..5990696a70 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -58,7 +58,7 @@ 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 - [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 -- [NVENC recovery](/quest/m1/nvenc-recovery.md) - partial initialization and rejected rate changes preserve valid state +- [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 - [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode - [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name diff --git a/quest/m1/nvdec-teardown.md b/quest/m1/nvdec-teardown.md new file mode 100644 index 0000000000..cb631e47c5 --- /dev/null +++ b/quest/m1/nvdec-teardown.md @@ -0,0 +1,21 @@ +# [S] Dropping an NVDEC decoder does not crash + +## Goal + +Dropping a `moq-video` NVDEC decoder releases it without a segfault, and the +hardware tests `nvdec_h264_round_trip` and `nvdec_resize_scales_output` pass. + +## Plan + +Both tests die with SIGSEGV on `main` on an RTX 3070 Ti (driver 595.91), while +`nvdec_h265_round_trip` and `nvdec_to_nvenc_zero_copy` pass. The backtrace +ends in `cuEventDestroy_v2`, called by `cuvidDestroyDecoder` from +`::drop` while the test drops the backend. That +`Drop` assumes the caller keeps the CUDA context bound, which nothing +enforces. Suspect the context is not current on the dropping thread, or is +released before the decoder; confirm before fixing. + +Under the Nix dev shell the driver libraries are not on the loader path, so +the NVIDIA tests skip. To run them, expose `libcuda`, `libnvidia-encode`, and +`libnvcuvid` from `/usr/lib/x86_64-linux-gnu` through a directory of symlinks +on `LD_LIBRARY_PATH`. diff --git a/quest/m1/nvenc-recovery.md b/quest/m1/nvenc-recovery.md deleted file mode 100644 index 201b96cbc7..0000000000 --- a/quest/m1/nvenc-recovery.md +++ /dev/null @@ -1,25 +0,0 @@ -# [M] Recover NVENC initialization and rate-change failures - -## Goal - -NVENC releases every partially initialized resource, destruction does not -panic, and a rejected rate change leaves the last accepted settings intact. - -## Plan - -Registration rollback and non-panicking destructors landed with the ownership -work (#3834, #3835, #3838). A submission whose wait fails abandons its -buffers: the raw handles leak, but the encoder reference is released so the -session is still destroyed. Session::reconfigure still mutates retained -bitrate/VBV fields before the driver accepts the change; a rejected zero-rate -update can corrupt the basis of the next proportional update. - -Use the settled resource ownership model and a fake driver to verify rollback -after each acquisition stage, cleanup order, and teardown during an existing -failure. Compute candidate rate settings with checked arithmetic and commit -only on driver success. A following successful update must be based on the -last accepted settings. Preserve fallible explicit operations and non-panicking -Drop without disguising a live operational failure as success. - -Run the failure-injection suite in CI. Do not duplicate cleanup guarantees -already delivered by the ownership quest. Public API and wire: unchanged. diff --git a/rs/moq-nvenc/src/safe/buffer.rs b/rs/moq-nvenc/src/safe/buffer.rs index 3234005885..a2f135994d 100644 --- a/rs/moq-nvenc/src/safe/buffer.rs +++ b/rs/moq-nvenc/src/safe/buffer.rs @@ -687,8 +687,8 @@ struct Mapping { reg_ptr: *mut c_void, map_ptr: *mut c_void, api: A, - // Dropped after the resource is unregistered. - _marker: T, + // Dropped only once the resource is unregistered. + marker: ManuallyDrop, } impl Mapping { @@ -712,7 +712,7 @@ impl Mapping { reg_ptr, map_ptr, api, - _marker: marker, + marker: ManuallyDrop::new(marker), }) } } @@ -721,8 +721,17 @@ impl Mapping { /// when it goes out of scope. impl Drop for Mapping { fn drop(&mut self) { - let _ = self.api.unmap_input_resource(self.map_ptr); - let _ = self.api.unregister_resource(self.reg_ptr); + // A failed unmap leaves the resource mapped. Unregistering or freeing + // it could release memory NVENC still holds, so leak the owner instead. + if self.api.unmap_input_resource(self.map_ptr).is_err() { + return; + } + // As in `new`: NVENC may still refer to an allocation it failed to + // unregister, so leak its owner rather than free it. + if self.api.unregister_resource(self.reg_ptr).is_ok() { + // SAFETY: dropped once, here, and never touched again. + unsafe { ManuallyDrop::drop(&mut self.marker) }; + } } } @@ -771,8 +780,10 @@ mod tests { #[derive(Debug)] struct TestApi { calls: RefCell, + mapped: Cell, owner_alive: Rc>, map_error: Option, + unmap_error: Option, unregister_error: Option, } @@ -780,8 +791,10 @@ mod tests { fn new(owner_alive: Rc>) -> Self { Self { calls: RefCell::new(Calls::default()), + mapped: Cell::new(false), owner_alive, map_error: None, + unmap_error: None, unregister_error: None, } } @@ -810,18 +823,28 @@ mod tests { self.calls.borrow_mut().map += 1; match self.map_error { Some(kind) => Err(EncodeError::new(kind, None)), - None => Ok(TestApi::handle()), + None => { + self.mapped.set(true); + Ok(TestApi::handle()) + } } } fn unmap_input_resource(&self, _mapped: *mut c_void) -> Result<(), EncodeError> { self.assert_owner_alive(); self.calls.borrow_mut().unmap += 1; - Ok(()) + match self.unmap_error { + Some(kind) => Err(EncodeError::new(kind, None)), + None => { + self.mapped.set(false); + Ok(()) + } + } } fn unregister_resource(&self, _registered: *mut c_void) -> Result<(), EncodeError> { self.assert_owner_alive(); + assert!(!self.mapped.get(), "unregistered a resource that is still mapped"); self.calls.borrow_mut().unregister += 1; match self.unregister_error { Some(kind) => Err(EncodeError::new(kind, None)), @@ -931,4 +954,44 @@ mod tests { } ); } + + #[test] + fn failed_unmap_on_drop_retains_the_owner() { + let (owner, alive) = setup(); + let mut api = TestApi::new(alive.clone()); + api.unmap_error = Some(ErrorKind::ResourceNotMapped); + + drop(register(&api, owner).expect("mapping should succeed")); + + assert!(alive.get(), "a possibly mapped allocation must remain owned"); + assert_eq!( + *api.calls.borrow(), + Calls { + register: 1, + map: 1, + unmap: 1, + unregister: 0, + } + ); + } + + #[test] + fn failed_unregister_on_drop_retains_the_owner() { + let (owner, alive) = setup(); + let mut api = TestApi::new(alive.clone()); + api.unregister_error = Some(ErrorKind::ResourceNotRegistered); + + drop(register(&api, owner).expect("mapping should succeed")); + + assert!(alive.get(), "a possibly registered allocation must remain owned"); + assert_eq!( + *api.calls.borrow(), + Calls { + register: 1, + map: 1, + unmap: 1, + unregister: 1, + } + ); + } } diff --git a/rs/moq-nvenc/src/safe/encoder.rs b/rs/moq-nvenc/src/safe/encoder.rs index af50e7846d..f94ecb9a8f 100644 --- a/rs/moq-nvenc/src/safe/encoder.rs +++ b/rs/moq-nvenc/src/safe/encoder.rs @@ -94,7 +94,6 @@ impl Encoder { /// ``` pub fn initialize_with_cuda(cuda_ctx: Arc) -> Result { let api = api::get()?; - let mut encoder = ptr::null_mut(); let mut session_params = NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS { version: NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER, deviceType: NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA, @@ -104,14 +103,10 @@ impl Encoder { device: cuda_ctx.cu_ctx().cast::(), ..Default::default() }; - - if let err @ Err(_) = - unsafe { (api.open_encode_session_ex)(&mut session_params, &mut encoder) }.result_without_string() - { - // We are required to destroy the encoder if there was an error. - unsafe { (api.destroy_encoder)(encoder) }.result_without_string()?; - err?; - } + let encoder = open_session( + |encoder| unsafe { (api.open_encode_session_ex)(&mut session_params, encoder) }.result_without_string(), + |encoder| unsafe { (api.destroy_encoder)(encoder) }.result_without_string(), + )?; Ok(Self { ptr: encoder, @@ -460,6 +455,26 @@ impl Encoder { } } +/// Open an encode session, destroying whatever a failed open left behind as +/// NVENC requires. A failed destroy is attached to the open error, not +/// reported in its place. +fn open_session( + open: impl FnOnce(&mut *mut c_void) -> Result<(), EncodeError>, + destroy: impl FnOnce(*mut c_void) -> Result<(), EncodeError>, +) -> Result<*mut c_void, EncodeError> { + let mut encoder = ptr::null_mut(); + let Err(primary) = open(&mut encoder) else { + return Ok(encoder); + }; + if encoder.is_null() { + return Err(primary); + } + match destroy(encoder) { + Ok(()) => Err(primary), + Err(cleanup) => Err(primary.with_cleanup(cleanup)), + } +} + /// A safe wrapper for [`NV_ENC_INITIALIZE_PARAMS`], which is the encoder /// initialize parameter. pub struct EncoderInitParams { @@ -554,3 +569,72 @@ impl EncoderInitParams { self } } + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + use crate::safe::result::ErrorKind; + + fn handle() -> *mut c_void { + ptr::NonNull::::dangling().as_ptr().cast() + } + + fn fail(kind: ErrorKind) -> Result<(), EncodeError> { + Err(EncodeError::new(kind, None)) + } + + #[test] + fn failed_open_destroys_the_partial_session() { + let destroyed = RefCell::new(Vec::new()); + let error = open_session( + |encoder| { + *encoder = handle(); + fail(ErrorKind::InvalidVersion) + }, + |encoder| { + destroyed.borrow_mut().push(encoder); + Ok(()) + }, + ) + .expect_err("open failed"); + assert_eq!(error.kind(), ErrorKind::InvalidVersion); + assert!(error.cleanup().is_none()); + assert_eq!(*destroyed.borrow(), [handle()]); + } + + #[test] + fn failed_destroy_keeps_the_open_error() { + let error = open_session( + |encoder| { + *encoder = handle(); + fail(ErrorKind::NoEncodeDevice) + }, + |_| fail(ErrorKind::InvalidPtr), + ) + .expect_err("open failed"); + assert_eq!(error.kind(), ErrorKind::NoEncodeDevice); + assert_eq!(error.cleanup().map(EncodeError::kind), Some(ErrorKind::InvalidPtr)); + } + + #[test] + fn failed_open_without_a_handle_destroys_nothing() { + let error = open_session(|_| fail(ErrorKind::OutOfMemory), |_| panic!("destroyed a null session")) + .expect_err("open failed"); + assert_eq!(error.kind(), ErrorKind::OutOfMemory); + } + + #[test] + fn successful_open_destroys_nothing() { + let encoder = open_session( + |encoder| { + *encoder = handle(); + Ok(()) + }, + |_| panic!("destroyed a live session"), + ) + .unwrap(); + assert_eq!(encoder, handle()); + } +} diff --git a/rs/moq-nvenc/src/safe/session.rs b/rs/moq-nvenc/src/safe/session.rs index 5e6156cd51..68d2e3fea5 100644 --- a/rs/moq-nvenc/src/safe/session.rs +++ b/rs/moq-nvenc/src/safe/session.rs @@ -114,9 +114,12 @@ impl Session { /// # Errors /// /// Returns [`ErrorKind::InvalidParam`] when the session was started without - /// an encode config, since there is then no config to resubmit. Otherwise - /// returns whatever `NvEncReconfigureEncoder` reports, e.g. + /// an encode config, since there is then no config to resubmit, when + /// `bitrate` is zero, when a nonzero VBV would scale to zero, or when the + /// proportionally scaled VBV overflows. + /// Otherwise returns whatever `NvEncReconfigureEncoder` reports, e.g. /// [`ErrorKind::UnsupportedParam`] if the driver rejects the rate change. + /// After any error the session keeps its last accepted rate settings. pub fn reconfigure(&mut self, bitrate: u32) -> Result<(), EncodeError> { let Some(config) = self.config.as_mut() else { return Err(EncodeError::new( @@ -124,32 +127,16 @@ impl Session { Some("session was started without an encode config to reconfigure".into()), )); }; - - // Keep a caller-sized VBV proportional to the rate, so a buffer sized to - // one frame at open stays one frame: left alone it would loosen the - // keyframe cap as the bitrate falls, right when the link can least afford it. - if config.rcParams.vbvBufferSize != 0 && config.rcParams.averageBitRate != 0 { - let scale = |v: u32| (u64::from(v) * u64::from(bitrate) / u64::from(config.rcParams.averageBitRate)) as u32; - config.rcParams.vbvBufferSize = scale(config.rcParams.vbvBufferSize); - config.rcParams.vbvInitialDelay = scale(config.rcParams.vbvInitialDelay); - } - config.rcParams.averageBitRate = bitrate; debug_assert_eq!( self.init.encodeConfig, std::ptr::from_mut::(&mut **config), "init.encodeConfig must point at our owned copy, not the caller's dead one" ); - let mut params = NV_ENC_RECONFIGURE_PARAMS { - version: NV_ENC_RECONFIGURE_PARAMS_VER, - reInitEncodeParams: self.init, - ..unsafe { std::mem::zeroed() } - }; - // Leave resetEncoder and forceIDR clear: retune in place, no keyframe. - params.set_resetEncoder(0); - params.set_forceIDR(0); - - unsafe { (self.encoder.api.reconfigure_encoder)(self.encoder.ptr, &mut params) }.result(&self.encoder) + let encoder = &self.encoder; + retune(&self.init, config, bitrate, |params| { + unsafe { (encoder.api.reconfigure_encoder)(encoder.ptr, params) }.result(encoder) + }) } /// Encode a frame. @@ -362,6 +349,60 @@ fn same_session(input: &Arc, output: &Arc, session: &Arc) -> bool { Arc::ptr_eq(input, session) && Arc::ptr_eq(output, session) } +/// Submit `config` retuned to `bitrate` and commit it only once `submit` +/// accepts, so a rejected change cannot skew the basis of the next one. +fn retune( + init: &NV_ENC_INITIALIZE_PARAMS, + config: &mut NV_ENC_CONFIG, + bitrate: u32, + submit: impl FnOnce(&mut NV_ENC_RECONFIGURE_PARAMS) -> Result<(), EncodeError>, +) -> Result<(), EncodeError> { + let invalid = |reason: &str| EncodeError::new(ErrorKind::InvalidParam, Some(reason.into())); + // A zero rate would also zero a proportional VBV, which no later rate could scale back up. + if bitrate == 0 { + return Err(invalid("bitrate must be nonzero")); + } + + let mut candidate = *config; + let rc = &mut candidate.rcParams; + // Keep a caller-sized VBV proportional to the rate, so a buffer sized to + // one frame at open stays one frame: left alone it would loosen the + // keyframe cap as the bitrate falls, right when the link can least afford it. + if rc.vbvBufferSize != 0 && rc.averageBitRate != 0 { + let basis = u64::from(rc.averageBitRate); + let scale = |v: u32| { + let scaled = u64::from(v) * u64::from(bitrate) / basis; + // Rounding a nonzero VBV to zero is the same dead end as a zero rate: + // the next retune would skip this branch and could never restore it. + if v != 0 && scaled == 0 { + return Err(invalid("scaled VBV must be nonzero")); + } + u32::try_from(scaled).map_err(|_| invalid("scaled VBV exceeds u32")) + }; + rc.vbvBufferSize = scale(rc.vbvBufferSize)?; + rc.vbvInitialDelay = scale(rc.vbvInitialDelay)?; + } + rc.averageBitRate = bitrate; + + // NVENC copies the config during the call, so pointing it at the local + // candidate is sound; `init` keeps pointing at the committed copy. + let mut params = NV_ENC_RECONFIGURE_PARAMS { + version: NV_ENC_RECONFIGURE_PARAMS_VER, + reInitEncodeParams: NV_ENC_INITIALIZE_PARAMS { + encodeConfig: &mut candidate, + ..*init + }, + ..unsafe { std::mem::zeroed() } + }; + // Leave resetEncoder and forceIDR clear: retune in place, no keyframe. + params.set_resetEncoder(0); + params.set_forceIDR(0); + + submit(&mut params)?; + *config = candidate; + Ok(()) +} + trait CompletionDriver { type Input; type Output; @@ -513,6 +554,73 @@ mod tests { assert_eq!(*events.lock().unwrap(), ["wait", "input", "output"]); } + fn rate_config(bitrate: u32, vbv: u32) -> NV_ENC_CONFIG { + let mut config = NV_ENC_CONFIG::default(); + config.rcParams.averageBitRate = bitrate; + config.rcParams.vbvBufferSize = vbv; + config.rcParams.vbvInitialDelay = vbv; + config + } + + /// The (average, VBV size, VBV delay) a reconfigure submitted. + fn submitted(params: &NV_ENC_RECONFIGURE_PARAMS) -> (u32, u32, u32) { + let rc = unsafe { &(*params.reInitEncodeParams.encodeConfig).rcParams }; + (rc.averageBitRate, rc.vbvBufferSize, rc.vbvInitialDelay) + } + + fn rates(config: &NV_ENC_CONFIG) -> (u32, u32, u32) { + let rc = &config.rcParams; + (rc.averageBitRate, rc.vbvBufferSize, rc.vbvInitialDelay) + } + + #[test] + fn rejected_rate_change_keeps_the_last_accepted_basis() { + let init = NV_ENC_INITIALIZE_PARAMS { + encodeWidth: 1280, + ..Default::default() + }; + let mut config = rate_config(1_000_000, 100_000); + + let error = retune(&init, &mut config, 500_000, |params| { + assert_eq!(submitted(params), (500_000, 50_000, 50_000)); + assert_eq!(params.reInitEncodeParams.encodeWidth, 1280); + Err(EncodeError::new(ErrorKind::UnsupportedParam, None)) + }) + .expect_err("the driver rejected the change"); + assert_eq!(error.kind(), ErrorKind::UnsupportedParam); + assert_eq!(rates(&config), (1_000_000, 100_000, 100_000)); + + // Scaled from the last accepted rate, not the rejected one. + retune(&init, &mut config, 2_000_000, |params| { + assert_eq!(submitted(params), (2_000_000, 200_000, 200_000)); + Ok(()) + }) + .unwrap(); + assert_eq!(rates(&config), (2_000_000, 200_000, 200_000)); + } + + #[test] + fn rate_that_zeroes_a_nonzero_vbv_is_refused_before_the_driver() { + let init = NV_ENC_INITIALIZE_PARAMS::default(); + let mut config = rate_config(1_000, 1); + let error = + retune(&init, &mut config, 1, |_| panic!("submitted a zero VBV")).expect_err("the scaled VBV is zero"); + assert_eq!(error.kind(), ErrorKind::InvalidParam); + assert_eq!(rates(&config), (1_000, 1, 1)); + } + + #[test] + fn invalid_rate_change_is_refused_before_the_driver() { + let init = NV_ENC_INITIALIZE_PARAMS::default(); + let mut config = rate_config(1, u32::MAX); + for bitrate in [0, 2] { + let error = retune(&init, &mut config, bitrate, |_| panic!("submitted an invalid rate")) + .expect_err("the rate is invalid"); + assert_eq!(error.kind(), ErrorKind::InvalidParam); + assert_eq!(rates(&config), (1, u32::MAX, u32::MAX)); + } + } + #[test] fn failed_wait_abandons_without_waiting_again() { let events = Events::default(); diff --git a/rs/moq-video/src/encode/backend/nvenc.rs b/rs/moq-video/src/encode/backend/nvenc.rs index 703779120c..60c916934a 100644 --- a/rs/moq-video/src/encode/backend/nvenc.rs +++ b/rs/moq-video/src/encode/backend/nvenc.rs @@ -481,6 +481,35 @@ mod tests { assert!(types.contains(&8), "forced IDR is missing inline PPS: {types:?}"); } + /// A refused retune leaves the session encoding at its last accepted rate, + /// and a later retune is accepted by the real driver. Same skip rule as the + /// H.264 test. + #[test] + fn nvenc_refused_retune_keeps_encoding() { + if !driver_available() { + return; + } + let mut config = crate::encode::Config::new(320, 240, crate::Rate::new(30, 1).unwrap()); + config.kind = crate::encode::Kind::Named(NAME.into()); + config.bitrate = Some(moq_net::bandwidth::Rate::from_bps(1_000_000)); + let Ok(mut encoder) = crate::encode::Encoder::new(&config) else { + return; + }; + + let frame = gray_rgba(320, 240); + assert!(!encoder.encode(&gray_frame(&frame, 0)).unwrap().is_empty()); + encoder + .set_bitrate(moq_net::bandwidth::Rate::ZERO) + .expect_err("a zero rate must be refused"); + assert_eq!(encoder.bitrate(), moq_net::bandwidth::Rate::from_bps(1_000_000)); + encoder + .set_bitrate(moq_net::bandwidth::Rate::from_bps(500_000)) + .unwrap(); + for i in 1..5 { + assert!(!encoder.encode(&gray_frame(&frame, i)).unwrap().is_empty()); + } + } + /// Real-hardware H.265 encode through NVENC. Same skip rule as the H.264 test. /// Asserts the HEVC GUID path emits Annex-B with a self-contained IRAP /// (VPS+SPS+PPS+IDR slice) and repeats them on a mid-stream forced keyframe, From 9cb5416ec35516d2bcac01f1bab5d25dfd058761 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:00:19 -0700 Subject: [PATCH 09/40] feat(moq-net): sans-IO session benchmark over a relay mesh (#4160) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 2 +- quest/m1/bench-ci.md | 1 - quest/m1/bench-relay.md | 8 +- quest/m1/bench-session.md | 31 -- quest/m1/cache-expiry-growth.md | 24 ++ quest/m1/perf/README.md | 3 + quest/m1/perf/announce-replay.md | 22 + quest/m1/perf/group-cost.md | 25 ++ quest/m1/perf/lite-route-rescan.md | 29 ++ rs/moq-net/Cargo.toml | 4 + rs/moq-net/benches/session.rs | 427 ++++++++++++++++++++ rs/moq-net/tests/announce_to_serve.rs | 2 +- rs/moq-net/tests/datagram.rs | 4 +- rs/moq-net/tests/finished_broadcast_mock.rs | 2 +- rs/moq-net/tests/goaway.rs | 4 +- rs/moq-net/tests/rejoin.rs | 2 +- rs/moq-net/tests/support/harness.rs | 4 +- rs/moq-net/tests/track_tail.rs | 2 +- 18 files changed, 547 insertions(+), 49 deletions(-) delete mode 100644 quest/m1/bench-session.md create mode 100644 quest/m1/cache-expiry-growth.md create mode 100644 quest/m1/perf/announce-replay.md create mode 100644 quest/m1/perf/group-cost.md create mode 100644 quest/m1/perf/lite-route-rescan.md create mode 100644 rs/moq-net/benches/session.rs diff --git a/quest/m1/README.md b/quest/m1/README.md index 5990696a70..38fe901419 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -81,7 +81,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Benchmark regressions in CI](/quest/m1/bench-ci.md) - PRs get a non-blocking comparison of the Criterion benches they affect, and a nightly trend on main alerts on regressions - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - retained evidence, repeated paired runs, and uncertainty for performance claims - [#3126](/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - moq-bench reports per-interval latency percentiles so the ramp leaves the steady state -- [Sans-IO session bench](/quest/m1/bench-session.md) - a moq-net bench drives publisher, relay, and subscribers over the in-memory transport, swept over publishers, subscribers, and frame size - [Relay session bench](/quest/m1/bench-relay.md) - the same scenario through moq-relay's own connection handling - [Bench coverage](/quest/m1/bench-coverage.md) - Criterion targets for moq-mux containers, the hang catalog, moq-auth verification, and moq-pattern matching - [Relay profiling](/quest/m1/performance-profiles.md) - reproducible CPU and allocation captures under the existing workloads @@ -90,6 +89,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Watch worker](/quest/m1/watch-worker.md) - watch playback runs in a worker onto an OffscreenCanvas, so main-thread jank never stalls video or audio - [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw - [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies +- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on moq-transport as on moq-lite - [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/bench-ci.md b/quest/m1/bench-ci.md index 3514730d8a..b317185d53 100644 --- a/quest/m1/bench-ci.md +++ b/quest/m1/bench-ci.md @@ -50,5 +50,4 @@ a GitHub App: ## Related - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - extends the same `bench/run.sh` with repeated paired rounds -- [Sans-IO session bench](/quest/m1/bench-session.md) - a low-noise end-to-end bench this job picks up - [Bench coverage](/quest/m1/bench-coverage.md) - more targets for this job to track diff --git a/quest/m1/bench-relay.md b/quest/m1/bench-relay.md index 596cddb10e..1cd12ddfe9 100644 --- a/quest/m1/bench-relay.md +++ b/quest/m1/bench-relay.md @@ -16,9 +16,5 @@ transport session without widening the public API. If the seam costs more than the bench is worth, say so and stop. Reuse the scenario, the publisher and subscriber sweeps, and the delivery -accounting from the `moq-net` bench so the two results line up, and the -difference is the relay layer. - -## Required - -- [Sans-IO session bench](/quest/m1/bench-session.md) - supplies the scenario and harness this bench reuses +accounting from `rs/moq-net/benches/session.rs` so the two results line up, and +the difference is the relay layer. diff --git a/quest/m1/bench-session.md b/quest/m1/bench-session.md deleted file mode 100644 index 82aa479d22..0000000000 --- a/quest/m1/bench-session.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] Sans-IO session benchmark in moq-net - -## Goal - -A Criterion bench in `moq-net` measures a full publishers to relay to -subscribers path with no sockets: M publishing clients, relay sessions that -forward through an origin the way `moq-relay` does, and N subscribing -clients, all over the in-memory transport. It sweeps publishers, subscribers, -and frame size independently, over lite and IETF, so a per-publisher, -per-subscriber, or per-frame cost shows as a slope and CI can compare it with -little noise. - -## Plan - -`rs/moq-net/tests/support/{mock,harness}.rs` already pairs sessions over an -in-memory WebTransport mock and runs the full handshake. Reuse it from the -bench, as `moq-uring`'s benches reuse their test support, rather than -exporting a mock from the crate. - -- Keep setup (handshakes, announce, the subscribe round trip) outside the - timed region. Time delivery of groups until every subscriber has received - every frame, and count delivered bytes so a skipped delivery can't look like - a speedup. -- Also time subscriber join, since a relay pays that per viewer. -- Report throughput in bytes and frames. -- Pick sweep points that finish quickly enough to run on every `moq-net` PR. - -## Related - -- [Relay session bench](/quest/m1/bench-relay.md) - the same shape through the real `moq-relay` handler -- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - tracks this bench on PRs and nightly diff --git a/quest/m1/cache-expiry-growth.md b/quest/m1/cache-expiry-growth.md new file mode 100644 index 0000000000..17cac468ed --- /dev/null +++ b/quest/m1/cache-expiry-growth.md @@ -0,0 +1,24 @@ +# [S] Cached groups expire on the IETF path + +## Goal + +With the default cache pool, a relay's memory plateaus once groups start +reaching the expiry window, on moq-transport as on moq-lite, or the cause of +continued growth is found and fixed. + +## Plan + +In `session_delivery_broadcasts` with 4096 announced broadcasts and the +default unbounded pool (30 s expiry), IETF RSS reached 1.5 GB at 10 s, 3.5 GB at +30 s, and 4.9 GB at 50 s, still climbing past the expiry window. Lite +reached about 0.36 GB by 50 s. With a 16 MiB bounded pool both stay flat, so +the retained bytes are cached groups, not a leak elsewhere. + +Reproduce with a focused test first. Unexpired groups at higher throughput, +expiry not running on a path IETF uses, 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. + +## Related + +- [Relay memory](/quest/m1/relay-memory.md) - what an announcement costs in memory diff --git a/quest/m1/perf/README.md b/quest/m1/perf/README.md index b1a0a9cf4f..c855d6344d 100644 --- a/quest/m1/perf/README.md +++ b/quest/m1/perf/README.md @@ -43,6 +43,9 @@ row per io_uring worker. - [Open contract](/quest/m1/perf/uring-open-contract.md) - plan concurrent WebTransport opening and cancellation +- [Lite route rescan](/quest/m1/perf/lite-route-rescan.md) - a moq-lite session's per-group cost stops growing with the routes its peer announced +- [Announce replay](/quest/m1/perf/announce-replay.md) - the initial announce set replays in linear time, so joins don't slow with the route count +- [Group cost](/quest/m1/perf/group-cost.md) - count and cut the allocations and time spent relaying one small group to one viewer - [One enter per turn](/quest/m1/perf/uring-one-enter.md) - a parking turn pays one io_uring_enter, submits flush deferred completions, and SQEs per enter is a counter - [Run to quiescence](/quest/m1/perf/uring-quiescence.md) - a received packet's reply is staged in the same turn, under a pass budget that keeps the fairness rule - [Lock wait](/quest/m1/perf/lock-wait.md) - each worker reports time blocked on cross-worker locks, deciding whether the shared model needs work diff --git a/quest/m1/perf/announce-replay.md b/quest/m1/perf/announce-replay.md new file mode 100644 index 0000000000..db7f81b036 --- /dev/null +++ b/quest/m1/perf/announce-replay.md @@ -0,0 +1,22 @@ +# [S] Linear announce replay on subscribe + +## Goal + +A session that subscribes to announcements receives the initial set in time +linear in the number of announced broadcasts, so a viewer joining a relay +with thousands of routes connects as fast per route as one joining a handful. + +## Plan + +The moq-lite publisher's initial replay (`AnnounceRun::init` in +`rs/moq-net/src/lite/publisher.rs`) de-duplicates by scanning the pending list +for every route it drains: `initial.retain` on Lite05+, `init.contains` on +the Lite01/02 init. That is quadratic in the replay size. + +Measured with `session_join_broadcasts` (2026-09, one relay): lite join takes +199 µs with 1 announced broadcast, 517 µs with 64, and 8.9 ms with 1024, +about 8.7 µs per route at the top. IETF is 7.2 ms at 1024 with no quadratic +scan; its profile is SipHash hashing and `Path` comparison, so check whether +a faster hasher for path-keyed maps pays off on both. + +Keep the replay's order and its last-update-wins semantics. diff --git a/quest/m1/perf/group-cost.md b/quest/m1/perf/group-cost.md new file mode 100644 index 0000000000..b558d76b62 --- /dev/null +++ b/quest/m1/perf/group-cost.md @@ -0,0 +1,25 @@ +# [M] Measure and cut the fixed cost of relaying a group + +## Goal + +The fixed cost of relaying one small group to one viewer drops, measured as +allocations and time per viewer-group in the session benchmark, with no +change to what is delivered. + +## Plan + +`session_delivery_viewers` (2026-09) spends about 32 µs per viewer per +4-frame, 64-byte group over the in-memory transport, through a publisher +session, a relay origin, and a viewer session. No single function dominates. +The profile spreads it over `kio` waiter registration and parking, +`TrackState::evict_expired_scan` (4-5%), `Waiter`'s lazily allocated shared +waker (`Once::call`, about 3%, so waiters are created per poll), and +malloc/free (10-12%). + +Count allocations per viewer-group first (a counting allocator in the bench, +as `moq-json`'s allocation bench does), then remove the largest sources. A +measured no-win abandons the quest, per this line's rules. + +## Related + +- [Owned decoding copies](/quest/m1/perf/coding-decode.md) - decode-side copies are part of the same per-group cost diff --git a/quest/m1/perf/lite-route-rescan.md b/quest/m1/perf/lite-route-rescan.md new file mode 100644 index 0000000000..6591609912 --- /dev/null +++ b/quest/m1/perf/lite-route-rescan.md @@ -0,0 +1,29 @@ +# [S] Lite subscriber polls only routes with a request + +## Goal + +A moq-lite session's per-group cost stops growing with the number of +broadcasts its peer has announced: delivery over a session holding thousands +of announced routes costs what it costs with a handful, as it already does +over moq-transport. + +## Plan + +`Announced::poll_serve` (`rs/moq-net/src/lite/subscriber.rs`) polls every +attached route's `Dynamic::poll_requested_broadcast` on every driver wake, so +each incoming group pays for every route, and each pending poll registers the +driver's waiter on every idle route's list. The IETF subscriber runs one task +per route and only wakes the one that got a request. + +Measured with `cargo bench -p moq-net --bench session` (2026-09, one relay, +16 viewers each watching one broadcast): + +- `session_delivery_broadcasts`: lite 445 µs at 16 announced, 15.2 ms at + 4096; IETF flat around 458 µs. 57% of lite CPU is `WaiterList::register` + under `poll_requested_broadcast`. +- `session_delivery_scale` at 256 publishers x 256 viewers: lite 40 ms, IETF + 16.5 ms, since every viewer session holds all 256 routes. + +Wake only routes with a pending request. A task per route like IETF's or a +ready set both fit; pick by what keeps the driver's fairness rules. Land +with the before/after of those two groups. diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 1dade572a1..288ac36bb2 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -84,3 +84,7 @@ harness = false [[bench]] name = "stats" harness = false + +[[bench]] +name = "session" +harness = false diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs new file mode 100644 index 0000000000..42e65e2fd2 --- /dev/null +++ b/rs/moq-net/benches/session.rs @@ -0,0 +1,427 @@ +//! End-to-end session benchmarks with no sockets: publishing clients, a mesh of +//! relays that forward through their origins the way `moq-relay` does, and +//! viewing clients, all connected over the in-memory mock transport. +//! +//! `session_delivery_*` times one round: every watched broadcast writes a group +//! and every viewer reads one group per broadcast it watches. Each group sweeps +//! one or two axes of a [`Shape`] (relays, publisher sessions, broadcasts per +//! publisher, viewers, broadcasts per viewer, frame size) with the rest held +//! fixed, so a cost that grows with a table instead of the touched path shows +//! as a slope. Unwatched broadcasts stay announced and silent: their cost is the +//! route table they occupy, not a publisher writing into its own cache. +//! +//! `session_join_*` times a new viewer connecting, resolving a broadcast, and +//! receiving its latest group, swept over what the relays already announce. +//! +//! Everything runs on one current-thread runtime, so the only work measured is +//! the protocol and model code, never scheduling across threads. +//! +//! Run with `cargo bench -p moq-net --bench session`. + +#[path = "../tests/support/mod.rs"] +mod support; + +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_net::{Hop, Timestamp, Version, broadcast, cache, origin, track}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +/// Newest lite draft (`moq-lite-07-wip`, opt-in) and newest IETF draft. +const VERSIONS: [&str; 2] = ["moq-lite-07-wip", "moq-transport-22"]; + +/// Frames per group, so per-frame and per-group costs both appear. +const FRAMES: usize = 4; + +const TRACK: &str = "video"; + +/// Each endpoint gets its own bounded pool, as a relay configures one per +/// process: without a byte target, every hop keeps each group for the whole +/// expiry window and memory grows with the run length instead of the shape. +const RELAY_CACHE: u64 = 16 * 1024 * 1024; +const CLIENT_CACHE: u64 = 1024 * 1024; + +/// One topology: publishers and viewers spread round-robin over a full mesh of +/// relays. +#[derive(Clone, Copy)] +struct Shape { + /// Relays, each peered with every other. + relays: usize, + /// Publisher sessions. + publishers: usize, + /// Broadcasts each publisher session announces. + broadcasts: usize, + /// Viewer sessions. + viewers: usize, + /// Broadcasts each viewer subscribes to. + watch: usize, + /// Payload bytes per frame. + frame: usize, +} + +impl Shape { + /// A 16-publisher, 16-viewer room on one relay, each viewer watching one + /// broadcast with small frames, so sweeps measure per-message costs. + const BASE: Self = Self { + relays: 1, + publishers: 16, + broadcasts: 1, + viewers: 16, + watch: 1, + frame: 64, + }; + + fn total(&self) -> usize { + self.publishers * self.broadcasts + } + + /// Payload bytes every viewer reads in one round. + fn expected(&self) -> usize { + self.viewers * self.watch * FRAMES * self.frame + } + + fn id(&self, version: &str) -> String { + format!( + "{version}/relays={}/publishers={}/broadcasts={}/viewers={}/watch={}/frame={}", + self.relays, self.publishers, self.broadcasts, self.viewers, self.watch, self.frame + ) + } +} + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap() +} + +fn write_group(track: &track::Producer, payload: &Bytes) { + let mut group = track.append_group().unwrap(); + for _ in 0..FRAMES { + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + } + group.finish().unwrap(); +} + +/// Read one whole group, returning its payload bytes so skipped work can't look +/// like a speedup. +async fn read_group(subscriber: &mut track::Subscriber) -> usize { + let mut group = subscriber.recv_group().await.unwrap().expect("track ended"); + let mut bytes = 0; + while let Some(frame) = group.read_frame().await.unwrap() { + bytes += frame.payload.len(); + } + bytes +} + +fn path(broadcast: usize) -> String { + format!("room/{broadcast}") +} + +/// Relays and publishers, before any viewer joins. Holds every handle that +/// keeps a session or broadcast alive. +struct Cluster { + version: Version, + relays: Vec, + /// One track per broadcast, indexed like [`path`]. + tracks: Vec, + _broadcasts: Vec, + _origins: Vec, + _pairs: Vec, + next_hop: u64, +} + +impl Cluster { + async fn new(version: &str, shape: Shape) -> Self { + let mut this = Self { + version: version.parse().unwrap(), + relays: Vec::new(), + tracks: Vec::new(), + _broadcasts: Vec::new(), + _origins: Vec::new(), + _pairs: Vec::new(), + next_hop: 0, + }; + + for _ in 0..shape.relays { + let relay = this.origin(RELAY_CACHE); + this.relays.push(relay); + } + + // Peer every pair the way a cluster dial does: each side publishes its + // whole origin, hidden broadcasts included, and subscribes into it. + for server in 0..shape.relays { + for client in 0..server { + let client = this.relays[client].clone().peer(); + let server = this.relays[server].clone().peer(); + let mut options = MockConnectOptions::new(this.version); + options.client_publish = Some(client.consume().with_hidden(true)); + options.client_subscribe = Some(client); + options.server_publish = Some(server.consume().with_hidden(true)); + options.server_subscribe = Some(server); + this._pairs.push(connect_mock(options).await); + } + } + + for publisher in 0..shape.publishers { + let origin = this.origin(CLIENT_CACHE); + for index in 0..shape.broadcasts { + let broadcast = origin + .publish(path(publisher * shape.broadcasts + index), Default::default()) + .unwrap(); + this.tracks.push(broadcast.create_track(TRACK, None).unwrap()); + this._broadcasts.push(broadcast); + } + + let mut options = MockConnectOptions::new(this.version); + options.client_publish = Some(origin.consume()); + options.server_subscribe = Some(this.relays[publisher % shape.relays].clone()); + this._pairs.push(connect_mock(options).await); + this._origins.push(origin); + } + + this + } + + fn origin(&mut self, capacity: u64) -> origin::Producer { + self.next_hop += 1; + let mut config = origin::Config::new(Hop::new(self.next_hop).unwrap()); + config.pool = cache::Pool::new(cache::Config::default().with_capacity(capacity)); + let (producer, driver) = origin::Producer::new(config); + tokio::spawn(support::harness::run(driver)); + producer + } + + /// Connect a viewer to `relay` and subscribe it to each of `broadcasts`. + async fn join(&mut self, relay: usize, broadcasts: impl Iterator) -> Viewer { + let origin = self.origin(CLIENT_CACHE); + let mut options = MockConnectOptions::new(self.version); + options.server_publish = Some(self.relays[relay].consume()); + options.client_subscribe = Some(origin.clone()); + let pair = connect_mock(options).await; + + let mut subscribers = Vec::new(); + for broadcast in broadcasts { + let broadcast = origin.consume().routed_broadcast(path(broadcast)).await.unwrap(); + subscribers.push(broadcast.track(TRACK).unwrap().subscribe(None).await.unwrap()); + } + + Viewer { + subscribers, + _pair: pair, + _origin: origin, + } + } +} + +struct Viewer { + subscribers: Vec, + _pair: MockPair, + _origin: origin::Producer, +} + +/// A cluster with its viewers attached. +struct Room { + cluster: Cluster, + viewers: Vec, + /// Broadcasts at least one viewer watches; only these write each round. + watched: Vec, + payload: Bytes, + expected: usize, +} + +impl Room { + async fn new(version: &str, shape: Shape) -> Self { + assert!( + shape.watch <= shape.total(), + "a viewer can't watch more broadcasts than exist" + ); + let mut cluster = Cluster::new(version, shape).await; + + // Viewer v watches a contiguous window starting at v * watch, so viewers + // spread over the broadcasts before any doubles up. + let mut viewers = Vec::new(); + let mut watched = vec![false; shape.total()]; + for viewer in 0..shape.viewers { + let broadcasts: Vec<_> = (0..shape.watch) + .map(|k| (viewer * shape.watch + k) % shape.total()) + .collect(); + for &broadcast in &broadcasts { + watched[broadcast] = true; + } + // Offset by one so a viewer lands on a different relay than the + // publisher it watches whenever there is more than one. + let relay = (viewer + 1) % shape.relays; + viewers.push(cluster.join(relay, broadcasts.into_iter()).await); + } + + let mut room = Self { + cluster, + viewers, + watched: watched + .iter() + .enumerate() + .filter(|(_, w)| **w) + .map(|(i, _)| i) + .collect(), + payload: Bytes::from(vec![0; shape.frame]), + expected: shape.expected(), + }; + // Warm every path so the timed rounds skip first-group setup. + room.round().await; + room + } + + async fn round(&mut self) { + for &broadcast in &self.watched { + write_group(&self.cluster.tracks[broadcast], &self.payload); + } + let mut bytes = 0; + for viewer in &mut self.viewers { + for subscriber in &mut viewer.subscribers { + bytes += read_group(subscriber).await; + } + } + assert_eq!(bytes, self.expected); + } + + async fn measure(&mut self, iters: u64) -> Duration { + let start = Instant::now(); + for _ in 0..iters { + self.round().await; + } + start.elapsed() + } +} + +fn delivery(c: &mut Criterion, name: &str, shapes: impl IntoIterator) { + let rt = runtime(); + let shapes: Vec<_> = shapes.into_iter().collect(); + let mut group = c.benchmark_group(format!("session_delivery_{name}")); + for version in VERSIONS { + for shape in &shapes { + // Built on first call: Criterion only calls a routine its filter + // selects, and calls it again for every sample. + let mut room = None; + group.throughput(Throughput::Bytes(shape.expected() as u64)); + group.bench_function(BenchmarkId::from_parameter(shape.id(version)), |b| { + let room = room.get_or_insert_with(|| rt.block_on(Room::new(version, *shape))); + b.iter_custom(|iters| rt.block_on(room.measure(iters))) + }); + } + } + group.finish(); +} + +fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) { + let rt = runtime(); + let shapes: Vec<_> = shapes.into_iter().collect(); + let mut group = c.benchmark_group(format!("session_join_{name}")); + for version in VERSIONS { + for shape in &shapes { + let mut cluster = None; + group.bench_function(BenchmarkId::from_parameter(shape.id(version)), |b| { + let cluster = cluster.get_or_insert_with(|| { + rt.block_on(async { + let cluster = Cluster::new(version, *shape).await; + let payload = Bytes::from(vec![0; shape.frame]); + for track in &cluster.tracks { + write_group(track, &payload); + } + cluster + }) + }); + // Broadcasts published to relay 0, joined from the last relay: every + // sample is local on one relay and crosses one peer hop on a mesh. + let hosted: Vec<_> = (0..shape.total()) + .filter(|broadcast| (broadcast / shape.broadcasts) % shape.relays == 0) + .collect(); + let relay = shape.relays - 1; + b.iter_custom(|iters| { + rt.block_on(async { + let mut elapsed = Duration::ZERO; + for iter in 0..iters as usize { + let broadcast = hosted[iter % hosted.len()]; + let start = Instant::now(); + let mut viewer = cluster.join(relay, std::iter::once(broadcast)).await; + let bytes = read_group(&mut viewer.subscribers[0]).await; + elapsed += start.elapsed(); + assert_eq!(bytes, FRAMES * shape.frame); + // Teardown is a leaving viewer's cost, not a joining one's. + drop(viewer); + tokio::task::yield_now().await; + } + elapsed + }) + }) + }); + } + } + group.finish(); +} + +fn session(c: &mut Criterion) { + let base = Shape::BASE; + + delivery( + c, + "publishers", + [1, 16, 256].map(|publishers| Shape { publishers, ..base }), + ); + delivery(c, "viewers", [1, 16, 256].map(|viewers| Shape { viewers, ..base })); + delivery( + c, + "scale", + [16, 64, 256].map(|n| Shape { + publishers: n, + viewers: n, + ..base + }), + ); + // One publisher session announcing many broadcasts, most of them unwatched. + delivery( + c, + "broadcasts", + [16, 256, 4096].map(|broadcasts| Shape { + publishers: 1, + broadcasts, + ..base + }), + ); + // Each viewer watches many broadcasts over one session, like a conference. + delivery( + c, + "watch", + [1, 16, 256].map(|watch| Shape { + publishers: 1, + broadcasts: 256, + watch, + ..base + }), + ); + delivery(c, "frame", [64, 1024, 16 * 1024].map(|frame| Shape { frame, ..base })); + delivery( + c, + "relays", + [1, 2, 4, 8].map(|relays| Shape { + relays, + viewers: 64, + ..base + }), + ); + + join( + c, + "broadcasts", + [1, 64, 1024].map(|broadcasts| Shape { + publishers: 1, + broadcasts, + ..base + }), + ); + join(c, "relays", [1, 2, 8].map(|relays| Shape { relays, ..base })); +} + +criterion_group!(benches, session); +criterion_main!(benches); diff --git a/rs/moq-net/tests/announce_to_serve.rs b/rs/moq-net/tests/announce_to_serve.rs index 6eb4b57eb6..8fd27f86d8 100644 --- a/rs/moq-net/tests/announce_to_serve.rs +++ b/rs/moq-net/tests/announce_to_serve.rs @@ -119,7 +119,7 @@ async fn lifecycle(observer: Observer) -> Vec { Observer::Remote(version) => { let subscriber = produce_origin(2); let mut options = MockConnectOptions::new(version.parse::().unwrap()); - options.server_publish = Some(publisher.clone()); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(subscriber.clone()); let pair = connect_mock(options).await; (subscriber.consume(), Some(pair)) diff --git a/rs/moq-net/tests/datagram.rs b/rs/moq-net/tests/datagram.rs index b16264be9b..4cc78b1297 100644 --- a/rs/moq-net/tests/datagram.rs +++ b/rs/moq-net/tests/datagram.rs @@ -43,7 +43,7 @@ async fn connect_datagram_track() -> Fixture { broadcast.announce(Default::default()).unwrap(); let mut options = MockConnectOptions::new("moq-lite-05".parse::().unwrap()); - options.server_publish = Some(publisher); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(consumer_origin.clone()); let pair = connect_mock(options).await; @@ -122,7 +122,7 @@ async fn ietf_does_not_deliver_datagrams() { broadcast.announce(Default::default()).unwrap(); let mut options = MockConnectOptions::new("moq-transport-19".parse::().unwrap()); - options.server_publish = Some(publisher); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(consumer_origin.clone()); let _pair = connect_mock(options).await; diff --git a/rs/moq-net/tests/finished_broadcast_mock.rs b/rs/moq-net/tests/finished_broadcast_mock.rs index 30fcefe404..4c987f9317 100644 --- a/rs/moq-net/tests/finished_broadcast_mock.rs +++ b/rs/moq-net/tests/finished_broadcast_mock.rs @@ -38,7 +38,7 @@ async fn round(finish_broadcast: bool) -> (Vec>, Option) let subscriber = produce_origin(2); let mut options = MockConnectOptions::new("moq-lite-05".parse::().unwrap()); - options.server_publish = Some(publisher.clone()); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(subscriber.clone()); let pair: MockPair = connect_mock(options).await; diff --git a/rs/moq-net/tests/goaway.rs b/rs/moq-net/tests/goaway.rs index c85713fa82..bc407c2a49 100644 --- a/rs/moq-net/tests/goaway.rs +++ b/rs/moq-net/tests/goaway.rs @@ -336,7 +336,7 @@ async fn goaway_gates_new_subscribes_moq_lite_04() { let sub_origin = produce_origin(Hop::random()); let mut opts = MockConnectOptions::new(version); - opts.server_publish = Some(pub_origin.clone()); + opts.server_publish = Some(pub_origin.consume()); opts.client_subscribe = Some(sub_origin.clone()); let MockPair { client, server, .. } = connect_mock(opts).await; @@ -423,7 +423,7 @@ async fn goaway_drains_routes(version: Version) { let sub_origin = produce_origin(Hop::random()); let mut opts = MockConnectOptions::new(version); - opts.server_publish = Some(pub_origin.clone()); + opts.server_publish = Some(pub_origin.consume()); opts.client_subscribe = Some(sub_origin.clone()); let MockPair { client, server, .. } = connect_mock(opts).await; diff --git a/rs/moq-net/tests/rejoin.rs b/rs/moq-net/tests/rejoin.rs index 5ba6a80b3c..ecadbbfb18 100644 --- a/rs/moq-net/tests/rejoin.rs +++ b/rs/moq-net/tests/rejoin.rs @@ -42,7 +42,7 @@ async fn rejoin_recovers_the_group_reset_on_leave() { broadcast.announce(Default::default()).unwrap(); let mut options = MockConnectOptions::new(version.parse::().unwrap()); - options.server_publish = Some(publisher); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(relay.clone()); let _pair = connect_mock(options).await; diff --git a/rs/moq-net/tests/support/harness.rs b/rs/moq-net/tests/support/harness.rs index 701d83fb32..4cd2cf254f 100644 --- a/rs/moq-net/tests/support/harness.rs +++ b/rs/moq-net/tests/support/harness.rs @@ -22,11 +22,11 @@ pub struct MockConnectOptions { /// The MoQ version to negotiate (determines the ALPN protocol string). pub version: Version, /// Origin whose broadcasts the client publishes to the server. - pub client_publish: Option, + pub client_publish: Option, /// Origin the client inserts remote broadcasts into. pub client_subscribe: Option, /// Origin whose broadcasts the server publishes to the client. - pub server_publish: Option, + pub server_publish: Option, /// Origin the server inserts remote broadcasts into. pub server_subscribe: Option, } diff --git a/rs/moq-net/tests/track_tail.rs b/rs/moq-net/tests/track_tail.rs index 28ef3d957f..f9dd8ca201 100644 --- a/rs/moq-net/tests/track_tail.rs +++ b/rs/moq-net/tests/track_tail.rs @@ -66,7 +66,7 @@ async fn round(version: &str, late: Late) -> Outcome { let subscriber = produce_origin(2); let mut options = MockConnectOptions::new(version.parse::().unwrap()); - options.server_publish = Some(publisher.clone()); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(subscriber.clone()); let pair = connect_mock(options).await; From a05a2d5a5837e5f3463e34f1c089c386f6456f46 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:03:28 -0700 Subject: [PATCH 10/40] fix(uring): bound io_uring tests by the shared memlock budget (#4167) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- .config/nextest.toml | 12 ++++++++++ doc/bin/relay/config.md | 5 +++- rs/moq-uring/src/error.rs | 44 ++++++++++++++++++++++++++++++++++ rs/moq-uring/src/udp.rs | 3 ++- rs/moq-uring/src/worker.rs | 2 +- rs/moq-uring/tests/identity.rs | 7 +++++- rs/moq-uring/tests/teardown.rs | 6 ++++- rs/moq-uring/tests/workers.rs | 9 +++++++ 8 files changed, 83 insertions(+), 5 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 65691871bd..1d52e0c2ef 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -24,6 +24,18 @@ retries = 0 # workload once, and the large origin fan-outs take ~20s each for no signal. default-filter = "not kind(bench)" +# Every io_uring ring is charged to the user's `RLIMIT_MEMLOCK` (8 MiB by +# default), a budget shared with every other process that user runs, and +# desktop apps built on libuv already spend most of it. A worker's ring is +# ~84 KiB, so a parallel run of tests holding a few workers each exhausts it +# and fails with `ENOMEM`. Four at a time stays near 1 MiB. +[[profile.default.overrides]] +filter = "package(moq-uring) | binary_id(moq-relay::runtime_uring) | (binary_id(moq-relay::embed) & test(/uring/))" +test-group = "io-uring" + +[test-groups] +io-uring = { max-threads = 4 } + # CI has noisier neighbours and cold caches, so give a test longer before # calling it wedged. [profile.ci] diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 6f8677ac36..f72a4fb646 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -90,7 +90,10 @@ and refuses to start anywhere it cannot deliver. `[quic]` applies either way, except that `mtu_discovery` (its datagram path sends a fixed payload) and the three flow-control windows (these workers run fixed ones) are refused under `io_uring` rather than quietly ignored. Each worker reports its own counters at -[`/metrics`](/bin/relay/http#get-metrics). +[`/metrics`](/bin/relay/http#get-metrics). The kernel charges each worker's +ring (~100 KiB, plus a page per socket) to `RLIMIT_MEMLOCK`, a budget shared by +every io\_uring the user runs; raise it (`LimitMEMLOCK=` under systemd) if +workers fail to start with a message naming that limit. ## \[web] diff --git a/rs/moq-uring/src/error.rs b/rs/moq-uring/src/error.rs index d2791cc1fb..b4a43aa663 100644 --- a/rs/moq-uring/src/error.rs +++ b/rs/moq-uring/src/error.rs @@ -12,3 +12,47 @@ pub enum Error { #[error("io error: {0}")] Io(#[from] std::io::Error), } + +impl Error { + /// A failed ring setup or registration, naming the locked-memory limit when + /// that is what ran out. + /// + /// The kernel charges every ring and provided-buffer ring to the user's + /// `RLIMIT_MEMLOCK`, shared with every other io_uring that user runs, so a + /// bare `ENOMEM` points at the wrong culprit. + pub(crate) fn ring(err: std::io::Error) -> Self { + if err.raw_os_error() != Some(libc::ENOMEM) { + return Self::Io(err); + } + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: valid out-pointer. + let limit = match unsafe { libc::getrlimit(libc::RLIMIT_MEMLOCK, &mut limit) } { + 0 => format!("{} KiB", limit.rlim_cur / 1024), + _ => "unknown".into(), + }; + Self::Io(std::io::Error::new( + err.kind(), + format!( + "{err}: io_uring memory counts against RLIMIT_MEMLOCK ({limit}), shared by every process of this user; \ + raise it with `ulimit -l` or systemd's `LimitMEMLOCK=`" + ), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_ring_enomem_names_the_memlock_limit() { + let err = Error::ring(std::io::Error::from_raw_os_error(libc::ENOMEM)); + assert!(err.to_string().contains("RLIMIT_MEMLOCK"), "{err}"); + + let err = Error::ring(std::io::Error::from_raw_os_error(libc::EBADF)); + assert!(!err.to_string().contains("RLIMIT_MEMLOCK"), "{err}"); + } +} diff --git a/rs/moq-uring/src/udp.rs b/rs/moq-uring/src/udp.rs index 9ca5de176a..41d5b4ce94 100644 --- a/rs/moq-uring/src/udp.rs +++ b/rs/moq-uring/src/udp.rs @@ -576,7 +576,8 @@ impl Socket { unsafe { io_ring .submitter() - .register_buf_ring_with_flags(ring.ptr.as_ptr() as u64, rx_cap, bgid, 0)?; + .register_buf_ring_with_flags(ring.ptr.as_ptr() as u64, rx_cap, bgid, 0) + .map_err(Error::ring)?; } } for (bid, buf) in bufs.iter_mut().enumerate() { diff --git a/rs/moq-uring/src/worker.rs b/rs/moq-uring/src/worker.rs index 3a85fb00ec..45142d2823 100644 --- a/rs/moq-uring/src/worker.rs +++ b/rs/moq-uring/src/worker.rs @@ -118,7 +118,7 @@ impl Worker { kernel_release() )) } - _ => Error::Io(err), + _ => Error::ring(err), })?; // One feature bit gates the whole floor: MIN_TIMEOUT landed in 6.12 diff --git a/rs/moq-uring/tests/identity.rs b/rs/moq-uring/tests/identity.rs index 7df4aedb87..76662b0396 100644 --- a/rs/moq-uring/tests/identity.rs +++ b/rs/moq-uring/tests/identity.rs @@ -115,10 +115,14 @@ fn an_endpoint_runs_on_the_worker_that_adopted_its_socket() { // A client on its own thread and worker, dialing the endpoint. Its // Initial reaches the socket immediately; only the owner can read it. // The client is driven until the server closes on it: its side of the - // handshake completes before the server's, so it cannot stop earlier. + // handshake completes before the server's, so it cannot stop earlier. It + // reports in before dialing, so a setup failure fails here instead of as + // an accept that never arrives. + let (ready, started) = std::sync::mpsc::channel(); let client = std::thread::spawn(move || { let mut worker = Worker::new(Config::default()).expect("client worker"); let sock = socket(&worker.handle()); + ready.send(()).expect("test alive"); worker .block_on(async move { let mut conn = quic::client::connect(sock, &dial_config(addr)).await.expect("dial"); @@ -126,6 +130,7 @@ fn an_endpoint_runs_on_the_worker_that_adopted_its_socket() { }) .expect("client loop") }); + started.recv().expect("the client thread failed to start"); // Driving the bystander polls the accept from its loop, but the demux // that would feed it is a task on the owner, which is not running. diff --git a/rs/moq-uring/tests/teardown.rs b/rs/moq-uring/tests/teardown.rs index b0bde779f5..c04d58ecb8 100644 --- a/rs/moq-uring/tests/teardown.rs +++ b/rs/moq-uring/tests/teardown.rs @@ -103,7 +103,9 @@ fn a_published_close_has_already_left_the_client() { let server_addr = server_sock.local_addr().expect("server addr"); // The server outlives the client's teardown, so its verdict is only about - // what the client managed to send. + // what the client managed to send. It reports in once serving, so a setup + // failure fails here instead of as a dial that idles out. + let (ready, started) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || -> quic::Error { let mut worker = Worker::new(Config::default()).expect("server worker"); let handle = worker.handle(); @@ -113,6 +115,7 @@ fn a_published_close_has_already_left_the_client() { quic::endpoint::Config::default().with_server(server_config(&certs)), ) .expect("endpoint"); + ready.send(()).expect("test alive"); worker .block_on(async move { let mut conn = endpoint.accept().await.expect("accept"); @@ -120,6 +123,7 @@ fn a_published_close_has_already_left_the_client() { }) .expect("server worker") }); + started.recv().expect("the server thread failed to start"); let handle = client_worker.handle(); let sock = handle diff --git a/rs/moq-uring/tests/workers.rs b/rs/moq-uring/tests/workers.rs index 7cd4ae95ce..f3329d9dd2 100644 --- a/rs/moq-uring/tests/workers.rs +++ b/rs/moq-uring/tests/workers.rs @@ -96,6 +96,9 @@ fn a_steered_group_serves_a_shared_port() { // One stop each: the wakers are per-thread, so a shared slot would let one // worker's registration clobber the other's. let stops: Vec> = (0..WORKERS).map(|_| Arc::new(Stop::default())).collect(); + // Each worker reports in once serving, so a setup failure fails here + // instead of as the dials hashed to it idling out. + let (ready, started) = std::sync::mpsc::channel(); let threads: Vec<_> = members .into_iter() @@ -105,6 +108,7 @@ fn a_steered_group_serves_a_shared_port() { let stop = stop.clone(); let cert = certs.cert.clone(); let key = certs.key.clone(); + let ready = ready.clone(); std::thread::spawn(move || { let shard = member.shard(); let mut worker = Worker::new(Config::default()).expect("worker"); @@ -125,10 +129,15 @@ fn a_steered_group_serves_a_shared_port() { accepted[usize::from(shard.index())].fetch_add(1, Ordering::AcqRel); } }); + ready.send(()).expect("test alive"); worker.block_on(stop.wait()).expect("worker loop"); }) }) .collect(); + drop(ready); + for _ in 0..WORKERS { + started.recv().expect("a worker thread failed to start"); + } // Dial the shared port repeatedly from one client worker. Every handshake // completing is the steering assertion (see the module docs). From a1d993c6d4d23666f4fa7fbfc8bb5eb6f702fd34 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:04:58 -0700 Subject: [PATCH 11/40] chore(skills): spawn-quests judges readiness from line branches (#4161) Co-authored-by: Claude Opus 5.5 --- .claude/skills/spawn-quests/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md index ce7afbe14b..6ade2b5d88 100644 --- a/.claude/skills/spawn-quests/SKILL.md +++ b/.claude/skills/spawn-quests/SKILL.md @@ -10,6 +10,8 @@ If you are unsure of the best course of action, ask the user for clarification b 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. From 394c755a4e1c42d222d43832b3a279e9f91ce002 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:10:24 -0700 Subject: [PATCH 12/40] docs(quest): plan partial moxygen compatibility (#4205) Co-authored-by: Grok 4.7 --- quest/m1/README.md | 1 + quest/m1/moxygen/README.md | 40 ++++++++++++++++++++++++++++++++++++ quest/m1/moxygen/datagram.md | 23 +++++++++++++++++++++ quest/m1/moxygen/fetch.md | 26 +++++++++++++++++++++++ quest/m1/moxygen/priority.md | 28 +++++++++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 quest/m1/moxygen/README.md create mode 100644 quest/m1/moxygen/datagram.md create mode 100644 quest/m1/moxygen/fetch.md create mode 100644 quest/m1/moxygen/priority.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 38fe901419..253a183024 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -45,6 +45,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md) - moq play: a tune-in burst larger than the video queue parks the decoder, so the clock never reaches live at a wide `--delay` +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers - [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 diff --git a/quest/m1/moxygen/README.md b/quest/m1/moxygen/README.md new file mode 100644 index 0000000000..d7d7e72434 --- /dev/null +++ b/quest/m1/moxygen/README.md @@ -0,0 +1,40 @@ +# Moxygen compatibility + +## Goal + +More compatibility with moxygen's moq-test suite, never a full pass. A peer +that speaks one subgroup per group, FETCH of whole groups, and one datagram +per group gets those through the relay. + +## Plan + +`conformance_test.sh` on draft-16 scored 0/76 against moq-relay 0.15.1. +Subgroup subscribes did return objects. The relay decodes IETF into the +moq-lite track and writes a new subgroup. A green run of all 76 cases is not +the exit test. + +Out of scope, so they are not reopened as bugs: + +- PUBLISH. The relay refuses it. Routing stays SUBSCRIBE per namespace. +- Subgroups. A non-zero subgroup id is dropped. One stream per group. +- Per-group priority. `track::Info` has one priority. A 200/201 split by + group parity stays a gap. +- Foreign object extensions. Dropped. A timestamp we add is ours. +- The end-of-group bit. Optional in draft-16. The group's one stream FINs + at the end, so the bit adds nothing this line will do. +- Several datagram objects in one group. +- A peer that answers SUBSCRIBE_NAMESPACE with unimplemented. The session + already continues. + +Docs stay inline in the change that makes them stale. No new guide. + +## Quests + +- [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 +- [Datagram groups](/quest/m1/moxygen/datagram.md) - an IETF datagram that is one object in a group arrives as a moq-lite datagram group + +## Related + +- [JavaScript FETCH](/quest/m1/js-fetch.md) - the browser publisher answers a FETCH this relay forwards +- [Track priority scope](/quest/m1/track-priority-scope.md) - send-order fairness, not the default value diff --git a/quest/m1/moxygen/datagram.md b/quest/m1/moxygen/datagram.md new file mode 100644 index 0000000000..c794b5e57d --- /dev/null +++ b/quest/m1/moxygen/datagram.md @@ -0,0 +1,23 @@ +# [M] Datagram groups + +## Goal + +An IETF datagram that carries one object for a group is delivered as a +moq-lite datagram: one single-frame group, sequence preserved. Several +objects in one datagram group stay unsupported. + +## Plan + +moq-lite already does this. A datagram is a subscribe id, a group sequence, a +timestamp, and a payload. `insert_datagram` keeps the sequence so a relay +does not renumber it. + +The IETF session does not read or write QUIC datagrams, so a datagram +subscribe delivers nothing. Copy the lite path onto that session. Do not +invent a second object list inside the group. + +The moxygen cases with one object per group are the ones this can pass. + +## Related + +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - the line this belongs to diff --git a/quest/m1/moxygen/fetch.md b/quest/m1/moxygen/fetch.md new file mode 100644 index 0000000000..f236d1751f --- /dev/null +++ b/quest/m1/moxygen/fetch.md @@ -0,0 +1,26 @@ +# [L] Group FETCH + +## Goal + +An IETF FETCH that asks for whole groups is answered. A group already in +cache is served from there. A miss fetches that group upstream. Subscribers +still FETCH one group at a time. A publisher may be asked for a range, which +the relay walks one group at a time. A publisher refusal is what the +subscriber sees. + +## Plan + +Standalone FETCH and a non-zero joining FETCH are refused today with +"not supported". Walk `track::Consumer::fetch_group`, one group, then the +next. Do not add an archive. + +A joining FETCH is the same walk for the groups it names. A form the walk +cannot express is still an explicit refusal, not a hang. + +The moxygen FETCH cases that ask for whole groups are the check. The rest of +that suite is not. + +## Related + +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - the line this belongs to +- [JavaScript FETCH](/quest/m1/js-fetch.md) - the browser publisher that fills an upstream miss diff --git a/quest/m1/moxygen/priority.md b/quest/m1/moxygen/priority.md new file mode 100644 index 0000000000..6efd6aaa5a --- /dev/null +++ b/quest/m1/moxygen/priority.md @@ -0,0 +1,28 @@ +# [S] Default track priority + +## Goal + +A track that never set a priority is the midpoint on both wires. moq-lite +TRACK_INFO carries that midpoint as written. An IETF subgroup header carries +128. A track that set `track::Info.priority` still uses that value. Per-group +priority stays unsupported. + +## Plan + +`track::Info.priority` defaults to 0, higher-first. moq-lite writes it +verbatim, so the wire byte is 0. IETF flips it, so 0 goes out as 255. The +draft's usual publisher priority is 128. + +One model default serves both. 127 is the higher-first midpoint: lite writes +127, IETF writes 128. Do not special-case each wire to the byte 128. That +would make the two encodings different urgencies. + +Rust and JavaScript both pin the old default TRACK_INFO bytes +(`0x06, 0x00, ...`). Update those fixtures with the new default. + +The moxygen 200 versus 201 split by group parity is out of scope. + +## Related + +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - the line this belongs to +- [Track priority scope](/quest/m1/track-priority-scope.md) - fairness across owners, not this default From bc051b76880543fe890c83da3d1f0e0b20bf1c11 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:10:36 -0700 Subject: [PATCH 13/40] fix(net): skip old groups without reporting them as lost (#4208) Co-authored-by: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 6 +++++- rs/moq-net/src/model/frame.rs | 15 ++++++++++----- rs/moq-net/src/model/resume.rs | 16 ++++++++++------ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 46b90b0d5c..c0ab106215 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -766,7 +766,11 @@ impl GroupRecv { } }; - let GroupRecvState::Serve { group, .. } = std::mem::replace(&mut self.state, GroupRecvState::Done) + // Held until the group settles below, so a frame the track or group cut + // short drops into an already-aborted group instead of reporting a loss. + let GroupRecvState::Serve { + group, ingest: _ingest, .. + } = std::mem::replace(&mut self.state, GroupRecvState::Done) else { unreachable!() }; diff --git a/rs/moq-net/src/model/frame.rs b/rs/moq-net/src/model/frame.rs index 0b344f2f97..d2eab832f2 100644 --- a/rs/moq-net/src/model/frame.rs +++ b/rs/moq-net/src/model/frame.rs @@ -359,11 +359,16 @@ impl> Drop for Raw { if !self.done { // An unfinished frame leaves the group stream broken; fail the group so // consumers surface an error instead of hanging on the partial forever. - tracing::warn!( - group = self.group.borrow_mut().info().sequence, - "frame::Producer dropped before writing all bytes" - ); - self.group.borrow_mut().frame_abort(Error::Dropped); + // A group already aborted (superseded, evicted, cancelled) carries its own + // reason, so cutting its in-flight frame short is expected. + let group = self.group.borrow_mut(); + if !group.is_aborted() { + tracing::warn!( + group = group.info().sequence, + "frame::Producer dropped before writing all bytes" + ); + } + group.frame_abort(Error::Dropped); } } } diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index e2227a17f1..ee081f1474 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1214,12 +1214,16 @@ impl Group { Some((_, err)) => { // The only place a spliced group's loss becomes visible, so say which // frames went missing rather than leaving a stuck group to explain itself. - tracing::warn!( - group = self.sequence, - frame = self.index, - %err, - "no route can serve the rest of this group" - ); + // An old group was skipped on purpose (something newer superseded it), so + // it is not a loss worth reporting. + if !matches!(crate::StreamError::from(err), crate::StreamError::Old) { + tracing::warn!( + group = self.sequence, + frame = self.index, + %err, + "no route can serve the rest of this group" + ); + } Err(err.clone()) } None => Ok(false), From f70eba88e63cc21df2f35af7e04ef294750da842 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:11:05 -0700 Subject: [PATCH 14/40] fix(video): run the capture clock tests on a LocalSet (#4210) Co-authored-by: Claude Opus 5.5 --- rs/moq-video/src/encode/producer.rs | 291 +++++++++++++++------------- 1 file changed, 156 insertions(+), 135 deletions(-) diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index 86353d907b..dd76386bcd 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -947,7 +947,8 @@ mod tests { let (opens, rx) = tokio::sync::mpsc::unbounded_channel(); let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); - let task = tokio::spawn(async move { + // Local: a capture stream is `!Send` on macOS, so each test runs on a `LocalSet`. + let task = tokio::task::spawn_local(async move { let mut source = Opens(rx); let options = Options { kind: encoder::Kind::Software, @@ -1053,166 +1054,186 @@ mod tests { /// acquisition: not zero, and not the later instant the loop dequeued it. #[tokio::test] async fn a_late_first_frame_publishes_its_acquisition() { - let fixture = Fixture::start(Duration::from_secs(5), SystemTime::now()).await; - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - - let captured = Instant::now(); - // Delivered well after acquisition: dequeue time must not leak into the timestamp. - tokio::time::sleep(Duration::from_millis(50)).await; - camera.push_at(surface(), captured); - let published = read(&mut track).await; - - assert!(published >= 4_000_000, "{published}us restarted the broadcast at zero"); - fixture.assert_acquired(published, captured); - fixture.finish().await; + tokio::task::LocalSet::new() + .run_until(async { + let fixture = Fixture::start(Duration::from_secs(5), SystemTime::now()).await; + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + + let captured = Instant::now(); + // Delivered well after acquisition: dequeue time must not leak into the timestamp. + tokio::time::sleep(Duration::from_millis(50)).await; + camera.push_at(surface(), captured); + let published = read(&mut track).await; + + assert!(published >= 4_000_000, "{published}us restarted the broadcast at zero"); + fixture.assert_acquired(published, captured); + fixture.finish().await; + }) + .await } /// A device clock that restarts at zero, mid-stream or across a reopen, continues the /// broadcast forward with the device's spacing instead of rewinding it. #[tokio::test] async fn a_device_clock_restart_continues_forward() { - let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - - // The device numbers from zero, and real time keeps pace with it. - camera.push_native(surface(), us(0)); - let first = read(&mut track).await; - tokio::time::sleep(Duration::from_millis(40)).await; - camera.push_native(surface(), us(40_000)); - let second = read(&mut track).await; - assert_eq!(second - first, 40_000, "the device's spacing survives"); - - // The device restarts its clock without the stream ending. - camera.push_native(surface(), us(0)); - let restarted = read(&mut track).await; - assert!(restarted >= second, "{restarted}us rewound behind {second}us"); - tokio::time::sleep(Duration::from_millis(40)).await; - camera.push_native(surface(), us(40_000)); - let resumed = read(&mut track).await; - assert_eq!(resumed - restarted, 40_000, "the device's spacing resumes"); - - // The device goes away and comes back numbering from zero again. - camera.close(); - let camera = fixture.camera(); - let pushed = Instant::now(); - camera.push_native(surface(), us(0)); - let reopened = read(&mut track).await; - let arrived = fixture.at(Instant::now()); - assert!(reopened >= resumed, "{reopened}us rewound across the reopen"); - let early = u64::try_from(SAMPLING.as_micros()).unwrap(); - assert!(reopened + early >= fixture.at(pushed) && reopened <= arrived + ROUNDING); - fixture.finish().await; + tokio::task::LocalSet::new() + .run_until(async { + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + + // The device numbers from zero, and real time keeps pace with it. + camera.push_native(surface(), us(0)); + let first = read(&mut track).await; + tokio::time::sleep(Duration::from_millis(40)).await; + camera.push_native(surface(), us(40_000)); + let second = read(&mut track).await; + assert_eq!(second - first, 40_000, "the device's spacing survives"); + + // The device restarts its clock without the stream ending. + camera.push_native(surface(), us(0)); + let restarted = read(&mut track).await; + assert!(restarted >= second, "{restarted}us rewound behind {second}us"); + tokio::time::sleep(Duration::from_millis(40)).await; + camera.push_native(surface(), us(40_000)); + let resumed = read(&mut track).await; + assert_eq!(resumed - restarted, 40_000, "the device's spacing resumes"); + + // The device goes away and comes back numbering from zero again. + camera.close(); + let camera = fixture.camera(); + let pushed = Instant::now(); + camera.push_native(surface(), us(0)); + let reopened = read(&mut track).await; + let arrived = fixture.at(Instant::now()); + assert!(reopened >= resumed, "{reopened}us rewound across the reopen"); + let early = u64::try_from(SAMPLING.as_micros()).unwrap(); + assert!(reopened + early >= fixture.at(pushed) && reopened <= arrived + ROUNDING); + fixture.finish().await; + }) + .await } /// Releasing the camera while nobody watches keeps the broadcast clock running: the /// frame after a resume lands after the real idle gap, at its own acquisition. #[tokio::test] async fn a_restart_after_idle_keeps_the_gap() { - let idle = Duration::from_millis(300); - let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; - - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - let captured = Instant::now(); - camera.push_at(surface(), captured); - let before = read(&mut track).await; - fixture.assert_acquired(before, captured); - drop(track); - drop(camera); - - tokio::time::sleep(idle).await; - - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - let captured = Instant::now(); - camera.push_at(surface(), captured); - let after = read_new(&mut track, &[before]).await; - fixture.assert_acquired(after, captured); - assert!( - after - before >= u64::try_from(idle.as_micros()).unwrap(), - "the {idle:?} idle gap collapsed to {}us", - after - before - ); - fixture.finish().await; + tokio::task::LocalSet::new() + .run_until(async { + let idle = Duration::from_millis(300); + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let before = read(&mut track).await; + fixture.assert_acquired(before, captured); + drop(track); + drop(camera); + + tokio::time::sleep(idle).await; + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let after = read_new(&mut track, &[before]).await; + fixture.assert_acquired(after, captured); + assert!( + after - before >= u64::try_from(idle.as_micros()).unwrap(), + "the {idle:?} idle gap collapsed to {}us", + after - before + ); + fixture.finish().await; + }) + .await } /// The wall mapping is pinned when the broadcast clock is built. A system clock stepped /// an hour since then retimes neither the published timestamps nor the advertised mapping. #[tokio::test] async fn a_system_wall_adjustment_retimes_nothing() { - // Whole seconds, so the advertised mapping holds it exactly. - let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); - let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() - 3600); - let fixture = Fixture::start(Duration::from_secs(1), wall).await; - let advertised = fixture.catalog.snapshot().clock; - assert_eq!(advertised, Some(fixture.clock.wall())); - - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - let captured = Instant::now(); - camera.push_at(surface(), captured); - let published = read(&mut track).await; - - // Timestamps follow the monotonic epoch and map to walls under the pinned mapping. - fixture.assert_acquired(published, captured); - let mapped = advertised.unwrap().wall_clock(us(published)).unwrap(); - // The catalog maps to walls at millisecond precision. - assert_eq!(mapped, wall + Duration::from_millis(published / 1000)); - assert_eq!(fixture.catalog.snapshot().clock, advertised); - fixture.finish().await; + tokio::task::LocalSet::new() + .run_until(async { + // Whole seconds, so the advertised mapping holds it exactly. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() - 3600); + let fixture = Fixture::start(Duration::from_secs(1), wall).await; + let advertised = fixture.catalog.snapshot().clock; + assert_eq!(advertised, Some(fixture.clock.wall())); + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let published = read(&mut track).await; + + // Timestamps follow the monotonic epoch and map to walls under the pinned mapping. + fixture.assert_acquired(published, captured); + let mapped = advertised.unwrap().wall_clock(us(published)).unwrap(); + // The catalog maps to walls at millisecond precision. + assert_eq!(mapped, wall + Duration::from_millis(published / 1000)); + assert_eq!(fixture.catalog.snapshot().clock, advertised); + fixture.finish().await; + }) + .await } /// A recording replays what the live edge published: the archive's segment records /// carry the live timestamps across an idle restart, with the idle gap left in. #[tokio::test] async fn retained_archive_playback_keeps_the_live_timestamps() { - let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; - let section = fixture - .catalog - .snapshot() - .archive - .expect("the video track enrolls an archive"); - let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(&fixture.consumer, §ion) - .await - .unwrap(); - - let mut live = Vec::new(); - for _ in 0..2 { - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - let captured = Instant::now(); - camera.push_at(surface(), captured); - let published = read_new(&mut track, &live).await; - fixture.assert_acquired(published, captured); - live.push(published); - drop(track); - // Idle past the minimum segment, so each run is archived as its own segment. - tokio::time::sleep(moq_mux::timeline::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; - } + tokio::task::LocalSet::new() + .run_until(async { + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + let section = fixture + .catalog + .snapshot() + .archive + .expect("the video track enrolls an archive"); + let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(&fixture.consumer, §ion) + .await + .unwrap(); + + let mut live = Vec::new(); + for _ in 0..2 { + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let published = read_new(&mut track, &live).await; + fixture.assert_acquired(published, captured); + live.push(published); + drop(track); + // Idle past the minimum segment, so each run is archived as its own segment. + tokio::time::sleep(moq_mux::timeline::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; + } - let (catalog, _consumer) = fixture.finish().await; - catalog.timeline().finish().unwrap(); - let mut archived = Vec::new(); - while let Some(event) = timeline.next().await.unwrap() { - match event { - moq_mux::timeline::Event::Push { entry, .. } => archived.push(entry), - other => panic!("unexpected timeline event {other:?}"), - } - } + let (catalog, _consumer) = fixture.finish().await; + catalog.timeline().finish().unwrap(); + let mut archived = Vec::new(); + while let Some(event) = timeline.next().await.unwrap() { + match event { + moq_mux::timeline::Event::Push { entry, .. } => archived.push(entry), + other => panic!("unexpected timeline event {other:?}"), + } + } - assert_eq!(archived.len(), live.len(), "one segment per capture run: {archived:?}"); - for (entry, live) in archived.iter().zip(&live) { - // The archive keeps millisecond precision. - assert_eq!(entry.pts.as_micros() / 1000, u128::from(*live / 1000), "{archived:?}"); - assert!(entry.tracks.contains_key("video"), "{archived:?}"); - } - let first = &archived[0]; - assert!( - archived[1].pts.as_micros() >= first.pts.as_micros() + first.duration.as_micros(), - "the resumed segment overlaps the one before it: {archived:?}" - ); + assert_eq!(archived.len(), live.len(), "one segment per capture run: {archived:?}"); + for (entry, live) in archived.iter().zip(&live) { + // The archive keeps millisecond precision. + assert_eq!(entry.pts.as_micros() / 1000, u128::from(*live / 1000), "{archived:?}"); + assert!(entry.tracks.contains_key("video"), "{archived:?}"); + } + let first = &archived[0]; + assert!( + archived[1].pts.as_micros() >= first.pts.as_micros() + first.duration.as_micros(), + "the resumed segment overlaps the one before it: {archived:?}" + ); + }) + .await } } } From 322d887bda9ee2ada33faf21fe0e0ee781400e8d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:11:12 -0700 Subject: [PATCH 15/40] fix(quic): pin noq 1.3.1 for the BBR correctness fixes (#4206) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55b36549aa..9276b6998b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4522,9 +4522,9 @@ dependencies = [ [[package]] name = "moq-noq" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "274035555d1c26479ff5dabdf2e6d201fc2d9d1c1357993884eb72dfdc135392" +checksum = "39e37523750e5f1313c3fd1798c4ffdb00218571054f601c3193ceb4e100e5ca" dependencies = [ "bytes", "cfg_aliases", @@ -4544,9 +4544,9 @@ dependencies = [ [[package]] name = "moq-noq-proto" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef835fcf9b92c0f04a77c2fc060cf2a23616ee15176bf41c09847b38186fcb36" +checksum = "7448c4a6a6713b276b346931f3def60e0a89415cd8172d12f6465fed6501a261" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -4575,9 +4575,9 @@ dependencies = [ [[package]] name = "moq-noq-udp" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41114fce0ba95fa5caf3efd62565e71a40da4c2e15f189f482d85db2970d2de5" +checksum = "98e7d85788b7ec2437ac3dd83a231c4e403b96a5e53e39a8701ba5d1d4fad4a4" dependencies = [ "cfg_aliases", "libc", @@ -9843,9 +9843,9 @@ dependencies = [ [[package]] name = "web-transport-moq" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf318557d2c563036e914dc4282275e4196ea5cee22614075157f0d227dbeaf" +checksum = "3d3bdb2400eb49f82b778165df8da318f94be5a667a840a94fe6fc32f3798746" dependencies = [ "bytes", "futures", diff --git a/Cargo.toml b/Cargo.toml index c8d27c0f07..bed70b2450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,8 +151,8 @@ moq-mux = { version = "0.10.5", path = "rs/moq-mux" } moq-net = { version = "0.3.4", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. -moq-noq-proto = { version = "1.3", default-features = false } -moq-noq-udp = "1.3" +moq-noq-proto = { version = "1.3.1", default-features = false } +moq-noq-udp = "1.3.1" # 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. @@ -237,7 +237,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", default-features = false } +web-transport-moq = { version = "1.3.1", default-features = false } web-transport-proto = "0.6" web-transport-trait = "0.4" web-transport-wasm = "0.6" From c6412eee2d225022b14f1b33425d979ea2548c7a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:11:22 -0700 Subject: [PATCH 16/40] quest: dropped sources, video keyframe flag, interop flakes (#4201) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 3 +++ quest/m1/dropped-sources.md | 27 +++++++++++++++++++++++++++ quest/m1/interop-flakes.md | 23 +++++++++++++++++++++++ quest/m1/video-keyframe-flag.md | 24 ++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 quest/m1/dropped-sources.md create mode 100644 quest/m1/interop-flakes.md create mode 100644 quest/m1/video-keyframe-flag.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 253a183024..9f4a06a51b 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -23,8 +23,10 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` +- [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` - [JS bare FIN](/quest/m1/js-bare-fin.md) - a `@moq/net` subscriber aborts a track whose subscribe stream FINs before its declared end, like Rust - [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 - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for - [Auth expiry clock](/quest/m1/auth-expiry-clock.md) - moq-auth and the relay hold one fixed expiry deadline and honour the same skew allowance @@ -64,6 +66,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode - [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name - [Keyframe trigger](/quest/m1/keyframe-trigger.md) - an application can ask the built-in capture encoder for a keyframe +- [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 - [Drain](/quest/m1/drain/README.md) - relay restarts drain sessions over GOAWAY instead of hard-dropping them - [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 diff --git a/quest/m1/dropped-sources.md b/quest/m1/dropped-sources.md new file mode 100644 index 0000000000..9fc495174f --- /dev/null +++ b/quest/m1/dropped-sources.md @@ -0,0 +1,27 @@ +# [M] Consumers see the producer's real error, never Dropped + +## 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`. + +## 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. + +Public API: none expected; error values consumers observe change. Wire: none. + +## Related + +- [#4179](https://github.com/moq-dev/moq/pull/4179) - fixed the revoked-upstream path diff --git a/quest/m1/interop-flakes.md b/quest/m1/interop-flakes.md new file mode 100644 index 0000000000..679c0344cf --- /dev/null +++ b/quest/m1/interop-flakes.md @@ -0,0 +1,23 @@ +# [S] Interop harness runs clean in parallel + +## Goal + +`just test interop --all` passes reliably while other harness runs share the +machine. Two known flakes: concurrent Nix shells reserve the same port because +each keeps its reservations under its own `TMPDIR`, and the browser driver's +pause click is sometimes blocked by the canvas (`js -> js`). + +## Plan + +- Port reservations live under one root shared by every shell (not + `TMPDIR`), or ports come from the OS (bind to 0 and pass the result). Prefer + whichever removes the reservation file entirely. +- The pause control is driven in a way the canvas cannot intercept (an API or + keyboard path, or waiting for the element to be actionable), not a retry. +- Prove it by running two `--all` harnesses at once, several times. + +Public API: none. Wire: none. + +## Related + +- [#4181](https://github.com/moq-dev/moq/pull/4181) - where both flakes were seen diff --git a/quest/m1/video-keyframe-flag.md b/quest/m1/video-keyframe-flag.md new file mode 100644 index 0000000000..7c97c29d20 --- /dev/null +++ b/quest/m1/video-keyframe-flag.md @@ -0,0 +1,24 @@ +# [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 From 43fa4f7b7500a8e5a2480261a51c93ea25fb0a2f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:11:39 -0700 Subject: [PATCH 17/40] fix(transcode): choose a source rendition this host can decode (#4182) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- doc/bin/cli.md | 5 + quest/m1/README.md | 1 - quest/m1/egress-rendition-pick.md | 2 +- quest/m1/transcode-source.md | 24 -- rs/moq-transcode/src/catalog.rs | 349 +++++++++++++++++++++++++++--- rs/moq-transcode/src/lib.rs | 141 +++++++++++- rs/moq-transcode/src/pipeline.rs | 6 +- 7 files changed, 461 insertions(+), 67 deletions(-) delete mode 100644 quest/m1/transcode-source.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 43f26fdbcd..5f08497161 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -151,6 +151,11 @@ 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 +or AV1 one. When no rendition decodes, the command exits naming the decoder's +refusal. + The ladder is sized against the source picture and follows it, so a source that changes resolution mid-stream (a window capture renegotiated by a resize, a publisher reconnecting at a new size) resolves the rungs again. Rungs that still diff --git a/quest/m1/README.md b/quest/m1/README.md index 9f4a06a51b..0ed96c21b1 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -63,7 +63,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 -- [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode - [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name - [Keyframe trigger](/quest/m1/keyframe-trigger.md) - an application can ask the built-in capture encoder for a keyframe - [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 diff --git a/quest/m1/egress-rendition-pick.md b/quest/m1/egress-rendition-pick.md index b0b634a583..828f1d66ee 100644 --- a/quest/m1/egress-rendition-pick.md +++ b/quest/m1/egress-rendition-pick.md @@ -21,4 +21,4 @@ lower-quality rendition sorts first. ## Related - [WHEP ABR](/quest/m2/whep-abr.md) - switch the served rendition per peer instead of fixing one -- [Transcode source](/quest/m1/transcode-source.md) - the same largest-rendition ranking for transcode input +- [Transcode](/doc/bin/cli.md) - the source is the tallest rendition this host can decode diff --git a/quest/m1/transcode-source.md b/quest/m1/transcode-source.md deleted file mode 100644 index bf8e8d456d..0000000000 --- a/quest/m1/transcode-source.md +++ /dev/null @@ -1,24 +0,0 @@ -# [M] Choose a locally decodable transcode source - -## Goal - -A higher-resolution rendition that this host cannot decode does not prevent -transcoding another usable rendition in the same source catalog. - -## Plan - -catalog::choose_source filters by codec syntax and dimensions, then ranks the -largest rendition. It does not establish whether the selected backend can -decode it. On a software-only host, a larger H.265 or AV1 entry can win over a -usable H.264 entry and fail later when demand opens the decoder. - -Reproduce that case with deterministic backend capabilities and preserve the -explicit backend selection policy. Choose among locally supported candidates -or return an actionable refusal when none exists. Avoid repeated encoder or -decoder probes for every catalog edit, and do not turn malformed stream input -into silent fallback after a rendition is already being consumed. - -Test mixed-codec catalogs, an explicitly forced unavailable backend, no usable -candidate, and a catalog update while the selected rendition remains valid. -Keep rendition identity, output names, and existing live/fetch behavior stable. -Run the regressions in CI. Public API and wire: unchanged. diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index a164dd70a3..5568b15626 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -3,6 +3,7 @@ use hang::catalog::{AV1, Video, VideoCodec, VideoConfig}; use moq_net::path::RelativeOwned; +use moq_video::decode::Codec; use crate::{Error, Ladder}; @@ -69,24 +70,149 @@ pub(crate) struct Published { pub entry: VideoConfig, } -/// Pick the rendition to transcode from: the highest-resolution decodable -/// (H.264/H.265/AV1) rendition local to the source broadcast. -pub(crate) fn choose_source(video: &Video) -> Result<(String, VideoConfig), Error> { - video +/// Which codecs the configured decoder opens on this host, each probed once. +/// +/// A rendition's codec string says what a decoder must handle, not whether this +/// host has one: a software-only build decodes H.264 and nothing else, and a +/// backend forced by name may not be here at all. Only opening a decoder tells, +/// so each codec is opened (and dropped) the first time a candidate needs it and +/// the answer kept for every later snapshot. +pub(crate) struct Decoders { + config: moq_video::decode::Config, + /// Each codec probed so far, and whether its decoder opened. + probed: Vec<(Codec, bool)>, +} + +impl Decoders { + pub fn new(config: moq_video::decode::Config) -> Self { + Self { + config, + probed: Vec::new(), + } + } + + /// A host that decodes exactly `codecs`, for tests that must not depend on + /// the machine they run on. Nothing is opened until a refusal asks why. + #[cfg(test)] + fn assume(codecs: &[Codec]) -> Self { + let probed = [Codec::H264, Codec::H265, Codec::Av1] + .into_iter() + .map(|codec| (codec, codecs.contains(&codec))) + .collect(); + Self { + config: moq_video::decode::Config::new(), + probed, + } + } + + /// Whether `rendition` (in `codec`) has a decoder on this host. + async fn probe(&mut self, codec: Codec, rendition: &VideoConfig) -> bool { + if let Some((_, decodes)) = self.probed.iter().find(|(probed, _)| *probed == codec) { + return *decodes; + } + + let decodes = match self.open(rendition).await { + Ok(()) => true, + Err(err) => { + tracing::warn!(?codec, %err, "no decoder for this codec; its renditions will not be transcoded"); + false + } + }; + self.probed.push((codec, decodes)); + decodes + } + + /// Why `rendition`'s decoder refused to open. Only the verdict is cached, so + /// this opens it once more, on a path that has nothing left to serve. + /// + /// `Ok(())` means that reopen succeeded. The first failure was transient, so + /// the cached refusal is cleared and the caller selects this rendition + /// instead of waiting on a catalog that will not change. + async fn refusal(&mut self, rendition: &VideoConfig) -> Result<(), Error> { + match self.open(rendition).await { + Err(err) => Err(err.into()), + Ok(()) => { + if let Some(codec) = codec(rendition) + && let Some((_, decodes)) = self.probed.iter_mut().find(|(probed, _)| *probed == codec) + { + *decodes = true; + } + Ok(()) + } + } + } + + /// Open and drop a decoder for `rendition`. + async fn open(&self, rendition: &VideoConfig) -> Result<(), moq_video::Error> { + // Parameter sets in band, so the probe asks about the backend and not about + // this rendition's description: a malformed one belongs to the stream and + // fails when the rendition is decoded, not as a verdict on the whole codec. + let mut config = rendition.clone(); + config.description = None; + match &mut config.codec { + VideoCodec::H264(h264) => h264.inline = true, + VideoCodec::H265(h265) => h265.in_band = true, + _ => {} + } + moq_video::decode::Sink::open(&config, &self.config).await.map(drop) + } +} + +/// Pick the rendition to transcode from: the highest-resolution 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. +pub(crate) async fn choose_source(video: &Video, decoders: &mut Decoders) -> Result<(String, VideoConfig), Error> { + let mut candidates: Vec<_> = video .renditions .iter() // 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(|(_, config)| can_decode(config)) - // A publisher can advertise its codec before it knows its picture: a capture whose camera - // hasn't been opened publishes a rendition with no dimensions, and the first keyframe fills - // them in. There is no ladder to derive from that yet, so leave it for a later snapshot - // rather than choosing it and failing on geometry. - .filter(|(_, config)| dimensions(config).is_some()) - .max_by_key(|(_, config)| (config.coded_height, config.coded_width, config.bitrate)) - .map(|(name, config)| (name.clone(), config.clone())) - .ok_or(Error::NoSource) + .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, + )) + }); + + let mut refused = None; + for (name, config, codec) in candidates { + match decoders.probe(codec, config).await { + true if dimensions(config).is_some() => return Ok((name.clone(), config.clone())), + // A publisher can advertise its codec before it knows its picture: a capture + // whose camera hasn't been opened publishes a rendition with no dimensions, + // and the first keyframe fills them in. There is no ladder to derive from + // that yet, but it will be usable, so wait for it rather than refuse. + true => return Err(Error::NoSource), + false => { + refused.get_or_insert((name, config)); + } + } + } + + let Some((name, config)) = refused else { + return Err(Error::NoSource); + }; + match decoders.refusal(config).await { + Err(err) => { + tracing::warn!(rendition = %name, "no source rendition can be decoded on this host"); + Err(err) + } + // The picture is known, so this is the source. A missing size still has + // to wait, but the codec is cached as decodable for the next snapshot. + Ok(()) if dimensions(config).is_some() => Ok((name.clone(), config.clone())), + Ok(()) => Err(Error::NoSource), + } } /// Re-pick the source rendition for a new snapshot, preferring the one already @@ -97,13 +223,21 @@ pub(crate) fn choose_source(video: &Video) -> Result<(String, VideoConfig), Erro /// that was serving. Sticking to the current name follows the picture it now /// carries (the point of this path) without treating a momentary catalog edit as /// a source switch. -pub(crate) fn follow_source(video: &Video, current: &str) -> Result<(String, VideoConfig), Error> { - match video.renditions.get(current) { - Some(config) if config.broadcast.is_none() && can_decode(config) && dimensions(config).is_some() => { - Ok((current.to_string(), config.clone())) - } - _ => choose_source(video), +pub(crate) async fn follow_source( + video: &Video, + current: &str, + decoders: &mut Decoders, +) -> Result<(String, VideoConfig), Error> { + if let Some(config) = video.renditions.get(current) + && config.broadcast.is_none() + && dimensions(config).is_some() + && let Some(codec) = codec(config) + // The name can stay while the codec changes under it. + && decoders.probe(codec, config).await + { + return Ok((current.to_string(), config.clone())); } + choose_source(video, decoders).await } /// Whether a rung decoding `old` can keep decoding `new` untouched. @@ -124,11 +258,13 @@ fn dimensions(config: &VideoConfig) -> Option<(u64, u64)> { } } -fn can_decode(config: &VideoConfig) -> bool { +/// The decoder a rendition needs, if `moq-video` has one for its codec at all. +fn codec(config: &VideoConfig) -> Option { match &config.codec { - VideoCodec::H264(_) | VideoCodec::H265(_) => true, - VideoCodec::AV1(av1) => is_supported_av1(av1), - _ => false, + VideoCodec::H264(_) => Some(Codec::H264), + VideoCodec::H265(_) => Some(Codec::H265), + VideoCodec::AV1(av1) if is_supported_av1(av1) => Some(Codec::Av1), + _ => None, } } @@ -413,19 +549,43 @@ mod tests { /// `run` keeps waiting for a snapshot it can use. Choosing that rendition instead would fail on /// geometry and terminate the transcode before any rung could create the demand that opens the /// camera in the first place. - #[test] - fn dimensionless_rendition_is_not_a_source_yet() { + /// Every codec the tests build a rendition in. + fn decodes_all() -> Decoders { + Decoders::assume(&[Codec::H264, Codec::H265, Codec::Av1]) + } + + /// An H.265 rendition at `width`x`height`. + fn hevc(width: u32, height: u32) -> VideoConfig { + let mut config = VideoConfig::new(hang::catalog::H265 { + in_band: true, + 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], + }); + config.coded_width = Some(width); + config.coded_height = Some(height); + config + } + + #[tokio::test] + async fn dimensionless_rendition_is_not_a_source_yet() { let mut provisional = source(0, 0, None); provisional.coded_width = None; provisional.coded_height = None; let mut video = Video::default(); video.renditions.insert("video".to_string(), provisional.clone()); - assert!(matches!(choose_source(&video), Err(Error::NoSource))); + assert!(matches!( + choose_source(&video, &mut decodes_all()).await, + Err(Error::NoSource) + )); // The keyframe fills the geometry in, and the same rendition becomes usable. video.renditions.insert("video".to_string(), source(1920, 1080, None)); - let (name, chosen) = choose_source(&video).unwrap(); + let (name, chosen) = choose_source(&video, &mut decodes_all()).await.unwrap(); assert_eq!(name, "video"); assert_eq!(chosen.coded_width, Some(1920)); } @@ -529,8 +689,8 @@ mod tests { assert_eq!(names.mint(120), "video/120p.2"); } - #[test] - fn chooses_highest_local_rendition() { + #[tokio::test] + async fn chooses_highest_local_rendition() { let mut video = Video::default(); video.insert("low", source(640, 360, None)).unwrap(); video.insert("high", source(1920, 1080, None)).unwrap(); @@ -538,26 +698,26 @@ mod tests { remote.broadcast = Some(RelativeOwned::from("./other".to_string())); video.insert("remote", remote).unwrap(); - let (name, config) = choose_source(&video).unwrap(); + let (name, config) = choose_source(&video, &mut decodes_all()).await.unwrap(); assert_eq!(name, "high"); assert_eq!(config.coded_height, Some(1080)); } - #[test] - fn chooses_av1_source() { + #[tokio::test] + async fn chooses_av1_source() { let mut video = Video::default(); let mut av1 = VideoConfig::new(hang::catalog::AV1::default()); av1.coded_width = Some(1920); av1.coded_height = Some(1080); video.insert("av1", av1).unwrap(); - let (name, config) = choose_source(&video).unwrap(); + let (name, config) = choose_source(&video, &mut decodes_all()).await.unwrap(); assert_eq!(name, "av1"); assert!(matches!(config.codec, VideoCodec::AV1(_))); } - #[test] - fn skips_unsupported_av1_source() { + #[tokio::test] + async fn skips_unsupported_av1_source() { let mut video = Video::default(); let mut av1 = VideoConfig::new(hang::catalog::AV1 { bitdepth: 10, @@ -568,11 +728,128 @@ mod tests { video.insert("av1", av1).unwrap(); video.insert("h264", source(1920, 1080, None)).unwrap(); - let (name, config) = choose_source(&video).unwrap(); + let (name, config) = choose_source(&video, &mut decodes_all()).await.unwrap(); assert_eq!(name, "h264"); assert!(matches!(config.codec, VideoCodec::H264(_))); } + /// A software-only host decodes H.264 and nothing else, so a taller H.265 + /// rendition loses to it rather than failing once a rung opens its decoder. + #[tokio::test] + async fn skips_a_rendition_this_host_cannot_decode() { + let mut video = Video::default(); + video.insert("hevc", hevc(1920, 1080)).unwrap(); + video.insert("avc", source(640, 360, None)).unwrap(); + let mut av1 = VideoConfig::new(hang::catalog::AV1::default()); + av1.coded_width = Some(3840); + av1.coded_height = Some(2160); + video.insert("av1", av1).unwrap(); + + let mut decoders = Decoders::assume(&[Codec::H264]); + 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. + 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 + /// rendition's decoder refused, rather than waiting on a complete catalog. + #[tokio::test] + async fn refuses_when_no_rendition_decodes() { + let mut video = Video::default(); + video.insert("small", hevc(640, 360)).unwrap(); + video.insert("large", hevc(1920, 1080)).unwrap(); + + let mut config = moq_video::decode::Config::new(); + config.kind = moq_video::decode::Kind::Named("missing".to_string()); + match choose_source(&video, &mut Decoders::new(config)).await { + Err(Error::Video(moq_video::Error::UnknownDecoder { name, codec, .. })) => { + assert_eq!(name, "missing"); + assert_eq!(codec, Codec::H265); + } + other => panic!("expected the decoder's refusal, got {other:?}"), + } + } + + /// A probe can fail once and succeed when the refusal path opens the decoder + /// again. That rendition is the source. Returning `NoSource` would leave + /// `run` waiting on a catalog a static source never updates, with the stale + /// refusal still cached. + #[tokio::test] + async fn a_reopen_that_succeeds_is_the_source() { + let mut video = Video::default(); + video.insert("avc", source(640, 360, None)).unwrap(); + + // Cached as refused without opening, the shape of a probe that failed once. + let mut decoders = Decoders::assume(&[]); + decoders.config.kind = moq_video::decode::Kind::Software; + let (name, _) = choose_source(&video, &mut decoders).await.unwrap(); + assert_eq!(name, "avc"); + assert!(decoders.probe(Codec::H264, &source(640, 360, None)).await); + } + + /// A decodable rendition still waiting on its first keyframe will be usable, + /// so an undecodable one that already knows its picture is no reason to refuse. + /// A simulcast publisher fills in each rendition's geometry separately. + #[tokio::test] + async fn a_pending_decodable_rendition_defers_the_refusal() { + let mut pending = source(0, 0, None); + pending.coded_width = None; + pending.coded_height = None; + + let mut video = Video::default(); + video.insert("hevc", hevc(1920, 1080)).unwrap(); + video.insert("avc", pending).unwrap(); + + let mut decoders = Decoders::assume(&[Codec::H264]); + assert!(matches!( + choose_source(&video, &mut decoders).await, + Err(Error::NoSource) + )); + + video.renditions.insert("avc".to_string(), source(640, 360, None)); + let (name, _) = choose_source(&video, &mut decoders).await.unwrap(); + assert_eq!(name, "avc"); + } + + /// The current source stays while it is still decodable, even when a taller + /// rendition appears, and is replaced once its codec changes to one this host + /// can't decode. + #[tokio::test] + async fn follow_keeps_a_valid_source() { + let mut video = Video::default(); + video.insert("avc", source(640, 360, None)).unwrap(); + let mut decoders = Decoders::assume(&[Codec::H264]); + + video.insert("tall", source(1920, 1080, None)).unwrap(); + video.insert("hevc", hevc(3840, 2160)).unwrap(); + let (name, _) = follow_source(&video, "avc", &mut decoders).await.unwrap(); + assert_eq!(name, "avc"); + + video.renditions.insert("avc".to_string(), hevc(640, 360)); + let (name, _) = follow_source(&video, "avc", &mut decoders).await.unwrap(); + assert_eq!(name, "tall"); + } + + /// Probing opens a real decoder, so each codec is opened once per transcoder + /// rather than once per catalog edit. + #[tokio::test] + async fn each_codec_is_probed_once() { + let mut config = moq_video::decode::Config::new(); + config.kind = moq_video::decode::Kind::Named("missing".to_string()); + let mut decoders = Decoders::new(config); + + // Every catalog edit reshapes the rendition; the H.265 verdict stands. + for height in [360, 720, 1080] { + assert!(!decoders.probe(Codec::H265, &hevc(height * 16 / 9, height)).await); + } + assert_eq!(decoders.probed, [(Codec::H265, false)]); + } + #[test] fn populate_preserves_the_child_archive() { let mut child = moq_mux::catalog::hang::Catalog::<()>::default(); diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index a9cda09c0d..122591eacf 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -123,20 +123,29 @@ impl Transcoder { .subscribe(hang::Catalog::default_subscription()) .await?; let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track); + let mut decoders = catalog::Decoders::new(config.feed_decoder()); let (source_name, source_config, snapshot) = loop { let Some(snapshot) = catalogs.next().await? else { return Err(Error::NoSource); }; - match catalog::choose_source(&snapshot.video) { + match catalog::choose_source(&snapshot.video, &mut decoders).await { Ok((name, config)) => break (name, config, snapshot), - Err(_) => tracing::debug!("no transcodable rendition yet; waiting for a catalog update"), + Err(Error::NoSource) => tracing::debug!("no transcodable rendition yet; waiting for a catalog update"), + Err(err) => return Err(err), } }; // The ladder, the shared decode behind it, and the rungs serving off it. // Resolved again on every source catalog snapshot, so a source that resizes // mid-stream takes the ladder with it. - let mut ladder = - pipeline::Pipeline::new(source.clone(), config.clone(), active, source_name, source_config).await?; + let mut ladder = pipeline::Pipeline::new( + source.clone(), + config.clone(), + active, + decoders, + source_name, + source_config, + ) + .await?; // Publish the derivative catalog before any encoder exists, so subscribers // can pick a rung immediately. Commit so a catalog that cannot be published fails @@ -1328,6 +1337,130 @@ mod tests { transcoder.abort(); } + /// A source broadcast whose catalog offers a 1080p H.265 rendition next to a + /// 360p H.264 one, the way a simulcasting publisher would. The tracks exist + /// but carry no media, since no rung encodes until someone asks. + fn mixed_codec_source() -> ( + moq_net::broadcast::Producer, + moq_mux::catalog::Producer, + [moq_net::track::Producer; 2], + ) { + let mut broadcast = moq_net::broadcast::Info::default().produce(); + let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); + + let mut hevc = hang::catalog::VideoConfig::new(hang::catalog::H265 { + in_band: true, + 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], + }); + hevc.coded_width = Some(1920); + hevc.coded_height = Some(1080); + hevc.bitrate = Some(6_000_000); + + let mut avc = hang::catalog::VideoConfig::new(hang::catalog::H264 { + inline: true, + profile: 0x42, + constraints: 0, + level: 30, + }); + avc.coded_width = Some(640); + avc.coded_height = Some(360); + avc.bitrate = Some(1_000_000); + + let mut guard = catalog.modify().unwrap(); + guard.video.insert("hevc", hevc).unwrap(); + guard.video.insert("avc", avc).unwrap(); + guard.commit().unwrap(); + + let info = hang::container::track_info(hang::catalog::PRIORITY.video); + let tracks = [ + broadcast.create_track("hevc", info.clone()).unwrap(), + broadcast.create_track("avc", info).unwrap(), + ]; + (broadcast, catalog, tracks) + } + + /// A larger rendition this host can't decode must not win the source. With + /// only the software decoder, the 1080p H.265 entry would be chosen, sized a + /// ladder the H.264 one can't serve, and failed the moment a rung was asked for. + #[tokio::test] + async fn an_undecodable_larger_rendition_is_not_the_source() { + let (broadcast, _catalog, _tracks) = mixed_codec_source(); + + let config = Config { + ladder: Ladder::new([ + Rung::new(720, moq_net::bandwidth::Rate::from_bps(2_500_000)), + Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000)), + ]) + .unwrap(), + encoder: moq_video::encode::Kind::Software, + decoder: moq_video::decode::Kind::Software, + source: None, + ..Default::default() + }; + + let output = moq_net::broadcast::Info::default().produce(); + let consumer = output.consume(); + let transcoder = tokio::spawn(run(broadcast.consume(), output, config)); + + let track = loop { + match consumer.track(hang::Catalog::DEFAULT_NAME) { + Ok(track) => break track, + Err(moq_net::Error::NotFound) => tokio::task::yield_now().await, + Err(err) => panic!("catalog track: {err}"), + } + }; + let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap()); + let derived = await_catalog(&mut catalogs, |snapshot| !snapshot.video.renditions.is_empty()).await; + + // Sized against the 640x360 H.264 rendition: no room for 720p, and 120p is + // 212 wide either way. + let names: Vec<_> = derived.video.renditions.keys().map(String::as_str).collect(); + assert_eq!( + names, + ["video/120p"], + "the ladder was sized against the H.265 rendition" + ); + + transcoder.abort(); + } + + /// A decoder forced by name that this build doesn't have leaves nothing to + /// transcode from, and `run` has to say so rather than wait forever. + #[tokio::test] + async fn a_missing_decoder_is_refused() { + let (broadcast, _catalog, _tracks) = mixed_codec_source(); + + let config = Config { + ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(), + encoder: moq_video::encode::Kind::Software, + decoder: moq_video::decode::Kind::Named("missing".to_string()), + source: None, + ..Default::default() + }; + + let output = moq_net::broadcast::Info::default().produce(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + run(broadcast.consume(), output, config), + ) + .await + .expect("run kept waiting for a source it can never decode"); + + match result { + // Why the tallest 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); + } + other => panic!("expected the decoder's refusal, got {other:?}"), + } + } + /// `run` must terminate (not hang in its shutdown drain) when the source /// broadcast goes away, even with a rung task that was never subscribed. #[tokio::test] diff --git a/rs/moq-transcode/src/pipeline.rs b/rs/moq-transcode/src/pipeline.rs index 0c95f97db2..a81becc9fe 100644 --- a/rs/moq-transcode/src/pipeline.rs +++ b/rs/moq-transcode/src/pipeline.rs @@ -36,6 +36,8 @@ pub(crate) struct Pipeline { source: moq_net::broadcast::Consumer, config: Config, active: active::Producer, + /// Which codecs this host decodes, so a catalog edit never probes again. + decoders: catalog::Decoders, /// The source rendition the rungs are sized against. name: String, @@ -62,6 +64,7 @@ impl Pipeline { source: moq_net::broadcast::Consumer, config: Config, active: active::Producer, + decoders: catalog::Decoders, name: String, rendition: VideoConfig, ) -> Result { @@ -73,6 +76,7 @@ impl Pipeline { source, config, active, + decoders, name, rendition, feed, @@ -165,7 +169,7 @@ impl Pipeline { /// Resolve the ladder again against a new source catalog snapshot. pub(crate) async fn follow(&mut self, video: &Video) -> Result<(), Error> { - let (name, rendition) = match catalog::follow_source(video, &self.name) { + let (name, rendition) = match catalog::follow_source(video, &self.name, &mut self.decoders).await { Ok(chosen) => chosen, // Nothing transcodable in this snapshot: keep serving the ladder we // have rather than tearing it down over an edit the source may undo. From e5d1f1ceff1e74afa5fab885c3a1dd501ba79830 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:12:17 -0700 Subject: [PATCH 18/40] fix(relay): bind smoke-test listeners on :0 instead of probing for a free port (#4198) Co-authored-by: Claude Opus 5.5 --- rs/moq-relay/src/web.rs | 49 +++------ rs/moq-relay/tests/smoke.rs | 199 +++++++++--------------------------- 2 files changed, 63 insertions(+), 185 deletions(-) diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index f948c49fc0..e6c2f41657 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -227,8 +227,8 @@ impl Web { } } - /// Bind configured web sockets now, so an embedder can read ephemeral ports. - pub(crate) fn bind(mut self) -> anyhow::Result { + /// Bind the configured listeners now, so [`addrs`](Self::addrs) reports ephemeral ports before serving. + pub fn bind(mut self) -> anyhow::Result { if self.https_tls.is_none() && self.config.https.listen.is_some() { let tls = build_https_config(&self.config.https.cert, &self.config.https.key, &self.config.https.root)?; self.https_tls = Some(RustlsConfig::from_config(tls)); @@ -254,7 +254,7 @@ impl Web { Ok(self) } - /// The actual bound addresses after [`crate::Relay::load`]. + /// The actual bound addresses after [`bind`](Self::bind) or [`crate::Relay::load`]. pub fn addrs(&self) -> Addrs { self.addrs } @@ -1283,30 +1283,6 @@ mod tests { } } - /// Two ports the kernel just handed out, released together so neither bind can - /// be handed the other's. - #[cfg(all(unix, feature = "websocket"))] - fn free_ports() -> (u16, u16) { - let http = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let https = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - (http.local_addr().unwrap().port(), https.local_addr().unwrap().port()) - } - - /// Connect to `port`, waiting for [`Web::serve`] to finish binding. - #[cfg(all(unix, feature = "websocket"))] - async fn connect(port: u16) -> tokio::net::TcpStream { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { - Ok(stream) => return stream, - Err(err) if std::time::Instant::now() >= deadline => { - panic!("web listener never came up on port {port}: {err}") - } - Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await, - } - } - } - /// `GET /socket` over `io`, returning the body the handler produced. /// /// Hand-rolled rather than reached through an HTTP client so the same request @@ -1362,11 +1338,9 @@ mod tests { async fn serve_captures_the_socket_on_every_listener() { let dir = TempDir::new().unwrap(); let (ca, cert, key) = make_certs(&dir); - let (http, https) = free_ports(); - let mut config = Config::default(); - config.http.listen = Some(format!("127.0.0.1:{http}").parse().unwrap()); - config.https.listen = Some(format!("127.0.0.1:{https}").parse().unwrap()); + config.http.listen = Some("127.0.0.1:0".parse().unwrap()); + config.https.listen = Some("127.0.0.1:0".parse().unwrap()); config.https.cert = vec![cert.clone()]; config.https.key = vec![key]; @@ -1380,16 +1354,23 @@ mod tests { let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); let certificates = moq_tokio::tls::Certificates::from_pem(&std::fs::read(&cert).unwrap()).unwrap(); - let web = Web::new(auth, cluster, certificates, config); + let web = Web::new(auth, cluster, certificates, config).bind().unwrap(); + let Addrs { + http: Some(http), + https: Some(https), + } = web.addrs() + else { + panic!("both listeners are configured"); + }; let serving = tokio::spawn(web.serve(Router::new().route("/socket", get(report_socket)))); assert_eq!( - get_socket(connect(http).await).await, + get_socket(tokio::net::TcpStream::connect(http).await.unwrap()).await, "captured", "the HTTP listener must install the capturing acceptor" ); - let tcp = connect(https).await; + let tcp = tokio::net::TcpStream::connect(https).await.unwrap(); let name = rustls::pki_types::ServerName::try_from("localhost").unwrap(); let tls = tls_connector(&ca).connect(name, tcp).await.expect("TLS handshake"); assert_eq!( diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 63b47a00c4..50920fe0c1 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -1,13 +1,13 @@ //! End-to-end smoke test through a real moq-relay. //! -//! Stands up the relay's actual axum + auth + cluster stack on a free port, +//! Stands up the relay's actual axum + auth + cluster stack on an ephemeral port, //! connects a publisher and a subscriber via WebSocket, and confirms that //! a frame round-trips with the newest moq-lite version on both sides. The //! version assertion is the regression guard for the //! "axum-only-advertises-bare-`webtransport`" bug that silently downgraded //! relay clients to moq-lite-02. -use std::{net::TcpListener, time::Duration}; +use std::time::Duration; use moq_relay::{Config, Connection, Relay, auth, cluster, web}; use moq_tokio::moq_net; @@ -29,10 +29,14 @@ fn newest_lite_version() -> moq_net::Version { .expect("parse newest lite ALPN as a Version") } -async fn build_web(port: u16, ws: bool) -> web::Web { +/// A [`web::Web`] already bound to an ephemeral loopback HTTP port. +/// +/// Binding up front, rather than probing for a free port and rebinding it, +/// means no other process can take the port in between and answer our client. +async fn build_web(ws: bool) -> web::Web { let mut config = web::Config::default(); config.ws = ws; - config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + config.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); build_web_with(config).await } @@ -60,79 +64,39 @@ async fn build_web_with(web_config: web::Config) -> web::Web { let server = server_config.init(Default::default()).expect("server init"); web::Web::new(auth, cluster, server.certificates(), web_config) + .bind() + .expect("bind web listeners") } -fn free_tcp_port() -> u16 { - // Pick a free port for HTTP, then immediately drop the probe listener - // so axum_server can bind it. There's a tiny race window where the - // kernel could hand the same port to another process, but on localhost - // in a single-test process it's safe in practice. - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - -async fn wait_for_http(port: u16, server_result: &mut tokio::sync::oneshot::Receiver>) { - // Wait for axum_server to bind. A short poll is more reliable than a - // fixed sleep when CI is slow. - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - match server_result.try_recv() { - Ok(Ok(())) => panic!("relay web server exited before listening"), - Ok(Err(err)) => panic!("relay web server failed before listening: {err:#}"), - Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {} - Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { - panic!("relay web server task ended before listening") - } - } - if std::time::Instant::now() >= deadline { - panic!("relay http listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - -/// The shared bootstrap: stand up a relay listening on `127.0.0.1:` +/// The shared bootstrap: stand up a relay listening on `127.0.0.1:` /// with fully public auth, and return the port plus an abort handle for the /// spawned web server. async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { - let port = free_tcp_port(); - let web = build_web(port, true).await; - - let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); - let handle = tokio::spawn(async move { - // `Web::run` only returns on error; in tests we abort it at teardown. - let _ = server_result_tx.send(web.run().await); - }); + let web = build_web(true).await; + let port = web.addrs().http.expect("HTTP listener is configured").port(); - wait_for_http(port, &mut server_result_rx).await; + // `Web::run` only returns on error; in tests we abort it at teardown. + let handle = tokio::spawn(async move { web.run().await.expect("relay web server") }); (port, handle) } /// Stand up the assembled relay path with `--server-version` restricted. async fn spawn_versioned_relay(versions: Vec) -> (u16, tokio::task::JoinHandle<()>) { - let port = free_tcp_port(); let mut config = Config::default(); config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.listen.version = versions; config.web.ws = true; - config.web.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + config.web.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); config.auth.public = vec![moq_auth::Pattern::all()]; + // `load` binds the web listener, so the port is ours before anyone dials it. let relay = Relay::load(config).await.expect("load relay"); - let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); - let handle = tokio::spawn(async move { - let _ = server_result_tx.send(relay.run().await); - }); + let port = relay.web_addrs().http.expect("HTTP listener is configured").port(); + let handle = tokio::spawn(async move { relay.run().await.expect("relay") }); - wait_for_http(port, &mut server_result_rx).await; (port, handle) } @@ -387,18 +351,13 @@ async fn relay_websocket_honors_server_version() { #[tokio::test] async fn relay_web_serves_merged_routes() { tokio::time::pause(); - let port = free_tcp_port(); - let web = build_web(port, false).await; + let web = build_web(false).await; + let port = web.addrs().http.expect("HTTP listener is configured").port(); let app = web .routes() .route("/embedded", axum::routing::get(|| async { "embedded\n" })); - let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); - let handle = tokio::spawn(async move { - let _ = server_result_tx.send(web.serve(app).await); - }); - - wait_for_http(port, &mut server_result_rx).await; + let handle = tokio::spawn(async move { web.serve(app).await.expect("relay web server") }); let body = reqwest::get(format!("http://127.0.0.1:{port}/embedded")) .await @@ -419,7 +378,6 @@ async fn relay_web_serves_merged_routes() { /// A compile is not evidence any of that still handshakes. #[tokio::test] async fn relay_https_terminates_tls() { - let port = free_tcp_port(); let dir = tempfile::TempDir::new().expect("tempdir"); let key = rcgen::KeyPair::generate().expect("keypair"); @@ -432,22 +390,18 @@ async fn relay_https_terminates_tls() { let mut config = web::Config::default(); config.ws = false; - config.https.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + config.https.listen = Some("127.0.0.1:0".parse().expect("parse listen")); config.https.cert = vec![cert_path]; config.https.key = vec![key_path]; let web = build_web_with(config).await; + let port = web.addrs().https.expect("HTTPS listener is configured").port(); // Held past `serve`, which consumes the server: this is the whole point of // taking the handle up front. `Some` because a listener is configured; a relay // with neither HTTP nor HTTPS reports nothing rather than a permanent zero. let health = web.accept_health().expect("an HTTPS listener is configured"); - let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); - let handle = tokio::spawn(async move { - let _ = server_result_tx.send(web.run().await); - }); - - wait_for_http(port, &mut server_result_rx).await; + let handle = tokio::spawn(async move { web.run().await.expect("relay web server") }); let client = reqwest::Client::builder() .add_root_certificate(reqwest::Certificate::from_pem(cert.pem().as_bytes()).expect("parse root")) @@ -627,16 +581,19 @@ async fn two_publish_only_clients_coexist() { /// `main.rs` uses. Authenticates through the shared [`Auth`], here with fully /// public access (`--auth-public ""`) so no-JWT clients get the root. /// -/// Returns the QUIC socket the server bound, when it has one, so a caller that -/// asked for an ephemeral port can dial it. +/// 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. async fn spawn_accept_relay( config: moq_tokio::listen::Config, auth_config: auth::Config, -) -> (Option, tokio::task::JoinHandle<()>) { +) -> ( + Option, + Option, + tokio::task::JoinHandle<()>, +) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let server = config.init(Default::default()).expect("server init"); - let addr = server.local_addr().ok(); let auth = auth_config .init("test", &moq_tokio::tls::Connect::default()) @@ -644,6 +601,8 @@ async fn spawn_accept_relay( let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); let mut server = server.listen().await.expect("listen"); + let quic = server.local_addr().ok(); + let tcp = server.tcp_local_addr(); let handle = tokio::spawn(async move { let mut id = 0; @@ -658,40 +617,23 @@ async fn spawn_accept_relay( } }); - (addr, handle) + (quic, tcp, handle) } /// Stand up the relay listening only on a plain-TCP qmux `--server-bind` on a -/// free loopback port, with fully public auth (no-JWT => whole root). Returns +/// ephemeral loopback port, with fully public auth (no-JWT => whole root). Returns /// the port and an abort handle. async fn spawn_internal_relay() -> (u16, tokio::task::JoinHandle<()>) { - // Pick a free TCP port, then drop the probe so the listener can bind it. - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - // Stream-only: a TCP listener with no `--server-bind`, so no QUIC. let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + 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. let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; - let (_, handle) = spawn_accept_relay(config, auth_config).await; - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - if std::time::Instant::now() >= deadline { - panic!("internal listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - - (port, handle) + let (_, tcp, handle) = spawn_accept_relay(config, auth_config).await; + (tcp.expect("relay bound no TCP socket").port(), handle) } /// Connect a publisher and subscriber to a stream `--server-bind` over `tcp://` @@ -792,20 +734,7 @@ async fn spawn_internal_unix_relay() -> (std::path::PathBuf, tokio::task::JoinHa let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; - let (_, handle) = spawn_accept_relay(config, auth_config).await; - - // Wait for the socket file to appear. - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::UnixStream::connect(&path).await.is_ok() { - break; - } - if std::time::Instant::now() >= deadline { - panic!("internal Unix listener never became ready at {}", path.display()); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - + let (_, _, handle) = spawn_accept_relay(config, auth_config).await; (path, handle) } @@ -1009,8 +938,8 @@ async fn spawn_quic_relay() -> (std::net::SocketAddr, tokio::task::JoinHandle<() let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; - let (addr, handle) = spawn_accept_relay(config, auth_config).await; - (addr.expect("relay bound no QUIC socket"), handle) + let (quic, _, handle) = spawn_accept_relay(config, auth_config).await; + (quic.expect("relay bound no QUIC socket"), handle) } /// Raw QUIC has no request URI either, so `moqt://host:port/` only reaches the @@ -1057,31 +986,15 @@ async fn health_endpoint_reports_ok() { /// the TCP port and an abort handle. A no-JWT client gets the root for subscribing but /// no publish scope, so a publisher's role is rejected. async fn spawn_subscribe_only_relay() -> (u16, tokio::task::JoinHandle<()>) { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); // Subscribe-only public access: the root is granted for subscribing, never publishing. let mut auth_config = auth::Config::default(); auth_config.public_subscribe = vec![moq_auth::Pattern::all()]; - let (_, handle) = spawn_accept_relay(config, auth_config).await; - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - if std::time::Instant::now() >= deadline { - panic!("subscribe-only listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - - (port, handle) + let (_, tcp, handle) = spawn_accept_relay(config, auth_config).await; + (tcp.expect("relay bound no TCP socket").port(), handle) } /// A publisher whose token grants only subscribe scope is rejected during the @@ -1146,31 +1059,15 @@ async fn subscribe_only_public_accepts_subscriber_role() { /// The mirror of [`spawn_subscribe_only_relay`]: public access grants **publish only**, /// so a no-JWT client gets the root for publishing but no subscribe scope. async fn spawn_publish_only_relay() -> (u16, tokio::task::JoinHandle<()>) { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); // Publish-only public access: the root is granted for publishing, never subscribing. let mut auth_config = auth::Config::default(); auth_config.public_publish = vec![moq_auth::Pattern::all()]; - let (_, handle) = spawn_accept_relay(config, auth_config).await; - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - if std::time::Instant::now() >= deadline { - panic!("publish-only listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - - (port, handle) + let (_, tcp, handle) = spawn_accept_relay(config, auth_config).await; + (tcp.expect("relay bound no TCP socket").port(), handle) } /// The mirror of the publisher-reject test, covering the other branch of the role gate: From 33c9c9fb460b0fabb0bfcb628efc3feb74da41d2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:21:19 -0700 Subject: [PATCH 19/40] quest: SETUP AUTHORIZATION TOKEN becomes a standalone m1 quest (#4211) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 + quest/m1/auth/README.md | 3 ++ quest/m1/auth/request-token.md | 69 ++++++++++++++++++++++++++++++++++ quest/m1/auth/token-in-band.md | 2 + quest/m1/setup-token.md | 67 +++++++++++++++++++++++++++++++++ quest/m2/cat/README.md | 21 ++++++----- quest/m2/cat/present.md | 2 +- quest/m2/cat/setup-token.md | 67 --------------------------------- quest/m2/cat/verify.md | 2 +- 9 files changed, 156 insertions(+), 78 deletions(-) create mode 100644 quest/m1/auth/request-token.md create mode 100644 quest/m1/setup-token.md delete mode 100644 quest/m2/cat/setup-token.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 0ed96c21b1..8b4591d324 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -53,6 +53,7 @@ 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 - [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 diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index a7de50b497..7042f61feb 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -98,6 +98,9 @@ existing lite-06 ALPN. loses access resets with a dedicated UNAUTHORIZED stream code - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access +- [Request tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION + TOKEN` on a moq-transport request authorizes that request when the session + 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 - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md new file mode 100644 index 0000000000..c39486256a --- /dev/null +++ b/quest/m1/auth/request-token.md @@ -0,0 +1,69 @@ +# [M] A token on a request authorizes that request + +## Goal + +A moq-transport peer that is not on moq-dev can present or refresh a +credential the draft-17+ way, with the `AUTHORIZATION TOKEN` parameter +(`0x03`) on SUBSCRIBE, REQUEST_UPDATE, PUBLISH, FETCH, PUBLISH_NAMESPACE, +SUBSCRIBE_NAMESPACE, TRACK_STATUS, or any other request that carries +parameters. A request is authorized by the session's grant first; when that +does not cover it, by the token on the request; with neither it is refused +`UNAUTHORIZED`. The token's grant covers only the request it rode on and +lives exactly as long as that request, and a REQUEST_UPDATE carrying a new +token replaces it, which is how a peer refreshes. It scopes by path, never +by method. Today the strict decoder on newer drafts fails the whole message +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 + 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 + path the legacy drafts use, which ignores it. +- Fallback only: a request the session grant already covers is served + without verifying its token. Otherwise its token becomes an + `auth::Request` on the session's `auth::Handle`, the seam an AUTH stream's + token takes, marked as belonging to that one request (its path and kind). + The `Grant` the acceptor answers is checked against that request alone, + never joins the session union, and ends when the request ends. With no + `requests()` consumer the default acceptor rejects a non-empty token + `Unsupported`, so an app that does not opt in refuses every request that + needs one. +- The request waits on its token's verdict before it is resolved. A refused + or insufficient token refuses the request with the verdict's code + (`UNAUTHORIZED`, `EXPIRED_AUTH_TOKEN`, `MALFORMED_AUTH_TOKEN`, or + `NOT_SUPPORTED`) through `to_code` for the draft; the session continues. +- Refresh: a REQUEST_UPDATE with a token verifies it the same way and, once + accepted, replaces the request's grant; a refused one leaves the old grant + until it lapses. When a request's grant expires or is revoked, that request + alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant + that shrinks cancels the requests it covered, as for any request, through + [Origin narrowing](/quest/m1/origin-narrowing.md). +- 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. +- `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 + 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. + +## 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 6b9077aaf1..2c38ad0345 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -77,3 +77,5 @@ 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 new file mode 100644 index 0000000000..f90d4f5d97 --- /dev/null +++ b/quest/m1/setup-token.md @@ -0,0 +1,67 @@ +# [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 53ae09c224..8460cd1520 100644 --- a/quest/m2/cat/README.md +++ b/quest/m2/cat/README.md @@ -15,10 +15,10 @@ the URL, and the JWT stays the URL credential. Boundaries decided while planning: -- SETUP only. A token on SUBSCRIBE, PUBLISH, FETCH, or any other request is - refused; per-operation authorization is not planned. The relay authorizes - per session through origin scopes, and a token that arrives after SETUP is - the [in-band auth](/quest/m1/auth/README.md) line's problem. +- SETUP only. Per-operation authorization is not planned: the relay + authorizes by path through origin scopes. A token on any other request is + [Request tokens](/quest/m1/auth/request-token.md)'s path-scoped fallback + for that request, and a CAT's per-message scope is never enforced. - A token whose `moqt` scope restricts the track name is refused naming the token. Grants are broadcast-path patterns and tracks are not scoped. - Core CWT claims plus `moqt` and `moqt-reval` are enforced; any other CAT @@ -41,7 +41,8 @@ Boundaries decided while planning: ## Plan -Order: the wire first so a token reaches the auth server; verification; +Order: the wire first so a token reaches the auth server, which is the +standalone [Setup token](/quest/m1/setup-token.md) quest; verification; then our clients present one. Everything rides `moq_auth::Request` and `moq auth serve`, which shipped on dev. The JWT types sit at the crate root; the verify quest moves them under `moq_auth::jwt` so `cat` is a sibling @@ -49,18 +50,20 @@ module rather than a set of prefixed names. ## Quests -- [Setup token](/quest/m2/cat/setup-token.md) - the SETUP `AUTHORIZATION - TOKEN` option is decoded on both IETF stacks, reaches `moq_auth::Request` - as `token`, and is refused on every other message - [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 check the format - [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 - SETUP, which this line refuses on the IETF wire; its [Token in + SETUP, including on IETF requests; its [Token in band](/quest/m1/auth/token-in-band.md) quest owns the client token configuration a CAT joins diff --git a/quest/m2/cat/present.md b/quest/m2/cat/present.md index b342d69d32..cb318d20e1 100644 --- a/quest/m2/cat/present.md +++ b/quest/m2/cat/present.md @@ -34,7 +34,7 @@ Public API: additive on `moq-tokio` and `js/net`. Wire: none. ## Required -- [Setup token](/quest/m2/cat/setup-token.md) - the server-side exposure +- [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 diff --git a/quest/m2/cat/setup-token.md b/quest/m2/cat/setup-token.md deleted file mode 100644 index de68919466..0000000000 --- a/quest/m2/cat/setup-token.md +++ /dev/null @@ -1,67 +0,0 @@ -# [M] The SETUP AUTHORIZATION TOKEN option reaches the auth server - -## Goal - -A moq-transport peer's SETUP `AUTHORIZATION TOKEN` option (type `0x03`, -draft-14 through the newest supported draft) is decoded by `rs/moq-net` and -`js/net` into a token type and value, exposed on the accepted request, and -forwarded by the relay in `moq_auth::Request` as `token`, so an auth server -can verify it. The same parameter on any other message is refused. Today the -option is stored as opaque bytes and ignored, and the message parameter fails -decode as an unknown key. - -## 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. This is the type [Token in - band](/quest/m1/auth/token-in-band.md) writes for a configured JWT - (`USE_VALUE`, type 0), so the two quests share one struct and one encoder; - whichever lands first adds them. 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 - [Present](/quest/m2/cat/present.md) has nothing to add on the wire. -- Message parameter `0x03` on SUBSCRIBE, REQUEST_UPDATE, PUBLISH, FETCH, - PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE, TRACK_STATUS, and every other - message whose decoder reads parameters decodes and is refused with the - request's error path (`Unsupported`, mapped through `to_code` for the - draft) naming the parameter, so the session survives and the log says why. - Both decoder families change: the strict `decode_params!` path on newer - drafts, which rejects the key today, and the generic KVP path the legacy - drafts use, which keeps an odd key and lets the caller ignore it. -- `moq_net::Request::token() -> Option<&setup::Token>` on the accepted - request, carried through the `Legacy` (draft-14 to 16) and `PeerSetup` - (draft-17+) paths; `moq_tokio::server::Request` 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 where it builds the request, 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=`, refuses a kind it - does not know naming the code, and hands `0x01` to - [Verify](/quest/m2/cat/verify.md). 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, and the - `SetupOption.AuthorizationToken` handling in `handshake.ts`; the JS accept - side exposes the token on its request the same way. -- Docs: `doc/concept` on the IETF binding gains the option and the refusal; - `doc/lib/rs/moq-auth.md` gains the request field. -- 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; a token on SUBSCRIBE is refused and the - session continues, on one legacy draft and one strict draft; the relay forwards the bytes to a wiremock auth server - byte for byte; a lite session reports none. - -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` diff --git a/quest/m2/cat/verify.md b/quest/m2/cat/verify.md index 1966f630c3..29a03c3126 100644 --- a/quest/m2/cat/verify.md +++ b/quest/m2/cat/verify.md @@ -85,5 +85,5 @@ gain flags. Wire: none. ## Required -- [Setup token](/quest/m2/cat/setup-token.md) - the token reaches the +- [Setup token](/quest/m1/setup-token.md) - the token reaches the server's request From a0dc19748384e6c5a556dfdd250e6137ee3eae80 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:30:45 -0700 Subject: [PATCH 20/40] test: bind :0 up front instead of probing for a free port (#4212) Co-authored-by: Claude Opus 5.5 --- rs/moq-hls/src/server/routes.rs | 86 +++++++++++++--------------- rs/moq-relay/tests/goaway_cluster.rs | 78 ++++++++----------------- rs/moq-srt/src/dial.rs | 19 +++--- rs/moq-srt/src/server.rs | 36 ++++++++---- rs/moq-tokio/src/connection.rs | 74 +++++++++++++----------- rs/moq-tokio/src/server.rs | 31 +--------- 6 files changed, 138 insertions(+), 186 deletions(-) diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index a01195b488..324fdd80f8 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -484,59 +484,51 @@ mod tests { } async fn lite_pair_pub(config: impl Into) -> LitePair { - use std::net::TcpListener; - let lite: moq_net::Version = "moq-lite-05".parse().expect("lite version"); let pub_origin = moq_tokio::origin::spawn_config(config.into()); let sub_origin = moq_tokio::origin::spawn(); - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut listen = moq_tokio::listen::Config::default(); - listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("addr")); - listen.version = vec![lite]; - let Ok(server) = listen.init(Default::default()) else { - continue; - }; - let Ok(mut server) = server.listen().await else { - continue; - }; - - let pub_for_accept = pub_origin.clone(); - let accept = tokio::spawn(async move { - let request = server.accept().await.expect("accept"); - let session = request - .with_publisher(&pub_for_accept) - .ok() - .await - .expect("publisher session"); - let _ = session.closed().await; - }); - - let mut connect = moq_tokio::connect::Config::default(); - connect.version = vec![lite]; - let client = connect - .init(Default::default()) - .expect("client") - .with_subscriber(sub_origin.clone()) - .with_reconnect(false); - let url: url::Url = format!("tcp://127.0.0.1:{port}/").parse().expect("url"); - let connection = tokio::time::timeout(TIMEOUT, client.connect(url).established()) + let mut listen = moq_tokio::listen::Config::default(); + listen.tcp.bind = Some("127.0.0.1:0".parse().expect("addr")); + listen.version = vec![lite]; + let mut server = listen + .init(Default::default()) + .expect("server") + .listen() + .await + .expect("listen"); + let addr = server.tcp_local_addr().expect("tcp listener bound"); + + let pub_for_accept = pub_origin.clone(); + let accept = tokio::spawn(async move { + let request = server.accept().await.expect("accept"); + let session = request + .with_publisher(&pub_for_accept) + .ok() .await - .expect("connect timed out") - .expect("connect"); - - return LitePair { - pub_origin, - sub_origin, - _connection: connection, - accept, - }; + .expect("publisher session"); + let _ = session.closed().await; + }); + + let mut connect = moq_tokio::connect::Config::default(); + connect.version = vec![lite]; + let client = connect + .init(Default::default()) + .expect("client") + .with_subscriber(sub_origin.clone()) + .with_reconnect(false); + let url: url::Url = format!("tcp://{addr}/").parse().expect("url"); + let connection = tokio::time::timeout(TIMEOUT, client.connect(url).established()) + .await + .expect("connect timed out") + .expect("connect"); + + LitePair { + pub_origin, + sub_origin, + _connection: connection, + accept, } - panic!("could not bind a free TCP port after 20 attempts"); } async fn oneshot(app: axum::Router, uri: &str) -> axum::http::Response { diff --git a/rs/moq-relay/tests/goaway_cluster.rs b/rs/moq-relay/tests/goaway_cluster.rs index c2b6a061c5..d9cb9511c6 100644 --- a/rs/moq-relay/tests/goaway_cluster.rs +++ b/rs/moq-relay/tests/goaway_cluster.rs @@ -7,7 +7,6 @@ //! of the cluster origin observes contiguous groups and no unannounce. use std::collections::BTreeSet; -use std::net::TcpListener; use std::time::Duration; use moq_relay::{ @@ -54,23 +53,19 @@ where .expect("test thread panicked"); } -/// Bind a stream-only moq server to a free loopback TCP port, retrying if the -/// port is claimed in the window between the free-port probe and the real bind. -/// Returns the chosen port and the initialized server. Avoids the spurious -/// `init()` panic that a probe/drop/bind race can cause under parallel tests. -fn bind_free_tcp_server() -> (u16, moq_tokio::Server) { - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); - if let Ok(server) = config.init(Default::default()) { - return (port, server); - } - } - panic!("could not bind a free TCP port after 20 attempts"); +/// A stream-only moq listener on an ephemeral loopback TCP port, already +/// accepting, so a dial can follow immediately. Returns the port and listener. +async fn listen_tcp() -> (u16, moq_tokio::Listener) { + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + let server = config + .init(Default::default()) + .expect("server init") + .listen() + .await + .expect("listen"); + let port = server.tcp_local_addr().expect("tcp listener bound").port(); + (port, server) } #[test] @@ -98,8 +93,7 @@ async fn drain_session_with_zero_timeout_closes_at_once_inner() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let origin = moq_tokio::origin::spawn(); - let (port, mut accepted, _handle) = spawn_upstream(origin); - wait_listening(port).await; + let (port, mut accepted, _handle) = spawn_upstream(origin).await; let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); @@ -132,19 +126,18 @@ fn cluster_reconnects_on_empty_uri_goaway() { /// A fake sibling upstream: a stream-only moq server publishing `origin`'s /// broadcasts to whoever connects. Returns its port, a receiver yielding each /// accepted [`moq_net::Session`] (so the test can drain it), and the task. -fn spawn_upstream( +async fn spawn_upstream( origin: moq_net::origin::Producer, ) -> ( u16, tokio::sync::mpsc::UnboundedReceiver, tokio::task::JoinHandle<()>, ) { - let (port, server) = bind_free_tcp_server(); + let (port, mut server) = listen_tcp().await; let (accepted_tx, accepted_rx) = tokio::sync::mpsc::unbounded_channel(); let handle = tokio::spawn(async move { - let mut server = server.listen().await.expect("listen"); while let Some(request) = server.accept().await { // Serve the shared origin bidirectionally, like a relay peer would. let scratch = moq_tokio::origin::spawn(); @@ -162,21 +155,6 @@ fn spawn_upstream( (port, accepted_rx, handle) } -/// Wait for the upstream's TCP listener to come up. -async fn wait_listening(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - assert!( - std::time::Instant::now() < deadline, - "upstream never became ready on port {port}" - ); - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - /// An upstream GOAWAY with a redirect migrates the cluster dial to the sibling. /// The draining session keeps serving through the handover window, and the path /// never retracts on the cluster origin (route re-pricing and the sibling's @@ -191,10 +169,8 @@ async fn cluster_migrates_on_upstream_goaway_inner() { broadcast.announce(Default::default()).expect("create broadcast"); let track = broadcast.create_track("video", None).expect("create track"); - let (port_a, mut accepted_a, _handle_a) = spawn_upstream(upstream_origin.clone()); - let (port_b, mut accepted_b, _handle_b) = spawn_upstream(upstream_origin.clone()); - wait_listening(port_a).await; - wait_listening(port_b).await; + let (port_a, mut accepted_a, _handle_a) = spawn_upstream(upstream_origin.clone()).await; + let (port_b, mut accepted_b, _handle_b) = spawn_upstream(upstream_origin.clone()).await; // ── the relay cluster under test, dialing sibling A ───────────── let mut client_config = moq_tokio::connect::Config::default(); @@ -306,8 +282,7 @@ async fn spawn_relay_with_upstream( ) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let (port, server) = bind_free_tcp_server(); - let mut server = server.listen().await.expect("listen"); + let (port, mut server) = listen_tcp().await; // Fully public auth: any no-JWT stream client gets the whole root. let mut auth_config = auth::Config::default(); @@ -352,7 +327,6 @@ async fn spawn_relay_with_upstream( } }); - wait_listening(port).await; (port, handle) } @@ -388,8 +362,7 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { broadcast.announce(Default::default()).expect("create broadcast"); let track = broadcast.create_track("video", None).expect("create track"); - let (top_port, mut top_accepted, _top_handle) = spawn_upstream(top_origin.clone()); - wait_listening(top_port).await; + let (top_port, mut top_accepted, _top_handle) = spawn_upstream(top_origin.clone()).await; let top_url = format!("tcp://127.0.0.1:{top_port}/"); // ── MID-B: full relay clustered to TOP, up for the whole test ─────── @@ -420,8 +393,7 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { .await .expect("TOP accept channel closed"); - let (mid_a_port, mut mid_a_accepted, _mid_a_handle) = spawn_upstream(mid_a_origin.clone()); - wait_listening(mid_a_port).await; + let (mid_a_port, mut mid_a_accepted, _mid_a_handle) = spawn_upstream(mid_a_origin.clone()).await; let mid_a_url = format!("tcp://127.0.0.1:{mid_a_port}/"); // ── BOTTOM: full relay clustered to MID-A ─────────────────────────── @@ -660,8 +632,7 @@ async fn cluster_reconnects_on_empty_uri_goaway_inner() { broadcast.announce(Default::default()).expect("create broadcast"); let track = broadcast.create_track("video", None).expect("create track"); - let (port, mut accepted, _handle) = spawn_upstream(upstream_origin.clone()); - wait_listening(port).await; + let (port, mut accepted, _handle) = spawn_upstream(upstream_origin.clone()).await; let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); @@ -775,11 +746,10 @@ async fn goaway_handover_is_enforced_while_the_replacement_dial_hangs_inner() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let upstream_origin = moq_tokio::origin::spawn(); - let (port, mut accepted, _handle) = spawn_upstream(upstream_origin); - wait_listening(port).await; + let (port, mut accepted, _handle) = spawn_upstream(upstream_origin).await; // Accepts the connection, then never speaks MoQ: the dial hangs in the handshake. - let black_hole = TcpListener::bind("127.0.0.1:0").expect("bind black hole"); + let black_hole = std::net::TcpListener::bind("127.0.0.1:0").expect("bind black hole"); let black_hole_port = black_hole.local_addr().expect("local addr").port(); let _black_hole = std::thread::spawn(move || { // Hold every accepted socket open and idle for the life of the test. diff --git a/rs/moq-srt/src/dial.rs b/rs/moq-srt/src/dial.rs index 95b51faf4d..6c51666405 100644 --- a/rs/moq-srt/src/dial.rs +++ b/rs/moq-srt/src/dial.rs @@ -165,11 +165,11 @@ mod tests { use super::*; use crate::server::{Reject, Request, Server}; - /// Grab a free UDP port by binding `:0` and releasing it. Racy in principle, but - /// the window before the SRT server rebinds it is tiny; good enough for a test. - async fn free_udp_addr() -> SocketAddr { - let sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - sock.local_addr().unwrap() + /// An SRT server on an ephemeral loopback port. + async fn loopback() -> (Server, SocketAddr) { + let server = Server::bind("127.0.0.1:0".parse().unwrap(), None).await.unwrap(); + let addr = server.local_addr(); + (server, addr) } /// Loopback: dial the crate's own server with `m=publish`. The server classifies it @@ -179,8 +179,7 @@ mod tests { /// integration coverage; the TS bridge itself is shared with the tested server path.) #[tokio::test] async fn publish_caller_connects_and_routes() { - let addr = free_udp_addr().await; - let mut server = Server::bind(addr, None).await.unwrap(); + let (mut server, addr) = loopback().await; // Server accepts the publish so the caller's handshake completes; it ingests into // a throwaway origin and returns the routed direction + resource. @@ -219,8 +218,7 @@ mod tests { /// routes to a server [`Request::Subscribe`]. #[tokio::test] async fn request_caller_connects_and_routes() { - let addr = free_udp_addr().await; - let mut server = Server::bind(addr, None).await.unwrap(); + let (mut server, addr) = loopback().await; // Empty origin: the subscribe accept parks waiting for the broadcast, which is // fine -- the caller still connects, and the test aborts the wait. @@ -260,8 +258,7 @@ mod tests { /// caller's handshake completes stops the listener that still owes it the /// rejection packet, and the caller times out instead. async fn rejected(mode: Mode, reason: Reject, code: i32) { - let addr = free_udp_addr().await; - let mut server = Server::bind(addr, None).await.unwrap(); + let (mut server, addr) = loopback().await; let client = Client::new(addr, "cam0"); let (_, err) = tokio::join!( diff --git a/rs/moq-srt/src/server.rs b/rs/moq-srt/src/server.rs index a3c2dd5439..ddbab0d555 100644 --- a/rs/moq-srt/src/server.rs +++ b/rs/moq-srt/src/server.rs @@ -182,6 +182,7 @@ pub struct Server { /// Held to keep the listener (and its UDP socket) alive for the server's lifetime. _listener: SrtListener, incoming: SrtIncoming, + local_addr: SocketAddr, /// The negotiated SRT receive latency, reused as the egress skip threshold on /// each [`Subscribe`] (see [`crate::ts::Subscriber::new`]). latency: Duration, @@ -195,18 +196,34 @@ impl Server { /// threshold for [`Subscribe`] requests. pub async fn bind(addr: SocketAddr, latency: impl Into>) -> Result { let latency = latency.into().unwrap_or(DEFAULT_LATENCY); + + // srt-tokio refuses to listen on port 0 and never reports the port it bound, + // so bind the UDP socket first (with srt-tokio's own buffer sizing) and hand + // it over under its resolved address. + let mut options = SocketOptions::default(); + options.connect.local = addr; + let socket = srt_tokio::bind_socket(&options).await?; + let local_addr = socket.local_addr()?; + let (listener, incoming) = SrtListener::builder() + .socket(socket) .latency(latency) .set(configure_buffers) - .bind(addr) + .bind(local_addr) .await?; Ok(Self { _listener: listener, incoming, + local_addr, latency, }) } + /// The address the listener is bound to, with any `:0` port resolved. + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } + /// Wait for the next connection that wants to publish or subscribe. /// /// Connections whose stream id can't be routed (no usable resource name) are @@ -582,7 +599,6 @@ fn parse_stream_id(stream_id: Option<&StreamId>) -> Option<(String, ConnectionMo mod tests { use super::*; use bytes::Bytes; - use std::net::SocketAddr; use std::time::Duration; #[test] @@ -623,13 +639,12 @@ mod tests { /// (see [`configure_buffers`]). #[tokio::test] async fn accepted_socket_sends_a_burst_larger_than_srt_tokio_default() { - let probe = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = probe.local_addr().unwrap(); - drop(probe); - // TSBPD holds every payload for the negotiated latency before releasing it, so // ask for a short one: this asserts buffering, not delay. - let mut server = Server::bind(addr, Duration::from_millis(50)).await.unwrap(); + let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), Duration::from_millis(50)) + .await + .unwrap(); + let addr = server.local_addr(); let caller = tokio::spawn(async move { SrtSocket::builder() .call(addr, Some("#!::r=buffer-test,m=request")) @@ -881,11 +896,8 @@ mod tests { } } - let probe = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = probe.local_addr().unwrap(); - drop(probe); - - let mut server = Server::bind(addr, LATENCY).await.unwrap(); + let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), LATENCY).await.unwrap(); + let addr = server.local_addr(); let caller = tokio::spawn(async move { SrtSocket::builder() .call(addr, Some("#!::r=rewind,m=request")) diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 13f3fb4e2a..2bae530190 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1729,43 +1729,47 @@ mod tests { value.parse().expect("valid url") } - /// A loopback TCP port with nothing on it, which refuses instantly. + /// A loopback TCP port with nothing listening on it, which refuses instantly. /// /// A dead address that *refuses* rather than black-holes is what keeps these /// tests fast and deterministic: the walk itself is what's under test, and /// bounding a black-holed attempt is covered by /// [`only_a_candidate_with_a_fallback_is_bounded`] as a pure function. + /// + /// The returned socket is bound but never listens, so the port refuses while + /// staying reserved: no other listener (or an ephemeral self-connect) can take + /// it mid-test. Hold it for as long as the URL is dialed. #[cfg(feature = "tcp")] - fn refused() -> Url { - let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - url(&format!("tcp://127.0.0.1:{port}/")) + fn refused() -> (tokio::net::TcpSocket, Url) { + let socket = tokio::net::TcpSocket::new_v4().expect("tcp socket"); + socket.bind("127.0.0.1:0".parse().unwrap()).expect("bind"); + let addr = socket.local_addr().expect("local addr"); + (socket, url(&format!("tcp://{addr}/"))) } - /// A bound stream listener, the URL that reaches it, and a client for it. + /// A bound stream listener and the URL that reaches it. + #[cfg(feature = "tcp")] + async fn live() -> (crate::Listener, Url) { + let mut config = crate::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + let listener = config + .init(Default::default()) + .expect("build server") + .listen() + .await + .expect("listen"); + let addr = listener.tcp_local_addr().expect("tcp listener bound"); + (listener, url(&format!("tcp://{addr}/"))) + } + + /// A client that trusts the self-signed [`live`] listener. #[cfg(feature = "tcp")] - fn pair() -> (crate::Server, Url, Client) { + fn client() -> Client { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - for _ in 0..20 { - let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = crate::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("valid address")); - let Ok(server) = config.init(Default::default()) else { - continue; - }; - - let mut config = crate::connect::Config::default(); - config.tls.insecure = Some(true); - let client = config.init(Default::default()).expect("build client"); - - return (server, url(&format!("tcp://127.0.0.1:{port}/")), client); - } - panic!("could not bind a free TCP port after 20 attempts"); + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + config.init(Default::default()).expect("build client") } fn shared() -> Shared { @@ -1783,11 +1787,13 @@ mod tests { #[cfg(feature = "tcp")] #[tokio::test] async fn dial_any_walks_past_the_dead_addresses() { - let (server, live, client) = pair(); - let mut server = server.listen().await.expect("listen"); + let client = client(); + let (mut server, live) = live().await; tokio::spawn(async move { while server.accept().await.is_some() {} }); - let addrs = Addrs::collect([refused(), refused(), live.clone()]).expect("not empty"); + let (_a, dead_a) = refused(); + let (_b, dead_b) = refused(); + let addrs = Addrs::collect([dead_a, dead_b, live.clone()]).expect("not empty"); let shared = shared(); let mut draining = None; @@ -1805,9 +1811,11 @@ mod tests { #[cfg(feature = "tcp")] #[tokio::test] async fn dial_any_reports_failure_when_nothing_answers() { - let (_server, _live, client) = pair(); + let client = client(); - let addrs = Addrs::collect([refused(), refused()]).expect("not empty"); + let (_a, dead_a) = refused(); + let (_b, dead_b) = refused(); + let addrs = Addrs::collect([dead_a, dead_b]).expect("not empty"); let shared = shared(); let mut draining = None; @@ -1833,8 +1841,8 @@ mod tests { async fn dialing_never_logs_the_credential() { const SECRET: &str = "b91d7fe20c4a"; - let (_server, _live, client) = pair(); - let mut target = refused(); + let client = client(); + let (_dead, mut target) = refused(); target.set_path(&format!("/.cluster/{SECRET}")); let addrs = Addrs::new(target); diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index d71a05f658..dacd68c671 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1506,30 +1506,6 @@ mod tests { std::net::TcpListener::bind(("127.0.0.1", port)).expect("the tcp port must be free again"); } - /// Closing consumes the listener and immediately releases its stream sockets. - #[cfg(feature = "tcp")] - #[tokio::test] - async fn close_releases_stream_listeners() { - let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe"); - let addr = probe.local_addr().expect("probe addr"); - drop(probe); - - let mut config = crate::listen::Config::default(); - config.tcp.bind = Some(addr); - let listener = Config { - listen: config, - ..Default::default() - } - .init() - .expect("stream-only server") - .listen() - .await - .expect("listen"); - - listener.close().await; - std::net::TcpListener::bind(addr).expect("close must release the tcp port"); - } - /// The stream listeners must hand accepted sessions to the *configured* /// [`moq_net::Server`]. [`Server::serve_publish`] sets the publisher there /// rather than on the request, so a session that handshakes against any other @@ -1632,12 +1608,8 @@ mod tests { #[cfg(feature = "tcp")] #[tokio::test] async fn close_releases_stream_listener_socket() { - let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = probe.local_addr().unwrap(); - drop(probe); - let mut config = crate::listen::Config::default(); - config.tcp.bind = Some(addr); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); let server = Config { listen: config, ..Default::default() @@ -1645,6 +1617,7 @@ mod tests { .init() .expect("stream-only server"); let listener = server.listen().await.expect("listen"); + let addr = listener.tcp_local_addr().expect("tcp listener bound"); assert!(tokio::net::TcpListener::bind(addr).await.is_err(), "listener is bound"); listener.close().await; From ad48f03b6e3965602ced89b8a5a3f4fdb0613a4a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:31:56 -0700 Subject: [PATCH 21/40] fix(net): resolve a lite fetch only once the publisher answers (#4164) Co-authored-by: Claude Opus 5.5 --- doc/bin/cli.md | 4 ++-- doc/bin/relay/http.md | 2 +- quest/m1/README.md | 1 - quest/m1/fetch-missing-group.md | 18 ----------------- rs/moq-cli/src/fetch.rs | 19 +++++++++--------- rs/moq-net/src/coding/reader.rs | 4 ++-- rs/moq-net/src/lite/subscriber.rs | 32 +++++++++++++++++++++++++++++++ rs/moq-net/src/model/group.rs | 6 ++++-- rs/moq-net/src/model/track.rs | 5 +++-- rs/moq-relay/src/fetch.rs | 5 +++-- rs/moq-relay/src/web.rs | 5 ++++- 11 files changed, 61 insertions(+), 40 deletions(-) delete mode 100644 quest/m1/fetch-missing-group.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 5f08497161..8aca668760 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -187,8 +187,8 @@ zero-based `frame` and padded standard base64. `` is the literal track name. `/fetch` splits its path on the last `/`, so the two agree only for names without one. Fetch only dials `--connect`, and refuses a listener or cluster flag. It gives up after 30 seconds, as `/fetch` -does, and exits non-zero when the broadcast or group is not found, the relay -refuses, or the deadline passes. +does, and exits non-zero when the broadcast or group is not found (before +writing anything), the relay refuses, or the deadline passes. ## Multiple stages diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md index 28cad8de5c..80460efb30 100644 --- a/doc/bin/relay/http.md +++ b/doc/bin/relay/http.md @@ -13,7 +13,7 @@ operational ones that must stay private. | Endpoint | Returns | | --- | --- | | `GET /announced/` | Broadcasts announced under the prefix. | -| `GET /fetch//?group=N` | One group from the cache, the latest by default. Useful for catch-up and debugging. | +| `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/quest/m1/README.md b/quest/m1/README.md index 8b4591d324..6c95748db6 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,7 +17,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests -- [Missing fetch group](/quest/m1/fetch-missing-group.md) - HTTP /fetch answers 404 and `moq fetch` fails cleanly for a group the track lacks - [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them diff --git a/quest/m1/fetch-missing-group.md b/quest/m1/fetch-missing-group.md deleted file mode 100644 index e144de69aa..0000000000 --- a/quest/m1/fetch-missing-group.md +++ /dev/null @@ -1,18 +0,0 @@ -# [S] Fetching a missing group fails before any output - -## Goal - -HTTP `/fetch//?group=N` answers 404 for a group the track -does not have, and `moq fetch --group N` exits with a clean "not found" -before writing anything. Today the lookup hands back a group consumer that -only fails on its first frame read, so the endpoint answers 200 with a -cut-off body. - -## Plan - -- Resolve whether the group exists before the response starts, in the - shared lookup the relay and the CLI both use. Where the check belongs (the - relay helper or a `moq-net` track consumer method) is the implementer's - call; propose it in the PR. -- Test: a missing group is a 404 over HTTP and a non-zero `moq fetch` exit - with no stdout; an existing group is byte-identical to today. diff --git a/rs/moq-cli/src/fetch.rs b/rs/moq-cli/src/fetch.rs index 11cfc90258..fad7b7862b 100644 --- a/rs/moq-cli/src/fetch.rs +++ b/rs/moq-cli/src/fetch.rs @@ -86,7 +86,6 @@ async fn fetch( None => format!("no group of `{}` found", args.track), })?; - // A missing sequence can resolve to a group that fails on its first read. let sequence = group.sequence; let mut index = 0; while let Some(frame) = group @@ -209,13 +208,13 @@ mod tests { (result, out) } - /// The relay's HTTP `/fetch` body for the same group. - async fn curl(&self, query: &str) -> Vec { + /// The relay's HTTP `/fetch` status and body for the same group. + async fn curl(&self, query: &str) -> (u16, Vec) { let response = reqwest::get(format!("http://{}/fetch/demo/data{query}", self.http)) .await .expect("HTTP fetch"); - assert_eq!(response.status(), 200); - response.bytes().await.expect("HTTP body").to_vec() + let status = response.status().as_u16(); + (status, response.bytes().await.expect("HTTP body").to_vec()) } } @@ -254,7 +253,7 @@ mod tests { let (result, out) = fixture.fetch(&["data", "--group", "1"], TIMEOUT).await; result.expect("fetch"); assert_eq!(out, frames(1).concat()); - assert_eq!(out, fixture.curl("?group=1").await); + assert_eq!(fixture.curl("?group=1").await, (200, out)); } /// No `--group` reads the newest group, as `/fetch` does by default. @@ -266,9 +265,11 @@ mod tests { let (result, out) = fixture.fetch(&["data"], TIMEOUT).await; result.expect("fetch"); assert_eq!(out, frames(2).concat()); - assert_eq!(out, fixture.curl("").await); + assert_eq!(fixture.curl("").await, (200, out)); } + /// A missing sequence fails the lookup itself, before any output, as `/fetch` + /// answers 404 rather than starting a body. #[tokio::test] async fn a_missing_sequence_fails() { let _env = EnvGuard::clear(ENV); @@ -276,9 +277,9 @@ mod tests { let (result, out) = fixture.fetch(&["data", "--group", "99"], TIMEOUT).await; let err = result.expect_err("group 99 does not exist"); - let err = format!("{err:#}"); - assert!(err.contains("group 99") && err.contains("not found"), "{err}"); + assert_eq!(err.to_string(), "group 99 of `data` not found", "{err:#}"); assert!(out.is_empty()); + assert_eq!(fixture.curl("?group=99").await, (404, Vec::new())); } /// A track with no group never resolves "newest", so the deadline ends it. diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 2005bd11ae..12bf76bf5f 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -227,8 +227,8 @@ impl Reader { Poll::Ready(Ok(())) } - /// Poll for whether data is available in the buffer or stream. - fn poll_has_more(&mut self, cx: &mut Context<'_>) -> Poll> { + /// Poll for whether data is available in the buffer or stream: `false` once it finishes. + pub(crate) fn poll_has_more(&mut self, cx: &mut Context<'_>) -> Poll> { if !self.buffer.is_empty() { return Poll::Ready(Ok(true)); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c0ab106215..ca7bff6ba4 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -3507,6 +3507,12 @@ enum FetchRunState { stream: Stream, frame_start: u64, }, + /// Flushed; waiting for the publisher to answer before accepting. + Answer { + request: Option, + stream: Stream, + frame_start: u64, + }, Ingest { stream: Stream, producer: group::Producer, @@ -3617,7 +3623,33 @@ impl kio::Task for FetchServeRun { else { unreachable!() }; + self.state = FetchRunState::Answer { + request, + stream, + frame_start, + }; + } + FetchRunState::Answer { stream, .. } => { + // Lite has no FETCH_OK: a publisher without the group resets the + // stream instead. Accepting before the first byte (or a FIN, for an + // empty group) would resolve every joined `fetch_group` to a group + // that only fails on its first read, so wait for the answer. + let answered = ready!(stream.reader.poll_has_more(&mut cx)); + let FetchRunState::Answer { + request, + stream, + frame_start, + } = std::mem::replace(&mut self.state, FetchRunState::Done) + else { + unreachable!() + }; let request = request.expect("request pending"); + if let Err(err) = answered { + tracing::debug!(track = %self.serve.name, group = self.group, %err, "fetch refused"); + stream.writer.abort(&err); + request.reject(err); + return Poll::Ready(()); + } // Make the group available (resolving the downstream fetch) and fill // it. The track::Info only takes effect if the track isn't accepted yet diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 0205ae10b4..e9f3a4bbda 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -1804,8 +1804,10 @@ impl Fetch { /// The handler fulfills it by calling [`Self::accept`], which inserts the group /// into the track cache (resolving every [`track::Consumer::fetch_group`] that joined the /// attempt) and returns a [`Producer`] to fill. A relay typically opens a wire -/// FETCH, reads FETCH_OK, then accepts. The request carries its own producer handle, -/// so it works the same whether or not the track has been accepted yet. +/// FETCH and waits for the publisher to answer before accepting, so a group the +/// publisher lacks is rejected rather than accepted and then aborted. The request +/// carries its own producer handle, so it works the same whether or not the track +/// has been accepted yet. pub struct Request { pub(crate) state: kio::Producer, pub(crate) fetch: kio::Shared, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 6cfda585dc..8ac10f45b9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2609,8 +2609,9 @@ impl Consumer { /// or `group::Fetch::default()`. /// /// The returned future resolves to [`Error::NotFound`] when the group can never be served - /// (past the final sequence, or no [`Dynamic`] on the track), or the track's abort error - /// if it's already closed. Concurrent fetches for the same sequence coalesce onto one + /// (past the final sequence, or no [`Dynamic`] on the track), the handler's rejection + /// (a relay's upstream miss is [`StreamError::NotFound`](crate::StreamError::NotFound)), + /// or the track's abort error if it's already closed. Concurrent fetches for the same sequence coalesce onto one /// handler request. pub fn fetch_group(&self, sequence: u64, options: impl Into>) -> kio::Pending { let options = options.into().unwrap_or_default(); diff --git a/rs/moq-relay/src/fetch.rs b/rs/moq-relay/src/fetch.rs index 94dfb063ca..5cc958e8bd 100644 --- a/rs/moq-relay/src/fetch.rs +++ b/rs/moq-relay/src/fetch.rs @@ -3,8 +3,9 @@ /// The newest needs a live subscription to learn its sequence, since a fetch can /// only retrieve a sequence already known. Once known it is fetched rather than /// read off the subscription, so an evicted group is retrieved from upstream -/// instead of waited on forever. Returns [`moq_net::Error::NotFound`] when the -/// group can never be served, including a track that ends before any group. +/// instead of waited on forever. Fails before returning when the group can never +/// be served: [`moq_net::Error::NotFound`] locally, including a track that ends +/// before any group, or [`moq_net::StreamError::NotFound`] from upstream. pub async fn fetch_group( track: &moq_net::track::Consumer, sequence: Option, diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index e6c2f41657..b5e09791ed 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -926,7 +926,10 @@ async fn serve_fetch( }; let group = match async { crate::fetch_group(&broadcast.track(&track)?, sequence).await }.await { Ok(group) => group, - Err(moq_net::Error::NotFound) => return Err(StatusCode::NOT_FOUND), + // A miss upstream arrives as the stream reset that refused the FETCH. + Err(moq_net::Error::NotFound | moq_net::Error::Stream(moq_net::StreamError::NotFound)) => { + return Err(StatusCode::NOT_FOUND); + } Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; From 01e1e4d0e2842edc57e884e36e86874871c0601d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:54:16 -0700 Subject: [PATCH 22/40] fix(moq-net): serve only the lite routes a request woke, and bound kio waiter lists (#4216) Co-authored-by: Claude Opus 5.5 --- rs/kio/src/waiter.rs | 36 +++++++- rs/moq-net/src/lite/subscriber.rs | 144 +++++++++++++++++++++++++----- 2 files changed, 155 insertions(+), 25 deletions(-) diff --git a/rs/kio/src/waiter.rs b/rs/kio/src/waiter.rs index 3403d25e36..81a66ba48e 100644 --- a/rs/kio/src/waiter.rs +++ b/rs/kio/src/waiter.rs @@ -123,7 +123,8 @@ impl WaiterList { /// /// Each call probes at most two slots at the rotating cursor and reuses /// a dead one in place. The cursor advances on each live probe so the - /// window covers the list over time. + /// window covers the list over time. A list about to grow sweeps every + /// dead slot first. pub fn register(&mut self, waiter: &Waiter) { let new_weak = Arc::downgrade(waiter.shared()); @@ -139,6 +140,15 @@ impl WaiterList { self.cursor = (self.cursor + 1) % self.entries.len(); } + if self.entries.len() == self.entries.capacity() { + // Probing alone loses to a list that many live waiters keep re-registering on + // and nothing wakes: each retired waiter leaves a dead slot the probe window + // rarely lands on, so the list grows for as long as it lives. + self.entries.retain(|entry| entry.strong_count() > 0); + // Leave at least half free, so each sweep is paid for by the pushes before it. + self.entries.reserve(self.entries.len()); + self.cursor = 0; + } self.entries.push(new_weak); } @@ -796,6 +806,30 @@ mod tests { assert!(list.entries.len() <= 2); } + /// Tasks parked on one list are retired and re-register in whatever order they + /// wake, while the list itself is never woken (a rarely-changing value many tasks + /// watch). The probe window alone let such a list grow without bound. + #[test] + fn retired_live_waiters_do_not_grow_the_list() { + const LIVE: usize = 64; + let mut list = WaiterList::new(); + let mut waiters: Vec = (0..LIVE).map(|_| Waiter::noop()).collect(); + for waiter in &waiters { + waiter.register(&mut list); + } + + let mut seed = 1u64; + for _ in 0..100_000 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let i = (seed >> 33) as usize % LIVE; + waiters[i] = Waiter::noop(); + waiters[i].register(&mut list); + } + + let len = list.entries.len(); + assert!(len <= 4 * LIVE, "{len} slots for {LIVE} live waiters"); + } + #[test] fn register_appends_a_live_waiter() { let mut list = WaiterList::new(); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index ca7bff6ba4..4d66fe92a4 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -309,7 +309,7 @@ impl Subscriber { return Ok(false); }; - announced.attach(path, AnnouncedRoute::new(route, dynamic)); + announced.attach(path, route, dynamic); Ok(true) } @@ -411,7 +411,7 @@ impl Subscriber { announced.declined(path); return Ok(false); }; - announced.attach(path, AnnouncedRoute::new(metadata, dynamic)); + announced.attach(path, metadata, dynamic); Ok(true) } @@ -1236,12 +1236,6 @@ impl AnnouncePrefix { if self.subscriber.going_away.poll(waiter).is_ready() { run.announced.drain(); } - // Drain every buffered announce BEFORE serving requests: a route - // this pass attaches must have its request queue polled (and this - // machine's waiter registered on it) below, in the same pass. - // Serving first and then parking on the decode would strand a - // request that arrives in between: its wake finds no waiter, and - // nothing else re-polls this machine. loop { match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(announce))) => { @@ -1261,8 +1255,8 @@ impl AnnouncePrefix { Poll::Pending => break, } } - // Materialize requested paths under the attached routes; leaves the - // waiter registered on every route's request queue. + // Serve the routes whose request queue woke, including any attached + // above. Each route parks on its own waker, so the rest cost nothing. run.announced.poll_serve(&self.subscriber, waiter); return Poll::Pending; } @@ -1975,6 +1969,58 @@ mod tests { ); } + /// A serve pass polls only the routes whose request queue woke. The driver runs a + /// pass on every wake, which is once per group the session carries, so polling + /// every announced route there made each group cost the whole announce set. + #[tokio::test] + async fn a_request_readies_only_its_route() { + let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session: SinkSession::new(Default::default()), + origin: origin.clone(), + recv_bandwidth: None, + version: VERSION, + peer_setup: Default::default(), + cost: None, + peer_hop: Some(crate::Hop::new(777).unwrap()), + going_away: Default::default(), + }); + + let mut announced = Announced::default(); + for i in 0..64 { + assert!( + subscriber + .start_announce( + Path::new(&format!("room/{i}")).to_owned(), + crate::Hops::new(), + crate::origin::Cost::default(), + 0, + None, + &mut announced, + ) + .unwrap() + ); + } + + // Attaching queues each route for its first pass, and that pass leaves none queued. + assert_eq!(announced.ready.len(), 64); + announced.poll_serve(&subscriber, &kio::Waiter::noop()); + assert!(announced.ready.is_empty()); + + let consumer = origin.consume(); + let request = tokio::spawn(async move { consumer.request_broadcast("room/7").await }); + + let ready = kio::wait(|waiter| announced.ready.poll_pop(waiter)).await.unwrap(); + assert_eq!(ready.as_str(), "room/7"); + assert!(announced.ready.is_empty(), "only the requested route is ready"); + + // Hand it back, as its waker did, and serve it. + announced.ready.try_push(ready).unwrap(); + announced.poll_serve(&subscriber, &kio::Waiter::noop()); + request.await.unwrap().expect("the requested route serves it"); + } + /// Every path out of `start_announce` that declines an announce records it first. /// /// The decline paths are a list one edit can fall off the end of, and a miss is silent: @@ -2530,19 +2576,33 @@ enum Sub { /// A declined advertisement remains present with no route because the peer still /// owns its path and announce id until it retracts or restarts it. #[derive(Default)] -struct Announced(HashMap>); +struct Announced { + routes: HashMap>, + /// Attached routes whose request queue woke since the last serve pass. The + /// driver wakes for every group the session carries, so a pass must cost what + /// was requested, not every route the peer announced. + ready: kio::Queue, +} impl Announced { fn contains(&self, path: &PathOwned) -> bool { - self.0.contains_key(path) + self.routes.contains_key(path) } - fn attach(&mut self, path: PathOwned, route: AnnouncedRoute) { - self.0.insert(path, Some(route)); + /// Attach a route, queued for its first serve pass. + fn attach(&mut self, path: PathOwned, route: crate::origin::Route, dynamic: crate::origin::Dynamic) { + let wake = Arc::new(RouteWake { + path: path.clone(), + queued: atomic::AtomicBool::new(false), + ready: self.ready.clone(), + }); + let route = AnnouncedRoute::new(route, dynamic, wake); + route.waker.wake_by_ref(); + self.routes.insert(path, Some(route)); } fn declined(&mut self, path: PathOwned) { - if let Some(Some(route)) = self.0.insert(path, None) { + if let Some(Some(route)) = self.routes.insert(path, None) { route.finish(); } } @@ -2558,27 +2618,35 @@ impl Announced { /// with [`Self::contains`]. Overwriting an attached route here would drop its source /// without finishing it, which is [`Self::declined`]'s job. fn reserve(&mut self, path: PathOwned) { - debug_assert!(!self.0.contains_key(&path), "reserved a prefix already advertised"); - self.0.insert(path, None); + debug_assert!(!self.routes.contains_key(&path), "reserved a prefix already advertised"); + self.routes.insert(path, None); } fn attached(&mut self, path: &PathOwned) -> Option<&mut AnnouncedRoute> { - self.0.get_mut(path)?.as_mut() + self.routes.get_mut(path)?.as_mut() } fn retire(&mut self, path: &PathOwned) { - if let Some(Some(route)) = self.0.remove(path) { + if let Some(Some(route)) = self.routes.remove(path) { route.finish(); } } - /// Serve queued requests on every attached route: mint a source per requested + /// Serve queued requests on every ready route: mint a source per requested /// path, hand its dynamic to the driver's serve machines, and answer the /// requester with its consumer. fn poll_serve(&mut self, subscriber: &Subscriber, waiter: &kio::Waiter) { let root = subscriber.origin.root().to_owned(); - for entry in self.0.values_mut().flatten() { - while let Poll::Ready(Ok(request)) = entry.dynamic.poll_requested_broadcast(waiter) { + while let Poll::Ready(Ok(path)) = self.ready.poll_pop(waiter) { + // A route retired since it woke has nothing left to serve. + let Some(Some(entry)) = self.routes.get_mut(&path) else { + continue; + }; + // Cleared before polling, so a request landing mid-pass queues the route again. + entry.wake.queued.store(false, atomic::Ordering::Release); + let cx = std::task::Context::from_waker(&entry.waker); + let route_waiter = entry.park.hold(&cx); + while let Poll::Ready(Ok(request)) = entry.dynamic.poll_requested_broadcast(route_waiter) { // The request path is absolute; the wire (and our origin handle) // speak paths relative to the session's root. let Some(path) = request.path().strip_prefix(&root) else { @@ -2598,7 +2666,7 @@ impl Announced { /// Re-price every attached route to a draining cost (the peer sent a GOAWAY). fn drain(&mut self) { - for entry in self.0.values_mut().flatten() { + for entry in self.routes.values_mut().flatten() { entry.drain(); } } @@ -2618,15 +2686,23 @@ struct AnnouncedRoute { sources: HashMap, /// Whether the GOAWAY drain already re-priced this route. drained: bool, + /// Queues this route on its prefix's ready set when its request queue wakes. + wake: Arc, + waker: std::task::Waker, + /// Keeps this route's registration on its request queue alive between passes. + park: kio::Park, } impl AnnouncedRoute { - fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic) -> Self { + fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic, wake: Arc) -> Self { Self { route, dynamic, sources: HashMap::new(), drained: false, + waker: std::task::Waker::from(wake.clone()), + wake, + park: kio::Park::default(), } } @@ -2659,6 +2735,26 @@ impl AnnouncedRoute { } } +/// One attached route's waker: queues the route for the next serve pass. +struct RouteWake { + path: PathOwned, + /// Set while queued, so a burst of wakes queues the route once. + queued: atomic::AtomicBool, + ready: kio::Queue, +} + +impl std::task::Wake for RouteWake { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + if !self.queued.swap(true, atomic::Ordering::AcqRel) { + let _ = self.ready.try_push(self.path.clone()); + } + } +} + /// How a [`TrackServe`] run ends. enum ServeEnd { /// The upstream FIN'd: the track is over for good. From d537caffe1213b7fcb908bed1cfd976f2bc7a601 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 18:06:25 -0700 Subject: [PATCH 23/40] fix(uring): halve the completion queue and lift the relay's memlock limit (#4197) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- doc/bin/relay/config.md | 2 +- packaging/moq-relay/moq-relay.service | 3 +++ rs/moq-uring/src/worker.rs | 30 +++++++++++++++------------ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index f72a4fb646..8f5484fcba 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -91,7 +91,7 @@ except that `mtu_discovery` (its datagram path sends a fixed payload) and the three flow-control windows (these workers run fixed ones) are refused under `io_uring` rather than quietly ignored. Each worker reports its own counters at [`/metrics`](/bin/relay/http#get-metrics). The kernel charges each worker's -ring (~100 KiB, plus a page per socket) to `RLIMIT_MEMLOCK`, a budget shared by +ring (~56 KiB, plus a page per socket) to `RLIMIT_MEMLOCK`, a budget shared by every io\_uring the user runs; raise it (`LimitMEMLOCK=` under systemd) if workers fail to start with a message naming that limit. diff --git a/packaging/moq-relay/moq-relay.service b/packaging/moq-relay/moq-relay.service index 9ea9d34621..8cbe5ed7b6 100644 --- a/packaging/moq-relay/moq-relay.service +++ b/packaging/moq-relay/moq-relay.service @@ -17,6 +17,9 @@ RestartSec=5s RestartSteps=5 RestartMaxDelaySec=1min LimitNOFILE=1048576 +# io_uring workers charge their rings to RLIMIT_MEMLOCK (~56 KiB each), and hosts still on the old +# 64 KiB default fit one worker at most. +LimitMEMLOCK=infinity # Sandboxing. DynamicUser allocates a transient UID, StateDirectory creates # /var/lib/moq-relay owned by that UID so cert/key/jwk files placed there are diff --git a/rs/moq-uring/src/worker.rs b/rs/moq-uring/src/worker.rs index 45142d2823..972b31513a 100644 --- a/rs/moq-uring/src/worker.rs +++ b/rs/moq-uring/src/worker.rs @@ -20,13 +20,17 @@ const SQ_ENTRIES: u32 = 256; /// Completion queue depth. Every in-flight operation can post a completion /// (one per send buffer with GSO on, one per provided receive buffer, the -/// park futex, transient cancels), so this covers a few sockets at the -/// default pool ceilings in [`udp::Config`]. Running past it is not fatal: -/// the kernel backlogs completions (`IORING_FEAT_NODROP`) rather than drop -/// them. But the backlog is an allocation-per-CQE slow path and it ends any -/// armed multishot receive, so the CQ is sized to keep it out of steady -/// state. -const CQ_ENTRIES: u32 = 4096; +/// park futex, transient cancels), so this covers one socket at the default +/// pool ceilings in [`udp::Config`], the one-socket-per-worker layout the +/// relay runs. Running past it is not fatal: the kernel backlogs completions +/// (`IORING_FEAT_NODROP`) rather than drop them. But the backlog is an +/// allocation-per-CQE slow path and it ends any armed multishot receive, so +/// the CQ is sized to keep it out of steady state. +/// +/// No larger: the ring is charged to `RLIMIT_MEMLOCK` at 16 bytes per entry, +/// most of each worker's footprint, and that budget is shared by every +/// io_uring the user runs. +const CQ_ENTRIES: u32 = 2048; /// Maximum completions copied at once while teardown is deadline-bounded. const TEARDOWN_CQE_BATCH: usize = 64; @@ -891,14 +895,14 @@ mod tests { #[test] fn cq_covers_the_default_pool_ceilings() { - // The completion queue must cover at least two sockets at their - // default pool ceilings (plus the futex), or the kernel's overflow - // slow path becomes steady state for the workload the ceilings exist - // to serve. Fails when someone raises the udp defaults without - // revisiting CQ_ENTRIES. + // The completion queue must cover a socket at its default pool + // ceilings (plus the futex), or the kernel's overflow slow path + // becomes steady state for the workload the ceilings exist to serve. + // Fails when someone raises the udp defaults without revisiting + // CQ_ENTRIES. let config = udp::Config::default(); let per_socket = u32::from(config.tx_buffers_max) + u32::from(config.rx_buffers_max); - assert!(CQ_ENTRIES > 2 * per_socket, "CQ_ENTRIES fell behind the pool defaults"); + assert!(CQ_ENTRIES > per_socket, "CQ_ENTRIES fell behind the pool defaults"); } #[test] From bb426d9de7898d0695732d48211fc51318feda14 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 18:25:01 -0700 Subject: [PATCH 24/40] fix(watch): keep media-captions external so the package imports outside a browser (#4217) Co-authored-by: Claude Opus 5.5 --- js/watch/vite.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/watch/vite.config.ts b/js/watch/vite.config.ts index 3fbc9cc463..2ceb18c7c8 100644 --- a/js/watch/vite.config.ts +++ b/js/watch/vite.config.ts @@ -18,7 +18,10 @@ export default defineConfig({ rollupOptions: { // Keep every @moq workspace dep (and its subpaths, e.g. @moq/signals/dom) // external so consumers dedupe against their own copy instead of bundling ours. - external: (id) => id.startsWith("@moq/"), + // media-captions too: its browser build reads `window.VTTCue` on load, and only + // an external import lets a server runtime pick its `node` export instead. Its + // stylesheets stay bundled, since they are inlined as strings. + external: (id) => id.startsWith("@moq/") || id === "media-captions", }, sourcemap: true, target: "esnext", From cd379fe38d49a5c94bc4e01b91dfe21af462a31a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 18:27:33 -0700 Subject: [PATCH 25/40] feat(net): compress lite-07 announcements against live bases (#4196) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 2 +- doc/concept/moq-lite.md | 5 + drafts/draft-lcurley-moq-lite.md | 47 +- js/net/src/lite/announce.test.ts | 138 +++++ js/net/src/lite/announce.ts | 149 +++++- js/net/src/lite/subscriber.ts | 44 +- js/net/src/lite/version.ts | 16 + quest/m1/README.md | 1 - quest/m1/announce-compression.md | 106 ---- rs/justfile | 2 +- rs/moq-net/Cargo.toml | 10 +- rs/moq-net/benches/announce.rs | 156 ++++++ rs/moq-net/fuzz/Cargo.toml | 7 + rs/moq-net/fuzz/README.md | 4 +- rs/moq-net/fuzz/fuzz_targets/announce.rs | 7 + rs/moq-net/src/fuzz.rs | 133 ++++- rs/moq-net/src/lite/announce.rs | 271 ++++++++-- rs/moq-net/src/lite/compress.rs | 617 +++++++++++++++++++++++ rs/moq-net/src/lite/mod.rs | 2 + rs/moq-net/src/lite/publisher.rs | 103 ++-- rs/moq-net/src/lite/subscriber.rs | 31 +- rs/moq-net/src/lite/version.rs | 18 +- rs/moq-net/src/path/mod.rs | 2 +- rs/moq-tokio/tests/broadcast.rs | 41 +- 24 files changed, 1622 insertions(+), 290 deletions(-) delete mode 100644 quest/m1/announce-compression.md create mode 100644 rs/moq-net/benches/announce.rs create mode 100644 rs/moq-net/fuzz/fuzz_targets/announce.rs create mode 100644 rs/moq-net/src/lite/compress.rs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9f73f46109..62484ea295 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -224,7 +224,7 @@ jobs: strategy: fail-fast: false matrix: - target: [lite, ietf, varint, path, pattern] + target: [lite, announce, ietf, varint, path, pattern] steps: - name: Free disk space uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 33930649a2..ce9e8118cf 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -56,6 +56,11 @@ anonymous mark and travels the chain unchanged. A route that passed through an anonymous hop at any depth ranks below every fully identified route, whatever the costs say; among anonymous routes, cost keeps ordering. +On moq-lite 07, an announcement may copy the head of its path and the tail of +its relay chain from one still live on the same stream, so many broadcasts +from a few origins behind the same relays stop repeating those bytes. Rust +compresses when it helps; TypeScript decodes it but always sends literally. + A broadcast exists only while it is announced, for consumers in the same process and across a session alike: one that is created but never announced can be neither discovered nor requested. A broadcast published locally diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 679b2056d6..36bef0581f 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -383,6 +383,14 @@ A route covers a path when its prefix is a leading run of the path's segments; m A publisher answering a request stream presents each of its routes clamped to the intersection with the requested prefix: a route above the request's prefix appears as the request prefix itself (an empty suffix), which is exactly the covered set the subscriber may see. There MAY be multiple Announce Streams, potentially containing overlapping prefixes, that get their own ANNOUNCE_OK + announcements. +#### Compression {#announce-compression} +An ANNOUNCE_START or ANNOUNCE_UPDATE MAY copy the head of a path or the tail of a Hop ID list from a live advertisement on the same stream, its base. +A `Base` field names it by distance: the base's Announce ID is the next unassigned Announce ID minus `Base`, so 1 names the latest ANNOUNCE_START, and 0 names no base. +The receiver resolves every base against the advertisements live when the message arrives, and the result is identical to the literal encoding: a base ending later changes nothing. +A publisher MAY always send a `Base` of 0. + +The subscriber MUST close the session with a PROTOCOL_VIOLATION if a non-zero `Base` names an Announce ID that was never assigned or already retired, a `Keep` is non-zero with a `Base` of 0 or exceeds what the base has, or the resolved path or Hop ID list breaks its own rules. + #### Hidden Paths {#hidden} A route is hidden from a request when a segment of its path below the requested prefix starts with `.` (0x2E). A segment inside the prefix never hides anything, so a request that names the hidden segment itself (`.stats`) lists what is under it, and a route at or above the prefix has no segment below it. @@ -860,37 +868,53 @@ Only the suffix is encoded on the wire, as the full route prefix can be construc ANNOUNCE_START Message { Type (i) = 0x0 Message Length (i) + Path Base (i), + Path Keep (i), Route Prefix Suffix (s), - Hop Count (i), - Hop ID (i) ..., + Hops (..), Warm Route Cost (i), Cold Route Cost (i), } + +Hops { + Hop Base (i), + Hop Count (i), + Hop ID (i) ..., + Hop Keep (i), +} ~~~ **Type**: Set to 0x0 to indicate an ANNOUNCE_START message. +**Path Base** and **Path Keep**: +The suffix begins with the first `Path Keep` segments of the suffix advertised by the base that `Path Base` names (see [Compression](#announce-compression)). + **Route Prefix Suffix**: -This is combined with the requested prefix to form the route's full prefix. +The remaining segments of the suffix, which is combined with the requested prefix to form the route's full prefix. An empty suffix advertises the requested prefix itself, which is how a route covering more than the request presents (see [Announce](#announce)). +**Hop Base** and **Hop Keep**: +The Hop ID list ends with the last `Hop Keep` entries of the list advertised by the base that `Hop Base` names (see [Compression](#announce-compression)), following the literal `Hop ID` entries. +The two bases are independent. + **Hop Count**: -The number of Hop ID entries that follow, NOT including the publisher's own `Hop ID` from ANNOUNCE_OK. -A value of 0 means no Hop ID entries are present, indicating either that the announcement originated locally on the publisher (the publisher itself is the origin) or that the upstream peer does not support hop tracking. +The number of literal Hop ID entries that follow. +The resolved list does NOT include the publisher's own `Hop ID` from ANNOUNCE_OK. +An empty list indicates either that the announcement originated locally on the publisher (the publisher itself is the origin) or that the upstream peer does not support hop tracking. A receiver MUST close the stream with a PROTOCOL_VIOLATION if the Hop Count does not match the number of subsequent Hop ID entries. **Hop ID**: A unique identifier for each relay in the path from the origin publisher, ordered from origin to the upstream of the responding publisher. -The responding publisher's own Hop ID is NOT included in this list; it is carried once in ANNOUNCE_OK, so the total path length is `Hop Count + 1`. -When forwarding an announcement received from an upstream peer, a relay MUST append the upstream peer's ANNOUNCE_OK `Hop ID` to this list, since that ID is no longer implicit downstream. +The responding publisher's own Hop ID is NOT included in the resolved list; it is carried once in ANNOUNCE_OK, so the total path length is the resolved list's length plus 1 (`Hop Count + Hop Keep + 1`). +When forwarding an announcement received from an upstream peer, a relay MUST append the upstream peer's ANNOUNCE_OK `Hop ID` to the resolved list, since that ID is no longer implicit downstream. The first entry of the reconstructed path identifies the endpoint that originated the route. A Hop ID value of 0 means the hop is unknown: either it was never assigned or a relay deliberately withholds it (see [Routing](#routing)). A received 0 is forwarded unchanged. When bridging an announcement from an upstream that sent no hop list, a relay writes 0 for that hop. An identity a receiver assigned that upstream is local selection state and MUST NOT be forwarded as a Hop ID. -A receiver MUST close the session with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in this list. +A receiver MUST close the session with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in the resolved list. Duplicate values of 0 are not a violation, since 0 identifies nothing and any number of hops may be unknown. **Warm Route Cost** and **Cold Route Cost**: @@ -936,8 +960,7 @@ ANNOUNCE_UPDATE Message { Type (i) = 0x2 Message Length (i) Announce ID (i), - Hop Count (i), - Hop ID (i) ..., + Hops (..), Warm Route Cost (i), Cold Route Cost (i), } @@ -950,8 +973,9 @@ Set to 0x2 to indicate an ANNOUNCE_UPDATE message. The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream. Referencing an id that was never assigned, or one already retired by an ANNOUNCE_END, is a protocol violation. -**Hop Count**, **Hop ID**, **Warm Route Cost**, and **Cold Route Cost**: +**Hops**, **Warm Route Cost**, and **Cold Route Cost**: As defined for [ANNOUNCE_START](#announce-start). +`Hop Base` may name this advertisement itself, which resolves against the list being replaced. An update whose only change is a Route Cost is valid: it is how a relay re-prices a route without disturbing it. @@ -1315,6 +1339,7 @@ The `Message Length` describes the payload size on the wire. - Added `Stream Count` to SUBSCRIBE_END: the number of Group Streams opened for the subscription. SUBSCRIBE_END is now sent once every counted Group Stream has opened, rather than as soon as the final group is known. - Removed SUBSCRIBE_DROP and its type 0x2; a group without a Group Stream is not counted. - The Subscribe Stream FIN now follows once every counted Group Stream has finished or been reset. +- Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. ## moq-lite-06 diff --git a/js/net/src/lite/announce.test.ts b/js/net/src/lite/announce.test.ts index 4a6acc7dfc..69b34cb9e5 100644 --- a/js/net/src/lite/announce.test.ts +++ b/js/net/src/lite/announce.test.ts @@ -5,9 +5,11 @@ import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { type AnnounceBroadcast, + AnnounceHistory, AnnounceOk, AnnounceRequest, decodeAnnounceBroadcast, + decodeAnnounceBroadcastMaybe, encodeAnnounceBroadcast, } from "./announce.ts"; import { Version } from "./version.ts"; @@ -212,3 +214,139 @@ test("a hop chain that revisits a hop is refused in both directions", async () = }; expect(await roundTrip(anonymous, Version.DRAFT_05)).toEqual(anonymous); }); + +function hex(data: Uint8Array): string { + return Array.from(data, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function unhex(text: string): Uint8Array { + return new Uint8Array(text.match(/../g)?.map((b) => Number.parseInt(b, 16)) ?? []); +} + +// Resolve every announcement on a lite-07 stream, as the subscriber does. +async function resolveStream(data: Uint8Array) { + const reader = new Reader(undefined, data); + const history = new AnnounceHistory(); + const out: unknown[] = []; + for (;;) { + const msg = await decodeAnnounceBroadcastMaybe(reader, Version.DRAFT_07); + if (!msg) return out; + switch (msg.status) { + case "active": + out.push({ start: history.start(msg) }); + break; + case "restart": + out.push({ update: msg.id, hops: history.update(msg).hops }); + break; + case "endedId": + out.push({ end: msg.id, suffix: history.end(msg.id) }); + break; + default: + throw new Error(`unexpected ${msg.status}`); + } + } +} + +const hop = (id: bigint) => HopSchema.parse(id); +const relay = hop(0x2222n); +const GOLDEN_RESOLVED = [ + { start: { suffix: Path.from("room/a/cam"), hops: [hop(0x1111n), relay] } }, + { start: { suffix: Path.from("room/a/mic"), hops: [hop(0x3333n), relay] } }, + { update: 0n, hops: [hop(0x4444n), relay] }, + { end: 1n, suffix: Path.from("room/a/mic") }, + { start: { suffix: Path.from("room/b"), hops: [hop(0x5555n), relay] } }, +]; + +// Pinned from the Rust encoder (`lite::compress::tests::golden_stream_is_pinned`), so the +// JS decoder is checked against real compressed output. +const GOLDEN = + "001600000a726f6f6d2f612f63616d000251116222000000000d0102036d696301017333010000020a00010180004444010000010101000d02010162020180005555010000"; + +test("AnnounceHistory resolves the Rust encoder's compressed stream", async () => { + expect(await resolveStream(unhex(GOLDEN))).toEqual(GOLDEN_RESOLVED); +}); + +// JS always encodes literally; Rust decodes these bytes too (`js_literal_stream_decodes`). +const JS_LITERAL = + "001600000a726f6f6d2f612f63616d000251116222000000001600000a726f6f6d2f612f6d6963000273336222000000020c0000028000444462220000000101010014000006726f6f6d2f620002800055556222000000"; + +test("the literal draft-07 stream matches what Rust decodes", async () => { + const wire = await bytes(async (w) => { + const v = Version.DRAFT_07; + const cost = { warm: 0n, cold: 0n }; + await encodeAnnounceBroadcast( + w, + { status: "active", suffix: Path.from("room/a/cam"), hops: [hop(0x1111n), relay], cost }, + v, + ); + await encodeAnnounceBroadcast( + w, + { status: "active", suffix: Path.from("room/a/mic"), hops: [hop(0x3333n), relay], cost }, + v, + ); + await encodeAnnounceBroadcast(w, { status: "restart", id: 0n, hops: [hop(0x4444n), relay], cost }, v); + await encodeAnnounceBroadcast(w, { status: "endedId", id: 1n }, v); + await encodeAnnounceBroadcast( + w, + { status: "active", suffix: Path.from("room/b"), hops: [hop(0x5555n), relay], cost }, + v, + ); + }); + expect(hex(wire)).toBe(JS_LITERAL); + expect(await resolveStream(wire)).toEqual(GOLDEN_RESOLVED); +}); + +test("AnnounceHistory rejects a base that is not live", () => { + const history = new AnnounceHistory(); + const base = { distance: 1n, keep: 0 }; + // A new stream has nothing to copy from. + expect(() => history.start({ suffix: Path.from("a"), hops: [], pathBase: base })).toThrow(ProtocolViolation); + + const fresh = new AnnounceHistory(); + fresh.start({ suffix: Path.from("a/b"), hops: [hop(1n)] }); + fresh.start({ suffix: Path.from("c"), hops: [] }); + fresh.end(0n); + // Id 0 is two back, and retired. + expect(() => fresh.start({ suffix: Path.from("x"), hops: [], pathBase: { distance: 2n, keep: 1 } })).toThrow( + ProtocolViolation, + ); +}); + +test("AnnounceHistory rejects a keep longer than the base", () => { + const history = new AnnounceHistory(); + history.start({ suffix: Path.from("a/b"), hops: [hop(1n)] }); + expect(() => history.start({ suffix: Path.from("x"), hops: [], pathBase: { distance: 1n, keep: 3 } })).toThrow( + ProtocolViolation, + ); + expect(() => history.update({ id: 0n, hops: [], hopBase: { distance: 1n, keep: 2 } })).toThrow(ProtocolViolation); +}); + +test("AnnounceHistory rejects a resolved chain that repeats a hop", () => { + const history = new AnnounceHistory(); + history.start({ suffix: Path.from("a"), hops: [hop(1n), hop(2n)] }); + expect(() => + history.start({ suffix: Path.from("b"), hops: [hop(2n)], hopBase: { distance: 1n, keep: 1 } }), + ).toThrow(ProtocolViolation); +}); + +test("a keep without a base is a violation on draft-07", async () => { + // ANNOUNCE_START: type, length, path base 0, path keep 1, empty suffix, empty hops, cost. + const wire = await bytes(async (w) => { + await w.u53(0); + await w.u53(8); + for (const b of [0, 1, 0, 0, 0, 0, 0, 0]) await w.u8(b); + }); + await expect(decodeAnnounceBroadcast(new Reader(undefined, wire), Version.DRAFT_07)).rejects.toThrow( + ProtocolViolation, + ); +}); + +test("draft-06 has no room for a base", async () => { + const msg: AnnounceBroadcast = { + status: "active", + suffix: Path.from("x"), + hops: [], + pathBase: { distance: 1n, keep: 1 }, + }; + await expect(bytes((w) => encodeAnnounceBroadcast(w, msg, Version.DRAFT_06))).rejects.toThrow(); +}); diff --git a/js/net/src/lite/announce.ts b/js/net/src/lite/announce.ts index d9d6bf7874..da874389fa 100644 --- a/js/net/src/lite/announce.ts +++ b/js/net/src/lite/announce.ts @@ -3,7 +3,15 @@ import { type Cost, type Hop, HopSchema, MAX_HOPS, UNKNOWN_HOP } from "../hop.ts import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; -import { hasAnnounceId, hasAnnounceOk, hasExcludeHop, hasHidden, hasRouteCost, Version } from "./version.ts"; +import { + hasAnnounceCompression, + hasAnnounceId, + hasAnnounceOk, + hasExcludeHop, + hasHidden, + hasRouteCost, + Version, +} from "./version.ts"; // Pre-lite-06 inner status values, carried inside the single ANNOUNCE_BROADCAST body. const STATUS_ENDED = 0; @@ -19,6 +27,13 @@ const ANNOUNCE_RESTART = 2; export type { Cost }; +/** + * Lite-07: copy `keep` path segments (from the head) or hops (from the tail) of the + * live announcement `distance` back from the stream's next Announce ID, where 1 is the + * latest ANNOUNCE_START. Resolved by {@link AnnounceHistory}. + */ +export type Base = { distance: bigint; keep: number }; + /** * An announcement on the Announce Stream, advertising or retracting a broadcast. * @@ -32,8 +47,9 @@ export type { Cost }; export type AnnounceBroadcast = /** A broadcast is now available, carrying the path suffix, the hop chain, and * (lite-06+) the route cost. An absent cost encodes as zero; it decodes as - * `undefined` on a wire with no room for one. */ - | { status: "active"; suffix: Path.Valid; hops: Hop[]; cost?: Cost } + * `undefined` on a wire with no room for one. On lite-07, `suffix` follows the + * segments `pathBase` copies and `hops` precede the ones `hopBase` copies. */ + | { status: "active"; suffix: Path.Valid; hops: Hop[]; cost?: Cost; pathBase?: Base; hopBase?: Base } /** Pre-lite-06: a broadcast is no longer available, retracted by path. */ | { status: "ended"; suffix: Path.Valid } /** Lite06+: a broadcast is no longer available, retracted by announce id. @@ -41,7 +57,7 @@ export type AnnounceBroadcast = | { status: "endedId"; id: bigint } /** Lite06+: atomically replace the announcement with this id (e.g. a new hop * chain after a relay failover, or a route whose cost moved). The id stays live. */ - | { status: "restart"; id: bigint; hops: Hop[]; cost?: Cost } + | { status: "restart"; id: bigint; hops: Hop[]; cost?: Cost; hopBase?: Base } /** An unknown lite-06+ announce type, skipped by length. Does not assign an id. */ | { status: "skipped" }; @@ -112,6 +128,53 @@ async function decodeHops(r: Reader, version: Version): Promise { } } +// Lite-07 carries a base around a path and a hop list. A distance of 0 names +// nothing, and a keep without a base is a violation. Other versions have no room for +// a base at all. +function checkBase(version: Version, base: Base | undefined) { + if (base && !hasAnnounceCompression(version)) { + throw new Error("announce compression not supported for this version"); + } +} + +function toBase(distance: bigint, keep: number): Base | undefined { + if (distance !== 0n) return { distance, keep }; + if (keep !== 0) throw new ProtocolViolation("announce keep without a base"); + return undefined; +} + +async function encodePath(w: Writer, version: Version, suffix: Path.Valid, base: Base | undefined) { + checkBase(version, base); + if (hasAnnounceCompression(version)) { + await w.u62(base?.distance ?? 0n); + await w.u53(base?.keep ?? 0); + } + await w.string(Path.encode(suffix)); +} + +async function decodePath(r: Reader, version: Version): Promise<{ suffix: Path.Valid; pathBase?: Base }> { + if (!hasAnnounceCompression(version)) return { suffix: Path.decode(await r.string()) }; + const pathBase = toBase(await r.u62(), await r.u53()); + const suffix = Path.decode(await r.string()); + return pathBase ? { suffix, pathBase } : { suffix }; +} + +async function encodeHopsBlock(w: Writer, version: Version, hops: Hop[], base: Base | undefined) { + checkBase(version, base); + if (!hasAnnounceCompression(version)) return encodeHops(w, version, hops); + await w.u62(base?.distance ?? 0n); + await encodeHops(w, version, hops); + await w.u53(base?.keep ?? 0); +} + +async function decodeHopsBlock(r: Reader, version: Version): Promise<{ hops: Hop[]; hopBase?: Base }> { + if (!hasAnnounceCompression(version)) return { hops: await decodeHops(r, version) }; + const distance = await r.u62(); + const hops = await decodeHops(r, version); + const hopBase = toBase(distance, await r.u53()); + return hopBase ? { hops, hopBase } : { hops }; +} + // The route cost rides lite-06+ announcements as two varints, warm then cold; older // versions carry neither. async function encodeRouteCost(w: Writer, version: Version, cost: Cost | undefined) { @@ -129,8 +192,8 @@ async function decodeRouteCost(r: Reader, version: Version): Promise { switch (typ) { case ANNOUNCE_START: { - const suffix = Path.decode(await r.string()); - const hops = await decodeHops(r, version); - return { status: "active", suffix, hops, cost: await decodeRouteCost(r, version) }; + const path = await decodePath(r, version); + const hops = await decodeHopsBlock(r, version); + return { status: "active", ...path, ...hops, cost: await decodeRouteCost(r, version) }; } case ANNOUNCE_END: return { status: "endedId", id: await r.u62() }; case ANNOUNCE_RESTART: { const id = await r.u62(); - const hops = await decodeHops(r, version); - return { status: "restart", id, hops, cost: await decodeRouteCost(r, version) }; + const hops = await decodeHopsBlock(r, version); + return { status: "restart", id, ...hops, cost: await decodeRouteCost(r, version) }; } default: // Skip the length-prefixed body so an earlier Lite06 build negotiating @@ -191,6 +254,7 @@ async function decodeAnnounce06Body(r: Reader, typ: number, version: Version): P async function encodeLegacyBody(w: Writer, msg: AnnounceBroadcast, version: Version) { switch (msg.status) { case "active": + checkBase(version, msg.pathBase ?? msg.hopBase); await w.u8(STATUS_ACTIVE); await w.string(Path.encode(msg.suffix)); await encodeHops(w, version, msg.hops); @@ -253,6 +317,67 @@ export async function decodeAnnounceBroadcastMaybe( return Message.decodeMaybe(r, (r) => decodeLegacyBody(r, version)); } +type Advertised = { suffix: Path.Valid; hops: Hop[] }; + +/** + * The live announcements on one lite-06+ announce stream, by Announce ID: what + * ANNOUNCE_END and ANNOUNCE_UPDATE reference, and what a lite-07 base copies from. + * Every violation throws {@link ProtocolViolation}. + */ +export class AnnounceHistory { + #next = 0n; + #live = new Map(); + + #base(base: Base): Advertised { + const live = this.#live.get(this.#next - base.distance); + if (!live) throw new ProtocolViolation(`announce base ${base.distance} is not live`); + return live; + } + + #hops(hops: Hop[], base: Base | undefined): Hop[] { + if (!base) return hops; + const tail = this.#base(base).hops; + if (base.keep > tail.length) throw new ProtocolViolation(`announce keeps ${base.keep} of ${tail.length} hops`); + const resolved = [...hops, ...tail.slice(tail.length - base.keep)]; + checkHops(resolved); + return resolved; + } + + /** An ANNOUNCE_START: resolve it and assign it the next id. */ + start(msg: { suffix: Path.Valid; hops: Hop[]; pathBase?: Base; hopBase?: Base }): Advertised { + let suffix = msg.suffix; + if (msg.pathBase) { + const head = Path.parts(this.#base(msg.pathBase).suffix); + const keep = msg.pathBase.keep; + if (keep > head.length) throw new ProtocolViolation(`announce keeps ${keep} of ${head.length} segments`); + suffix = Path.join(Path.from(...head.slice(0, keep)), suffix); + if (Path.parts(suffix).length > Path.MAX_PARTS) { + throw new ProtocolViolation(`path exceeds ${Path.MAX_PARTS} parts`); + } + } + const hops = this.#hops(msg.hops, msg.hopBase); + this.#live.set(this.#next++, { suffix, hops }); + return { suffix, hops }; + } + + /** An ANNOUNCE_UPDATE: resolve its chain before replacing the old one, which it may be based on. */ + update(msg: { id: bigint; hops: Hop[]; hopBase?: Base }): Advertised { + const hops = this.#hops(msg.hops, msg.hopBase); + const live = this.#live.get(msg.id); + if (!live) throw new ProtocolViolation(`unknown announce id: ${msg.id}`); + this.#live.set(msg.id, { suffix: live.suffix, hops }); + return { suffix: live.suffix, hops }; + } + + /** An ANNOUNCE_END: retire the id, returning its suffix. */ + end(id: bigint): Path.Valid { + const live = this.#live.get(id); + if (!live) throw new ProtocolViolation(`unknown announce id: ${id}`); + this.#live.delete(id); + return live.suffix; + } +} + /** * ANNOUNCE_REQUEST: sent by the subscriber to request ANNOUNCE_BROADCAST messages * for a path prefix. Renamed from `AnnounceInterest` in lite-05. diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 6b6f97f18b..021fe1dae4 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -14,7 +14,13 @@ import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import { overrideBroadcastWire, wireOf } from "../wire.ts"; -import { AnnounceInit, AnnounceOk, AnnounceRequest, decodeAnnounceBroadcastMaybe } from "./announce.ts"; +import { + AnnounceHistory, + AnnounceInit, + AnnounceOk, + AnnounceRequest, + decodeAnnounceBroadcastMaybe, +} from "./announce.ts"; import { Datagram as DatagramMessage } from "./datagram.ts"; import * as DatagramStream from "./datagram_stream.ts"; import { Fetch as FetchMessage } from "./fetch.ts"; @@ -273,10 +279,10 @@ export class Subscriber { } // Lite06+: announce ids. Each received `active` implicitly assigns the next - // per-stream ordinal; `endedId`/`restart` reference it. Tracked even for - // announces we skip as reflected, since the sender doesn't know we skipped. - let nextAnnounceId = 0n; - const announcedById = new Map(); + // per-stream ordinal; `endedId`/`restart` reference it, and lite-07 bases copy + // from it. Tracked even for announces we skip as reflected, since the sender + // doesn't know we skipped. + const history = new AnnounceHistory(); // Receive announce updates (for Draft03, this includes initial state) for (;;) { @@ -295,39 +301,31 @@ export class Subscriber { let cost: Cost | undefined; switch (announce.status) { - case "active": + case "active": { + const resolved = hasAnnounceId(this.version) ? history.start(announce) : announce; // The wire names the suffix beneath the interest prefix; the consumer // sees the covered path from the session root. - path = Path.join(prefix, announce.suffix); + path = Path.join(prefix, resolved.suffix); active = true; - hops = announce.hops; + hops = resolved.hops; cost = announce.cost; - if (hasAnnounceId(this.version)) { - announcedById.set(nextAnnounceId++, path); - } break; + } case "ended": path = Path.join(prefix, announce.suffix); active = false; break; - case "endedId": { + case "endedId": // Resolve and retire the id; an unknown or retired id is a protocol violation. - const resolved = announcedById.get(announce.id); - if (resolved === undefined) throw new ProtocolViolation(`unknown announce id: ${announce.id}`); - announcedById.delete(announce.id); - if (resolved === null) continue; - path = resolved; + path = Path.join(prefix, history.end(announce.id)); active = false; break; - } case "restart": { // Resolve the id; it stays live (the replacement reuses it). - const resolved = announcedById.get(announce.id); - if (resolved === undefined) throw new ProtocolViolation(`unknown announce id: ${announce.id}`); - if (resolved === null) continue; - path = resolved; + const resolved = history.update(announce); + path = Path.join(prefix, resolved.suffix); active = true; - hops = announce.hops; + hops = resolved.hops; cost = announce.cost; break; } diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index 28d0470dd9..2d83114b6f 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -249,6 +249,22 @@ export function hasStreamCount(version: Version): boolean { } } +/** Whether ANNOUNCE_START and ANNOUNCE_UPDATE may copy a path head or hop-chain tail from a live announcement. Added in lite-07. */ +export function hasAnnounceCompression(version: Version): boolean { + // Explicitly list older versions so future versions keep the lite-07+ behavior. + switch (version) { + case Version.DRAFT_01: + case Version.DRAFT_02: + case Version.DRAFT_03: + case Version.DRAFT_04: + case Version.DRAFT_05: + case Version.DRAFT_06: + return false; + default: + return true; + } +} + /// The WebTransport subprotocol identifier for moq-lite. /// Version negotiation still happens via SETUP when this is used. export const ALPN = "moql"; diff --git a/quest/m1/README.md b/quest/m1/README.md index 6c95748db6..88edaefce0 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -19,7 +19,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts -- [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` diff --git a/quest/m1/announce-compression.md b/quest/m1/announce-compression.md deleted file mode 100644 index cb0dc30bc7..0000000000 --- a/quest/m1/announce-compression.md +++ /dev/null @@ -1,106 +0,0 @@ -# [L] Announce compression - -## Goal - -A moq-lite-07 ANNOUNCE_START can reuse the path of any live announcement on -its stream, and ANNOUNCE_START and ANNOUNCE_UPDATE can reuse the tail of any -live announcement's hop chain, so repeated names and relay paths stop costing -full bytes on every announce. Decoded routes are identical to the literal -encoding. Compression is mandatory for every lite-07 endpoint to decode, and -any encoder may still send everything literally. lite-06 and older are -unchanged. - -Non-goals: fewer announces (tree routing was dropped in favour of this), a -separate dictionary with its own bounds, and a production byte counter. - -## Plan - -### Wire - -A base is a live announcement on the same stream, named by its distance back -from the next unassigned Announce ID: `d` names `next - d`, so `1` is the -latest START, and `0` means none. Only the stream's ordered history is -consulted, so there is no new state, table limit, eviction, or reset rule: the -receiver already stores every live route, and a decoded route owns its values, -so a base ending later changes nothing. - -``` -ANNOUNCE_START { - Type (i) = 0x0 - Message Length (i) - Path Base (i), - Path Keep (i), // leading segments of the base's suffix - Route Prefix Suffix (s), // remaining segments, literal - Hops, - Warm Route Cost (i), - Cold Route Cost (i), -} - -ANNOUNCE_UPDATE { - Type (i) = 0x2 - Message Length (i) - Announce ID (i), - Hops, - Warm Route Cost (i), - Cold Route Cost (i), -} - -Hops { - Hop Base (i), - Hop Count (i), - Hop ID (i) ..., // literal leading hops, e.g. a new origin - Hop Keep (i), // trailing hops copied from the base's chain -} -``` - -- Paths share their head, so `Path Keep` counts whole leading segments of the - base's wire suffix (relative to the requested prefix). Segments, not bytes, - to keep the codec simple. -- Chains share their tail: the origin differs per publisher while the relays - behind it repeat. `Hop Keep` counts trailing entries of the base's wire hop - list (the one excluding ANNOUNCE_OK's Hop ID), appended after the literal - hops. The hop base is independent of the path base, since the best path - match can arrive over a different relay path. -- PROTOCOL_VIOLATION: a base that was never assigned or is already retired, a - non-zero keep with base 0, a keep larger than the base has, or a result that - breaks the existing path and hop rules (`MAX_PARTS`, 32 hops, repeated - non-zero Hop ID, duplicate live route). -- ANNOUNCE_END and ANNOUNCE_OK are unchanged. - -### Implementation - -- Rust (`rs/moq-net/src/lite/`): the codec is stateless today, so decoding - moves beside `PrefixRun` (subscriber) and encoding beside `AnnounceRun` - (publisher), which already hold the live set by Announce ID. The encoder - picks bases from two ordered indexes over its live announcements, one keyed - by path segments and one by reversed hop chain, so the longest shared head - or tail is a neighbour lookup. -- JS (`js/net/src/lite/`): decode everything, encode literally (both bases 0). -- Fuzz: `fuzz.rs` round-trips single messages; add a stream-level harness that - feeds message sequences through the stateful decoder. -- Update the MoQ-lite draft's ANNOUNCE_START and ANNOUNCE_UPDATE sections and - its lite-07 changelog, plus `doc/concept/moq-lite.md` where it describes the - format. - -### Verification - -- Codec tests for each violation, base reuse after ENDs, keep of the full - path/chain, anonymous (0) hops, and a new stream starting empty. -- Rust encoder against JS decoder (and literal JS against Rust), plus - `just test interop --all`. -- A benchmark of encoded announce bytes and encode/decode CPU against lite-06 - literal framing, swept over live routes and churn: a health-shaped workload - (`/private/channel_N/stream-health-` from several origins sharing - relay tails) and an all-unique control, whose overhead is the four zero - base/keep bytes per START. An earlier - prototype with separate path and chain dictionaries saved 69% on a similar - workload; report what this shape achieves. - -## Required - -- moq-lite-07 ships as `moq-lite-07-wip`, off by default, so its wire can still change ([#4148](https://github.com/moq-dev/moq/pull/4148)) - -## Related - -- [Relay memory](/quest/m1/relay-memory.md) - route state per relay, which - this leaves unchanged diff --git a/rs/justfile b/rs/justfile index e7fcbeb580..abeafecf82 100644 --- a/rs/justfile +++ b/rs/justfile @@ -969,7 +969,7 @@ loom *args: # run, and libFuzzer writes what it discovers to the FIRST corpus directory, which is # why the generated seeds are passed second and stay read-only. # -# TARGET is lite, ietf, varint, or path. Arguments pass through, so +# TARGET is lite, announce, ietf, varint, path, or pattern. Arguments pass through, so # `just rs fuzz lite -- -max_total_time=300` bounds a run. See rs/moq-net/fuzz/README.md. fuzz target *args: #!/usr/bin/env bash diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 288ac36bb2..a064f2beca 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -16,8 +16,8 @@ categories = ["multimedia", "network-programming", "web-programming"] ignored = ["getrandom"] [features] -# Exposes the wire codecs to the `fuzz/` harness through the hidden `fuzz` module. -# Off by default and hidden from the docs; nothing depends on it but that harness. +# Exposes the wire codecs to the `fuzz/` harness and the announce bench through the +# hidden `fuzz` module. Off by default and hidden from the docs. fuzz = [] [dependencies] @@ -69,6 +69,12 @@ required-features = ["fuzz"] name = "origin" harness = false +# Reaches the private announce codec through the hidden `fuzz` module. +[[bench]] +name = "announce" +harness = false +required-features = ["fuzz"] + [[bench]] name = "group" harness = false diff --git a/rs/moq-net/benches/announce.rs b/rs/moq-net/benches/announce.rs new file mode 100644 index 0000000000..8c8ad54354 --- /dev/null +++ b/rs/moq-net/benches/announce.rs @@ -0,0 +1,156 @@ +//! Lite-07 announce compression against lite-06 literal framing: the bytes an +//! announce stream costs, and the CPU to encode and decode it. +//! +//! Bytes and decode are measured over a whole stream: `routes` routes, then `churn` of +//! them replaced (an END and a fresh START), so the bases the encoder picks come and +//! go. Encode is measured in the steady state a relay sees: the encoder already holds +//! `routes` live routes, and each iteration retracts the oldest and starts a new one. +//! Two workloads bracket the gain: +//! +//! - health: `/private/channel_N/stream-health-` from a few origins behind +//! a shared pair of relays, the shape that motivated compression. +//! - unique: random single-segment paths and random two-hop chains, where nothing +//! is shared and every START pays the four zero base/keep bytes. +//! +//! The byte counts print once before the timings. +//! +//! Run with `cargo bench -p moq-net --features fuzz --bench announce`. + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_net::{ + Hop, Hops, PathOwned, + fuzz::{AnnounceWriter, Announced, decode_announces, encode_announces}, +}; + +/// `(routes, churn)` shapes for the whole-stream measurements. +const SHAPES: [(u64, u64); 4] = [(100, 0), (1_000, 0), (1_000, 1_000), (10_000, 1_000)]; + +/// Live routes for the steady-state encode. +const LIVE: [u64; 3] = [100, 1_000, 10_000]; + +/// How many origins publish health streams, each behind the same two relays. +const ORIGINS: u64 = 8; + +/// A deterministic 62-bit id, standing in for a random Hop ID. +fn id(seed: u64) -> u64 { + // SplitMix64, masked to the varint range and kept non-zero. + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + ((z ^ (z >> 31)) & ((1 << 62) - 1)).max(1) +} + +fn hops(ids: &[u64]) -> Hops { + Hops::try_from(ids.iter().map(|id| Hop::new(*id).unwrap()).collect::>()).unwrap() +} + +/// Announce `routes` routes, then replace `churn` of them, oldest first. +fn stream(routes: u64, churn: u64, route: impl Fn(u64) -> (PathOwned, Hops)) -> Vec { + let mut announced: Vec<_> = (0..routes) + .map(|n| { + let (path, hops) = route(n); + Announced::Start(path, hops) + }) + .collect(); + for n in 0..churn { + announced.push(Announced::End(n)); + let (path, hops) = route(routes + n); + announced.push(Announced::Start(path, hops)); + } + announced +} + +fn health(n: u64) -> (PathOwned, Hops) { + let origin = n % ORIGINS; + let path = format!( + "{:x}/private/channel_{}/stream-health-{}", + id(origin), + n % 16, + 1_700_000_000 + n + ); + (path.into(), hops(&[id(origin), id(100), id(101)])) +} + +fn unique(n: u64) -> (PathOwned, Hops) { + ( + format!("{:x}", id(n)).into(), + hops(&[id(n + 1_000_000), id(n + 2_000_000)]), + ) +} + +type Workload = (&'static str, fn(u64) -> (PathOwned, Hops)); +const WORKLOADS: [Workload; 2] = [("health", health), ("unique", unique)]; + +fn report() { + eprintln!("announce stream bytes (lite-06 literal -> lite-07 compressed):"); + for (name, route) in WORKLOADS { + for (routes, churn) in SHAPES { + let announced = stream(routes, churn, route); + let literal = encode_announces(&announced, false).len(); + let compressed = encode_announces(&announced, true).len(); + let starts = routes + churn; + eprintln!( + " {name:>6} routes={routes:>5} churn={churn:>5}: {literal:>8} -> {compressed:>8} bytes, {:.1} -> {:.1} per START ({:+.0}%)", + literal as f64 / starts as f64, + compressed as f64 / starts as f64, + (compressed as f64 / literal as f64 - 1.0) * 100.0, + ); + } + } +} + +fn bench(c: &mut Criterion) { + report(); + + for (name, route) in WORKLOADS { + let mut group = c.benchmark_group(format!("announce_{name}")); + + for routes in LIVE { + // A ring twice the live set, so the route started never collides with one + // still live. + let ring: Vec<_> = (0..2 * routes) + .map(|n| { + let (path, hops) = route(n); + Announced::Start(path, hops) + }) + .collect(); + group.throughput(Throughput::Elements(2)); + + for compress in [false, true] { + let version = if compress { "lite07" } else { "lite06" }; + group.bench_function(BenchmarkId::new(format!("churn_{version}"), routes), |b| { + let mut writer = AnnounceWriter::new(compress); + let mut data = Vec::new(); + for start in &ring[..routes as usize] { + writer.write(start, &mut data); + } + let mut oldest = 0; + b.iter(|| { + data.clear(); + writer.write(&Announced::End(oldest), &mut data); + writer.write(&ring[((routes + oldest) % (2 * routes)) as usize], &mut data); + oldest += 1; + }); + }); + } + } + + for (routes, churn) in SHAPES { + let announced = stream(routes, churn, route); + group.throughput(Throughput::Elements(announced.len() as u64)); + for compress in [false, true] { + let version = if compress { "lite07" } else { "lite06" }; + let encoded = encode_announces(&announced, compress); + group.bench_with_input( + BenchmarkId::new(format!("decode_{version}"), format!("{routes}x{churn}")), + &encoded, + |b, encoded| b.iter(|| decode_announces(encoded, compress)), + ); + } + } + group.finish(); + } +} + +criterion_group!(benches, bench); +criterion_main!(benches); diff --git a/rs/moq-net/fuzz/Cargo.toml b/rs/moq-net/fuzz/Cargo.toml index f19331315d..721e187412 100644 --- a/rs/moq-net/fuzz/Cargo.toml +++ b/rs/moq-net/fuzz/Cargo.toml @@ -27,6 +27,13 @@ test = false doc = false bench = false +[[bin]] +name = "announce" +path = "fuzz_targets/announce.rs" +test = false +doc = false +bench = false + [[bin]] name = "ietf" path = "fuzz_targets/ietf.rs" diff --git a/rs/moq-net/fuzz/README.md b/rs/moq-net/fuzz/README.md index a374f8b500..de42127674 100644 --- a/rs/moq-net/fuzz/README.md +++ b/rs/moq-net/fuzz/README.md @@ -9,7 +9,7 @@ cargo install --locked cargo-fuzz just rs fuzz lite ``` -The targets are `lite`, `ietf`, `varint`, `path`, and `pattern`. Extra arguments pass through to +The targets are `lite`, `announce`, `ietf`, `varint`, `path`, and `pattern`. Extra arguments pass through to libFuzzer, so `just rs fuzz lite -- -max_total_time=300` bounds a run. The Nightly workflow runs every target for five minutes and uploads failure inputs @@ -41,6 +41,8 @@ Beyond "does not panic": where a parameter map reaches the wire, since those encoders walk a `HashMap` and its iteration order differs per instance (moq-lite SETUP, and draft-14/15, which unlike draft-16+ do not sort by key first). +- A lite-07 announce stream our decoder accepts, recompressed by our encoder, resolves + to the same announcements. - `Path::relative` inverts `Path::resolve`, and never produces a reference that walks above the root. diff --git a/rs/moq-net/fuzz/fuzz_targets/announce.rs b/rs/moq-net/fuzz/fuzz_targets/announce.rs new file mode 100644 index 0000000000..f672273f18 --- /dev/null +++ b/rs/moq-net/fuzz/fuzz_targets/announce.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + moq_net::fuzz::announce_stream(data); +}); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 7d71f5a49e..5fc9f793b6 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -13,7 +13,7 @@ use bytes::Buf; use crate::{ - Path, Pattern, + Hops, Path, PathOwned, Pattern, coding::{Decode, Encode, VarInt}, ietf, lite, path::Relative, @@ -26,6 +26,7 @@ pub type Target = fn(&[u8]) -> bool; /// Every target, keyed by the name of its `fuzz_targets/.rs` shim. pub const TARGETS: &[(&str, Target)] = &[ ("lite", lite_wire), + ("announce", announce_stream), ("ietf", ietf_wire), ("varint", varint), ("path", path), @@ -154,6 +155,114 @@ pub fn lite_wire(data: &[u8]) -> bool { } } +/// One announcement on an announce stream, resolved: what the application sees. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Announced { + /// ANNOUNCE_START: a route's suffix and wire hop chain, taking the next id. + Start(PathOwned, Hops), + /// ANNOUNCE_UPDATE: a live id's new wire hop chain. + Update(u64, Hops), + /// ANNOUNCE_END: a live id retired. + End(u64), +} + +fn announce_version(compress: bool) -> lite::Version { + match compress { + true => lite::Version::Lite07, + false => lite::Version::Lite06, + } +} + +/// One publisher's side of an announce stream: lite-07 with compression, or lite-06 +/// literal framing. +pub struct AnnounceWriter { + version: lite::Version, + encoder: lite::AnnounceEncoder, +} + +impl AnnounceWriter { + /// A new stream, with nothing live. + pub fn new(compress: bool) -> Self { + let version = announce_version(compress); + Self { + version, + encoder: lite::AnnounceEncoder::new(version), + } + } + + /// Append one announcement to `data`. An update or end must name a live id. + pub fn write(&mut self, announced: &Announced, data: &mut Vec) { + let msg = match announced { + Announced::Start(suffix, hops) => { + let (_, suffix, hops) = self.encoder.start(suffix.clone(), hops.clone()); + lite::AnnounceBroadcast::Active { + suffix, + hops, + cost: Default::default(), + } + } + Announced::Update(id, hops) => lite::AnnounceBroadcast::Restart { + id: *id, + hops: self.encoder.update(*id, hops.clone()), + cost: Default::default(), + }, + Announced::End(id) => { + self.encoder.end(*id); + lite::AnnounceBroadcast::EndedId { id: *id } + } + }; + msg.encode(data, self.version) + .expect("could not encode an announcement"); + } +} + +/// Encode `announced` as a fresh [`AnnounceWriter`] stream. +pub fn encode_announces(announced: &[Announced], compress: bool) -> Vec { + let mut writer = AnnounceWriter::new(compress); + let mut data = Vec::new(); + for announced in announced { + writer.write(announced, &mut data); + } + data +} + +/// Decode and resolve an announce stream, as [`encode_announces`] writes it, stopping +/// at the first message that fails to decode or resolve: a subscriber closes the +/// session there. +pub fn decode_announces(mut data: &[u8], compress: bool) -> Vec { + let version = announce_version(compress); + let mut decoder = lite::AnnounceDecoder::default(); + let mut resolved = Vec::new(); + while let Ok(msg) = lite::AnnounceBroadcast::decode(&mut data, version) { + let announced = match msg { + lite::AnnounceBroadcast::Active { suffix, hops, .. } => decoder + .start(suffix, hops) + .map(|(suffix, hops)| Announced::Start(suffix, hops)), + lite::AnnounceBroadcast::Restart { id, hops, .. } => { + decoder.update(id, hops).map(|(_, hops)| Announced::Update(id, hops)) + } + lite::AnnounceBroadcast::EndedId { id } => decoder.end(id).map(|_| Announced::End(id)), + _ => continue, + }; + let Ok(announced) = announced else { + break; + }; + resolved.push(announced); + } + resolved +} + +/// Feed a lite-07 announce stream through the stateful decoder, then check that our +/// encoder's compression of what it resolved reads back as the same announcements. +/// +/// Returns whether anything resolved. +pub fn announce_stream(data: &[u8]) -> bool { + let resolved = decode_announces(data, true); + let echo = decode_announces(&encode_announces(&resolved, true), true); + assert_eq!(echo, resolved, "compression did not survive a round trip"); + !resolved.is_empty() +} + /// Decode one IETF moq-transport wire object from arbitrary bytes. /// /// Byte 0 picks the negotiated draft, byte 1 the object type, and the rest is the @@ -517,6 +626,28 @@ pub fn seeds() -> Vec { }); } + // Announce streams from our own encoder: routes sharing path heads and relay tails, + // with retractions and updates moving the bases around. + let mut announced = Vec::new(); + let hop = |id: u64| crate::Hop::new(id).expect("a valid hop"); + for n in 0..12u64 { + let suffix = PathOwned::from(format!("pid{}/private/channel_{}/health-{n}", n % 3, n % 2)); + let hops = Hops::try_from(vec![hop(100 + n % 3), hop(1 << 40), hop(1 << 41)]).expect("valid hops"); + announced.push(Announced::Start(suffix, hops)); + if n % 4 == 3 { + let hops = Hops::try_from(vec![hop(200), hop(1 << 41)]).expect("valid hops"); + announced.push(Announced::Update(n, hops)); + } + if n % 5 == 4 { + announced.push(Announced::End(n)); + } + seeds.push(Seed { + target: "announce", + kind: 0, + data: encode_announces(&announced, true), + }); + } + // One of each length tag in both codecs, plus the all-ones forms that only the // leading-ones codec accepts. for selector in 0..12u8 { diff --git a/rs/moq-net/src/lite/announce.rs b/rs/moq-net/src/lite/announce.rs index cfed2f2c9f..e54733e4f7 100644 --- a/rs/moq-net/src/lite/announce.rs +++ b/rs/moq-net/src/lite/announce.rs @@ -34,12 +34,20 @@ pub fn restart_supported(version: Version) -> bool { /// 0); `EndedId` (ANNOUNCE_END) and `Restart` (ANNOUNCE_RESTART) reference /// that id instead of repeating the path. Older versions send a single /// `ANNOUNCE_BROADCAST` message that retracts by path (`Ended`). +/// +/// The path and hop chain are as they appear on the wire: on lite-07 they may name a +/// base announcement, resolved against the stream's history by +/// [`AnnounceDecoder`](super::AnnounceDecoder). #[derive(Clone, Debug, PartialEq, Eq)] pub enum AnnounceBroadcast<'a> { /// ANNOUNCE_START (lite-06) / active (older): a broadcast is now available. /// Carries the path suffix, the hop chain, and (lite-06+) the warm and cold /// route costs, and assigns the next announce id. - Active { suffix: Path<'a>, hops: Hops, cost: Cost }, + Active { + suffix: PathRef<'a>, + hops: HopsRef, + cost: Cost, + }, /// Pre-lite-06: a broadcast is no longer available, retracted by path. Ended { suffix: Path<'a>, hops: Hops }, /// ANNOUNCE_END (lite-06+): a broadcast is no longer available, retracted by @@ -48,14 +56,136 @@ pub enum AnnounceBroadcast<'a> { /// ANNOUNCE_RESTART (lite-06+): atomically replace the announcement with this id /// (e.g. a new hop chain after a relay failover, or a route whose cost moved). /// The id stays live. - /// - /// Only ever received: we advertise a replacement as an `EndedId` + `Active` pair. - Restart { id: u64, hops: Hops, cost: Cost }, + Restart { id: u64, hops: HopsRef, cost: Cost }, /// An unknown lite-06+ announce type. The length-prefixed body was skipped so /// the stream stays up; it does not assign an announce id. Skipped, } +/// A path suffix as it travels: the first `keep` segments of a base announcement's +/// suffix, followed by `rest`. +/// +/// `base` is the base's distance back from the stream's next unassigned announce id, +/// so 1 is the latest ANNOUNCE_START, and 0 names no base (`keep` must be 0 too). Only +/// lite-07 carries a base; every other version is `rest` alone. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PathRef<'a> { + pub base: u64, + pub keep: u64, + pub rest: Path<'a>, +} + +impl<'a> PathRef<'a> { + /// The whole suffix, with no base. + pub fn literal(rest: Path<'a>) -> Self { + Self { base: 0, keep: 0, rest } + } +} + +impl Encode for PathRef<'_> { + fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + if version.has_announce_compression() { + self.base.encode(w, version)?; + self.keep.encode(w, version)?; + } else if self.base != 0 || self.keep != 0 { + return Err(EncodeError::Version); + } + self.rest.encode(w, version) + } +} + +impl Decode for PathRef<'_> { + fn decode(buf: &mut B, version: Version) -> Result { + if !version.has_announce_compression() { + return Ok(Self::literal(Path::decode(buf, version)?)); + } + let base = u64::decode(buf, version)?; + let keep = u64::decode(buf, version)?; + if base == 0 && keep != 0 { + return Err(DecodeError::InvalidValue); + } + let rest = Path::decode(buf, version)?; + Ok(Self { base, keep, rest }) + } +} + +/// A hop chain as it travels: `literal` leading hops, followed by the last `keep` +/// entries of a base announcement's chain. +/// +/// `base` counts back like [`PathRef::base`], independently of it. Only lite-07 +/// carries a base; every other version is `literal` alone. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HopsRef { + pub base: u64, + pub literal: Hops, + pub keep: u64, +} + +impl HopsRef { + /// The whole chain, with no base. + pub fn literal(literal: Hops) -> Self { + Self { + base: 0, + literal, + keep: 0, + } + } +} + +impl Encode for HopsRef { + fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + if !version.has_announce_compression() { + if self.base != 0 || self.keep != 0 { + return Err(EncodeError::Version); + } + return self.literal.encode(w, version); + } + self.base.encode(w, version)?; + self.literal.encode(w, version)?; + self.keep.encode(w, version) + } +} + +impl Decode for HopsRef { + fn decode(buf: &mut B, version: Version) -> Result { + if !version.has_announce_compression() { + return Ok(Self::literal(Hops::decode(buf, version)?)); + } + let base = u64::decode(buf, version)?; + let literal = Hops::decode(buf, version)?; + let keep = u64::decode(buf, version)?; + if base == 0 && keep != 0 { + return Err(DecodeError::InvalidValue); + } + Ok(Self { base, literal, keep }) + } +} + +impl AnnounceBroadcast<'_> { + /// Re-own a decoded message so it can outlive the decode buffer. + #[cfg(test)] + pub fn into_owned(self) -> AnnounceBroadcast<'static> { + match self { + Self::Active { suffix, hops, cost } => AnnounceBroadcast::Active { + suffix: PathRef { + base: suffix.base, + keep: suffix.keep, + rest: suffix.rest.into_owned(), + }, + hops, + cost, + }, + Self::Ended { suffix, hops } => AnnounceBroadcast::Ended { + suffix: suffix.into_owned(), + hops, + }, + Self::EndedId { id } => AnnounceBroadcast::EndedId { id }, + Self::Restart { id, hops, cost } => AnnounceBroadcast::Restart { id, hops, cost }, + Self::Skipped => AnnounceBroadcast::Skipped, + } + } +} + impl Encode for Cost { fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { if !version.has_route_cost() { @@ -119,9 +249,13 @@ impl Encode for AnnounceBroadcast<'_> { match self { // The cost is a lite-06 addition, so it is simply not on the wire here. Self::Active { suffix, hops, .. } => { + // Bases are a lite-07 addition; PathRef and HopsRef refuse one here. + if suffix.base != 0 || hops.base != 0 { + return Err(EncodeError::Version); + } AnnounceStatus::Active.encode(&mut body, version)?; - suffix.encode(&mut body, version)?; - encode_hops(&mut body, version, hops)?; + suffix.rest.encode(&mut body, version)?; + encode_hops(&mut body, version, &hops.literal)?; } Self::Ended { suffix, hops } => { AnnounceStatus::Ended.encode(&mut body, version)?; @@ -151,8 +285,8 @@ impl Decode for AnnounceBroadcast<'_> { let mut body = buf.take(size); let msg = match typ { ANNOUNCE_START => Self::Active { - suffix: Path::decode(&mut body, version)?, - hops: Hops::decode(&mut body, version)?, + suffix: PathRef::decode(&mut body, version)?, + hops: HopsRef::decode(&mut body, version)?, cost: Cost::decode(&mut body, version)?, }, ANNOUNCE_END => Self::EndedId { @@ -160,7 +294,7 @@ impl Decode for AnnounceBroadcast<'_> { }, ANNOUNCE_RESTART => Self::Restart { id: u64::decode(&mut body, version)?, - hops: Hops::decode(&mut body, version)?, + hops: HopsRef::decode(&mut body, version)?, cost: Cost::decode(&mut body, version)?, }, // Unknown types are skipped by length so an earlier Lite06 build @@ -214,8 +348,8 @@ impl AnnounceBroadcast<'_> { Ok(match status { AnnounceStatus::Active => Self::Active { - suffix, - hops, + suffix: PathRef::literal(suffix), + hops: HopsRef::literal(hops), cost: Cost::UNKNOWN, }, AnnounceStatus::Ended => Self::Ended { suffix, hops }, @@ -225,8 +359,8 @@ impl AnnounceBroadcast<'_> { // path it's a fresh announce. Older versions never defined this status, so it's an // invalid value there. AnnounceStatus::Restart if restart_supported(version) => Self::Active { - suffix, - hops, + suffix: PathRef::literal(suffix), + hops: HopsRef::literal(hops), cost: Cost::UNKNOWN, }, AnnounceStatus::Restart => return Err(DecodeError::InvalidValue), @@ -406,8 +540,8 @@ mod tests { // Encode a normal Active, then flip its status byte (1 -> 2). let mut buf = bytes::BytesMut::new(); AnnounceBroadcast::Active { - suffix: Path::new("foo/bar"), - hops: Hops::new(), + suffix: PathRef::literal(Path::new("foo/bar")), + hops: HopsRef::default(), cost: Cost::default(), } .encode(&mut buf, version) @@ -485,21 +619,7 @@ mod tests { let mut slice = &buf[..]; let got = AnnounceBroadcast::decode(&mut slice, version).unwrap(); assert!(slice.is_empty(), "trailing bytes after decode"); - // Decode borrows from `buf`; re-own so the value can outlive this frame. - match got { - AnnounceBroadcast::Active { suffix, hops, cost } => AnnounceBroadcast::Active { - suffix: suffix.to_owned(), - hops, - cost, - }, - AnnounceBroadcast::Ended { suffix, hops } => AnnounceBroadcast::Ended { - suffix: suffix.to_owned(), - hops, - }, - AnnounceBroadcast::EndedId { id } => AnnounceBroadcast::EndedId { id }, - AnnounceBroadcast::Restart { id, hops, cost } => AnnounceBroadcast::Restart { id, hops, cost }, - AnnounceBroadcast::Skipped => AnnounceBroadcast::Skipped, - } + got.into_owned() } #[test] @@ -507,8 +627,8 @@ mod tests { let mut hops = Hops::new(); hops.push(Hop::new(7).unwrap()).unwrap(); let msg = AnnounceBroadcast::Active { - suffix: Path::new("room/cam"), - hops: hops.clone(), + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::literal(hops.clone()), cost: Cost::UNKNOWN, }; assert_eq!(broadcast_round_trip(&msg, Version::Lite05), msg); @@ -530,8 +650,8 @@ mod tests { let cost = Cost { warm: 12, cold: 30 }; let active = AnnounceBroadcast::Active { - suffix: Path::new("room/cam"), - hops: hops.clone(), + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::literal(hops.clone()), cost, }; assert_eq!(broadcast_round_trip(&active, Version::Lite06), active); @@ -539,10 +659,81 @@ mod tests { let ended = AnnounceBroadcast::EndedId { id: 3 }; assert_eq!(broadcast_round_trip(&ended, Version::Lite06), ended); - let restart = AnnounceBroadcast::Restart { id: 3, hops, cost }; + let restart = AnnounceBroadcast::Restart { + id: 3, + hops: HopsRef::literal(hops), + cost, + }; assert_eq!(broadcast_round_trip(&restart, Version::Lite06), restart); } + // Lite07 carries both bases as they travel; the codec resolves nothing. + #[test] + fn announce_broadcast_round_trip_on_lite07() { + let mut hops = Hops::new(); + hops.push(Hop::new(7).unwrap()).unwrap(); + let cost = Cost { warm: 12, cold: 30 }; + + let active = AnnounceBroadcast::Active { + suffix: PathRef { + base: 2, + keep: 3, + rest: Path::new("cam"), + }, + hops: HopsRef { + base: 1, + literal: hops.clone(), + keep: 2, + }, + cost, + }; + assert_eq!(broadcast_round_trip(&active, Version::Lite07), active); + + let restart = AnnounceBroadcast::Restart { + id: 3, + hops: HopsRef { + base: 4, + literal: hops, + keep: 1, + }, + cost, + }; + assert_eq!(broadcast_round_trip(&restart, Version::Lite07), restart); + } + + // A keep copies from a base, so one without a base is malformed. + #[test] + fn a_keep_without_a_base_is_rejected() { + for (path_keep, hop_keep) in [(1u8, 0u8), (0, 1)] { + // Path base, path keep, empty rest, hop base, no hops, hop keep, cost. + let body = [0, path_keep, 0, 0, 0, hop_keep, 0, 0]; + let mut buf = vec![ANNOUNCE_START as u8, body.len() as u8]; + buf.extend_from_slice(&body); + assert!(matches!( + AnnounceBroadcast::decode(&mut &buf[..], Version::Lite07), + Err(DecodeError::InvalidValue) + )); + } + } + + // Only lite-07 has room for a base. + #[test] + fn a_base_needs_lite07() { + let msg = AnnounceBroadcast::Active { + suffix: PathRef { + base: 1, + keep: 1, + rest: Path::new("cam"), + }, + hops: HopsRef::default(), + cost: Cost::default(), + }; + for version in [Version::Lite05, Version::Lite06] { + let mut buf = bytes::BytesMut::new(); + assert!(matches!(msg.encode(&mut buf, version), Err(EncodeError::Version))); + } + } + // The id-referencing forms don't exist before lite-06, and the path form is gone on lite-06. #[test] fn announce_broadcast_rejects_cross_version_forms() { @@ -554,7 +745,7 @@ mod tests { assert!(matches!( AnnounceBroadcast::Restart { id: 1, - hops: Hops::new(), + hops: HopsRef::default(), cost: Cost::default() } .encode(&mut buf, Version::Lite05), @@ -577,16 +768,16 @@ mod tests { #[test] fn route_cost_is_dropped_before_lite06() { let msg = AnnounceBroadcast::Active { - suffix: Path::new("room/cam"), - hops: Hops::new(), + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::default(), cost: Cost { warm: 9, cold: 9 }, }; let got = broadcast_round_trip(&msg, Version::Lite05); assert_eq!( got, AnnounceBroadcast::Active { - suffix: Path::new("room/cam"), - hops: Hops::new(), + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::default(), cost: Cost::UNKNOWN, } ); diff --git a/rs/moq-net/src/lite/compress.rs b/rs/moq-net/src/lite/compress.rs new file mode 100644 index 0000000000..e908e8cbec --- /dev/null +++ b/rs/moq-net/src/lite/compress.rs @@ -0,0 +1,617 @@ +//! Lite-07 announce compression: an ANNOUNCE_START may copy the head of a live +//! announcement's path, and an ANNOUNCE_START or ANNOUNCE_UPDATE the tail of a live +//! announcement's hop chain, naming that base by its distance back from the stream's +//! next unassigned Announce ID. +//! +//! The codec in `announce.rs` stays stateless and carries bases as they travel. The +//! decoder here resolves them against the stream's live announcements, which the +//! subscriber has to track anyway, and the encoder picks them. + +use std::{ + borrow::Borrow, + collections::{BTreeSet, HashMap}, + hash::Hash, +}; + +use crate::{Error, Hop, Hops, Path, PathOwned, coding::Encode, coding::Sizer}; + +use super::{HopsRef, PathRef, Version}; + +/// A live announcement, as the peer holds it: its wire suffix and wire hop chain +/// (the one excluding ANNOUNCE_OK's Hop ID). +#[derive(Clone, Debug)] +struct Entry { + suffix: PathOwned, + hops: Hops, +} + +/// The live announcements on one stream by Announce ID, which is all a base can name. +#[derive(Debug, Default)] +struct Live { + next: u64, + entries: HashMap, +} + +impl Live { + /// The live announcement `distance` back from the next id, or `None` for 0. + fn base(&self, distance: u64) -> Result, Error> { + if distance == 0 { + return Ok(None); + } + let id = self.next.checked_sub(distance).ok_or(Error::ProtocolViolation)?; + self.entries.get(&id).map(Some).ok_or(Error::ProtocolViolation) + } + + fn resolve_path(&self, path: PathRef<'_>) -> Result { + let Some(base) = self.base(path.base)? else { + return Ok(path.rest.into_owned()); + }; + let keep = usize::try_from(path.keep).map_err(|_| Error::ProtocolViolation)?; + let head = head(&base.suffix, keep).ok_or(Error::ProtocolViolation)?; + let suffix = head.join(&path.rest); + if suffix.parts().count() > Path::MAX_PARTS { + return Err(Error::ProtocolViolation); + } + Ok(suffix) + } + + fn resolve_hops(&self, hops: HopsRef) -> Result { + let Some(base) = self.base(hops.base)? else { + return Ok(hops.literal); + }; + let keep = usize::try_from(hops.keep).map_err(|_| Error::ProtocolViolation)?; + let tail = base.hops.as_slice(); + let start = tail.len().checked_sub(keep).ok_or(Error::ProtocolViolation)?; + let mut out = hops.literal; + for hop in &tail[start..] { + out.push(*hop).map_err(|_| Error::ProtocolViolation)?; + } + Ok(out) + } +} + +/// The first `keep` segments of `path`, or `None` if it has fewer. +fn head<'a>(path: &'a Path<'_>, keep: usize) -> Option> { + if keep == 0 { + return Some(Path::empty()); + } + let s = path.as_str(); + let mut ends = s + .match_indices('/') + .map(|(i, _)| i) + .chain((!s.is_empty()).then_some(s.len())); + ends.nth(keep - 1).map(|end| Path::new(&s[..end])) +} + +/// Resolves the bases on a received announce stream. Only used on versions with +/// announce ids; before lite-06 nothing is ever referenced, so nothing is recorded. +#[derive(Debug, Default)] +pub(crate) struct AnnounceDecoder { + live: Live, +} + +impl AnnounceDecoder { + /// An ANNOUNCE_START: resolve it and assign it the next id. + pub fn start(&mut self, suffix: PathRef<'_>, hops: HopsRef) -> Result<(PathOwned, Hops), Error> { + let suffix = self.live.resolve_path(suffix)?; + let hops = self.live.resolve_hops(hops)?; + let id = self.live.next; + self.live.next += 1; + self.live.entries.insert( + id, + Entry { + suffix: suffix.clone(), + hops: hops.clone(), + }, + ); + Ok((suffix, hops)) + } + + /// An ANNOUNCE_UPDATE: resolve its chain before replacing the old one, which it + /// may itself be based on. + pub fn update(&mut self, id: u64, hops: HopsRef) -> Result<(PathOwned, Hops), Error> { + let hops = self.live.resolve_hops(hops)?; + let entry = self.live.entries.get_mut(&id).ok_or(Error::ProtocolViolation)?; + entry.hops = hops.clone(); + Ok((entry.suffix.clone(), hops)) + } + + /// An ANNOUNCE_END: retire the id, returning its suffix. + pub fn end(&mut self, id: u64) -> Result { + let entry = self.live.entries.remove(&id).ok_or(Error::ProtocolViolation)?; + Ok(entry.suffix) + } +} + +/// The live ids under a run of keys: path segments from the head, or hop ids from +/// the tail. Every node holds the ids of the entries beneath it, so the entry sharing +/// the longest run with a query is found by walking the query down once, at a cost +/// that grows with its length rather than with the number of entries. +#[derive(Debug)] +struct Trie { + children: HashMap>, +} + +#[derive(Debug)] +struct Node { + ids: BTreeSet, + children: HashMap>, +} + +impl Default for Trie { + fn default() -> Self { + Self { + children: HashMap::new(), + } + } +} + +impl Trie { + fn insert<'a, Q>(&mut self, keys: impl Iterator, id: u64) + where + Q: ?Sized + ToOwned + 'a, + { + let mut children = &mut self.children; + for key in keys { + let node = children.entry(key.to_owned()).or_insert_with(|| Node { + ids: BTreeSet::new(), + children: HashMap::new(), + }); + node.ids.insert(id); + children = &mut node.children; + } + } + + fn remove<'a, Q>(&mut self, keys: impl Iterator, id: u64) + where + Q: ?Sized + Hash + Eq + 'a, + K: Borrow, + { + Self::remove_from(&mut self.children, keys, id); + } + + fn remove_from<'a, Q>(children: &mut HashMap>, mut keys: impl Iterator, id: u64) + where + Q: ?Sized + Hash + Eq + 'a, + K: Borrow, + { + let Some(key) = keys.next() else { + return; + }; + let Some(node) = children.get_mut(key) else { + return; + }; + node.ids.remove(&id); + // Nothing else passes through here, so nothing else is beneath it either. + if node.ids.is_empty() { + children.remove(key); + return; + } + Self::remove_from(&mut node.children, keys, id); + } + + /// How many leading keys the query shares with its closest entry, and that entry's + /// id, preferring the newest (the smallest distance to encode). + fn longest<'a, Q>(&self, keys: impl Iterator) -> Option<(usize, u64)> + where + Q: ?Sized + Hash + Eq + 'a, + K: Borrow, + { + let mut children = &self.children; + let mut best = None; + for (depth, key) in keys.enumerate() { + let Some(node) = children.get(key) else { + break; + }; + let newest = *node.ids.last().expect("a node without ids is pruned"); + best = Some((depth + 1, newest)); + children = &node.children; + } + best + } +} + +/// Picks the bases for an outgoing announce stream, and assigns Announce IDs on +/// versions that have them. Only lite-07 keeps the live set, since nothing earlier +/// can name a base. +/// +/// One trie over the live paths by segment, and one over the live hop chains from the +/// last hop back, find the longest shared head and tail. A base is used only when it +/// encodes smaller than the literal. +#[derive(Debug)] +pub(crate) struct AnnounceEncoder { + version: Version, + live: Live, + heads: Trie, + tails: Trie, +} + +/// A hop chain from the last hop back. +fn reversed(hops: &Hops) -> impl Iterator { + hops.as_slice().iter().rev() +} + +impl AnnounceEncoder { + pub fn new(version: Version) -> Self { + Self { + version, + live: Live::default(), + heads: Trie::default(), + tails: Trie::default(), + } + } + + /// An ANNOUNCE_START: its id (on versions that assign one) and its wire form. + pub fn start(&mut self, suffix: PathOwned, hops: Hops) -> (Option, PathRef<'static>, HopsRef) { + if !self.version.has_announce_id() { + return (None, PathRef::literal(suffix), HopsRef::literal(hops)); + } + + let id = self.live.next; + self.live.next += 1; + + if !self.version.has_announce_compression() { + return (Some(id), PathRef::literal(suffix), HopsRef::literal(hops)); + } + + // Distances count back from the id this START takes. + let path = self.path_ref(&suffix, id); + let chain = self.hops_ref(&hops, id); + self.heads.insert(suffix.parts(), id); + self.tails.insert(reversed(&hops), id); + self.live.entries.insert(id, Entry { suffix, hops }); + (Some(id), path, chain) + } + + /// An ANNOUNCE_UPDATE for a live `id`: its wire hop chain. + pub fn update(&mut self, id: u64, hops: Hops) -> HopsRef { + if !self.version.has_announce_compression() { + return HopsRef::literal(hops); + } + // The decoder resolves against the chain this replaces, so it is a valid base. + let chain = self.hops_ref(&hops, self.live.next); + let entry = self.live.entries.get_mut(&id).expect("update for a live id"); + self.tails.remove(reversed(&entry.hops), id); + self.tails.insert(reversed(&hops), id); + entry.hops = hops; + chain + } + + /// An ANNOUNCE_END for a live `id`. + pub fn end(&mut self, id: u64) { + if !self.version.has_announce_compression() { + return; + } + let entry = self.live.entries.remove(&id).expect("end for a live id"); + self.heads.remove(entry.suffix.parts(), id); + self.tails.remove(reversed(&entry.hops), id); + } + + fn size>(&self, value: &T) -> usize { + let mut sizer = Sizer::default(); + value + .encode(&mut sizer, self.version) + .expect("sizing an encodable value"); + sizer.size + } + + /// The wire form of `suffix`, sized from its parts so only the winner is built. + fn path_ref(&self, suffix: &PathOwned, next: u64) -> PathRef<'static> { + let literal = || PathRef::literal(suffix.clone()); + let Some((keep, id)) = self.heads.longest(suffix.parts()) else { + return literal(); + }; + // Where the segments after `keep` start, sharing the suffix's buffer. + let s = suffix.as_str(); + let start = s.match_indices('/').nth(keep - 1).map_or(s.len(), |(i, _)| i + 1); + let rest = suffix.slice_from(start).to_owned(); + + let base = next - id; + let keep = keep as u64; + let size = self.size(&base) + self.size(&keep) + self.size(&rest); + match size < self.size(&0u64) * 2 + self.size(suffix) { + true => PathRef { base, keep, rest }, + false => literal(), + } + } + + /// The wire form of `hops`, sized from its parts so only the winner is built. + fn hops_ref(&self, hops: &Hops, next: u64) -> HopsRef { + let literal = || HopsRef::literal(hops.clone()); + let Some((keep, id)) = self.tails.longest(reversed(hops)) else { + return literal(); + }; + let head = &hops.as_slice()[..hops.len() - keep]; + + let base = next - id; + let keep = keep as u64; + let chain = + |hops: &[Hop]| self.size(&(hops.len() as u64)) + hops.iter().map(|hop| self.size(hop)).sum::(); + let size = self.size(&base) + chain(head) + self.size(&keep); + match size < self.size(&0u64) * 2 + chain(hops.as_slice()) { + true => HopsRef { + base, + literal: Hops::try_from(head.to_vec()).expect("a prefix of a valid chain is valid"), + keep, + }, + false => literal(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERSION: Version = Version::Lite07; + + fn hops(ids: &[u64]) -> Hops { + Hops::try_from(ids.iter().map(|id| Hop::from_wire(*id).unwrap()).collect::>()).unwrap() + } + + fn path(base: u64, keep: u64, rest: &str) -> PathRef<'static> { + PathRef { + base, + keep, + rest: Path::new(rest).to_owned(), + } + } + + fn chain(base: u64, literal: &[u64], keep: u64) -> HopsRef { + HopsRef { + base, + literal: hops(literal), + keep, + } + } + + fn literal(suffix: &str, ids: &[u64]) -> (PathRef<'static>, HopsRef) { + (path(0, 0, suffix), chain(0, ids, 0)) + } + + #[test] + fn resolves_a_path_head_and_hop_tail() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("pid/private/channel_1/health-1", &[1, 2, 3]); + decoder.start(p, h).unwrap(); + + // Three leading segments of the latest START, and its last two hops. + let (suffix, chain) = decoder.start(path(1, 3, "health-2"), chain(1, &[7], 2)).unwrap(); + assert_eq!(suffix.as_str(), "pid/private/channel_1/health-2"); + assert_eq!(chain, hops(&[7, 2, 3])); + } + + #[test] + fn keeps_the_whole_path_and_chain() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("a/b", &[1, 2]); + decoder.start(p, h).unwrap(); + // A full keep with an empty rest names a route above the base's: not a + // duplicate, since the rest extends it. + let (suffix, chain) = decoder.start(path(1, 2, "c"), chain(1, &[], 2)).unwrap(); + assert_eq!(suffix.as_str(), "a/b/c"); + assert_eq!(chain, hops(&[1, 2])); + // An update copying its own full chain. + let (suffix, chain) = decoder.update(0, chain_ref(2, 2)).unwrap(); + assert_eq!(suffix.as_str(), "a/b"); + assert_eq!(chain, hops(&[1, 2])); + } + + fn chain_ref(base: u64, keep: u64) -> HopsRef { + chain(base, &[], keep) + } + + #[test] + fn a_new_stream_has_no_base() { + let mut decoder = AnnounceDecoder::default(); + assert!(matches!( + decoder.start(path(1, 0, "a"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + let mut decoder = AnnounceDecoder::default(); + assert!(matches!( + decoder.start(path(0, 0, "a"), chain(1, &[], 0)), + Err(Error::ProtocolViolation) + )); + } + + #[test] + fn rejects_a_retired_or_unassigned_base() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("a", &[1]); + decoder.start(p, h).unwrap(); + let (p, h) = literal("b", &[2]); + decoder.start(p, h).unwrap(); + decoder.end(0).unwrap(); + + // Id 0 is two back, and retired. + assert!(matches!( + decoder.start(path(2, 1, "x"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + // Three back was never assigned. + assert!(matches!( + decoder.start(path(3, 0, "x"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + // Id 1 is still live, so reusing it works after the END. + let (suffix, _) = decoder.start(path(1, 1, "x"), chain(1, &[], 1)).unwrap(); + assert_eq!(suffix.as_str(), "b/x"); + } + + #[test] + fn rejects_a_keep_longer_than_the_base() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("a/b", &[1]); + decoder.start(p, h).unwrap(); + assert!(matches!( + decoder.start(path(1, 3, "x"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + assert!(matches!( + decoder.update(0, chain_ref(1, 2)), + Err(Error::ProtocolViolation) + )); + } + + #[test] + fn rejects_a_resolved_chain_breaking_the_hop_rules() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("a", &[1, 2]); + decoder.start(p, h).unwrap(); + // A literal hop the kept tail repeats. + assert!(matches!( + decoder.start(path(0, 0, "b"), chain(1, &[2], 1)), + Err(Error::ProtocolViolation) + )); + + // Anonymous hops may repeat across the two halves. + let (p, h) = literal("c", &[0]); + decoder.start(p, h).unwrap(); + let (_, resolved) = decoder.start(path(0, 0, "d"), chain(1, &[0], 1)).unwrap(); + assert_eq!(resolved, hops(&[0, 0])); + + // And the resolved chain is still capped. + // MAX_HOPS entries. + let full: Vec = (1..=32).collect(); + let (p, h) = literal("e", &full); + decoder.start(p, h).unwrap(); + assert!(matches!( + decoder.start(path(0, 0, "f"), chain(1, &[100], 32)), + Err(Error::ProtocolViolation) + )); + } + + #[test] + fn rejects_a_resolved_path_too_deep() { + let mut decoder = AnnounceDecoder::default(); + let deep = vec!["x"; Path::MAX_PARTS].join("/"); + let (p, h) = literal(&deep, &[]); + decoder.start(p, h).unwrap(); + assert!(matches!( + decoder.start(path(1, Path::MAX_PARTS as u64, "y"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + } + + /// Encode a message with `encoder`, decode it with `decoder`, and check that the + /// resolved values match the input. + fn start(encoder: &mut AnnounceEncoder, decoder: &mut AnnounceDecoder, suffix: &str, ids: &[u64]) -> (u64, usize) { + let (id, wire_path, wire_hops) = encoder.start(Path::new(suffix).to_owned(), hops(ids)); + let size = encoder.size(&wire_path) + encoder.size(&wire_hops); + let (got_path, got_hops) = decoder.start(wire_path, wire_hops).unwrap(); + assert_eq!(got_path.as_str(), suffix); + assert_eq!(got_hops, hops(ids)); + (id.unwrap(), size) + } + + #[test] + fn encoder_output_decodes_to_its_input() { + let mut encoder = AnnounceEncoder::new(VERSION); + let mut decoder = AnnounceDecoder::default(); + + // Several origins behind a shared pair of relays. + let relays = [1u64 << 40, 1u64 << 41]; + let mut ids = Vec::new(); + for n in 0..40u64 { + let origin = 1000 + n % 4; + let suffix = format!("pid{}/private/channel_{}/stream-health-{}", n % 4, n % 7, n); + let (id, _) = start(&mut encoder, &mut decoder, &suffix, &[origin, relays[0], relays[1]]); + ids.push(id); + // Retire every third one so bases come and go. + if n % 3 == 0 { + let id = ids.remove(0); + encoder.end(id); + decoder.end(id).unwrap(); + } + } + + // Updates resolve against the chain they replace. + for id in ids.iter().copied().take(5) { + let chain = hops(&[2000, relays[1]]); + let wire = encoder.update(id, chain.clone()); + let (_, got) = decoder.update(id, wire).unwrap(); + assert_eq!(got, chain); + } + + // And a later START still matches what the encoder remembers. + start( + &mut encoder, + &mut decoder, + "pid0/private/channel_0/x", + &[1000, relays[0], relays[1]], + ); + } + + #[test] + fn encoder_picks_the_longest_shared_head_and_tail() { + let mut encoder = AnnounceEncoder::new(VERSION); + let mut decoder = AnnounceDecoder::default(); + start(&mut encoder, &mut decoder, "a/b/c", &[1u64 << 50, 1u64 << 51]); + // Shares a byte prefix with `a/b/d`, but no second segment. + start(&mut encoder, &mut decoder, "a/b-z", &[3]); + + let (_, wire_path, wire_hops) = encoder.start(Path::new("a/b/d").to_owned(), hops(&[5, 1u64 << 51])); + assert_eq!(wire_path, path(2, 2, "d")); + assert_eq!(wire_hops, chain(2, &[5], 1)); + } + + #[test] + fn encoder_sends_literal_when_nothing_is_shared() { + let mut encoder = AnnounceEncoder::new(VERSION); + let mut decoder = AnnounceDecoder::default(); + start(&mut encoder, &mut decoder, "a", &[1]); + let (_, wire_path, wire_hops) = encoder.start(Path::new("b").to_owned(), hops(&[2])); + assert_eq!(wire_path, PathRef::literal(Path::new("b"))); + assert_eq!(wire_hops, HopsRef::literal(hops(&[2]))); + } + + #[test] + fn lite06_assigns_ids_without_bases() { + let mut encoder = AnnounceEncoder::new(Version::Lite06); + let (id, _, _) = encoder.start(Path::new("a/b").to_owned(), hops(&[1])); + assert_eq!(id, Some(0)); + let (id, wire_path, wire_hops) = encoder.start(Path::new("a/c").to_owned(), hops(&[1])); + assert_eq!(id, Some(1)); + assert_eq!(wire_path, PathRef::literal(Path::new("a/c"))); + assert_eq!(wire_hops, HopsRef::literal(hops(&[1]))); + encoder.end(0); + assert_eq!(encoder.update(1, hops(&[1])), HopsRef::literal(hops(&[1]))); + } + + /// A compressed stream pinned byte for byte: `js/net/src/lite/announce.test.ts` + /// decodes the same hex, so a codec change on either side breaks both. + const GOLDEN: &str = "001600000a726f6f6d2f612f63616d000251116222000000000d0102036d696301017333010000020a00010180004444010000010101000d02010162020180005555010000"; + + fn golden() -> Vec { + use crate::fuzz::Announced; + vec![ + Announced::Start(Path::new("room/a/cam").to_owned(), hops(&[0x1111, 0x2222])), + Announced::Start(Path::new("room/a/mic").to_owned(), hops(&[0x3333, 0x2222])), + Announced::Update(0, hops(&[0x4444, 0x2222])), + Announced::End(1), + Announced::Start(Path::new("room/b").to_owned(), hops(&[0x5555, 0x2222])), + ] + } + + #[test] + fn golden_stream_is_pinned() { + let encoded = crate::fuzz::encode_announces(&golden(), true); + let hex: String = encoded.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, GOLDEN); + assert_eq!(crate::fuzz::decode_announces(&encoded, true), golden()); + } + + /// The literal lite-07 stream `js/net` writes for the same announcements, which + /// never picks a base. + const JS_LITERAL: &str = "001600000a726f6f6d2f612f63616d000251116222000000001600000a726f6f6d2f612f6d6963000273336222000000020c0000028000444462220000000101010014000006726f6f6d2f620002800055556222000000"; + + #[test] + fn js_literal_stream_decodes() { + let bytes: Vec = (0..JS_LITERAL.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&JS_LITERAL[i..i + 2], 16).unwrap()) + .collect(); + assert_eq!(crate::fuzz::decode_announces(&bytes, true), golden()); + } +} diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index bafa81b58c..566f2c5b8a 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -5,6 +5,7 @@ //! Specification: [] mod announce; +mod compress; mod datagram; mod fetch; mod goaway; @@ -26,6 +27,7 @@ mod track; mod version; pub use announce::*; +pub(crate) use compress::*; #[allow(unused_imports)] pub use datagram::*; #[allow(unused_imports)] diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index c0ec29e3c2..668ca43a1b 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -581,8 +581,8 @@ struct AnnounceRun { // Lite06+: announce ids. Every `active` we send implicitly assigns the next // per-stream ordinal, and `ended` references the id instead of repeating the // path. Only announces that actually hit the wire get an id (filtered ones - // were never seen by the peer). - next_announce_id: u64, + // were never seen by the peer). Lite07 also picks compression bases here. + encoder: lite::AnnounceEncoder, // The routes the peer currently holds, keyed by the suffix under the requested // prefix. The value is the announce id on versions that assign them. live: HashMap>, @@ -603,7 +603,7 @@ impl AnnounceRun { prefix, self_origin, version, - next_announce_id: 0, + encoder: lite::AnnounceEncoder::new(version), live: HashMap::new(), phase: AnnouncePhase::Init, } @@ -648,14 +648,22 @@ impl AnnounceRun { Some((hops, cost)) } - /// The next announce id, on versions that assign them. - fn assign_id(&mut self) -> Option { - if !self.version.has_announce_id() { - return None; - } - let id = self.next_announce_id; - self.next_announce_id += 1; - Some(id) + /// Start advertising `suffix`, recording its announce id. + fn start( + &mut self, + stream: &mut Stream, + suffix: crate::PathOwned, + hops: Hops, + cost: crate::origin::Cost, + ) -> Result<(), Error> { + let (id, wire, hops) = self.encoder.start(suffix.clone(), hops); + self.live.insert(suffix, id); + stream.writer.buffer(&lite::AnnounceBroadcast::Active { + suffix: wire, + hops, + cost, + })?; + Ok(()) } /// Retract the peer's advertisement for `suffix`, if it holds one. @@ -671,7 +679,10 @@ impl AnnounceRun { }; tracing::debug!(route = %absolute, "unannounce"); match id { - Some(id) => stream.writer.buffer(&lite::AnnounceBroadcast::EndedId { id })?, + Some(id) => { + self.encoder.end(id); + stream.writer.buffer(&lite::AnnounceBroadcast::EndedId { id })? + } // An ended announce doesn't need hops; the receiver matches on path only. None => stream.writer.buffer(&lite::AnnounceBroadcast::Ended { suffix, @@ -749,11 +760,7 @@ impl AnnounceRun { }; stream.writer.buffer(&ok)?; for (suffix, hops, cost) in initial { - let id = self.assign_id(); - self.live.insert(suffix.clone(), id); - stream - .writer - .buffer(&lite::AnnounceBroadcast::Active { suffix, hops, cost })?; + self.start(stream, suffix, hops, cost)?; } } _ => { @@ -818,12 +825,18 @@ impl AnnounceRun { Some(&id) if lite::restart_supported(self.version) => { tracing::debug!(route = %absolute, "reannounce"); match id { - Some(id) => stream - .writer - .buffer(&lite::AnnounceBroadcast::Restart { id, hops, cost })?, - None => stream - .writer - .buffer(&lite::AnnounceBroadcast::Active { suffix, hops, cost })?, + Some(id) => { + let hops = self.encoder.update(id, hops); + stream + .writer + .buffer(&lite::AnnounceBroadcast::Restart { id, hops, cost })? + } + // lite-05: a duplicate ANNOUNCE, which assigns no id. + None => stream.writer.buffer(&lite::AnnounceBroadcast::Active { + suffix: lite::PathRef::literal(suffix), + hops: lite::HopsRef::literal(hops), + cost, + })?, } } // Pre-restart versions have no way to update a live @@ -831,11 +844,7 @@ impl AnnounceRun { Some(_) => {} None => { tracing::debug!(route = %absolute, "announce"); - let id = self.assign_id(); - self.live.insert(suffix.clone(), id); - stream - .writer - .buffer(&lite::AnnounceBroadcast::Active { suffix, hops, cost })?; + self.start(stream, suffix, hops, cost)?; } }, // The chain must not be forwarded (reflected, or full): retract @@ -1684,9 +1693,11 @@ mod announce_test { let mut slice = &buf[..]; let mut msgs = Vec::new(); while !slice.is_empty() { - msgs.push(own( - lite::AnnounceBroadcast::decode(&mut slice, VERSION).expect("announce message") - )); + msgs.push( + lite::AnnounceBroadcast::decode(&mut slice, VERSION) + .expect("announce message") + .into_owned(), + ); } self.cursor += buf.len(); msgs @@ -1699,24 +1710,6 @@ mod announce_test { } } - /// Re-own a decoded message so it can outlive the decode buffer. - fn own(msg: lite::AnnounceBroadcast<'_>) -> lite::AnnounceBroadcast<'static> { - match msg { - lite::AnnounceBroadcast::Active { suffix, hops, cost } => lite::AnnounceBroadcast::Active { - suffix: suffix.to_owned(), - hops, - cost, - }, - lite::AnnounceBroadcast::Ended { suffix, hops } => lite::AnnounceBroadcast::Ended { - suffix: suffix.to_owned(), - hops, - }, - lite::AnnounceBroadcast::EndedId { id } => lite::AnnounceBroadcast::EndedId { id }, - lite::AnnounceBroadcast::Restart { id, hops, cost } => lite::AnnounceBroadcast::Restart { id, hops, cost }, - lite::AnnounceBroadcast::Skipped => lite::AnnounceBroadcast::Skipped, - } - } - struct Harness { /// Held for the whole test: dropping the origin producer retracts every /// route under it, which would end the announce loop. @@ -1769,8 +1762,8 @@ mod announce_test { assert_eq!(wire.take_ok().active, 1, "expected one initial announce"); match wire.take_announces().as_slice() { [lite::AnnounceBroadcast::Active { suffix, hops, cost }] => { - assert_eq!(suffix.as_str(), "cam"); - assert_eq!(hops, &pub_hops()); + assert_eq!(suffix.rest.as_str(), "cam"); + assert_eq!(hops, &lite::HopsRef::literal(pub_hops())); assert_eq!(*cost, crate::origin::Cost::new(7)); } other => panic!("expected the initial announce, got {other:?}"), @@ -1796,7 +1789,7 @@ mod announce_test { .unwrap(); settle().await; match h.wire.take_announces().as_slice() { - [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.as_str(), "mic"), + [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.rest.as_str(), "mic"), other => panic!("expected an announce, got {other:?}"), } @@ -1821,7 +1814,7 @@ mod announce_test { settle().await; match h.wire.take_announces().as_slice() { [lite::AnnounceBroadcast::Restart { id: 0, hops, cost }] => { - assert_eq!(hops, &pub_hops()); + assert_eq!(hops, &lite::HopsRef::literal(pub_hops())); assert_eq!(*cost, crate::origin::Cost::new(3)); } other => panic!("expected a restart, got {other:?}"), @@ -1872,7 +1865,7 @@ mod announce_test { let mut wire = Wire { writes, cursor: 0 }; assert_eq!(wire.take_ok().active, 1, "only the clean route is announced"); match wire.take_announces().as_slice() { - [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.as_str(), "local"), + [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.rest.as_str(), "local"), other => panic!("expected the clean announce, got {other:?}"), } task.abort(); @@ -1888,7 +1881,7 @@ mod announce_test { .unwrap(); settle().await; match h.wire.take_announces().as_slice() { - [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.as_str(), "mic"), + [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.rest.as_str(), "mic"), other => panic!("expected ANNOUNCE_START, got {other:?}"), } h.assert_idle(); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 4d66fe92a4..20987dbbc7 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -163,12 +163,13 @@ impl Subscriber { ) -> Result<(), Error> { match announce { lite::AnnounceBroadcast::Active { suffix, hops, cost } => { - let path = prefix.join(&suffix); - if self.version.has_announce_id() { + let (suffix, hops) = match self.version.has_announce_id() { // Every `active` assigns the next ordinal, even ones we drop locally. - run.announced_by_id.insert(run.next_announce_id, path.clone()); - run.next_announce_id += 1; - } + true => run.decoder.start(suffix, hops)?, + // Nothing references an announcement here, so there are no bases. + false => (suffix.rest.into_owned(), hops.literal), + }; + let path = prefix.join(&suffix); if lite::restart_supported(self.version) && !self.version.has_announce_id() && run.announced.contains(&path) @@ -204,18 +205,15 @@ impl Subscriber { lite::AnnounceBroadcast::EndedId { id } => { // Resolve and retire the id; an unknown or already-retired id is a // protocol violation. - let Some(path) = run.announced_by_id.remove(&id) else { - return Err(Error::ProtocolViolation); - }; + let path = prefix.join(&run.decoder.end(id)?); tracing::debug!(broadcast = %self.log_path(&path), "unannounced"); run.announced.retire(&path); } lite::AnnounceBroadcast::Restart { id, hops, cost } => { // Resolve the id; it stays live (the replacement reuses it). An unknown // or retired id is a protocol violation. - let Some(path) = run.announced_by_id.get(&id).cloned() else { - return Err(Error::ProtocolViolation); - }; + let (suffix, hops) = run.decoder.update(id, hops)?; + let path = prefix.join(&suffix); self.restart_announce( path, hops, @@ -1104,11 +1102,9 @@ struct PrefixRun { announced: Announced, // 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 - // the sender doesn't know we dropped them. We never send a restart ourselves, - // but a peer may. - next_announce_id: u64, - announced_by_id: HashMap, + // path, and lite-07 bases name it too. Tracked even for announces we drop + // locally (reflected loops), since the sender doesn't know we dropped them. + decoder: lite::AnnounceDecoder, } impl AnnouncePrefix { @@ -1194,8 +1190,7 @@ impl AnnouncePrefix { responder_origin, link_cost, announced: Announced::default(), - next_announce_id: 0, - announced_by_id: HashMap::new(), + decoder: lite::AnnounceDecoder::default(), }; // Lite01/02 send the initial set as one ANNOUNCE_INIT message, so they diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 72e4ffa30b..3c81f2a994 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -20,9 +20,10 @@ pub enum Version { /// Lite-07. Adds the hidden opt-in to ANNOUNCE_REQUEST: without it, a route with /// a `.`-prefixed segment below the requested prefix is left out. SUBSCRIBE_END /// carries the number of group streams opened, replacing SUBSCRIBE_DROP. - /// The wire format is still work-in-progress, so it is advertised over ALPN as - /// `moq-lite-07-wip` and only when explicitly requested; the default version sets - /// leave it out. + /// ANNOUNCE_START and ANNOUNCE_UPDATE may copy a path head or hop-chain tail from a + /// live announcement on the same stream. The wire format is still work-in-progress, + /// so it is advertised over ALPN as `moq-lite-07-wip` and only when explicitly + /// requested; the default version sets leave it out. Lite07, } @@ -194,6 +195,17 @@ impl Version { } } + /// Whether ANNOUNCE_START and ANNOUNCE_UPDATE may copy a path head or hop-chain tail + /// from a live announcement on the same stream. Added in lite-07. + #[allow(clippy::match_like_matches_macro)] + pub fn has_announce_compression(self) -> bool { + // Match form so future versions default forward (CLAUDE.md convention). + match self { + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, + _ => true, + } + } + /// Whether announcements carry the route cost: the marginal cost of pulling /// the broadcast via this route, accumulated per link. Added in lite-06. /// Older versions carry nothing, so a received route stays at zero and ranks diff --git a/rs/moq-net/src/path/mod.rs b/rs/moq-net/src/path/mod.rs index 70f0b342c4..21acabb44e 100644 --- a/rs/moq-net/src/path/mod.rs +++ b/rs/moq-net/src/path/mod.rs @@ -150,7 +150,7 @@ impl<'a> Path<'a> { } // A copy of this path skipping the first `n` bytes, reusing the shared buffer when possible. - fn slice_from(&'a self, n: usize) -> Path<'a> { + pub(crate) fn slice_from(&'a self, n: usize) -> Path<'a> { match &self.0 { Repr::Borrowed(s) => Path(Repr::Borrowed(&s[n..])), Repr::Shared { buf, start } => Path(Repr::Shared { diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 3a0ddbacae..203cfaae4c 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -724,16 +724,29 @@ async fn next_announce(announcements: &mut moq_net::announce::Consumer) -> moq_n #[tracing_test::traced_test] #[tokio::test] async fn broadcast_moq_lite_06_announce_lifecycle() { + announce_lifecycle_test("moq-lite-06").await; +} + +/// The same lifecycle on lite-07, where every path shares its head with a live one, +/// so the announcements after the first copy it from a base that the retractions +/// keep moving. +#[tracing_test::traced_test] +#[tokio::test] +async fn broadcast_moq_lite_07_announce_lifecycle() { + announce_lifecycle_test("moq-lite-07-wip").await; +} + +async fn announce_lifecycle_test(version: &str) { let pub_origin = moq_tokio::origin::spawn(); // Announced before the client connects, so it rides the initial set. - let first = pub_origin.create_broadcast("first").expect("create broadcast"); + let first = pub_origin.create_broadcast("room/first").expect("create broadcast"); first.announce(Default::default()).expect("create broadcast"); let mut server_config = moq_tokio::listen::Config::default(); server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; - server_config.version = vec!["moq-lite-06".parse().unwrap()]; + server_config.version = vec![version.parse().unwrap()]; let server = server_config.init(Default::default()).expect("init server"); let mut server = server.listen().await.expect("failed to listen"); let addr = server.local_addr().expect("local addr"); @@ -744,7 +757,7 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.version = vec!["moq-lite-06".parse().unwrap()]; + client_config.version = vec![version.parse().unwrap()]; let client = client_config.init(Default::default()).expect("init client"); let url: url::Url = format!("moqt://localhost:{}", addr.port()).parse().unwrap(); @@ -764,28 +777,28 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { // The initial set: "first" was announced before the session existed. let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "room/first"); assert!(update.kind.is_active(), "expected initial announce"); // A live announce after the initial set. - let second = pub_origin.create_broadcast("second").expect("create broadcast"); + let second = pub_origin.create_broadcast("room/second").expect("create broadcast"); second.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "room/second"); assert!(update.kind.is_active(), "expected live announce"); // Unannounce: retracted by announce id on the wire. Dropping the announcement // retracts the route; the broadcast's own end is independent. second.finish(); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "room/second"); assert!(!update.kind.is_active(), "expected retraction"); // Re-announce the same path: a fresh announce assigning a fresh id. - let _second = pub_origin.create_broadcast("second").expect("create broadcast"); + let _second = pub_origin.create_broadcast("room/second").expect("create broadcast"); _second.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "room/second"); assert!(update.kind.is_active(), "expected re-announce"); // Replace the route at "first": retract the original (retiring its announce @@ -793,19 +806,19 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { // Await the retraction first so the events cannot coalesce away. first.finish(); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "room/first"); assert!(!update.kind.is_active(), "expected the replaced retraction"); - let _replacement = pub_origin.create_broadcast("first").expect("create replacement"); + let _replacement = pub_origin.create_broadcast("room/first").expect("create replacement"); _replacement.announce(Default::default()).expect("create replacement"); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "room/first"); assert!(update.kind.is_active(), "expected the replacement announce"); // A sentinel proves no stray event for "first" snuck in behind the replacement. - let _sentinel = pub_origin.create_broadcast("sentinel").expect("create broadcast"); + let _sentinel = pub_origin.create_broadcast("room/sentinel").expect("create broadcast"); _sentinel.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "sentinel"); + assert_eq!(update.prefix.as_str(), "room/sentinel"); assert!(update.kind.is_active(), "expected sentinel announce"); drop(connection); From df392d8e5917aff7b22b5641c6c61a2b48822efe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 18:31:22 -0700 Subject: [PATCH 26/40] fix(relay): bind every listener in Relay::load so tests read ports back (#4215) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/index.md | 5 +- rs/moq-cli/src/auth.rs | 21 +---- rs/moq-relay/src/internal.rs | 70 +++++++------- rs/moq-relay/src/relay.rs | 28 +++--- rs/moq-relay/tests/auth_lifetime.rs | 43 +++------ rs/moq-relay/tests/cluster_unknown.rs | 24 +---- rs/moq-relay/tests/embed.rs | 87 ++++------------- rs/moq-relay/tests/hidden_cluster.rs | 29 +++--- rs/moq-relay/tests/runtime_uring.rs | 41 +++----- rs/moq-relay/tests/runtime_workers.rs | 23 +---- rs/moq-relay/tests/session_revalidate.rs | 44 +++------ rs/moq-relay/tests/shutdown_signal.rs | 38 ++------ rs/moq-tokio/src/server.rs | 114 +++++++++++++++++------ 13 files changed, 229 insertions(+), 338 deletions(-) diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index 0d449cb0bd..969ab37397 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -66,8 +66,9 @@ let web = relay.web().routes().route("/hello", get(|| async { "hello" })); relay.with_web(web).run().await?; ``` -`Relay::load` binds QUIC and web sockets. Read their actual addresses with -`quic_addr()` and `web_addrs()`, including ports assigned for `:0`. Clone +`Relay::load` binds every socket, so a taken port fails there. Read the actual +addresses with `quic_addr()`, `tcp_addr()`, `web_addrs()`, and +`internal().addr()`, including ports assigned for `:0`. Clone `ready()` before spawning `run`, then await `ready.wait()` when startup must finish before other workers begin. `config()` returns the resolved settings; `cluster().id()` returns the chosen origin ID. `with_listeners()` registers an diff --git a/rs/moq-cli/src/auth.rs b/rs/moq-cli/src/auth.rs index 6eeced4201..3061bbc1ea 100644 --- a/rs/moq-cli/src/auth.rs +++ b/rs/moq-cli/src/auth.rs @@ -823,30 +823,19 @@ mod tests { request.query = Some("jwt=secret".into()); let _reg = sessions.register(request); - let listen = std::net::TcpListener::bind("127.0.0.1:0") - .expect("probe bind") - .local_addr() - .expect("probe addr"); let mut internal_config = moq_relay::internal::Config::default(); - internal_config.listen = Some(listen); + internal_config.listen = Some("127.0.0.1:0".parse().unwrap()); let internal = moq_relay::internal::Internal::new(internal_config, moq_tokio::moq_net::stats::Registry::disabled()) - .with_sessions(sessions); + .with_sessions(sessions) + .bind() + .expect("bind internal listener"); + let listen = internal.addr().expect("internal listener is configured"); tokio::spawn(async move { let _ = internal.run().await; }); let url = format!("http://{listen}"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while reqwest::Client::new() - .get(format!("{url}/health")) - .send() - .await - .is_err() - { - assert!(std::time::Instant::now() < deadline, "internal listener never came up"); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } run(&["moq", "auth", "revalidate", "--internal-url", &url, "--id", "abc"]) .await diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index c3e576a8a8..fa97f3dfc8 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -89,6 +89,8 @@ pub struct Internal { health: moq_tokio::accept::Health, listeners: Vec, uring: Vec, + listener: Option, + addr: Option, } #[derive(Clone)] @@ -122,9 +124,31 @@ impl Internal { health, listeners, uring: Vec::new(), + listener: None, + addr: None, } } + /// Bind the configured listener now, so [`addr`](Self::addr) reports an ephemeral port before serving. + pub fn bind(mut self) -> anyhow::Result { + if let Some(listen) = self.config.listen + && self.listener.is_none() + { + let listener = moq_tokio::bind::tcp(listen).context("failed to bind internal listener")?; + let addr = listener + .local_addr() + .context("failed to resolve internal bind address")?; + self.addr = Some(addr); + self.listener = Some(listener); + } + Ok(self) + } + + /// The bound address after [`bind`](Self::bind) or [`crate::Relay::load`], if configured. + pub fn addr(&self) -> Option { + self.addr + } + /// Report other listeners' accept health at `/metrics`. /// /// Takes an iterator so the accessors feed it directly, however many listeners @@ -220,18 +244,7 @@ impl Internal { /// resolves), so it drops cleanly into a `select!` as a disabled no-op - /// mirroring how the relay treats other optional services. pub async fn serve(self, app: Router) -> anyhow::Result<()> { - let listener = self.bind()?; - self.serve_bound(app, listener).await - } - - pub(crate) fn bind(&self) -> anyhow::Result> { - self.config - .listen - .map(|listen| moq_tokio::bind::tcp(listen).context("failed to bind internal listener")) - .transpose() - } - - pub(crate) async fn serve_bound(self, app: Router, listener: Option) -> anyhow::Result<()> { + let Internal { listener, health, .. } = self.bind()?; let Some(listener) = listener else { std::future::pending::<()>().await; return Ok(()); @@ -241,7 +254,7 @@ impl Internal { // that single top-level layer, matching `Web::serve` / `Cluster::run`. // No accept-time work: the ops router never hands a connection to qmux, so // capturing a descriptor per health check would spend one for nothing. - crate::listener::server(listener, self.health, DefaultAcceptor::new())? + crate::listener::server(listener, health, DefaultAcceptor::new())? .serve(app.into_make_service()) .await?; Ok(()) @@ -803,32 +816,23 @@ mod tests { /// and forking both the socket options and the disabled-listener contract. #[tokio::test] async fn serve_hosts_merged_routes_alongside_the_defaults() { - // A throwaway bind picks a free port, released before `serve` claims it - // for real (`bind::tcp` sets SO_REUSEADDR, and nothing ever connected). - let listen = std::net::TcpListener::bind("127.0.0.1:0") - .expect("probe bind") - .local_addr() - .expect("probe addr"); - - let internal = Internal::new(Config { listen: Some(listen) }, moq_net::stats::Registry::disabled()); + let listen = Some("127.0.0.1:0".parse().unwrap()); + let internal = Internal::new(Config { listen }, moq_net::stats::Registry::disabled()) + .bind() + .expect("bind internal listener"); + let addr = internal.addr().expect("internal listener is configured"); let app = internal .routes() .merge(Router::new().route("/embedder", get(async || "embedded\n"))); let server = tokio::spawn(internal.serve(app)); - // `serve` binds inside the task, so poll rather than assume it is up the - // instant the spawn returns. let client = reqwest::Client::new(); - let url = format!("http://{listen}"); - let mut embedder = None; - for _ in 0..200 { - if let Ok(res) = client.get(format!("{url}/embedder")).send().await { - embedder = Some(res); - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - let embedder = embedder.expect("internal listener never accepted a connection"); + let url = format!("http://{addr}"); + let embedder = client + .get(format!("{url}/embedder")) + .send() + .await + .expect("embedder request"); assert_eq!(embedder.status(), reqwest::StatusCode::OK); assert_eq!(embedder.text().await.expect("embedder body"), "embedded\n"); diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index ba04e67570..082ab27978 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -71,7 +71,7 @@ impl Ready { pub struct Relay { ready: tokio::sync::watch::Sender, config: Config, - server: moq_tokio::Server, + server: moq_tokio::Listener, client: moq_tokio::Client, auth: auth::Auth, /// The sessions the embedder decides, until it takes them. `None` when the @@ -107,10 +107,10 @@ impl Relay { /// Assemble a relay from its configuration: bind the listeners, resolve /// auth, and build the cluster with its cache and stats attached. /// - /// This performs the side effects of starting up (binding sockets, reading - /// key material, spawning the cache governor), so a returned `Relay` is - /// ready to serve; nothing accepts a connection until [`Self::run`] drives - /// it. + /// This performs the side effects of starting up (binding every socket, + /// reading key material, spawning the cache governor), so a returned `Relay` + /// reports its ephemeral ports and a taken port fails here; no session is + /// admitted until [`Self::run`] drives it. pub async fn load(mut config: Config) -> anyhow::Result { config.resolve()?; let resolved_config = config.clone(); @@ -274,6 +274,10 @@ impl Relay { .with_versions(server_versions) .with_sessions(sessions.clone()) .bind()?; + // `bind`, not `listen`: the TCP/Unix accept loops handshake as soon as + // they run, and `load` is not yet willing to take a session. `run` + // starts them on its first accept. + let server = server.bind().await.context("failed to bind listeners")?; // Internal (ops) listener (plain HTTP, opt-in via `--internal-listen`) for // /metrics + /health + /nodes, separate from the customer-facing web server. No-op @@ -284,7 +288,8 @@ impl Relay { .with_cluster(&cluster) .with_sessions(sessions.clone()) .with_listeners(web.accept_health()) - .with_listeners(server.accept_health()); + .with_listeners(server.accept_health()) + .bind()?; // Bound but not yet serving: registering here (rather than after the // threads start) is what gives every worker a series from the first // scrape, including one that is about to fail setup. @@ -352,6 +357,11 @@ impl Relay { self.web.addrs() } + /// The bound plain TCP (qmux) address, or `None` when `listen.tcp.bind` is unset. + pub fn tcp_addr(&self) -> Option { + self.server.tcp_local_addr() + } + /// The client used to dial cluster peers. Already handed to [`Self::cluster`]; /// clone it for your own outbound dials so they share the connection config. pub fn client(&self) -> &moq_tokio::Client { @@ -505,10 +515,6 @@ impl Relay { .context("failed to start the io_uring QUIC workers")?; } - // Bind the shared TCP/Unix and optional internal sockets before reporting - // readiness, so an unavailable port cannot leave a falsely ready relay. - let server = server.listen().await.context("failed to bind listeners")?; - let internal_listener = internal.bind()?; ready.send_replace(true); #[cfg(unix)] @@ -616,7 +622,7 @@ impl Relay { let result = tokio::select! { Err(err) = started.run() => Err(err).context("cluster failed"), Err(err) = web.serve(web_routes) => Err(err).context("web server failed"), - Err(err) = internal.serve_bound(internal_routes, internal_listener) => Err(err).context("internal server failed"), + Err(err) = internal.serve(internal_routes) => Err(err).context("internal server failed"), Err(err) = serve_shared => Err(err).context("server failed"), Err(err) = quic_workers => Err(err).context("QUIC workers failed"), err = uring_failed => Err(err).context("io_uring QUIC workers failed"), diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index a4e421c8ad..63b2aa18a4 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -9,7 +9,6 @@ //! The last tests swap the server for an in-process decider answering //! `Admissions`, and prove the lease it drives reaches the session the same way. -use std::net::TcpListener; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; @@ -125,23 +124,6 @@ fn build_auth(url: url::Url) -> moq_relay::auth::Auth { .expect("auth init") } -/// Wait for a TCP listener to become dialable, or panic. -async fn wait_for_listener(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_err() { - assert!( - std::time::Instant::now() < deadline, - "relay listener never became ready on port {port}" - ); - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - -fn free_port() -> u16 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - probe.local_addr().expect("local addr").port() -} - /// Stand up the relay's accept loop on a plain-TCP qmux listener and return the /// port plus an abort handle. async fn spawn_relay(auth: moq_relay::auth::Auth) -> (u16, tokio::task::JoinHandle<()>) { @@ -155,12 +137,12 @@ async fn spawn_relay_with( cluster: cluster::Cluster, ) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let port = free_port(); let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); let server = config.init(Default::default()).expect("server init"); let mut server = server.listen().await.expect("listen"); + let port = server.tcp_local_addr().expect("TCP listener is configured").port(); let handle = tokio::spawn(async move { let mut id = 0; @@ -173,7 +155,6 @@ async fn spawn_relay_with( } }); - wait_for_listener(port).await; (port, handle) } @@ -181,7 +162,6 @@ async fn spawn_relay_with( /// port plus an abort handle. async fn spawn_ws_relay(auth: moq_relay::auth::Auth) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let port = free_port(); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); // Stream listeners bind lazily, so this server never opens a socket; only @@ -196,14 +176,16 @@ async fn spawn_ws_relay(auth: moq_relay::auth::Auth) -> (u16, tokio::task::JoinH let mut web_config = web::Config::default(); web_config.ws = true; - web_config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); - let web = web::Web::new(auth, cluster, certificates, web_config); + 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; }); - wait_for_listener(port).await; (port, handle) } @@ -1023,26 +1005,25 @@ async fn a_fixed_lease_still_expires() { #[tokio::test] async fn a_relay_without_an_auth_source_is_decided_by_the_embedder() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let config = |port: u16| { + let config = || { let mut config = Config::default(); - config.listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); // The sessions are gone by the time the trigger fires; no need to wait out the default window. config.drain_timeout = Duration::from_millis(100); config }; - let untaken = Relay::load(config(free_port())).await.expect("load relay"); + let untaken = Relay::load(config()).await.expect("load relay"); let err = untaken.run().await.expect_err("nobody can authenticate"); assert!(err.to_string().contains("nobody can authenticate"), "{err}"); - let port = free_port(); - let mut relay = Relay::load(config(port)).await.expect("load relay"); + let mut relay = Relay::load(config()).await.expect("load relay"); + let port = relay.tcp_addr().expect("TCP listener bound").port(); let admissions = relay.admissions().expect("an empty [auth] hands over the admissions"); assert!(relay.admissions().is_none(), "taken once"); let decider = Decider::spawn(admissions, grant(Duration::from_secs(3600))); let trigger = relay.shutdown_trigger().clone(); let running = tokio::spawn(relay.run()); - wait_for_listener(port).await; let (pub_session, sub_session) = connect_and_round_trip(&room_url("tcp", port)).await; assert_eq!(decider.seen.lock().unwrap().len(), 2, "one admission per session"); diff --git a/rs/moq-relay/tests/cluster_unknown.rs b/rs/moq-relay/tests/cluster_unknown.rs index 9ad71cdde3..5409b85818 100644 --- a/rs/moq-relay/tests/cluster_unknown.rs +++ b/rs/moq-relay/tests/cluster_unknown.rs @@ -2,7 +2,7 @@ //! The relay records that publisher as `Hop::UNKNOWN`; reflected cluster paths //! must not replace it while gossiping around a redundant mesh. -use std::{net::TcpListener, time::Duration}; +use std::time::Duration; use moq_relay::{Config, Relay}; use url::Url; @@ -10,24 +10,15 @@ use url::Url; const TIMEOUT: Duration = Duration::from_secs(10); const PATH: &str = "opalin/cell-clumsy-octopus/cameras/left.hang"; -fn free_tcp_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("bind probe") - .local_addr() - .expect("local addr") - .port() -} - async fn spawn_relay( id: u64, connect: Vec, cluster_version: Option, ) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let port = free_tcp_port(); let mut config = Config::default(); - config.listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse bind")); + config.listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse bind")); config.connect.bind = Some("127.0.0.1:0".parse().expect("parse client bind")); config.connect.tls.insecure = Some(true); config.connect.version.extend(cluster_version); @@ -38,20 +29,13 @@ async fn spawn_relay( config.cluster.id = Some(id); config.cluster.connect = connect.into_iter().map(moq_relay::cluster::Peer::new).collect(); + // `load` binds the TCP listener, so the port is ours before anyone dials it. let relay = Relay::load(config).await.expect("relay load"); + let port = relay.tcp_addr().expect("TCP listener is configured").port(); let handle = tokio::spawn(async move { let _ = relay.run().await; }); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - assert!(std::time::Instant::now() < deadline, "relay {id} never became ready"); - tokio::time::sleep(Duration::from_millis(25)).await; - } - (port, handle) } diff --git a/rs/moq-relay/tests/embed.rs b/rs/moq-relay/tests/embed.rs index e65b5577e9..bd8b4bfbb8 100644 --- a/rs/moq-relay/tests/embed.rs +++ b/rs/moq-relay/tests/embed.rs @@ -9,8 +9,6 @@ #![cfg(feature = "_quic")] -#[cfg(target_os = "linux")] -use std::net::UdpSocket; use std::net::{SocketAddr, TcpListener}; use std::time::Duration; @@ -19,23 +17,6 @@ use moq_tokio::moq_net; const TIMEOUT: Duration = Duration::from_secs(10); -fn free_tcp_port() -> u16 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - -/// Only used by the Linux-only worker/uring tests below; without the gate the -/// macOS test build fails `-D warnings` on dead code. -#[cfg(target_os = "linux")] -fn free_udp_port() -> u16 { - let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { let key = rcgen::KeyPair::generate().expect("keypair"); let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).expect("cert params"); @@ -59,19 +40,6 @@ fn client() -> moq_tokio::Client { config.init(Default::default()).expect("client init") } -async fn wait_for_http(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - return; - } - if std::time::Instant::now() >= deadline { - panic!("relay http listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - async fn assert_owner_stopped(quic: SocketAddr, http: SocketAddr) { assert!( tokio::net::TcpStream::connect(http).await.is_err(), @@ -109,17 +77,17 @@ async fn embed_and_stop(mut config: Config) { // No drain window: the sessions are already gone by the time the owner // stops, and the test should not wait out the default. config.drain_timeout = Duration::ZERO; - let http = config.web.http.listen.expect("http listener configured"); let relay = Relay::load(config.clone()).await.expect("load relay"); let quic = relay.quic_addr().expect("quic listener bound"); - assert_eq!(relay.web_addrs().http, Some(http)); + let http = relay.web_addrs().http.expect("http listener bound"); assert_eq!( relay.config().quic.max_streams, Some(moq_tokio::quic::DEFAULT_MAX_STREAMS) ); assert_eq!(relay.cluster().id(), relay.cluster().origin.hop().id()); - // Pin the replacement to the same ports, including a `:0` first bind. + // Pin the replacement to the same ports the `:0` first binds got. config.listen.bind = Some(moq_tokio::listen::Bind::Addr(quic)); + config.web.http.listen = Some(http); // The application handles: in-process workers publish into the origin the // QUIC sessions see, and the trigger stops the owner from any task. Both @@ -138,8 +106,6 @@ async fn embed_and_stop(mut config: Config) { .route("/plain-post", axum::routing::post(|| async { "plain" })); let running = tokio::spawn(relay.with_web(web).run()); ready.wait().await.expect("relay ready"); - - wait_for_http(http.port()).await; assert!(!running.is_finished(), "the relay stopped while serving"); let body = reqwest::get(format!("http://127.0.0.1:{}/embedded", http.port())) @@ -287,7 +253,6 @@ async fn embed_and_stop(mut config: Config) { assert_eq!(replacement.addr(), Some(quic), "replacement bound a different address"); let trigger = replacement.shutdown_trigger().clone(); let replacing = tokio::spawn(replacement.run()); - wait_for_http(http.port()).await; let health = reqwest::get(format!("http://127.0.0.1:{}/health", http.port())) .await .expect("replacement health") @@ -304,55 +269,39 @@ fn http_and_quic(cert: &std::path::Path, key: &std::path::Path, quic_bind: Strin config.listen.bind = Some(quic_bind.parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; - config.web.http.listen = Some(format!("127.0.0.1:{}", free_tcp_port()).parse().expect("parse http")); + config.web.http.listen = Some("127.0.0.1:0".parse().expect("parse http")); config.web.ws = false; public_auth(&mut config); config } -/// A late TCP bind failure must close readiness without reporting success. +/// An occupied TCP port fails `load`, before anything could report readiness. #[tokio::test] -async fn tcp_bind_failure_does_not_report_ready() { +async fn tcp_bind_failure_fails_load() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let occupied = TcpListener::bind("127.0.0.1:0").expect("reserve TCP port"); let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.listen.tcp.bind = Some(occupied.local_addr().expect("reserved address")); - let relay = Relay::load(config).await.expect("load relay before TCP bind"); - let ready = relay.ready(); - let running = tokio::spawn(relay.run()); - - let result = tokio::time::timeout(TIMEOUT, ready.wait()) + let error = Relay::load(config) .await - .expect("readiness never resolved"); - assert!(result.is_err(), "failed TCP bind reported readiness"); - let error = running - .await - .expect("run panicked") - .expect_err("run accepted an occupied TCP port"); + .err() + .expect("load accepted an occupied TCP port"); assert!(error.to_string().contains("failed to bind listeners"), "{error:#}"); } -/// An occupied internal port must fail before the relay reports readiness. +/// An occupied internal port fails `load`, before anything could report readiness. #[tokio::test] -async fn internal_bind_failure_does_not_report_ready() { +async fn internal_bind_failure_fails_load() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let occupied = TcpListener::bind("127.0.0.1:0").expect("reserve internal port"); let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.internal.listen = Some(occupied.local_addr().expect("reserved address")); - let relay = Relay::load(config).await.expect("load relay before internal bind"); - let ready = relay.ready(); - let running = tokio::spawn(relay.run()); - - let result = tokio::time::timeout(TIMEOUT, ready.wait()) - .await - .expect("readiness never resolved"); - assert!(result.is_err(), "failed internal bind reported readiness"); - let error = running + let error = Relay::load(config) .await - .expect("run panicked") - .expect_err("run accepted an occupied internal port"); + .err() + .expect("load accepted an occupied internal port"); assert!( error.to_string().contains("failed to bind internal listener"), "{error:#}" @@ -373,7 +322,7 @@ async fn shared_tokio_custom_route_and_quic() { async fn worker_tokio_custom_route_and_quic() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let mut config = http_and_quic(&cert, &key, format!("127.0.0.1:{}", free_udp_port())); + let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.runtime.workers = Some(2); config.runtime.pin = false; embed_and_stop(config).await; @@ -395,7 +344,7 @@ async fn uring_custom_route_and_quic() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let mut config = http_and_quic(&cert, &key, format!("127.0.0.1:{}", free_udp_port())); + let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.runtime.workers = Some(2); config.runtime.pin = false; config.runtime.io_uring = true; @@ -408,10 +357,10 @@ async fn embedded_listener_health_reaches_metrics() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); - config.internal.listen = Some(format!("127.0.0.1:{}", free_tcp_port()).parse().unwrap()); + config.internal.listen = Some("127.0.0.1:0".parse().unwrap()); config.drain_timeout = Duration::ZERO; - let internal = config.internal.listen.unwrap(); let relay = Relay::load(config).await.expect("load relay"); + let internal = relay.internal().addr().expect("internal listener bound"); let ready = relay.ready(); let trigger = relay.shutdown_trigger().clone(); let health = moq_tokio::accept::Health::new("embedded"); diff --git a/rs/moq-relay/tests/hidden_cluster.rs b/rs/moq-relay/tests/hidden_cluster.rs index 2132607289..e49437910f 100644 --- a/rs/moq-relay/tests/hidden_cluster.rs +++ b/rs/moq-relay/tests/hidden_cluster.rs @@ -1,7 +1,6 @@ //! A cluster peer that predates the hidden opt-in still discovers the relay's //! `.`-named broadcasts, so a mixed-version mesh keeps `.internal/origins`. -use std::net::TcpListener; use std::time::Duration; use moq_relay::cluster::{self, Peer}; @@ -28,21 +27,16 @@ where .expect("test thread panicked"); } -/// A stream-only moq server on a free loopback TCP port, speaking only `version`. -fn bind_free_tcp_server(version: moq_net::Version) -> (u16, moq_tokio::Server) { - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); - config.version = vec![version]; - if let Ok(server) = config.init(Default::default()) { - return (port, server); - } - } - panic!("could not bind a free TCP port after 20 attempts"); +/// A stream-only moq server listening on an ephemeral loopback TCP port, +/// speaking only `version`, and that port. +async fn listen_tcp(version: moq_net::Version) -> (u16, moq_tokio::Listener) { + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + config.version = vec![version]; + let server = config.init(Default::default()).expect("server init"); + let listener = server.listen().await.expect("listen"); + let port = listener.tcp_local_addr().expect("TCP listener is configured").port(); + (port, listener) } #[test] @@ -58,11 +52,10 @@ async fn old_peer_keeps_hidden_paths(version: moq_net::Version) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); tokio::time::timeout(TEST_TIMEOUT, async { - let (port, server) = bind_free_tcp_server(version); + let (port, mut server) = listen_tcp(version).await; let peer_origin = moq_tokio::origin::spawn(); let mut discovered = peer_origin.consume().with_hidden(true).announced(); let accept = tokio::spawn(async move { - let mut server = server.listen().await.expect("listen"); let request = server.accept().await.expect("cluster dial"); let session = request.with_subscriber(peer_origin).ok().await.expect("accept"); session.closed().await; diff --git a/rs/moq-relay/tests/runtime_uring.rs b/rs/moq-relay/tests/runtime_uring.rs index 6fbebee5c8..0d8be349f9 100644 --- a/rs/moq-relay/tests/runtime_uring.rs +++ b/rs/moq-relay/tests/runtime_uring.rs @@ -7,7 +7,7 @@ //! floor (GitHub-hosted CI), where it skips loudly. #![cfg(all(target_os = "linux", feature = "_uring"))] -use std::net::{SocketAddr, UdpSocket}; +use std::net::SocketAddr; use std::time::Duration; use moq_relay::{Config, Relay}; @@ -28,15 +28,6 @@ fn supported() -> bool { } } -/// A UDP port nothing is bound to. Every worker binds the same port, so this -/// cannot be `:0`. -fn free_udp_port() -> u16 { - let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - /// A CA on disk plus a certificate it signed, for the mTLS test. Returns the /// root, the client's certificate, and its key. fn signed_client(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { @@ -78,9 +69,9 @@ fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf /// A relay config serving QUIC from io_uring workers. Pinning is off because a /// CI container may restrict which cores it may run on. -fn uring_config(cert: &std::path::Path, key: &std::path::Path, port: u16) -> Config { +fn uring_config(cert: &std::path::Path, key: &std::path::Path) -> Config { let mut config = Config::default(); - config.listen.bind = Some(format!("127.0.0.1:{port}").parse().unwrap()); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; config.runtime.workers = Some(WORKERS); @@ -118,11 +109,8 @@ async fn uring_workers_serve_webtransport_and_raw_quic() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let relay = Relay::load(uring_config(&cert, &key, port)).await.expect("load relay"); - let expected: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); - assert_eq!(relay.addr(), Some(expected), "workers bound a different address"); + let relay = Relay::load(uring_config(&cert, &key)).await.expect("load relay"); + let port = relay.addr().expect("workers bound an address").port(); // The stock loop serves everything: the uring workers own QUIC, the shared // runtime owns auth and supervision. @@ -209,10 +197,9 @@ async fn uring_workers_report_link_facts() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let relay = Relay::load(uring_config(&cert, &key, port)).await.expect("load relay"); + let relay = Relay::load(uring_config(&cert, &key)).await.expect("load relay"); let local = relay.addr().expect("bound address"); + let port = local.port(); let sessions = relay.sessions().clone(); let running = tokio::spawn(relay.run()); @@ -278,9 +265,7 @@ async fn uring_workers_publish_their_certificate_fingerprint() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let relay = Relay::load(uring_config(&cert, &key, free_udp_port())) - .await - .expect("load relay"); + let relay = Relay::load(uring_config(&cert, &key)).await.expect("load relay"); // The relay's own web listener needs TLS; its router does not, and the // handler reads the same certificate handle either way. @@ -319,14 +304,13 @@ async fn an_mtls_client_authenticates_without_a_token() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let (root, client_cert, client_key) = signed_client(dir.path()); - let port = free_udp_port(); - - let mut config = uring_config(&cert, &key, port); + let mut config = uring_config(&cert, &key); config.auth.public = Vec::new(); config.auth.url = Some(spawn_auth_server(mtls_only()).await); config.listen.tls.root = vec![root]; let relay = Relay::load(config).await.expect("load relay"); + let port = relay.addr().expect("workers bound an address").port(); let running = tokio::spawn(relay.run()); let client = || { @@ -414,11 +398,10 @@ async fn uring_workers_write_qlog_traces() { let (cert, key) = certificate(dir.path()); let traces = dir.path().join("qlog"); std::fs::create_dir(&traces).expect("create qlog dir"); - let port = free_udp_port(); - - let mut config = uring_config(&cert, &key, port); + let mut config = uring_config(&cert, &key); config.quic.qlog = Some(traces.clone()); let relay = Relay::load(config).await.expect("load relay"); + let port = relay.addr().expect("workers bound an address").port(); let running = tokio::spawn(relay.run()); // A real session, so a trace covers a handshake and application data diff --git a/rs/moq-relay/tests/runtime_workers.rs b/rs/moq-relay/tests/runtime_workers.rs index 7f6040ec1d..97cce1fd36 100644 --- a/rs/moq-relay/tests/runtime_workers.rs +++ b/rs/moq-relay/tests/runtime_workers.rs @@ -6,7 +6,6 @@ //! a build without one refuses `runtime.workers` at load. #![cfg(all(target_os = "linux", feature = "_quic"))] -use std::net::{SocketAddr, UdpSocket}; use std::time::Duration; use moq_relay::{Config, Relay}; @@ -15,17 +14,6 @@ use moq_tokio::moq_net; const TIMEOUT: Duration = Duration::from_secs(10); const WORKERS: u16 = 4; -/// A UDP port nothing is bound to. -/// -/// Every worker binds the same port, so this cannot be `:0`: each would pick an -/// ephemeral port of its own and they would not form a group. -fn free_udp_port() -> u16 { - let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - /// A self-signed certificate on disk. Workers refuse `listen.tls.generate`, /// since each would generate one of its own and serve a different identity. fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { @@ -43,9 +31,9 @@ fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf /// /// Pinning is off because a CI container may restrict which cores it may run on, /// and none of these tests are about placement. -fn worker_config(cert: &std::path::Path, key: &std::path::Path, port: u16, workers: u16) -> Config { +fn worker_config(cert: &std::path::Path, key: &std::path::Path, workers: u16) -> Config { let mut config = Config::default(); - config.listen.bind = Some(format!("127.0.0.1:{port}").parse().unwrap()); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; config.runtime.workers = Some(workers); @@ -82,13 +70,10 @@ async fn workers_serve_quic_and_share_one_origin() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let config = worker_config(&cert, &key, port, WORKERS); + let config = worker_config(&cert, &key, WORKERS); let relay = Relay::load(config).await.expect("load relay"); - let expected: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); - assert_eq!(relay.addr(), Some(expected), "workers bound a different address"); + let port = relay.addr().expect("workers bound an address").port(); // The owner keeps the worker group; aborting this task is what joins them. let running = tokio::spawn(relay.run()); diff --git a/rs/moq-relay/tests/session_revalidate.rs b/rs/moq-relay/tests/session_revalidate.rs index 5d777ae0ba..77225c7802 100644 --- a/rs/moq-relay/tests/session_revalidate.rs +++ b/rs/moq-relay/tests/session_revalidate.rs @@ -5,7 +5,6 @@ //! an authority: the server's next word is what kicks, reties, or keeps the //! session. -use std::net::TcpListener; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; @@ -113,22 +112,6 @@ fn build_auth(url: url::Url) -> moq_relay::auth::Auth { .expect("auth init") } -async fn wait_for_listener(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_err() { - assert!( - std::time::Instant::now() < deadline, - "listener never became ready on port {port}" - ); - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - -fn free_port() -> u16 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - probe.local_addr().expect("local addr").port() -} - struct Fixture { url: url::Url, internal: url::Url, @@ -148,19 +131,18 @@ impl Fixture { async fn spawn(auth: moq_relay::auth::Auth, websocket: bool) -> Self { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let sessions = moq_relay::session::Registry::new(); - let internal_port = free_port(); - let internal_addr: std::net::SocketAddr = format!("127.0.0.1:{internal_port}").parse().unwrap(); let mut internal_config = internal::Config::default(); - internal_config.listen = Some(internal_addr); + internal_config.listen = Some("127.0.0.1:0".parse().unwrap()); let internal = internal::Internal::new(internal_config, moq_net::stats::Registry::disabled()) - .with_sessions(sessions.clone()); + .with_sessions(sessions.clone()) + .bind() + .expect("bind internal listener"); + let internal_addr = internal.addr().expect("internal listener is configured"); tokio::spawn(async move { let _ = internal.run().await; }); - wait_for_listener(internal_port).await; let (url, handle) = if websocket { - let port = free_port(); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); let mut server_config = moq_tokio::listen::Config::default(); server_config.bind = Some("[::]:0".parse().unwrap()); @@ -171,19 +153,22 @@ impl Fixture { .certificates(); let mut web_config = web::Config::default(); web_config.ws = true; - web_config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); - let web = web::Web::new(auth, cluster, certificates, web_config).with_sessions(sessions.clone()); + web_config.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); + let web = web::Web::new(auth, cluster, certificates, web_config) + .with_sessions(sessions.clone()) + .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; }); - wait_for_listener(port).await; (format!("ws://127.0.0.1:{port}").parse().unwrap(), handle) } else { - let port = free_port(); let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); let server = config.init(Default::default()).expect("server init"); let mut server = server.listen().await.expect("listen"); + let port = server.tcp_local_addr().expect("TCP listener is configured").port(); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); let sessions = sessions.clone(); let handle = tokio::spawn(async move { @@ -198,13 +183,12 @@ impl Fixture { }); } }); - wait_for_listener(port).await; (format!("tcp://127.0.0.1:{port}").parse().unwrap(), handle) }; Self { url, - internal: format!("http://127.0.0.1:{internal_port}").parse().unwrap(), + internal: format!("http://{internal_addr}").parse().unwrap(), sessions, _relay: handle, } diff --git a/rs/moq-relay/tests/shutdown_signal.rs b/rs/moq-relay/tests/shutdown_signal.rs index 23cf66e264..3f45135fd0 100644 --- a/rs/moq-relay/tests/shutdown_signal.rs +++ b/rs/moq-relay/tests/shutdown_signal.rs @@ -14,7 +14,7 @@ #![cfg(unix)] -use std::{net::TcpListener, time::Duration}; +use std::time::Duration; use moq_relay::{Config, Relay, auth}; @@ -51,10 +51,9 @@ async fn sigint_drains_sessions_before_exiting_inner() { let _interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()).expect("register SIGINT"); - let (port, config) = relay_config(); - let relay = Relay::load(config).await.expect("load relay"); + let relay = Relay::load(relay_config()).await.expect("load relay"); + let port = relay.tcp_addr().expect("TCP listener bound").port(); let run = tokio::spawn(relay.run()); - wait_listening(port).await; let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); @@ -106,37 +105,16 @@ async fn sigint_drains_sessions_before_exiting_inner() { ); } -/// A stream-only relay on a free loopback TCP port, fully public, with a short -/// drain window. Returns the port and the config to hand [`Relay::load`]. -fn relay_config() -> (u16, Config) { - // The listener is bound by `Relay::run`, not here, so this leaves the usual - // probe/bind gap; on loopback it is not worth retrying around. - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - +/// A stream-only relay on an ephemeral loopback TCP port, fully public, with a +/// short drain window, to hand [`Relay::load`]. +fn relay_config() -> Config { // Fully public auth: any no-JWT stream client gets the whole root. let mut auth = auth::Config::default(); auth.public = vec![moq_auth::Pattern::all()]; let mut config = Config::default(); - config.listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); config.auth = auth; config.drain_timeout = DRAIN_TIMEOUT; - - (port, config) -} - -async fn wait_listening(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - assert!( - std::time::Instant::now() < deadline, - "relay never became ready on port {port}" - ); - tokio::time::sleep(Duration::from_millis(25)).await; - } + config } diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index dacd68c671..c06beaf474 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -484,32 +484,63 @@ impl Server { health } + /// Bind whatever is still unbound and hand back the [`Listener`], without + /// accepting. + /// + /// Same terminal bind as [`listen`](Self::listen), including ephemeral ports + /// via [`Listener::tcp_local_addr`]. Stream accept loops stay stopped until + /// [`Listener::accept`], so nothing is read off the socket before the caller + /// is ready to take sessions. [`listen`](Self::listen) is this plus starting + /// those loops immediately. + /// + /// A bind failure is the error, not a silent `None` from a later accept. It + /// leaves nothing bound: the partially built `Listener` drops here, closing + /// whatever it opened. Build a fresh `Server` from the (cloneable) config to try + /// again. + // `mut` is only needed to bind the stream listeners, which a QUIC-only build has none of. + #[allow(unused_mut)] + pub async fn bind(mut self) -> crate::Result { + #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] + self.streams.bind().await?; + Ok(Listener { server: self }) + } + /// Start serving: bind whatever is still unbound and hand back the /// [`Listener`] to accept sessions from. /// /// Terminal, and that is the point: it consumes the `Server`, so the builders /// above cannot run afterwards and every session is served the configuration /// this call captured. The QUIC socket is bound by [`crate::listen::Config::init`], but - /// the stream (`tcp`/`unix`) listeners need a runtime, so they bind here. + /// the stream (`tcp`/`unix`) listeners need a runtime, so they bind here, and + /// their accept loops start here too. Use [`bind`](Self::bind) to learn the + /// port without accepting yet. /// /// A bind failure is the error, not a silent `None` from a later accept. It /// leaves nothing bound: the partially built `Listener` drops here, closing /// whatever it opened. Build a fresh `Server` from the (cloneable) config to try /// again. - // `mut` is only needed to bind the stream listeners, which a QUIC-only build has none of. - #[allow(unused_mut)] - pub async fn listen(mut self) -> crate::Result { + pub async fn listen(self) -> crate::Result { + #[cfg(not(any(feature = "tcp", all(feature = "uds", unix))))] + { + self.bind().await + } #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] { - // The stream listeners offer a wider version set than the server's own - // (see `stream_versions`), against the same configuration. - let server = self.moq.clone().with_versions(self.streams.versions.clone()); - self.streams.start(&server).await?; + let mut listener = self.bind().await?; + // Accept immediately, so a dial can finish its handshake before the + // caller polls `accept`. `bind` leaves that until the first accept. + let server = listener + .server + .moq + .clone() + .with_versions(listener.server.streams.versions.clone()); + listener.server.streams.serve(&server); + Ok(listener) } - Ok(Listener { server: self }) } - /// The body of [`Listener::accept`]; the listeners are already running. + /// The body of [`Listener::accept`]. Stream accept loops start here if + /// [`Server::bind`] left them stopped; [`Server::listen`] already started them. #[cfg(any( feature = "noq", feature = "iroh", @@ -518,6 +549,14 @@ impl Server { all(feature = "uds", unix) ))] async fn accept_next(&mut self) -> Option { + // A `bind` (rather than `listen`) leaves the stream loops stopped so an + // embedder can read the port before it is willing to take sessions. + // Starting them here, on the first accept, is that moment. + #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] + { + let server = self.moq.clone().with_versions(self.streams.versions.clone()); + self.streams.serve(&server); + } loop { // The QUIC endpoint address, reported as a QUIC session's local side. #[cfg(feature = "noq")] @@ -825,10 +864,11 @@ impl StreamBind { /// The stream (`tcp`/`unix`) listeners owned by a [`Server`]. /// -/// Bound by [`Server::listen`] (they need a runtime), after which each runs an -/// accept loop in its own task and feeds completed [`Request`]s back over a channel. -/// The tasks own their listeners and are stopped when the [`Listener`] closes or -/// drops, so bound sockets don't linger. +/// Bound by [`Server::bind`] (they need a runtime). [`Server::listen`] then starts +/// each accept loop; a bind-only listener starts them on the first +/// [`Listener::accept`] instead. Each loop feeds completed [`Request`]s back over +/// a channel. The tasks own their listeners and are stopped when the [`Listener`] +/// closes or drops, so bound sockets don't linger. #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] struct StreamListeners { binds: Vec, @@ -839,7 +879,10 @@ struct StreamListeners { versions: moq_net::Versions, #[cfg(all(feature = "uds", unix))] unix_allow: Option, - /// The address the TCP listener bound, once [`Self::start`] has run. + /// Bound sockets whose accept loops have not started. Empty once [`Self::serve`] + /// runs, and when nothing was configured. + pending: Vec, + /// The address the TCP listener bound, once [`Self::bind`] has run. #[cfg(feature = "tcp")] tcp_local_addr: Option, rx: Option>, @@ -863,6 +906,7 @@ impl StreamListeners { versions, #[cfg(all(feature = "uds", unix))] unix_allow, + pending: Vec::new(), #[cfg(feature = "tcp")] tcp_local_addr: None, rx: None, @@ -870,21 +914,17 @@ impl StreamListeners { } } - /// Bind every configured listener and spawn its accept loop. - /// - /// Called once, from [`Server::listen`]. Everything binds before anything is - /// spawned, so a failure part-way drops the listeners already opened and frees - /// their sockets there and then, rather than leaving accept loops to be aborted - /// at some later point. + /// Bind every configured listener without accepting. /// - /// `server` is the configuration each accepted session handshakes against, - /// already fixed by the time this runs. - async fn start(&mut self, server: &moq_net::Server) -> crate::Result<()> { + /// Everything binds before [`Self::serve`] starts a loop, so a failure + /// part-way drops the listeners already opened and frees their sockets there + /// and then, rather than leaving accept loops to be aborted later. + async fn bind(&mut self) -> crate::Result<()> { if self.binds.is_empty() { return Ok(()); } - let mut bound = Vec::with_capacity(self.binds.len()); + let mut pending = Vec::with_capacity(self.binds.len()); for (bind, health) in self.binds.drain(..).zip(self.health.iter().cloned()) { let alpns = self.versions.alpns(); match bind { @@ -900,7 +940,7 @@ impl StreamListeners { let local = listener.local_addr()?; tracing::info!(addr = %local, "listening (tcp)"); self.tcp_local_addr = Some(local); - bound.push(BoundListener::Tcp(listener)); + pending.push(BoundListener::Tcp(listener)); } #[cfg(all(feature = "uds", unix))] StreamBind::Unix(path) => { @@ -912,13 +952,26 @@ impl StreamListeners { // directory or uid/gid/pid allowlist is the access gate. listener.set_mode(0o666)?; tracing::info!(path = %path.display(), allow = ?self.unix_allow, "listening (unix)"); - bound.push(BoundListener::Unix(listener)); + pending.push(BoundListener::Unix(listener)); } } } + self.pending = pending; + Ok(()) + } + + /// Spawn an accept loop for every listener [`Self::bind`] opened. + /// + /// No-op when nothing is waiting: either nothing was configured, or the loops + /// are already running. `server` is the configuration each accepted session + /// handshakes against, fixed by the time this runs. + fn serve(&mut self, server: &moq_net::Server) { + if self.pending.is_empty() { + return; + } let (tx, rx) = tokio::sync::mpsc::channel(16); - for listener in bound { + for listener in self.pending.drain(..) { let task = match listener { #[cfg(feature = "tcp")] BoundListener::Tcp(listener) => spawn_tcp_loop(listener, server.clone(), tx.clone()), @@ -927,9 +980,7 @@ impl StreamListeners { }; self.tasks.push(task); } - self.rx = Some(rx); - Ok(()) } /// Yield the next stream [`Request`], or `None` if no listener is running: @@ -944,6 +995,9 @@ impl StreamListeners { /// Stop every accept loop and wait until its listener has released the socket. async fn shutdown(&mut self) { self.binds.clear(); + // Drop sockets that were bound but never accepted, so the address frees + // even when `run` never started the loops. + self.pending.clear(); self.rx = None; for task in self.tasks.drain(..) { task.abort(); From 50313f2b290cc12a44ff91472a39de2362974c6a Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:35:09 -0700 Subject: [PATCH 27/40] chore: release (#4199) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 54 +++++++++++++++++------------------ Cargo.toml | 38 ++++++++++++------------ rs/hang/CHANGELOG.md | 6 ++++ 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 | 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 | 6 ++++ 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 | 6 ++++ rs/moq-mux/Cargo.toml | 2 +- rs/moq-net/CHANGELOG.md | 10 +++++++ rs/moq-net/Cargo.toml | 2 +- rs/moq-nvenc/CHANGELOG.md | 6 ++++ rs/moq-nvenc/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 6 ++++ 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 | 6 ++++ 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, 239 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9276b6998b..73b7247c95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.5" +version = "0.21.6" dependencies = [ "anyhow", "bytes", @@ -3884,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.5" +version = "0.6.6" dependencies = [ "anyhow", "bytes", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.5" +version = "0.0.6" dependencies = [ "async-trait", "bytes", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.4" +version = "0.1.5" dependencies = [ "block2 0.6.2", "bytes", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "moq-auth" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "aws-lc-rs", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.4" +version = "0.1.5" dependencies = [ "bytes", "kio 0.6.0", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.5" +version = "0.5.6" dependencies = [ "anyhow", "boytacean", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.5" +version = "0.12.6" dependencies = [ "anyhow", "axum", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.5" +version = "0.0.6" dependencies = [ "aws-lc-rs", "base64 0.23.1", @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.5" +version = "0.4.6" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4374,7 +4374,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "bytes", @@ -4393,7 +4393,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.5" +version = "0.5.6" dependencies = [ "axum", "bytes", @@ -4417,7 +4417,7 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.5.2" +version = "0.5.3" dependencies = [ "bytes", "criterion", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.13" +version = "0.2.14" dependencies = [ "bytes", "moq-net", @@ -4452,7 +4452,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.5" +version = "0.10.6" dependencies = [ "anyhow", "base64 0.23.1", @@ -4489,7 +4489,7 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.4" +version = "0.3.5" dependencies = [ "arrayvec", "bytes", @@ -4588,7 +4588,7 @@ dependencies = [ [[package]] name = "moq-nvenc" -version = "0.1.1" +version = "0.1.2" dependencies = [ "cudarc", "libloading 0.9.0", @@ -4606,7 +4606,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.5" +version = "0.15.6" dependencies = [ "anyhow", "axum", @@ -4649,7 +4649,7 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.5" +version = "0.2.6" dependencies = [ "kio 0.6.0", "moq-auth", @@ -4663,7 +4663,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.5" +version = "0.3.6" dependencies = [ "aws-lc-rs", "axum", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.5" +version = "0.3.6" dependencies = [ "anyhow", "byteorder", @@ -4731,7 +4731,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.5" +version = "0.3.6" dependencies = [ "bytes", "futures", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.5" +version = "0.2.6" dependencies = [ "futures", "moq-json", @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.16" +version = "0.19.17" dependencies = [ "anyhow", "bytes", @@ -4815,7 +4815,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "bytes", @@ -4834,7 +4834,7 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "bytes", @@ -4891,7 +4891,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index bed70b2450..5f9dcd4431 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,7 +121,7 @@ dispatch2 = "0.3.1" flate2 = { version = "1.1", default-features = false } futures = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } -hang = { version = "0.21.5", path = "rs/hang" } +hang = { version = "0.21.6", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" @@ -139,16 +139,16 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.1.4", path = "rs/moq-audio", default-features = false } -moq-auth = { version = "0.1.2", path = "rs/moq-auth" } -moq-binary = { version = "0.1.4", path = "rs/moq-binary" } +moq-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-flate = { version = "0.2.0", path = "rs/moq-flate" } -moq-hls = { version = "0.5.5", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.5.2", path = "rs/moq-json" } -moq-loc = { version = "0.2.13", path = "rs/moq-loc" } +moq-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.5", path = "rs/moq-mux" } -moq-net = { version = "0.3.4", path = "rs/moq-net" } +moq-mux = { version = "0.10.6", path = "rs/moq-mux" } +moq-net = { version = "0.3.5", 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 } @@ -156,25 +156,25 @@ moq-noq-udp = "1.3.1" # NVENC bindings, forked from ViliamVadocz/nvidia-video-codec-sdk to dlopen the # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. -moq-nvenc = { version = "0.1.1", path = "rs/moq-nvenc" } +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.5", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.5", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.5", path = "rs/moq-rtmp" } +moq-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-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.5", path = "rs/moq-srt" } -moq-stats = { version = "0.2.5", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.16", path = "rs/moq-tokio", default-features = false } +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 } # 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.4", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.5", 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.6", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.7", 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.4", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.5", 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 c547539b83..8eb3f06886 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.6](https://github.com/moq-dev/moq/compare/hang-v0.21.5...hang-v0.21.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.21.5](https://github.com/moq-dev/moq/compare/hang-v0.21.4...hang-v0.21.5) - 2026-09-25 ### Other diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 2113563ee7..3a91b6cc15 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.5" +version = "0.21.6" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index 8a8498ced7..729971df95 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.6](https://github.com/moq-dev/moq/compare/libmoq-v0.6.5...libmoq-v0.6.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-video, moq-json, hang, moq-mux, moq-tokio, moq-audio + ## [0.6.5](https://github.com/moq-dev/moq/compare/libmoq-v0.6.4...libmoq-v0.6.5) - 2026-09-25 ### Other diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 118272a410..1333199898 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.5" +version = "0.6.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-archive/CHANGELOG.md b/rs/moq-archive/CHANGELOG.md index a1cfbf4762..32ca63360b 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.6](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.5...moq-archive-v0.0.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.0.5](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.4...moq-archive-v0.0.5) - 2026-09-25 ### Other diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index 538837409d..99608653d3 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.5" +version = "0.0.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index a2c05831c2..1f1528d686 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.5](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.4...moq-audio-v0.1.5) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.3...moq-audio-v0.1.4) - 2026-09-25 ### Fixed diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 67de1334a0..8e2dd4c426 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.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-auth/CHANGELOG.md b/rs/moq-auth/CHANGELOG.md index 4cb4d45f86..b765d35fab 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.3](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.2...moq-auth-v0.1.3) - 2026-09-26 + +### Other + +- *(auth)* wait on the recorded request instead of a fixed sleep ([#4194](https://github.com/moq-dev/moq/pull/4194)) + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.1...moq-auth-v0.1.2) - 2026-09-25 ### Fixed diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 6be26b37a5..9c65a292ea 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.2" +version = "0.1.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-binary/CHANGELOG.md b/rs/moq-binary/CHANGELOG.md index 122a0ff8ca..74f69c0200 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.5](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.4...moq-binary-v0.1.5) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.3...moq-binary-v0.1.4) - 2026-09-25 ### Other diff --git a/rs/moq-binary/Cargo.toml b/rs/moq-binary/Cargo.toml index 85dd3a5d71..f4b4ea44fb 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.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index 3bfe971696..bedc2bcc32 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.6](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.5...moq-boy-v0.5.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-video, moq-json, hang, moq-mux, moq-tokio, moq-audio + ## [0.5.5](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.4...moq-boy-v0.5.5) - 2026-09-25 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 1b260e84e4..d2407abfc2 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.5" +version = "0.5.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 40cbbbf6c9..55370f5586 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.6](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.5...moq-cli-v0.12.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-video, moq-auth, hang, moq-mux, moq-tokio, moq-audio, moq-hls, moq-relay, moq-rtc, moq-rtmp, moq-srt, moq-transcode + ## [0.12.5](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.4...moq-cli-v0.12.5) - 2026-09-25 ### Other diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 032ef22831..a1739c6a86 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.5" +version = "0.12.6" 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 676857bed3..4172aa7f0b 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.6](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.5...moq-e2ee-v0.0.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.0.5](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.4...moq-e2ee-v0.0.5) - 2026-09-25 ### Other diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index fb37ee65ab..e87619e285 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.5" +version = "0.0.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index ca2afe7700..9d52f99f9e 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.6](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.5...moq-ffi-v0.4.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-video, moq-json, hang, moq-mux, moq-tokio, moq-audio + ## [0.4.5](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.4...moq-ffi-v0.4.5) - 2026-09-25 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 6e3d76ae70..7b822275cc 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.5" +version = "0.4.6" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 71a0d63210..5461b5432c 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.6](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.5...moq-gst-v0.4.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux, moq-tokio + ## [0.4.5](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.4...moq-gst-v0.4.5) - 2026-09-25 ### Other diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 2e81769e45..40439c0c0b 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.5" +version = "0.4.6" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index c4767cc0d5..967b0c349e 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.6](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.5...moq-hls-v0.5.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.5.5](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.4...moq-hls-v0.5.5) - 2026-09-25 ### Other diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 3a95a6abef..090727281d 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.5" +version = "0.5.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index 8e6ac917dc..eb07288e02 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.3](https://github.com/moq-dev/moq/compare/moq-json-v0.5.2...moq-json-v0.5.3) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.5.2](https://github.com/moq-dev/moq/compare/moq-json-v0.5.1...moq-json-v0.5.2) - 2026-09-25 ### Other diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 847ae81c4a..62944fcdb3 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.2" +version = "0.5.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-loc/CHANGELOG.md b/rs/moq-loc/CHANGELOG.md index c7cd3ed095..247d789404 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.14](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.13...moq-loc-v0.2.14) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.2.13](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.12...moq-loc-v0.2.13) - 2026-09-25 ### Other diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index 1c07c79750..11d260f4be 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.13" +version = "0.2.14" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index 1d5b8a25c4..bf54e97eb3 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.6](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.5...moq-mux-v0.10.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-json, hang, moq-binary, moq-loc + ## [0.10.5](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.4...moq-mux-v0.10.5) - 2026-09-25 ### Added diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 88d5e3da40..f3a46c917e 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.5" +version = "0.10.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 7fc73f3d32..607907fe5a 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.5](https://github.com/moq-dev/moq/compare/moq-net-v0.3.4...moq-net-v0.3.5) - 2026-09-26 + +### Added + +- *(net)* count lite-07 group streams in SUBSCRIBE_END ([#4118](https://github.com/moq-dev/moq/pull/4118)) + +### Fixed + +- *(net)* accept EXPIRES in SUBSCRIBE_OK, PUBLISH_OK, and REQUEST_OK ([#4195](https://github.com/moq-dev/moq/pull/4195)) + ## [0.3.4](https://github.com/moq-dev/moq/compare/moq-net-v0.3.3...moq-net-v0.3.4) - 2026-09-25 ### Fixed diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index a064f2beca..7af52d3da3 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.4" +version = "0.3.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-nvenc/CHANGELOG.md b/rs/moq-nvenc/CHANGELOG.md index 3c433c654f..b5fd76cb57 100644 --- a/rs/moq-nvenc/CHANGELOG.md +++ b/rs/moq-nvenc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2](https://github.com/moq-dev/moq/compare/moq-nvenc-v0.1.1...moq-nvenc-v0.1.2) - 2026-09-26 + +### Fixed + +- *(nvenc)* commit rate changes and cleanup only once the driver accepts ([#4146](https://github.com/moq-dev/moq/pull/4146)) + ## [0.1.1](https://github.com/moq-dev/moq/compare/moq-nvenc-v0.1.0...moq-nvenc-v0.1.1) - 2026-09-25 ### Fixed diff --git a/rs/moq-nvenc/Cargo.toml b/rs/moq-nvenc/Cargo.toml index 4674d790c1..1f5d5643db 100644 --- a/rs/moq-nvenc/Cargo.toml +++ b/rs/moq-nvenc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moq-nvenc" -version = "0.1.1" +version = "0.1.2" edition = "2021" license = "MIT" rust-version.workspace = true diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 77d7452de9..f0ad948636 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.6](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.5...moq-relay-v0.15.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-auth, moq-tokio, moq-uring, moq-stats + ## [0.15.5](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.4...moq-relay-v0.15.5) - 2026-09-25 ### Other diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 8ea12937ab..1dc1a0abcb 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.5" +version = "0.15.6" 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 3f3b26d5f6..b28e108eae 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.6](https://github.com/moq-dev/moq/compare/moq-room-v0.2.5...moq-room-v0.2.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-auth, moq-json + ## [0.2.5](https://github.com/moq-dev/moq/compare/moq-room-v0.2.4...moq-room-v0.2.5) - 2026-09-25 ### Other diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index cca9dff114..2b0df1e037 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.5" +version = "0.2.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index 5fb7b1d958..e96565dce7 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.6](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.5...moq-rtc-v0.3.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.5](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.4...moq-rtc-v0.3.5) - 2026-09-25 ### Other diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index d0ede4ab36..ea23c0717f 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.5" +version = "0.3.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtmp/CHANGELOG.md b/rs/moq-rtmp/CHANGELOG.md index 97da0d525e..b5fb3d8b25 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.6](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.5...moq-rtmp-v0.3.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.5](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.4...moq-rtmp-v0.3.5) - 2026-09-25 ### Other diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index 9d538c0c00..1335fb73aa 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.5" +version = "0.3.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-srt/CHANGELOG.md b/rs/moq-srt/CHANGELOG.md index 035ee8269b..eeb7c8ce93 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.6](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.5...moq-srt-v0.3.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-mux + ## [0.3.5](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.4...moq-srt-v0.3.5) - 2026-09-25 ### Other diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index 7c881c0673..a6ed2225bc 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.5" +version = "0.3.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index da4c8aa3da..35cb95084d 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.6](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.5...moq-stats-v0.2.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.2.5](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.4...moq-stats-v0.2.5) - 2026-09-25 ### Other diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index 8647abedf2..3df8fb6278 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.5" +version = "0.2.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index ed16002c1b..9feb940d26 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.17](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.16...moq-tokio-v0.19.17) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.19.16](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.15...moq-tokio-v0.19.16) - 2026-09-25 ### Fixed diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 23f1008a84..14e0a53689 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.16" +version = "0.19.17" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index 1e5792541a..ee90a8c0b6 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.5](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.4...moq-transcode-v0.1.5) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-video, hang, moq-mux + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.3...moq-transcode-v0.1.4) - 2026-09-25 ### Other diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index 0cde22ea92..f58c3ec02d 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.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-uring/CHANGELOG.md b/rs/moq-uring/CHANGELOG.md index beea4c6ba5..80c3577805 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.7](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.6...moq-uring-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-uring-v0.0.5...moq-uring-v0.0.6) - 2026-09-25 ### Other diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 35163a9583..10fce20851 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.6" +version = "0.0.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 137a3e22f5..3914d39499 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.5](https://github.com/moq-dev/moq/compare/moq-video-v0.1.4...moq-video-v0.1.5) - 2026-09-26 + +### Fixed + +- *(nvenc)* commit rate changes and cleanup only once the driver accepts ([#4146](https://github.com/moq-dev/moq/pull/4146)) + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-video-v0.1.3...moq-video-v0.1.4) - 2026-09-25 ### Other diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 968a7c1fcb..a2b4fe45ee 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.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true From b99cad9d5c869cf911e6c380a06a65f5a4ef8fd4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 19:04:35 -0700 Subject: [PATCH 28/40] fix(net): end a track with its session's error when the session dies (#4120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 Co-authored-by: Jakub MigdaÅ‚ Co-authored-by: Grok 4.7 --- js/net/src/error.ts | 25 ++ js/net/src/ietf/adapter.ts | 23 +- js/net/src/ietf/connection.ts | 2 +- js/net/src/ietf/publisher.test.ts | 3 +- js/net/src/ietf/subscriber.ts | 28 +- js/net/src/integration.test.ts | 99 ++++++- js/net/src/lite/connection.ts | 8 +- js/net/src/lite/subscriber.ts | 31 ++- js/net/src/mock.ts | 26 +- quest/m1/README.md | 1 - quest/m1/session-close.md | 1 - quest/m1/session-death-error.md | 55 ---- quest/m1/track-tail-interop.md | 5 - rs/moq-net/src/ietf/session.rs | 18 +- rs/moq-net/src/ietf/subscriber.rs | 142 ++++++++-- rs/moq-net/src/lite/session.rs | 4 + rs/moq-net/src/lite/subscriber.rs | 133 ++++++++- rs/moq-net/src/model/resume.rs | 193 ++++++++----- rs/moq-net/src/model/track.rs | 63 ++++- .../tests/subscription_end_integrity.rs | 263 ++++++++++++++++++ rs/moq-net/tests/support/mock.rs | 136 ++++++--- .../tests/subscription_end_integrity.rs | 188 +++++++++++++ 22 files changed, 1199 insertions(+), 248 deletions(-) delete mode 100644 quest/m1/session-death-error.md create mode 100644 rs/moq-net/tests/subscription_end_integrity.rs create mode 100644 rs/moq-tokio/tests/subscription_end_integrity.rs diff --git a/js/net/src/error.ts b/js/net/src/error.ts index 912fae64cd..04ad2b84c2 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -424,6 +424,31 @@ export function fromClose(info: WebTransportCloseInfo): Session | null { return new Session(code, { reason: info.reason }); } +/** + * The session's close as the error it ends everything with, carrying the peer's code. A clean + * close code is still an error here: whatever the close cut off did not end. + * + * @internal + */ +export function closeError(quic: WebTransport): Promise { + return quic.closed.then( + (info) => fromClose(info) ?? new Session(SessionCode.Cancel, { reason: info.reason }), + (err: unknown) => error(err), + ); +} + +/** + * Report a failure the session's close caused as the session's own error, which carries the + * peer's close code; any other failure passes through. + * + * @internal + */ +export async function sessionCause(quic: WebTransport | undefined, err: unknown): Promise { + const source = typeof err === "object" && err !== null ? (err as { source?: unknown }).source : undefined; + if (quic && source === "session") return closeError(quic); + return error(err); +} + /** * Coerce an unknown thrown value into an `Error`. * diff --git a/js/net/src/ietf/adapter.ts b/js/net/src/ietf/adapter.ts index fd16a5349d..b51e48d8da 100644 --- a/js/net/src/ietf/adapter.ts +++ b/js/net/src/ietf/adapter.ts @@ -1,4 +1,5 @@ import { Mutex } from "async-mutex"; +import { error, ProtocolViolation } from "../error.ts"; import { Reader, Stream, type Writer } from "../stream.ts"; import * as Varint from "../varint.ts"; import * as Namespace from "./namespace.ts"; @@ -246,6 +247,8 @@ export class ControlStreamAdapter implements Session { * Must be called after construction. Runs until the control stream closes. */ async run(): Promise { + // Why the virtual streams end: undefined only for a GOAWAY, which is not a failure. + let cause: Error | undefined; try { // v16: also accept real bidi streams (for SubscribeNamespace) if (this.version === Version.DRAFT_16) { @@ -254,7 +257,10 @@ export class ControlStreamAdapter implements Session { for (;;) { const done = await this.#reader.done(); - if (done) break; + if (done) { + cause = new ProtocolViolation("control stream closed"); + break; + } const typeId = await this.#reader.u53(); const size = await this.#reader.u16(); @@ -293,8 +299,11 @@ export class ControlStreamAdapter implements Session { break; } } + } catch (err: unknown) { + cause = error(err); + throw err; } finally { - this.close(); + this.close(cause); } } @@ -719,15 +728,19 @@ export class ControlStreamAdapter implements Session { } } - close() { + /** + * Ends every virtual stream: cleanly for a deliberate close, or with `err` when the + * control stream died under them, since every request riding it was cut off. + */ + close(err?: Error) { if (this.#closed) return; this.#closed = true; console.debug("adapter: close() called"); - // Close all virtual streams for (const entry of this.#streams.values()) { try { - entry.controller.close(); + if (err) entry.controller.error(err); + else entry.controller.close(); } catch { // Already closed } diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index e1d22f9bf8..bcbcf01abc 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -141,7 +141,7 @@ export class Connection implements Established { }); this.#solicit = solicit; this.#cluster = cluster; - this.#subscriber = new Subscriber({ session: this.#session, cluster, hidden }); + this.#subscriber = new Subscriber({ session: this.#session, quic, cluster, hidden }); registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index a16ce4ccbc..76fca819a2 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -639,8 +639,9 @@ test("closing the session ends the unsolicited announce loop", async () => { // The session ends. The origin is untouched: it is shared, and other sessions keep using it. pair.server.close(); + // Ending with the session's error is ending too: the close fails its open streams. await Promise.race([ - loop, + loop.catch(() => undefined), new Promise((_resolve, reject) => setTimeout(() => reject(new Error("the announce loop outlived its session")), STREAM_WAIT), ), diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index b107b0ca23..0294b35e3d 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -2,7 +2,7 @@ import { race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; import * as broadcast from "../broadcast.ts"; import { BroadcastCache } from "../consume.ts"; -import { controlTimeout, error, ProtocolViolation, reason } from "../error.ts"; +import { closeError, controlTimeout, error, ProtocolViolation, reason, sessionCause } from "../error.ts"; 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"; @@ -97,6 +97,10 @@ function sees(filter: Filter, path: Path.Valid): boolean { export class Subscriber { #session: Session; + // The transport, so a request cut off by the session's close ends with the session's + // error. Optional for tests that drive a bare session. + #quic?: WebTransport; + // The Hop IDs this session declared; see {@link Cluster}. What the peer declared is what // says whether an advertisement carries a hop path, and ours is what a path looping back // to us contains. @@ -140,17 +144,21 @@ export class Subscriber { */ constructor({ session, + quic, cluster, hidden = false, }: { /** The session abstraction for bidi streams and request IDs. */ session: Session; + /** The transport the session runs on. */ + quic?: WebTransport; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ hidden?: boolean; }) { this.#session = session; + this.#quic = quic; this.#cluster = cluster; this.#hidden = hidden; } @@ -479,10 +487,22 @@ export class Subscriber { return consumer; } + // The adapter is gone. If the transport has already closed, that close is the + // error. A still-open transport, such as a GOAWAY drain, has no peer code yet, + // so this does not wait for it. + async #closedSession(): Promise { + const quic = this.#quic; + if (!quic) return new Error("session closed"); + return Promise.race([ + closeError(quic), + new Promise((resolve) => queueMicrotask(() => resolve(new Error("session closed")))), + ]); + } + async #runSubscribe(broadcast: Path.Valid, request: track.Request) { const requestId = await this.#session.nextRequestId(); if (requestId === undefined) { - request.reject(new Error("session closed")); + request.reject(await this.#closedSession()); return; } @@ -533,7 +553,7 @@ export class Subscriber { console.debug(`subscribe ok: id=${requestId} broadcast=${broadcast} track=${request.name}`); } catch (err) { // A control request that timed out is not late content, so it carries its own code. - const e = err instanceof TimeoutError ? controlTimeout(err) : error(err); + const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err); request.reject(e); console.warn( `subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`, @@ -617,7 +637,7 @@ export class Subscriber { stream.close(); console.debug(`subscribe close: id=${requestId} broadcast=${broadcast} track=${request.name}`); } catch (err) { - const e = error(err); + const e = await sessionCause(this.#quic, err); producer.close(e); stream.abort(e); console.warn( diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 3107cb2b14..aaed493344 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -9,7 +9,7 @@ import { connect as connectSession, type Established, } from "./connection/index.ts"; -import { StreamCode, StreamError, TooFarBehind } from "./error.ts"; +import { SessionCode, SessionError, StreamCode, StreamError, TooFarBehind } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair } from "./mock.ts"; @@ -2218,3 +2218,100 @@ test("a handle serves a request under live/** over the wire", async () => { server.close(); origin.close(); }); + +// The peer's close code a killed session carries. +const DEATH = SessionCode(71); + +/** + * Serve one track with a group left open, kill the publisher's session once the subscriber has + * read into it, and return how the subscriber's track ended. + */ +async function runSessionDeath(protocol: string, version?: number): Promise { + const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); + + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { version, publish: origin.consume() }), + ]); + + const broadcast = publish(origin, Path.from("test")); + const serving = (async () => { + for (;;) { + const req = await wireOf(broadcast).requested(); + if (!req) break; + req.accept().appendGroup().writeString("head"); + } + })(); + + const remote = wireOf(client).consume(Path.from("test")); + const track = remote.track("video").subscribe(); + const group = await track.recvGroup(); + expect(await group?.readString()).toBe("head"); + + pair.server.close({ closeCode: DEATH, reason: "killed" }); + const closed = await withTimeout(Promise.resolve(track.closed), 2000, "the track never ended"); + + broadcast.close(); + await serving; + remote.close(); + client.close(); + server.close(); + origin.close(); + return closed; +} + +for (const [name, protocol, version] of [ + ["lite draft-03", Lite.ALPN_03, undefined], + ["lite draft-05", Lite.ALPN_05, undefined], + ["ietf draft-14", "", Ietf.Version.DRAFT_14], + ["ietf draft-17", Ietf.ALPN.DRAFT_17, undefined], +] as const) { + test(`integration: ${name} ends a track with its session's error`, async () => { + const closed = await runSessionDeath(protocol, version); + expect(closed).toBeInstanceOf(SessionError); + expect((closed as SessionError).code).toBe(DEATH); + }); +} + +// On lite-05+ the subscribe stream carries responses until its FIN, so a reset of it is how the +// publisher ends a subscription with an error, and the track ends with that error. +test("integration: lite draft-05 ends a track with the publisher's reset", async () => { + const pair = createMockTransportPair(Lite.ALPN_05); + 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 served: TrackProducer[] = []; + const serving = (async () => { + for (;;) { + const req = await wireOf(broadcast).requested(); + if (!req) break; + const producer = req.accept(); + producer.appendGroup().writeString("head"); + served.push(producer); + } + })(); + + const remote = wireOf(client).consume(Path.from("test")); + const track = remote.track("video").subscribe(); + const group = await track.recvGroup(); + expect(await group?.readString()).toBe("head"); + + const reset = StreamCode(70); + for (const producer of served) producer.close(new StreamError(reset)); + const closed = await withTimeout(Promise.resolve(track.closed), 2000, "the track never ended"); + expect(closed).toBeInstanceOf(StreamError); + expect((closed as StreamError).code).toBe(reset); + + broadcast.close(); + await serving; + remote.close(); + client.close(); + server.close(); + origin.close(); +}); diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 75e681d6f8..4aec63c3a3 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -3,7 +3,7 @@ import type * as announce from "../announced.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; -import { error, fromClose, StreamCode, StreamError } from "../error.ts"; +import { closeError, error, fromClose, StreamCode, StreamError, sessionCause } from "../error.ts"; import { type Hop, randomHop } from "../hop.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import type * as Path from "../path.ts"; @@ -161,11 +161,17 @@ export class Connection implements Established { tasks.push(this.#subscriber.runDatagrams()); } + let fatal: Error | undefined; try { await Promise.all(tasks); } catch (err) { console.error("fatal error running connection", err); + // A session-sourced failure is the peer's close, not the raw transport error. + fatal = await sessionCause(this.#quic, err); } finally { + // The session died under every track it was receiving, so they end with its + // error. A deliberate close() already ended them cleanly, which makes this a no-op. + this.#subscriber.close(fatal ?? (await closeError(this.#quic))); this.close(); } } diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 021fe1dae4..eb5aff5569 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -3,7 +3,7 @@ import * as announce from "../announced.ts"; import * as broadcast from "../broadcast.ts"; import type { Probe as ProbeStats } from "../connection/stats.ts"; import { BroadcastCache } from "../consume.ts"; -import { controlTimeout, error, ProtocolViolation, reason, StreamCode, StreamError } from "../error.ts"; +import { controlTimeout, error, ProtocolViolation, reason, StreamCode, StreamError, sessionCause } from "../error.ts"; 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"; @@ -548,7 +548,7 @@ export class Subscriber { } catch (err) { // The setup outlived its deadline waiting for the first response: a control // timeout, not content that arrived late. - const e = err instanceof TimeoutError ? controlTimeout(err) : error(err); + const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err); request.reject(e); this.#subscribes.delete(id); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); @@ -571,11 +571,14 @@ export class Subscriber { // // On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream until // its FIN; older drafts just close it. Either way group streams can still be in - // flight, so the track ends only once the tail is accounted for. + // flight, so the track ends only once the tail is accounted for. A reset rejects + // instead, so the track ends with that error rather than a clean tail. const responses = supportsTrackStream(this.version) ? this.#runResponses(stream, entry) : stream.reader.closed; const closed = responses.then(() => this.#settleTail(entry)); + // A reset that lands after the race below settled is moot; the race observes one before. + closed.catch(() => {}); const subscriptionUpdates = this.version === Version.DRAFT_01 || this.version === Version.DRAFT_02 ? undefined @@ -603,7 +606,7 @@ export class Subscriber { stream.close(); console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.name}`); } catch (err) { - const e = error(err); + const e = await sessionCause(this.#quic, err); producer.close(e); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); stream.abort(e); @@ -807,17 +810,11 @@ export class Subscriber { // Reads SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+), recording // the range the tail is accounted against. SUBSCRIBE_END declares the track's end right - // away, so a consumer learns it before the last groups arrive. Resolves on FIN or on the - // stream being reset out from under it; rejects only on a response that breaks the range. + // away, so a consumer learns it before the last groups arrive. Resolves on FIN and rejects + // when the stream is reset, so the track ends with the publisher's error rather than cleanly. async #runResponses(stream: Stream, entry: SubscribeEntry): Promise { for (;;) { - let resp: Awaited>; - try { - resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); - } catch { - // Stream closed or reset; nothing more to read. - return; - } + const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); if (!resp) return; if ("start" in resp) { @@ -1138,11 +1135,15 @@ export class Subscriber { } } - close() { + /** + * Ends every subscribed track: cleanly for a deliberate close, or with `err` when the + * session died, since those tracks were cut off rather than ended. + */ + close(err?: Error) { this.#closed.abort(); for (const { track } of this.#subscribes.values()) { - track.close(); + track.close(err); } this.#subscribes.clear(); diff --git a/js/net/src/mock.ts b/js/net/src/mock.ts index 7bfdc9035f..78fdd6d191 100644 --- a/js/net/src/mock.ts +++ b/js/net/src/mock.ts @@ -12,9 +12,15 @@ import type { SendStream } from "./stream.ts"; const WRITABLE_STRATEGY: QueuingStrategy = { highWaterMark: 256 }; const READABLE_STRATEGY: QueuingStrategy = { highWaterMark: 256 }; -function newStream(): TransformStream { +/** Every stream of a transport pair, so a session close can fail the ones still open. */ +type StreamSet = Set>; + +function newStream(streams: StreamSet): TransformStream { return new TransformStream( { + start(controller) { + streams.add(controller); + }, // Copy each chunk to simulate real WebTransport's kernel-boundary copy. // Without this, Writer's scratch buffer reuse corrupts queued data. transform(chunk, controller) { @@ -57,6 +63,10 @@ export class MockTransport implements WebTransport { // Reference to the peer so we can enqueue streams to them #peer?: MockTransport; + // Shared with the peer: a session close fails every stream either side opened, the way + // a real WebTransport session does. + #streams: StreamSet = new Set(); + constructor( protocol: string, datagramsEnabled = true, @@ -149,6 +159,7 @@ export class MockTransport implements WebTransport { setPeer(peer: MockTransport) { this.#peer = peer; + this.#streams = peer.#streams; } async createBidirectionalStream(options?: WebTransportSendStreamOptions): Promise { @@ -156,8 +167,8 @@ export class MockTransport implements WebTransport { if (!peer) throw new Error("no peer"); // Create two TransformStreams for the two directions - const c2s = newStream(); - const s2c = newStream(); + const c2s = newStream(this.#streams); + const s2c = newStream(this.#streams); // Local side: writes to c2s, reads from s2c const local = { @@ -184,7 +195,7 @@ export class MockTransport implements WebTransport { const peer = this.#peer; if (!peer) throw new Error("no peer"); - const c2s = newStream(); + const c2s = newStream(this.#streams); // Record before handing the peer its end, so a peer that waits for the stream to arrive // always finds it here. @@ -216,6 +227,13 @@ export class MockTransport implements WebTransport { const info = _closeInfo ?? { closeCode: 0, reason: "" }; this.#closeResolve(info); + // Shaped like the WebTransportError a real session close fails its streams with. + const closed = Object.assign(new Error("session closed"), { source: "session", streamErrorCode: null }); + for (const stream of this.#streams) { + stream.error(closed); + } + this.#streams.clear(); + try { this.#bidiController.close(); } catch { diff --git a/quest/m1/README.md b/quest/m1/README.md index 88edaefce0..c0f3270309 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -20,7 +20,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached -- [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` - [JS bare FIN](/quest/m1/js-bare-fin.md) - a `@moq/net` subscriber aborts a track whose subscribe stream FINs before its declared end, like Rust - [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` diff --git a/quest/m1/session-close.md b/quest/m1/session-close.md index a846d5e977..3a297810e6 100644 --- a/quest/m1/session-close.md +++ b/quest/m1/session-close.md @@ -25,5 +25,4 @@ abort does not. No new page. ## Related -- [Session death error](/quest/m1/session-death-error.md) - a session that dies still ends its tracks with its own error - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` on a broadcast, which is not this session end diff --git a/quest/m1/session-death-error.md b/quest/m1/session-death-error.md deleted file mode 100644 index b4112d5c24..0000000000 --- a/quest/m1/session-death-error.md +++ /dev/null @@ -1,55 +0,0 @@ -# [M] Session death error - -## Goal - -When a session dies, every track it was receiving ends with the session's own -error, in Rust and JS, over moq-lite and IETF. A reader never sees a clean end -for a stream that was cut off, and never a generic `Dropped` or `Cancel` -standing in for the real cause. A publisher's reset of a subscription reaches -the reader as that reset's error. - -## Plan - -Today the cause is lost in several places. Rust: - -- The lite subscriber's serve loop gives back `Error::Dropped` when the - session dies, and `SubscriptionCleanup` aborts the remaining tracks with - `Error::Cancel` (`rs/moq-net/src/lite/subscriber.rs`). `Dropped` here is a - bug. -- The IETF subscriber's `State::drop` aborts every subscription with - `Error::Cancel` (`rs/moq-net/src/ietf/subscriber.rs`). -- Worse, the reader can see a clean end. Once SUBSCRIBE_END has declared - the boundary, a session that dies with a group below it still in flight - leaves `recv_group()` returning `Ok(None)`, skipping that group (#4061). - The abort does reach the track, but the clean-end check wins whenever the - reader polls after the close lands, so the outcome depends on who reads - first. A declared end is only clean once every group below it is - accounted for; an abort before then wins. - -JS: - -- The lite `Subscriber.close()` runs from `Connection.close()` even after a - fatal error and closes every track cleanly (`js/net/src/lite/subscriber.ts`, - `js/net/src/lite/connection.ts`). -- On lite-05 and later, `#drainResponses` swallows a reset of the subscribe - stream and reports it as a clean end. On lite-01 to -04 the error does reach - the track. -- On IETF drafts 14-16, where one control stream carries every request, the - adapter closes every per-request stream cleanly when the session or control - stream dies (`js/net/src/ietf/adapter.ts`), so the tracks end cleanly. Drafts - 17 and later reject with the transport error. - -Thread the session's close error to wherever tracks are torn down, and abort -with it. A deliberate local close of the session is still a close, not an -error. - -Test by killing a session mid-track in both languages and asserting the reader -sees the session's error, and by resetting a subscribe stream on lite-05 and -later. For #4061, pin the deterministic mock repro and the real-QUIC one from -`kidq330/bug/subscription_ends_clean_with_missing_group` -(`subscription_end_integrity` in `moq-net` and `moq-tokio`), with their -controls: a finished track still ends clean while its session lives. - -## Closes - -- [#4061](https://github.com/moq-dev/moq/issues/4061) - close this issue when the quest finishes diff --git a/quest/m1/track-tail-interop.md b/quest/m1/track-tail-interop.md index 0078cdae17..7489cbdfb3 100644 --- a/quest/m1/track-tail-interop.md +++ b/quest/m1/track-tail-interop.md @@ -27,8 +27,3 @@ What stood in the way when the Rust half landed: QUIC on localhost rarely reorders, so this is a smoke check that the end is delivered and clean. The ordering race itself stays in the unit tests. - -## Related - -- [Session death error](/quest/m1/session-death-error.md) - the other way a - track ends wrong diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index f1c7258608..cb0fd4538a 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -239,7 +239,7 @@ where Ok(()) })); - kio::wait(|waiter| { + let res = kio::wait(|waiter| { use std::task::Poll; if let Poll::Ready(err) = waiter.poll_future(adapter_run.as_mut()) { return Poll::Ready(Err::<(), Error>(err)); @@ -261,7 +261,12 @@ where } Poll::Pending }) - .await + .await; + if let Err(err) = &res { + // Every track this session was receiving ends with its error. + subscriber.abort(err); + } + res } _ => { // Send SETUP and keep the stream alive: it is also our GOAWAY channel. @@ -366,7 +371,7 @@ where Ok(()) })); - kio::wait(|waiter| { + let res = kio::wait(|waiter| { use std::task::Poll; if let Poll::Ready(err) = waiter.poll_future(unis.as_mut()) { return Poll::Ready(Err::<(), Error>(err)); @@ -391,7 +396,12 @@ where } Poll::Pending }) - .await + .await; + if let Err(err) = &res { + // Every track this session was receiving ends with its error. + subscriber.abort(err); + } + res } }; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 342cf6e054..e01227f073 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -152,22 +152,35 @@ struct State { broadcasts: HashMap, } -impl Drop for State { - fn drop(&mut self) { - // The session dispatcher owns this state and can be dropped at any await. - // Active receive tasks abort their own groups. Cancel any head waiting for - // its tail here, along with the track. Ordinary unsubscribe removes its entry. - for (_, track) in self.subscribes.drain() { +impl State { + /// End every active subscription with the error that ended the session. + /// + /// Active receive tasks abort their own groups. Abort any head waiting for its + /// tail here, along with the track. Ordinary unsubscribe removes its entry. + fn abort(&mut self, err: &Error) { + for (_, mut track) in self.subscribes.drain() { + if let Some(request) = track.pending.take() { + request.reject(err.clone()); + } if let Fill::Ready { producer, .. } = &*track.fill.read() { - let _ = producer.clone().abort(Error::Cancel); + let _ = producer.clone().abort(err.clone()); } if let Some(producer) = track.producer { - let _ = producer.abort(Error::Cancel); + let _ = producer.abort(err.clone()); } } } } +impl Drop for State { + fn drop(&mut self) { + // The session dispatcher owns this state and can be dropped at any await. A + // session that ended with an error already aborted these with it; what + // remains was cancelled with the dispatcher. + self.abort(&Error::Cancel); + } +} + /// The head of a joined group, delivered on the subscription's fill fetch stream. /// /// Draft-20's current-group join (section 5.1.6) splits one group across two streams: the @@ -298,6 +311,9 @@ struct Accepted { struct TrackState { producer: Option, + /// The origin request, until SUBSCRIBE_OK accepts it. Abort rejects this: the + /// producer does not exist yet, and dropping the setup task would be `Dropped`. + pending: Option, name: String, alias: Option, @@ -344,6 +360,7 @@ impl TrackState { fn pending(name: String, broadcast: PathOwned, fill: kio::Producer, joining: Option) -> Self { Self { producer: None, + pending: None, name, alias: None, broadcast, @@ -513,6 +530,11 @@ where } } + /// End every active subscription with the error that ended the session. + pub fn abort(&self, err: &Error) { + self.state.lock().abort(err); + } + /// Leave `alias` in the state a cancelled subscription leaves behind: bound to a /// subscription, then retired. /// @@ -641,6 +663,11 @@ where held.broadcast == new.broadcast && held.name == new.name } + /// Take the origin request back out of a subscription that is still setting up. + fn take_pending(&self, request_id: RequestId) -> Option { + self.state.lock().subscribes.get_mut(&request_id)?.pending.take() + } + fn remove_subscribe(&self, request_id: RequestId) -> Option { let mut state = self.state.lock(); let track = state.subscribes.remove(&request_id)?; @@ -1642,6 +1669,19 @@ where tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %request.name(), "subscribe started"); + // Park the origin request where a session abort can reject it. The producer + // does not exist until SUBSCRIBE_OK, and dropping this task would otherwise + // end the track as `Dropped`. + let track_name = request.name().to_owned(); + { + let mut state = self.state.lock(); + let Some(held) = state.subscribes.get_mut(&request_id) else { + request.reject(Error::Cancel); + return; + }; + held.pending = Some(request); + } + // A publisher can be serving before its SUBSCRIBE_OK reaches us, since the data // streams are independent of the request stream. Waiting for the response alone would // miss the local side going away in that window and leave the publisher serving a @@ -1651,9 +1691,10 @@ where enum Setup { Response(Result, Error>), Unused, + /// `abort` already rejected the parked request. + Gone, } - let track_name = request.name().to_owned(); let setup = { let mut response = std::pin::pin!(self.read_subscribe_response(&mut stream)); loop { @@ -1665,7 +1706,15 @@ where if let Poll::Ready(res) = waiter.poll_future(response.as_mut()) { return Poll::Ready(Setup::Response(res)); } - if request.poll_unused(waiter).is_ready() { + let mut state = self.state.lock(); + let Some(pending) = state + .subscribes + .get_mut(&request_id) + .and_then(|held| held.pending.as_mut()) + else { + return Poll::Ready(Setup::Gone); + }; + if pending.poll_unused(waiter).is_ready() { return Poll::Ready(Setup::Unused); } Poll::Pending @@ -1673,23 +1722,36 @@ where .await; match setup { - Setup::Response(res) => break Some((res, request)), + Setup::Response(res) => break Some(res), + Setup::Gone => break None, Setup::Unused => { - if request.reject_unused(Error::Cancel) { + let mut state = self.state.lock(); + let Some(pending) = state + .subscribes + .get_mut(&request_id) + .and_then(|held| held.pending.take()) + else { + break None; + }; + if pending.reject_unused(Error::Cancel) { break None; } + if let Some(held) = state.subscribes.get_mut(&request_id) { + held.pending = Some(pending); + } } } } }; - let Some((response, request)) = setup else { + let Some(response) = setup else { tracing::info!( broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe abandoned before it was accepted" ); - // The publisher may already be serving before it answers. + // The publisher may already be serving before it answers. A session abort + // already rejected the parked request; dropping what remains is not a second one. self.remove_subscribe(request_id); self.cancel_subscribe(stream, request_id).await; return; @@ -1700,14 +1762,18 @@ where let accepted = match response { Ok(Some(accepted)) => accepted, Ok(None) => { + if let Some(pending) = self.take_pending(request_id) { + pending.reject(Error::UnexpectedMessage); + } self.remove_subscribe(request_id); - request.reject(Error::UnexpectedMessage); return; } Err(err) => { tracing::debug!(%err, "subscribe response error"); + if let Some(pending) = self.take_pending(request_id) { + pending.reject(err); + } self.remove_subscribe(request_id); - request.reject(err); return; } }; @@ -1723,6 +1789,11 @@ where .with_priority(super::priority::from_wire(priority.unwrap_or(128))); // Declared before the track is released to readers, so a warm cache waiting on // this copy judges itself against where the live feed actually starts. + let Some(request) = self.take_pending(request_id) else { + // Aborted while the answer was in hand. The parked request is already rejected. + self.remove_subscribe(request_id); + return; + }; let request = match live { true => request.resolving_start(), false => request, @@ -2991,6 +3062,45 @@ mod tests { assert_eq!(pending.await.unwrap(), RequestId(11)); } + /// SUBSCRIBE_OK has not accepted the track, so the map holds no producer. + /// Abort still has to reject the parked origin request with the session error. + #[tokio::test] + async fn session_death_rejects_a_subscribe_still_setting_up() { + let broadcast = crate::broadcast::Info::new().produce(); + let mut dynamic = broadcast.dynamic(); + let consumer = broadcast.consume(); + let mut waiting = std::pin::pin!(consumer.track("video").unwrap().subscribe(None)); + assert!(poll!(&mut waiting).is_pending()); + + let mut requested = std::pin::pin!(dynamic.requested_track()); + let std::task::Poll::Ready(Ok(request)) = poll!(&mut requested) else { + panic!("the subscribe did not request a track"); + }; + + let mut state = State::default(); + state.subscribes.insert( + RequestId(1), + TrackState { + pending: Some(request), + ..TrackState::pending( + "video".to_string(), + crate::Path::new("bcast").to_owned(), + kio::Producer::new(Fill::Done), + None, + ) + }, + ); + state.abort(&Error::Session(crate::SessionError::App(7))); + + assert!( + matches!( + poll!(&mut waiting), + std::task::Poll::Ready(Err(Error::Session(crate::SessionError::App(7)))) + ), + "setup was not rejected with the session error" + ); + } + #[tokio::test(start_paused = true)] async fn unknown_track_alias_times_out() { let aliases = TrackAliases::default(); diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 0d00e5a29d..7417c05c2c 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -230,6 +230,10 @@ where { pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let res = std::task::ready!(self.poll_protocol(waiter)); + if let Err(err) = &res { + // Every track this session was receiving ends with its error. + self.subscriber.abort(err); + } match &res { Err(Error::Transport(_)) => { tracing::info!("session terminated"); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 20987dbbc7..11fcce659f 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -70,6 +70,10 @@ pub(super) struct Subscriber { // assigned id stays local and is never written into a hop chain. session_origin: crate::Hop, subscribes: Lock>, + /// Why this session ended, once it has. A track still waiting on TRACK_INFO is + /// not in [`Self::subscribes`], so dropping its request reads this instead of + /// becoming [`Error::Dropped`]. + ended: Lock>, next_id: Arc, version: Version, /// The peer's advertised SETUP (lite-05+), set when its Setup stream is read. @@ -107,6 +111,7 @@ impl Subscriber { self_origin, session_origin: config.peer_hop.unwrap_or(crate::Hop::UNKNOWN), subscribes: Default::default(), + ended: Default::default(), next_id: Default::default(), version: config.version, peer_setup: config.peer_setup, @@ -116,6 +121,20 @@ impl Subscriber { } } + /// Record the error that ended the session. The first one wins: a later cancel + /// from dropping the driver must not replace it. + fn note_end(&self, err: &Error) { + let mut slot = self.ended.lock(); + if slot.is_none() { + *slot = Some(err.clone()); + } + } + + /// The recorded session end, or [`Error::Dropped`] when nothing recorded one. + fn end_reason(&self) -> Error { + self.ended.lock().clone().unwrap_or(Error::Dropped) + } + /// Reject a new request once the peer has sent a GOAWAY: it told us to stop /// opening streams on this session (existing subscriptions keep flowing). fn check_going_away(&self) -> Result<(), Error> { @@ -460,20 +479,28 @@ impl Subscriber { // poll returning Ready. struct SubscriptionCleanup(Lock>); -impl Drop for SubscriptionCleanup { - fn drop(&mut self) { - // Group machines own their cancellation cleanup independently. This records - // session cancellation as the track's terminal state. +impl SubscriptionCleanup { + /// End every active subscription with the session's error. Group machines own + /// their cleanup independently; this records the track's terminal state. + fn abort(&self, err: &Error) { for (_, entry) in self.0.lock().drain() { - let _ = entry.producer.abort(Error::Cancel); + let _ = entry.producer.abort(err.clone()); } } } +impl Drop for SubscriptionCleanup { + fn drop(&mut self) { + // A session that ended with an error already aborted these with it; what + // remains was cancelled with the driver. + self.abort(&Error::Cancel); + } +} + pub(super) struct SubscriberDriver { subscriber: Subscriber, - /// Aborts whatever is still subscribed when the driver is dropped. - _cleanup: SubscriptionCleanup, + /// Aborts whatever is still subscribed when the session ends. + cleanup: SubscriptionCleanup, /// One machine per permitted prefix. Only an error ends the session; a /// prefix finishing cleanly (publisher FIN) just retires. prefixes: Vec>, @@ -497,7 +524,7 @@ impl SubscriberDriver { Self { prefixes, - _cleanup: SubscriptionCleanup(subscriber.subscribes.clone()), + cleanup: SubscriptionCleanup(subscriber.subscribes.clone()), uni: UniAccept::new(subscriber.clone()), bandwidth: Some(RecvBandwidth::new(subscriber.clone())), datagrams: Some(DatagramRecv::new(subscriber.clone())), @@ -506,6 +533,14 @@ impl SubscriberDriver { } } + /// End every active subscription with the error that ended the session. + pub fn abort(&self, err: &Error) { + // Before the subscribe map, so a TRACK_INFO request dropped with this driver + // rejects with `err` rather than `Dropped`. + self.subscriber.note_end(err); + self.cleanup.abort(err); + } + pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let mut i = 0; while i < self.prefixes.len() { @@ -547,6 +582,14 @@ impl SubscriberDriver { } } +impl Drop for SubscriberDriver { + fn drop(&mut self) { + // `abort` already recorded a session error when the driver failed. A driver + // dropped without that still has to name an end before its setup machines drop. + self.subscriber.note_end(&Error::Cancel); + } +} + /// Accepts incoming uni streams (GROUP data plus the peer's SETUP) and drives /// each as a child machine. Resolves only on a transport error. struct UniAccept { @@ -1405,6 +1448,51 @@ mod tests { ); } + /// A lite-05 subscribe still waiting on TRACK_INFO is not in the subscribe map. + /// Dropping that machine must reject the origin request with the session's error. + #[tokio::test] + async fn session_death_rejects_a_track_waiting_for_info() { + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session: SinkSession::default(), + origin: origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + recv_bandwidth: None, + version: VERSION, + peer_setup: Default::default(), + peer_hop: None, + cost: None, + going_away: Default::default(), + }); + + let broadcast = crate::broadcast::Info::new().produce(); + let mut dynamic = broadcast.dynamic(); + let consumer = broadcast.consume(); + let mut waiting = std::pin::pin!(consumer.track("video").unwrap().subscribe(None)); + assert!( + futures::poll!(waiting.as_mut()).is_pending(), + "waiting on the publisher" + ); + let request = dynamic.requested_track().now_or_never().unwrap().expect("request"); + + let run = TrackServeRun::new( + TrackServe { + subscriber: subscriber.clone(), + path: Path::new("room").to_owned(), + name: "video".to_string(), + }, + request, + ); + let death = Error::Session(crate::SessionError::App(7)); + subscriber.note_end(&death); + drop(run); + + let ended = waiting.now_or_never().expect("subscribe must end"); + assert!( + matches!(ended, Err(Error::Session(crate::SessionError::App(7)))), + "waiting track did not end with the session error" + ); + } + /// `establish` puts exactly one SUBSCRIBE on the wire, and the id is registered /// before any of it reaches the transport. /// @@ -3088,8 +3176,8 @@ impl Establish { return Poll::Ready(Ok(self.activate(stream))); } EstablishState::WaitOk { stream } => { - if self.closed.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Err(Error::Dropped)); + if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { + return Poll::Ready(Err(Error::from_transport(err))); } let resp = ready!(stream.reader.poll_decode::(&mut cx))?; if !matches!(resp, lite::SubscribeResponse::Ok(_)) { @@ -3224,6 +3312,20 @@ impl kio::Task for TrackServeRun { } } +impl Drop for TrackServeRun { + fn drop(&mut self) { + // Still waiting on TRACK_INFO: the request never reached the subscribe map, + // so the driver's abort cannot see it. Reject with the session's error. + let TrackRunState::Info { request, .. } = &mut self.state else { + return; + }; + let Some(request) = request.take() else { + return; + }; + request.reject(self.serve.subscriber.end_reason()); + } +} + /// Opens a TRACK stream, reads the single TRACK_INFO, and maps it to the /// model's [`track::Info`]. Lite05+ only. Bails if the session dies meanwhile. struct TrackInfoFetch { @@ -3272,8 +3374,8 @@ impl TrackInfoFetch { self.state = TrackInfoState::Read { stream }; } TrackInfoState::Read { stream } => { - if self.closed.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Err(Error::Dropped)); + if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { + return Poll::Ready(Err(Error::from_transport(err))); } let info = ready!(stream.reader.poll_decode::(&mut cx))?; // The publisher FINs after TRACK_INFO; FIN our side too and let the @@ -3565,9 +3667,10 @@ impl ServeLoop { } } - // (5) The session died: hand the track back for another route. - if self.closed.poll_closed(&mut cx).is_ready() { - return Poll::Ready(ServeEnd::GiveBack(Error::Dropped)); + // (5) The session died: hand the track back for another route, with + // the session's error in case none takes it. + if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { + return Poll::Ready(ServeEnd::GiveBack(Error::from_transport(err))); } return Poll::Pending; diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index ee081f1474..0d9b8ba038 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1405,8 +1405,8 @@ impl SegmentSub { } /// Move an active cursor into terminal retention and mark the segment done. - fn complete(&mut self, count: Option) { - let previous = std::mem::replace(&mut self.sub, SubState::Done(count)); + fn complete(&mut self, end: Result) { + let previous = std::mem::replace(&mut self.sub, SubState::Done(end)); if let SubState::Active(sub) = previous { self.terminal = Some(*sub); } @@ -1449,23 +1449,22 @@ enum SubState { /// Live cursor over the underlying track. Boxed: the subscriber dwarfs the other /// variants, and every segment holds this enum. Active(Box), - /// The underlying track ended: `Some` with the group count when it finished - /// cleanly, `None` when it aborted or was dropped. An abort is deliberately - /// not surfaced: a dead route stalls the logical track until the next switch - /// replaces it. - Done(Option), + /// The underlying track ended: `Ok` with the group count when it finished + /// cleanly, `Err` with the cause when it aborted or was dropped. An abort only + /// surfaces once no switch can follow: until then a dead route stalls the + /// logical track for the next switch to replace. + Done(Result), } /// A live subscription spliced across every segment of a logical track. /// /// Reads switch between the underlying [`track::Subscriber`]s at the segment /// boundaries. A segment's session failing does not error the subscription; it -/// stalls until [`Producer::switch`] provides a replacement, or ends cleanly once -/// the producer [`finish`](Producer::finish)es and the final segment completes. -/// The producer itself going away without a terminal state ends the track as its -/// final segment's track ended, once the remaining segments drain: cleanly if it -/// finished, [`Error::Dropped`] if it died. With nobody left to splice a -/// replacement, a stall would never end. +/// stalls until [`Producer::switch`] provides a replacement. Once the producer +/// [`finish`](Producer::finish)es, or goes away without a terminal state, no +/// replacement can follow, so the track ends as its final segment's track ended +/// once the remaining segments drain: cleanly if it finished, with its error if it +/// died. With nobody left to splice a replacement, a stall would never end. pub struct Subscriber { state: kio::Consumer, @@ -1535,7 +1534,7 @@ impl Subscriber { break; } if seg.pruned { - seg.sub = SubState::Done(None); + seg.sub = SubState::Done(Err(Error::Dropped)); seg.terminal = None; seg.parked.clear(); cut -= 1; @@ -1831,7 +1830,8 @@ impl Subscriber { // The copy was asked for the cache's newest group and started past it: its // source judged that group stale, so the older cache is no use to live reads. if start.is_some_and(|start| edge.is_some_and(|edge| start > edge)) { - seg.complete(None); + // Discarded, not finished: there is no group count, same as a dropped segment. + seg.complete(Err(Error::Dropped)); return Poll::Ready(()); } } @@ -1852,7 +1852,7 @@ impl Subscriber { seg.sub = SubState::Active(Box::new(sub)); } // The underlying track was rejected or closed: stall, not error. - Err(_) => seg.sub = SubState::Done(None), + Err(err) => seg.sub = SubState::Done(Err(err)), } } Poll::Ready(()) @@ -1881,18 +1881,17 @@ impl Subscriber { return Poll::Ready(Some(group)); } Ok(None) => { - let count = sub.poll_finished(waiter).map(|res| res.ok()); - let count = match count { - Poll::Ready(count) => count, - Poll::Pending => None, + let end = match sub.poll_finished(waiter) { + Poll::Ready(end) => end, + Poll::Pending => Err(Error::Dropped), }; - seg.complete(count); + seg.complete(end); return Poll::Ready(None); } // A dead segment stalls the logical track rather than erroring; // the next switch resumes it. - Err(_) => { - seg.complete(None); + Err(err) => { + seg.complete(Err(err)); return Poll::Ready(None); } }, @@ -1971,15 +1970,15 @@ impl Subscriber { } // The track ran out at or below the floor: the segment drained. Poll::Ready(Ok(None)) => { - let count = match sub.poll_finished(waiter) { - Poll::Ready(count) => count.ok(), - Poll::Pending => None, + let end = match sub.poll_finished(waiter) { + Poll::Ready(end) => end, + Poll::Pending => Err(Error::Dropped), }; - seg.complete(count); + seg.complete(end); } // A dead segment stalls the logical track rather than erroring; // the next switch resumes it. - Poll::Ready(Err(_)) => seg.complete(None), + Poll::Ready(Err(err)) => seg.complete(Err(err)), Poll::Pending => all_done = false, }, SubState::Pending(_) => all_done = false, @@ -2007,15 +2006,10 @@ impl Subscriber { if let Some(err) = &self.abort { return Poll::Ready(Err(err.clone())); } - if all_done { - if self.finished { - return Poll::Ready(Ok(None)); - } - // The producer is gone without finishing: no takeover can ever resume - // the drained segments, so the track ends as its final segment did. - if self.closed { - return Poll::Ready(self.orphan_end().map(|()| None)); - } + // Finished or gone, no switch can follow: the track ends as its final + // segment did. + if all_done && (self.finished || self.closed) { + return Poll::Ready(self.final_end().map(|()| None)); } return Poll::Pending; } @@ -2028,9 +2022,9 @@ impl Subscriber { /// out exactly once no matter how many routes contributed to it. /// /// Returns `Poll::Ready(Ok(None))` once every segment completed and the - /// producer finished, or was dropped with the final segment's track finished. - /// Returns `Poll::Ready(Err(_))` if the producer aborted, or was dropped and - /// the final segment's track died ([`Error::Dropped`]). + /// producer finished or was dropped, with the final segment's track finished. + /// Returns `Poll::Ready(Err(_))` if the producer aborted, or if it finished or + /// was dropped and the final segment's track died (with that track's error). pub fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.poll_sync(waiter); @@ -2134,16 +2128,10 @@ impl Subscriber { if let Some(err) = &self.abort { return Poll::Ready(Err(err.clone())); } - if all_done { - if self.finished { - return Poll::Ready(Ok(None)); - } - // The producer is gone without finishing: no takeover can ever - // resume the drained segments, so the track ends as its final - // segment did rather than stalling forever. - if self.closed { - return Poll::Ready(self.orphan_end().map(|()| None)); - } + // Finished or gone, no switch can follow: the track ends as its final + // segment did rather than stalling forever. + if all_done && (self.finished || self.closed) { + return Poll::Ready(self.final_end().map(|()| None)); } Poll::Pending } @@ -2217,14 +2205,24 @@ impl Subscriber { if let Some(err) = &self.abort { return Poll::Ready(Err(err.clone())); } + // The producer finished, so this channel ends now. A final segment that + // already died is that death: the datagram read above drops a Ready(Err) + // on the floor, and a clean Ok(None) here would hide it. A segment that + // has not activated yet has no end to report. if self.finished { - return Poll::Ready(Ok(None)); + if pending_activation { + return Poll::Ready(Ok(None)); + } + return match ready!(self.poll_final(waiter)) { + Some(end) => Poll::Ready(end.map(|_| None)), + None => Poll::Ready(Ok(None)), + }; } // The producer is gone: no takeover is coming, so the track ends when // its newest segment does, and the way it did. if self.closed && !pending_activation { return match ready!(self.poll_final(waiter)) { - Some(_) => Poll::Ready(Ok(None)), + Some(end) => Poll::Ready(end.map(|_| None)), None => Poll::Ready(Err(Error::Dropped)), }; } @@ -2249,40 +2247,42 @@ impl Subscriber { if !self.finished && !self.closed { return Poll::Pending; } - let end = ready!(self.poll_final(waiter)); - match self.finished { - true => Poll::Ready(Ok(end.unwrap_or(0))), - // The producer is gone without finishing: the track ends as its final - // segment did. - false => Poll::Ready(end.ok_or(Error::Dropped)), + // The track ends as its final segment did, or with nothing spliced, cleanly + // only if it finished. + match ready!(self.poll_final(waiter)) { + Some(end) => Poll::Ready(end), + None if self.finished => Poll::Ready(Ok(0)), + None => Poll::Ready(Err(Error::Dropped)), } } /// Wait for the final segment's track to end: its group count when it - /// finished, `None` when it died or there is no segment. Earlier segments don't + /// finished, its error when it died, and `None` when there is no segment. Earlier segments don't /// decide the end. Only the subscription is resolved here: consuming groups, or /// completing the segment, would steal them from a `recv_group` caller on the /// same subscriber. - fn poll_final(&mut self, waiter: &kio::Waiter) -> Poll> { + fn poll_final(&mut self, waiter: &kio::Waiter) -> Poll>> { let Some(seg) = self.segments.last_mut() else { return Poll::Ready(None); }; ready!(Self::poll_activate(seg, &self.last_prefs, self.min_sequence, waiter)); match &mut seg.sub { - SubState::Done(count) => Poll::Ready(*count), + SubState::Done(end) => Poll::Ready(Some(end.clone())), // Observe only: the cursor may still hold groups, so the read path // completes the segment once it drains. - SubState::Active(sub) => Poll::Ready(ready!(sub.poll_finished(waiter)).ok()), + SubState::Active(sub) => Poll::Ready(Some(ready!(sub.poll_finished(waiter)))), SubState::Pending(_) => unreachable!("poll_activate resolved above"), } } - /// How a logical track whose producer went away without finishing ends, once - /// every segment drained: cleanly if its final segment's track finished, since - /// that is where the content ended, and [`Error::Dropped`] otherwise. - fn orphan_end(&self) -> Result<()> { + /// How a logical track ends once every segment drained and no switch can follow + /// (the producer finished or went away): as its final segment's track ended, since + /// that is where the content ended. A finished track with no segment ends cleanly; + /// an orphaned one with [`Error::Dropped`]. + fn final_end(&self) -> Result<()> { match self.segments.last().map(|seg| &seg.sub) { - Some(SubState::Done(Some(_))) => Ok(()), + Some(SubState::Done(end)) => end.clone().map(|_| ()), + None if self.finished => Ok(()), _ => Err(Error::Dropped), } } @@ -4895,12 +4895,61 @@ mod test { assert_eq!(recv(&mut sub), 0); // The segment dies, then the producer goes away without finish/abort: - // no takeover can ever come, so stalling would hang forever. - track_a.abort(Error::Cancel).unwrap(); + // no takeover can ever come, so stalling would hang forever. The track + // ends with the segment's own error. + track_a.abort(Error::Timeout).unwrap(); drop(producer); let result = sub.recv_group().now_or_never().expect("must not stall forever"); - assert!(matches!(result, Err(Error::Dropped))); + assert!(matches!(result, Err(Error::Timeout))); + } + + /// A finished logical track ends as its final segment did: a segment cut off + /// after the finish (its session died with a group still in flight) is an + /// error, not a clean end. + #[tokio::test] + async fn finished_producer_ends_with_a_dead_final_segment() { + let (mut track_a, consumer_a) = track_pair("a"); + + let mut producer = Producer::new(); + producer.switch(&consumer_a, None).unwrap(); + let mut sub = producer.consume().subscribe(None); + + write_group(&mut track_a, 0, "a0"); + assert_eq!(recv(&mut sub), 0); + + producer.finish().unwrap(); + track_a.abort(Error::Timeout).unwrap(); + + let result = sub.recv_group().now_or_never().expect("must not stall forever"); + assert!(matches!(result, Err(Error::Timeout))); + } + + /// A datagram-only reader of a finished logical track ends with the final + /// segment's error. The datagram poll drops a segment error that is not + /// `Ok(Some)`, so the finished path has to ask the segment itself. + #[tokio::test] + async fn finished_producer_ends_datagrams_with_a_dead_final_segment() { + let (track_a, consumer_a) = track_pair("a"); + + let mut producer = Producer::new(); + producer.switch(&consumer_a, None).unwrap(); + let mut sub = producer.consume().subscribe(None); + + assert!( + kio::wait(|waiter| sub.poll_recv_datagram(waiter)) + .now_or_never() + .is_none(), + "no datagram yet" + ); + + producer.finish().unwrap(); + track_a.abort(Error::Timeout).unwrap(); + + let result = kio::wait(|waiter| sub.poll_recv_datagram(waiter)) + .now_or_never() + .expect("must not stall forever"); + assert!(matches!(result, Err(Error::Timeout))); } #[tokio::test] @@ -4943,12 +4992,12 @@ mod test { ); match clean { true => track_a.finish().unwrap(), - false => track_a.abort(Error::Cancel).unwrap(), + false => track_a.abort(Error::Timeout).unwrap(), } let result = sub.finished().now_or_never().expect("must not stall forever"); match clean { true => assert!(matches!(result, Ok(0))), - false => assert!(matches!(result, Err(Error::Dropped))), + false => assert!(matches!(result, Err(Error::Timeout))), } } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 8ac10f45b9..d4f3216a6d 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -241,6 +241,10 @@ pub(crate) struct TrackState { // The error that caused the track to be aborted, if any. abort: Option, + // Whether the declared end still stands after an abort: every group below it was + // produced and finished before the abort landed. See [`Self::is_complete`]. + settled: bool, + // Active subscriptions, in their own [`kio::Shared`] so a read-only `Consumer` // registers under that lock instead of writing back into the track state. // Kept here (rather than threaded through every handle) so any holder reaches it. @@ -1077,9 +1081,25 @@ impl TrackState { /// until the remaining groups are produced, or until the last producer drops without /// them. Drives the end-of-stream signal from /// the read methods (`recv_group` / `next_group` / `read_frame` return `None`). + /// + /// An abort before the end settled wins over it: a group below the boundary was + /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - self.final_sequence - .is_some_and(|fin| self.sealed || self.max_sequence.map_or(0, |max| max.saturating_add(1)) >= fin) + // `sealed` is a clean end when the last producer drops. An abort still wins + // unless that end had already settled: a group below it was still open. + let reached = self + .final_sequence + .is_some_and(|fin| self.sealed || self.max_sequence.map_or(0, |max| max.saturating_add(1)) >= fin); + reached && (self.abort.is_none() || self.settled) + } + + /// Whether the declared end is reached and every cached group below it finished, + /// so nothing the end promised is still in flight. + fn is_settled(&self) -> bool { + let Some(fin) = self.final_sequence else { + return false; + }; + self.is_complete() && self.lookup.range(..fin).all(|(_, slot)| slot.group.is_finished()) } /// Where a replacement route should pick this track up: one past the last frame @@ -1172,6 +1192,8 @@ fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) { // Snapshot the frame boundary before the cache it's derived from 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. + state.settled = state.is_settled(); state.abort = Some(err); state.clear_cache(); state.datagrams.clear(); @@ -6894,6 +6916,43 @@ mod test { assert!(done.is_none(), "track completes once the boundary is reached"); } + /// An abort before the declared end settled wins over it: the boundary was reached, + /// but a group below it was still open, so the track was cut off rather than ended. + #[tokio::test] + async fn abort_before_the_end_settles_wins() { + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None); + + let head = producer.create_group(group::Info { sequence: 0 }).unwrap(); + head.finish().unwrap(); + let _tail = producer.create_group(group::Info { sequence: 1 }).unwrap(); + producer.finish_at(2).unwrap(); + assert_eq!(consumer.assert_group().sequence, 0); + + producer.abort(Error::Timeout).unwrap(); + let res = consumer.recv_group().now_or_never().expect("should not block"); + assert!(matches!(res, Err(Error::Timeout))); + } + + /// 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() { + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None); + + for sequence in 0..2 { + let group = producer.create_group(group::Info { sequence }).unwrap(); + group.finish().unwrap(); + } + producer.finish_at(2).unwrap(); + assert_eq!(consumer.assert_group().sequence, 0); + assert_eq!(consumer.assert_group().sequence, 1); + + producer.abort(Error::Timeout).unwrap(); + let res = consumer.recv_group().now_or_never().expect("should not block"); + assert!(matches!(res, Ok(None))); + } + #[tokio::test] async fn recv_group_finishes_without_waiting_for_gaps() { let producer = track_producer("test", None); diff --git a/rs/moq-net/tests/subscription_end_integrity.rs b/rs/moq-net/tests/subscription_end_integrity.rs new file mode 100644 index 0000000000..11ade6b507 --- /dev/null +++ b/rs/moq-net/tests/subscription_end_integrity.rs @@ -0,0 +1,263 @@ +//! A subscription ends cleanly only when every group in its range has been accounted +//! for; a session closing with a group still in flight ends it with an error. +//! +//! moq-lite, Subscribe: "The publisher closes the stream (FIN) only once every group +//! from start to end has been accounted for, either via a Group Stream (completed or +//! reset) or a SUBSCRIBE_DROP message." And Routing: "when the serving session ends, +//! in-flight subscriptions end with it (a reset)". So `recv_group()` may return +//! `Ok(None)` only after that FIN; any other ending is an error. +//! +//! The publisher opens the final group and writes its first frame (the group stream +//! opens), finishes the track (SUBSCRIBE_END goes out), then writes the rest, finishes +//! the group and disconnects with no await in between, so the final group's tail and +//! its FIN never leave. The close lands before the subscriber reads on. The subscriber +//! must then either see every frame or end with an error; it must not end `Ok(None)` +//! short of the final group. +//! +//! Deterministic: paused time, single-threaded runtime, and every "let the session +//! send" step is an idle-advance rather than a race. + +mod support; + +use std::time::Duration; + +use moq_net::{Hop, Timestamp, Version}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +const TIMEOUT: Duration = Duration::from_secs(10); +const HEAD: [&[u8]; 2] = [b"head-a", b"head-b"]; +const TAIL: [&[u8]; 2] = [b"tail-a", b"tail-b"]; + +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 +} + +/// Let the drivers run until nothing is runnable: under paused time an idle runtime +/// auto-advances the clock, so this returns once every queued write has been sent. +async fn settle() { + tokio::time::sleep(Duration::from_millis(1)).await; +} + +fn expected() -> Vec> { + HEAD.iter().chain(TAIL.iter()).map(|p| p.to_vec()).collect() +} + +/// Publish a two-group track over one mock session, cut the publisher's session with the +/// final group half sent (or keep it up), and return what the subscriber read: the frame +/// payloads and the error it ended with, if any. +async fn round(drop_session: bool) -> (Vec>, Option) { + let publisher = produce_origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let subscriber = produce_origin(2); + let mut options = MockConnectOptions::new("moq-lite-05".parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(subscriber.clone()); + let MockPair { client, server, .. } = connect_mock(options).await; + + let consumer = subscriber.consume(); + tokio::time::timeout(TIMEOUT, consumer.routed("bcast")) + .await + .expect("announce timeout") + .expect("routed"); + let remote = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("bcast")) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + + // The publisher only learns of a subscription once the subscriber polls it, so the + // reader runs concurrently. It reports the head group drained, then waits to be told + // to read on, so the final group is still unread when the session goes away. + let (drained_tx, drained_rx) = tokio::sync::oneshot::channel(); + let (go_tx, go_rx) = tokio::sync::oneshot::channel::<()>(); + + let reader = tokio::spawn(async move { + let subscription = moq_net::track::Subscription::default().with_start(moq_net::track::Position::group(0)); + let mut sub = remote + .track("video") + .unwrap() + .subscribe(subscription) + .await + .expect("subscribe"); + let mut got = Vec::new(); + let mut drained = Some(drained_tx); + let mut go = Some(go_rx); + loop { + let mut group = match sub.recv_group().await { + Ok(Some(group)) => group, + Ok(None) => return (got, None), + Err(err) => return (got, Some(err)), + }; + loop { + match group.read_frame().await { + Ok(Some(frame)) => got.push(frame.payload.to_vec()), + Ok(None) => break, + Err(err) => return (got, Some(err)), + } + } + if let Some(tx) = drained.take() { + let _ = tx.send(()); + let _ = go.take().unwrap().await; + } + } + }); + + tokio::time::timeout(TIMEOUT, track.used()) + .await + .expect("no subscriber appeared") + .unwrap(); + + let mut head = track.append_group().unwrap(); + for payload in HEAD { + head.write_frame(Timestamp::ZERO, payload).unwrap(); + } + head.finish().unwrap(); + tokio::time::timeout(TIMEOUT, drained_rx) + .await + .expect("the head group never arrived") + .expect("reader gone"); + + // The final group opens and its first frame is sent, then the track's end is + // declared: SUBSCRIBE_END reaches the subscriber with the group still open, as it + // does whenever a finish races a group still in flight. + let mut tail = track.append_group().unwrap(); + tail.write_frame(Timestamp::ZERO, TAIL[0]).unwrap(); + track.finish().unwrap(); + settle().await; + + // The rest of the group, its clean end, and the disconnect happen with no await in + // between, so the session has sent none of it when it closes. + tail.write_frame(Timestamp::ZERO, TAIL[1]).unwrap(); + tail.finish().unwrap(); + drop(track); + let server = if drop_session { + drop(server); + None + } else { + Some(server) + }; + + // The close lands before the subscriber reads on, as it does for a subscriber that is + // merely slower than the network. + settle().await; + let _ = go_tx.send(()); + let result = tokio::time::timeout(TIMEOUT, reader) + .await + .expect("the subscription never ended") + .expect("reader panicked"); + drop((client, server, broadcast, publisher, subscriber)); + result +} + +/// The session closes with the final group in flight: the subscription ends with an +/// error, or delivers every frame. +#[tokio::test] +async fn a_subscription_cut_by_the_session_close_does_not_end_clean() { + tokio::time::pause(); + let (got, err) = round(true).await; + assert!( + err.is_some() || got == expected(), + "the subscription ended Ok(None) with {}/{} frames, got {:?} \ + (a subscription that did not deliver every group must end with an error, not as complete)", + got.len(), + expected().len(), + got.iter() + .map(|p| String::from_utf8_lossy(p).into_owned()) + .collect::>(), + ); +} + +/// With the session kept alive the same track arrives whole and ends `Ok(None)`. +#[tokio::test] +async fn a_finished_track_ends_clean_while_its_session_lives() { + tokio::time::pause(); + let (got, err) = round(false).await; + assert!( + err.is_none() && got == expected(), + "session kept alive: got {} frames, err={err:?}", + got.len() + ); +} + +/// Every wire the session-death rule covers: lite before and after the track and +/// subscribe-response streams, and IETF on its shared and per-request control streams. +const DEATH_VERSIONS: &[&str] = &[ + "moq-lite-03", + "moq-lite-05", + "moq-lite-07-wip", + "moq-transport-14", + "moq-transport-17", + "moq-transport-22", +]; + +/// The error the publisher's session is aborted with, carried in the close code. +const DEATH: moq_net::SessionError = moq_net::SessionError::App(7); + +/// Kill the publisher's session with a group still open, and return the error the +/// subscriber's `recv_group` ends with once it drains what arrived. +async fn killed(version: &str) -> Option { + let publisher = produce_origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let subscriber = produce_origin(2); + let mut options = MockConnectOptions::new(version.parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(subscriber.clone()); + let MockPair { client, server, .. } = connect_mock(options).await; + + let consumer = subscriber.consume(); + consumer.routed("bcast").await.expect("routed"); + let remote = consumer.request_broadcast("bcast").await.expect("broadcast resolves"); + // Subscribing resolves only once the publisher serves the track, which it does + // only after seeing the subscription, so the reader runs concurrently. + let reader = tokio::spawn(async move { + let subscription = moq_net::track::Subscription::default().with_start(moq_net::track::Position::group(0)); + let mut sub = remote + .track("video") + .unwrap() + .subscribe(subscription) + .await + .expect("subscribe"); + loop { + let mut group = match sub.recv_group().await { + Ok(Some(group)) => group, + Ok(None) => return None, + Err(err) => return Some(err), + }; + // The open group ends however it ends; only the track's end is at stake here. + while let Ok(Some(_)) = group.read_frame().await {} + } + }); + + track.used().await.expect("no subscriber appeared"); + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, HEAD[0]).unwrap(); + settle().await; + + server.abort(moq_net::Error::Session(DEATH)); + let err = reader.await.expect("reader panicked"); + drop((client, server, group, track, broadcast, publisher, subscriber)); + err +} + +/// A session dying mid-track ends the subscriber's track with the session's own +/// error: not a clean end, and not a generic `Dropped` or `Cancel`. +#[tokio::test] +async fn a_session_death_ends_the_track_with_its_error() { + tokio::time::pause(); + for version in DEATH_VERSIONS { + let err = tokio::time::timeout(TIMEOUT, killed(version)) + .await + .unwrap_or_else(|_| panic!("{version}: the track never ended")); + assert!( + matches!(&err, Some(moq_net::Error::Session(code)) if *code == DEATH), + "{version}: the track ended with {err:?}, not the session's error" + ); + } +} diff --git a/rs/moq-net/tests/support/mock.rs b/rs/moq-net/tests/support/mock.rs index 81fbd49f1c..7043f020d2 100644 --- a/rs/moq-net/tests/support/mock.rs +++ b/rs/moq-net/tests/support/mock.rs @@ -8,7 +8,9 @@ //! //! The mock guarantees that data written and FIN'd on a stream before `close()` //! is readable by the peer, eliminating the Quinn CONNECTION_CLOSE race that -//! plagues real-transport tests. +//! plagues real-transport tests. Like a real CONNECTION_CLOSE, nothing written +//! after it is delivered, and a stream left unfinished fails with the close once +//! its earlier data is read. use std::{ sync::{Arc, Mutex}, @@ -20,24 +22,32 @@ use web_transport_trait::poll; // ── Error ─────────────────────────────────────────────────────────── -/// Error type for mock transport operations. +/// Error type for mock transport operations: a session close or a stream reset, +/// each reporting its code only through its own registry. #[derive(Debug, Clone)] pub struct MockError { - code: Option, + session: Option, + stream: Option, reason: String, } impl MockError { fn closed() -> Self { + Self::session(0, "session closed".into()) + } + + fn session(code: u32, reason: String) -> Self { Self { - code: Some(0), - reason: "session closed".into(), + session: Some(code), + stream: None, + reason, } } fn stream_reset(code: u32) -> Self { Self { - code: Some(code), + session: None, + stream: Some(code), reason: "stream reset".into(), } } @@ -53,11 +63,11 @@ impl std::error::Error for MockError {} impl web_transport_trait::Error for MockError { fn session_error(&self) -> Option<(u32, String)> { - self.code.map(|c| (c, self.reason.clone())) + self.session.map(|c| (c, self.reason.clone())) } fn stream_error(&self) -> Option { - self.code + self.stream } } @@ -68,6 +78,8 @@ enum StreamChunk { Data(Bytes), Fin, Reset(u32), + /// The connection closed before the stream ended; never queued. + Closed, } /// Shared closed-signal state between a paired SendStream and RecvStream. @@ -100,45 +112,66 @@ pub struct MockSendStream { /// Acknowledge the FIN as soon as it is sent, for a stream the peer's transport holds /// back from its application (see [`MockSession::hold_unis`]). ack_fin: bool, + conn: Arc, +} + +impl MockSendStream { + /// Queue a chunk for the peer, unless the connection closed: nothing sent after a + /// CONNECTION_CLOSE reaches the peer. + fn push(&mut self, chunk: StreamChunk) -> Result<(), MockError> { + if let Some(err) = self.conn.error() { + self.tx = None; + return Err(err); + } + let tx = self.tx.as_ref().ok_or_else(MockError::closed)?; + tx.try_push(chunk).map_err(|_| MockError::closed()) + } } impl poll::SendStream for MockSendStream { type Error = MockError; fn poll_write(&mut self, _cx: &mut Context<'_>, buf: &[u8]) -> Poll> { - let Some(tx) = self.tx.as_ref() else { - return Poll::Ready(Err(MockError::closed())); - }; - match tx.try_push(StreamChunk::Data(Bytes::copy_from_slice(buf))) { - Ok(()) => Poll::Ready(Ok(buf.len())), - Err(_) => Poll::Ready(Err(MockError::closed())), - } + Poll::Ready( + self.push(StreamChunk::Data(Bytes::copy_from_slice(buf))) + .map(|()| buf.len()), + ) } fn set_priority(&mut self, _order: u8) {} fn finish(&mut self) -> Result<(), Self::Error> { - if let Some(tx) = self.tx.take() { - let _ = tx.try_push(StreamChunk::Fin); - if self.ack_fin { + if self.tx.is_some() { + // A FIN that never left must not look acknowledged: poll_closed + // trusts this signal ahead of the connection error. + let pushed = self.push(StreamChunk::Fin); + if pushed.is_ok() && self.ack_fin { self.closed.set(Ok(())); } + self.tx = None; + pushed?; } Ok(()) } fn reset(&mut self, code: u32) { - if let Some(tx) = self.tx.take() { - let _ = tx.try_push(StreamChunk::Reset(code)); + if self.tx.is_some() { + let _ = self.push(StreamChunk::Reset(code)); + self.tx = None; } } fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { // Register before checking so a signal racing this poll still wakes it. - self.closed.waiters.register(self.park.hold(cx)); + let waiter = self.park.hold(cx); + self.closed.waiters.register(waiter); + self.conn.waiters.register(waiter); match self.closed.result.lock().unwrap().clone() { Some(result) => Poll::Ready(result), - None => Poll::Pending, + None => match self.conn.error() { + Some(err) => Poll::Ready(Err(err)), + None => Poll::Pending, + }, } } } @@ -147,8 +180,8 @@ impl Drop for MockSendStream { fn drop(&mut self) { // Dropped without an explicit FIN: deliver an implicit one, matching a // sender that went away cleanly. - if let Some(tx) = self.tx.take() { - let _ = tx.try_push(StreamChunk::Fin); + if self.tx.is_some() { + let _ = self.push(StreamChunk::Fin); } } } @@ -165,16 +198,22 @@ pub struct MockRecvStream { /// Shared signal to notify the peer's send-side `poll_closed`. closed: Arc, park: kio::Park, + conn: Arc, } impl MockRecvStream { - /// Pop the next chunk, mapping queue closure to an implicit FIN. + /// Pop the next chunk, mapping queue closure to an implicit FIN. Once everything + /// sent before a CONNECTION_CLOSE is read, an unfinished stream fails with it. fn poll_chunk(&mut self, cx: &mut Context<'_>) -> Poll> { let waiter = self.park.hold(cx); + self.conn.waiters.register(waiter); match self.rx.poll_pop(waiter) { Poll::Ready(Ok(chunk)) => Poll::Ready(Some(chunk)), Poll::Ready(Err(_)) => Poll::Ready(None), - Poll::Pending => Poll::Pending, + Poll::Pending => match self.conn.error() { + Some(_) => Poll::Ready(Some(StreamChunk::Closed)), + None => Poll::Pending, + }, } } } @@ -212,6 +251,10 @@ impl poll::RecvStream for MockRecvStream { self.done = true; Poll::Ready(Err(MockError::stream_reset(code))) } + Some(StreamChunk::Closed) => { + self.done = true; + Poll::Ready(Err(self.conn.error().unwrap_or_else(MockError::closed))) + } } } @@ -236,6 +279,10 @@ impl poll::RecvStream for MockRecvStream { self.done = true; return Poll::Ready(Err(MockError::stream_reset(code))); } + Some(StreamChunk::Closed) => { + self.done = true; + return Poll::Ready(Err(self.conn.error().unwrap_or_else(MockError::closed))); + } } } } @@ -253,7 +300,7 @@ impl Drop for MockRecvStream { // ── Stream pair constructor ───────────────────────────────────────── /// Create a linked (send, recv) stream pair. -fn new_stream_pair() -> (MockSendStream, MockRecvStream) { +fn new_stream_pair(conn: &Arc) -> (MockSendStream, MockRecvStream) { let queue = kio::Queue::new(); let closed = Arc::new(ClosedSignal::default()); @@ -262,6 +309,7 @@ fn new_stream_pair() -> (MockSendStream, MockRecvStream) { closed: closed.clone(), park: kio::Park::default(), ack_fin: false, + conn: conn.clone(), }; let recv = MockRecvStream { rx: queue, @@ -269,6 +317,7 @@ fn new_stream_pair() -> (MockSendStream, MockRecvStream) { done: false, closed, park: kio::Park::default(), + conn: conn.clone(), }; (send, recv) } @@ -287,6 +336,16 @@ struct ConnectionState { waiters: kio::Fan, } +impl ConnectionState { + /// The close every stream and accept fails with, once the connection closed. + fn error(&self) -> Option { + let state = self.close_state.lock().unwrap(); + state + .as_ref() + .map(|(code, reason)| MockError::session(*code, reason.clone())) + } +} + /// Per-side state: stream queues and a reference to the shared connection. struct SessionSide { /// Bidi streams opened by the peer, popped by accept_bi. @@ -370,8 +429,8 @@ impl poll::Session for MockSession { _cx: &mut Context<'_>, ) -> Poll> { // Create two stream pairs: one for each direction. - let (our_send, peer_recv) = new_stream_pair(); - let (peer_send, our_recv) = new_stream_pair(); + let (our_send, peer_recv) = new_stream_pair(&self.side.conn); + let (peer_send, our_recv) = new_stream_pair(&self.side.conn); // Deliver (peer_send, peer_recv) to the peer's accept_bi. match self.side.peer_bidi.try_push((peer_send, peer_recv)) { @@ -381,7 +440,7 @@ impl poll::Session for MockSession { } fn poll_open_uni(&mut self, _cx: &mut Context<'_>) -> Poll> { - let (mut our_send, peer_recv) = new_stream_pair(); + let (mut our_send, peer_recv) = new_stream_pair(&self.side.conn); if let Some(held) = self.side.held.lock().unwrap().as_mut() { our_send.ack_fin = true; @@ -442,10 +501,7 @@ impl poll::Session for MockSession { self.side.conn.waiters.register(self.closed.hold(cx)); let state = self.side.conn.close_state.lock().unwrap().clone(); match state { - Some((code, reason)) => Poll::Ready(MockError { - code: Some(code), - reason, - }), + Some((code, reason)) => Poll::Ready(MockError::session(code, reason)), None => Poll::Pending, } } @@ -485,17 +541,7 @@ impl MockSession { impl MockSession { fn close_error(&self) -> MockError { - self.side - .conn - .close_state - .lock() - .unwrap() - .as_ref() - .map(|(code, reason)| MockError { - code: Some(*code), - reason: reason.clone(), - }) - .unwrap_or_else(MockError::closed) + self.side.conn.error().unwrap_or_else(MockError::closed) } } diff --git a/rs/moq-tokio/tests/subscription_end_integrity.rs b/rs/moq-tokio/tests/subscription_end_integrity.rs new file mode 100644 index 0000000000..e4da46f1a1 --- /dev/null +++ b/rs/moq-tokio/tests/subscription_end_integrity.rs @@ -0,0 +1,188 @@ +//! Over real QUIC, a subscription ends cleanly only when every group in its range has +//! been accounted for; the publisher disconnecting with a group still in flight ends it +//! with an error. +//! +//! moq-lite, Subscribe: "The publisher closes the stream (FIN) only once every group +//! from start to end has been accounted for, either via a Group Stream (completed or +//! reset) or a SUBSCRIBE_DROP message." And Routing: "when the serving session ends, +//! in-flight subscriptions end with it (a reset)". So `recv_group()` may return +//! `Ok(None)` only after that FIN; any other ending is an error. +//! +//! The in-process twin of this test is `moq-net`'s `subscription_end_integrity`, which +//! pins the rule deterministically over the mock transport. This one repeats it over a +//! real QUIC connection, where the publisher's disconnect is a `CONNECTION_CLOSE` that +//! cuts the final group's stream mid-flight. +//! +//! The subscriber drains a head group first (so the subscription is established and +//! only the tail is at stake), then stops reading for [`STALL`] while the publisher +//! writes a 4 MB final group, finishes the track and disconnects, so the group is still +//! queued behind the connection's flow-control window when the session goes away. The +//! subscriber must then either see every frame or end with an error; it must not end +//! `Ok(None)` short of the final group. +//! +//! Whether the publisher should have been able to flush before disconnecting is a +//! separate question (it has nothing to await today); this test is only about the +//! signal the subscriber gets when it could not. + +use std::time::Duration; + +use moq_tokio::moq_net; + +const TIMEOUT: Duration = Duration::from_secs(10); +const FRAMES: usize = 10; +/// Frames are padded so the final group (4 MB of it) cannot fit in the connection's +/// flow-control window while the subscriber is not reading: without unsent bytes at +/// close, the publisher would only ever lose its FIN, and the test would not cover +/// a group cut mid-flight. +const PADDING: usize = 400_000; +/// How long the subscriber stops reading after the head group, so the final group is +/// still queued in the publisher when the session goes away. +const STALL: Duration = Duration::from_millis(300); + +/// One round: a head group the subscriber drains, then a final group, `finish()`, and +/// the publisher's session dropped with no await in between. Returns how many frames +/// the subscriber read and the error its subscription ended with, if any. +async fn round(drop_session: bool) -> (usize, Option) { + let pub_origin = moq_tokio::origin::spawn(); + let broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast.create_track("video", None).expect("create track"); + + 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 mut server = server_config + .init(Default::default()) + .expect("server init") + .listen() + .await + .expect("listen"); + let port = server.local_addr().expect("local addr").port(); + + // The publisher only learns of a subscription once the subscriber polls it, so the + // reader runs concurrently. It tells the publisher when it has drained the head + // group, so the final group is written against an established subscription. + let (drained_tx, drained_rx) = tokio::sync::oneshot::channel(); + + let reader = tokio::spawn(async move { + let mut client_config = moq_tokio::connect::Config::default(); + client_config.tls.insecure = Some(true); + let sub_origin = moq_tokio::origin::spawn(); + let consumer = sub_origin.consume(); + let client = client_config.init(Default::default()).expect("client init"); + let url: url::Url = format!("https://localhost:{port}").parse().expect("parse url"); + let _connection = client + .with_subscriber(sub_origin) + .with_reconnect(false) + .connect(url) + .established() + .await + .expect("client connect"); + + consumer.routed("test").await.expect("routed"); + let remote = consumer.request_broadcast("test").await.expect("broadcast resolves"); + let mut sub = remote.track("video").unwrap().subscribe(None).await.expect("subscribe"); + + let mut got = 0; + let mut drained = Some(drained_tx); + loop { + let mut group = match sub.recv_group().await { + Ok(Some(group)) => group, + Ok(None) => return (got, None), + Err(err) => return (got, Some(err)), + }; + loop { + match group.read_frame().await { + Ok(Some(_)) => got += 1, + Ok(None) => break, + Err(err) => return (got, Some(err)), + } + } + // The head group is in: from here on, only the tail can be lost. Stop reading + // for a moment, so the publisher's final group is still queued behind the + // connection's flow-control window when it disconnects. + if let Some(tx) = drained.take() { + let _ = tx.send(()); + tokio::time::sleep(STALL).await; + } + } + }); + + let request = tokio::time::timeout(TIMEOUT, server.accept()) + .await + .expect("accept timeout") + .expect("no incoming connection"); + let session = request + .with_publisher(&pub_origin) + .ok() + .await + .expect("publisher session"); + + tokio::time::timeout(TIMEOUT, track.used()) + .await + .expect("no subscriber appeared") + .expect("track closed"); + + write_group(&track); + tokio::time::timeout(TIMEOUT, drained_rx) + .await + .expect("the head group never arrived") + .expect("reader gone"); + + // The final group, a clean group end and a clean track end, innermost first and with + // no await in between: the session has sent none of it yet. + write_group(&track); + track.finish().expect("finish track"); + drop(track); + + // The publisher is done, so it disconnects. Nothing it could have awaited first. + let session = if drop_session { + drop(session); + None + } else { + Some(session) + }; + + let result = tokio::time::timeout(TIMEOUT, reader) + .await + .expect("the subscription never ended") + .expect("reader panicked"); + drop((session, broadcast, pub_origin, server)); + result +} + +/// Append one group of [`FRAMES`] padded frames. +fn write_group(track: &moq_net::track::Producer) { + let mut group = track.append_group().expect("append group"); + for _ in 0..FRAMES { + group + .write_frame(moq_net::Timestamp::ZERO, vec![0; PADDING]) + .expect("write frame"); + } + group.finish().expect("finish group"); +} + +/// The publisher disconnects with the final group in flight: the subscription ends +/// with an error, or delivers every frame. +#[tokio::test] +async fn a_subscription_cut_by_the_publisher_disconnecting_does_not_end_clean() { + let (got, err) = round(true).await; + assert!( + err.is_some() || got == 2 * FRAMES, + "the subscription ended Ok(None) with {got}/{} frames \ + (a subscription that did not deliver every group must end with an error, not as complete)", + 2 * FRAMES, + ); +} + +/// With the session kept alive until the subscriber is done, the same track arrives +/// whole and ends `Ok(None)`. +#[tokio::test] +async fn a_finished_track_ends_clean_while_its_session_lives() { + let (got, err) = round(false).await; + assert!( + err.is_none() && got == 2 * FRAMES, + "session kept alive: got {got}/{} frames, err={err:?}", + 2 * FRAMES, + ); +} From 7461eca6be02ea3331894da54eb0c15f7b848a77 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 20:28:58 -0700 Subject: [PATCH 29/40] chore(moq-net): bench lite-06, smoke-run benches nightly, refresh perf quests (#4229) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 9 +++++++++ quest/m1/README.md | 2 +- quest/m1/cache-expiry-growth.md | 24 ++++++++++++++---------- quest/m1/perf/README.md | 1 - quest/m1/perf/announce-replay.md | 12 +++++++----- quest/m1/perf/group-cost.md | 9 +++++---- quest/m1/perf/lite-route-rescan.md | 29 ----------------------------- rs/moq-net/benches/session.rs | 5 +++-- 8 files changed, 39 insertions(+), 52 deletions(-) delete mode 100644 quest/m1/perf/lite-route-rescan.md diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 62484ea295..d15d3ff914 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -77,6 +77,15 @@ jobs: nix develop --command bun install --frozen-lockfile nix develop --command bun js/net/bench/broadcasts.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 + # `harness = false` target. `fuzz` enables the announce bench, which the + # `'*'` glob otherwise refuses. + - name: moq-net benchmark smoke + if: ${{ !cancelled() }} + run: nix develop --command cargo bench --locked -p moq-net --features fuzz --bench '*' -- --test + - name: Audit if: ${{ !cancelled() }} run: nix develop --command just rs audit diff --git a/quest/m1/README.md b/quest/m1/README.md index c0f3270309..2077fb1231 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -90,7 +90,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Watch worker](/quest/m1/watch-worker.md) - watch playback runs in a worker onto an OffscreenCanvas, so main-thread jank never stalls video or audio - [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw - [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies -- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on moq-transport as on moq-lite +- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on every version - [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/cache-expiry-growth.md b/quest/m1/cache-expiry-growth.md index 17cac468ed..fc2ea10f9d 100644 --- a/quest/m1/cache-expiry-growth.md +++ b/quest/m1/cache-expiry-growth.md @@ -1,23 +1,27 @@ -# [S] Cached groups expire on the IETF path +# [S] Cached groups expire under the default pool ## Goal With the default cache pool, a relay's memory plateaus once groups start -reaching the expiry window, on moq-transport as on moq-lite, or the cause of -continued growth is found and fixed. +reaching the expiry window, on every version, or the cause of continued +growth is found and fixed. ## Plan In `session_delivery_broadcasts` with 4096 announced broadcasts and the -default unbounded pool (30 s expiry), IETF RSS reached 1.5 GB at 10 s, 3.5 GB at -30 s, and 4.9 GB at 50 s, still climbing past the expiry window. Lite -reached about 0.36 GB by 50 s. With a 16 MiB bounded pool both stay flat, so -the retained bytes are cached groups, not a leak elsewhere. +default unbounded pool (30 s expiry), RSS keeps climbing past the expiry +window on both wires (2026-09-25, Apple M4, sampled every 10 s): IETF +1.9 GB at 30 s and 4.4 GB at 120 s, lite-06 2.3 GB at 30 s and 3.5 GB at +120 s, both oscillating by a gigabyte between samples. With a 16 MiB bounded +pool both stay flat (0.5 GB IETF, 0.4 GB lite), so the retained bytes are +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, -expiry not running on a path IETF uses, 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. +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. ## Related diff --git a/quest/m1/perf/README.md b/quest/m1/perf/README.md index c855d6344d..b0b17f4d6f 100644 --- a/quest/m1/perf/README.md +++ b/quest/m1/perf/README.md @@ -43,7 +43,6 @@ row per io_uring worker. - [Open contract](/quest/m1/perf/uring-open-contract.md) - plan concurrent WebTransport opening and cancellation -- [Lite route rescan](/quest/m1/perf/lite-route-rescan.md) - a moq-lite session's per-group cost stops growing with the routes its peer announced - [Announce replay](/quest/m1/perf/announce-replay.md) - the initial announce set replays in linear time, so joins don't slow with the route count - [Group cost](/quest/m1/perf/group-cost.md) - count and cut the allocations and time spent relaying one small group to one viewer - [One enter per turn](/quest/m1/perf/uring-one-enter.md) - a parking turn pays one io_uring_enter, submits flush deferred completions, and SQEs per enter is a counter diff --git a/quest/m1/perf/announce-replay.md b/quest/m1/perf/announce-replay.md index db7f81b036..3a59f0c7cb 100644 --- a/quest/m1/perf/announce-replay.md +++ b/quest/m1/perf/announce-replay.md @@ -13,10 +13,12 @@ The moq-lite publisher's initial replay (`AnnounceRun::init` in for every route it drains: `initial.retain` on Lite05+, `init.contains` on the Lite01/02 init. That is quadratic in the replay size. -Measured with `session_join_broadcasts` (2026-09, one relay): lite join takes -199 µs with 1 announced broadcast, 517 µs with 64, and 8.9 ms with 1024, -about 8.7 µs per route at the top. IETF is 7.2 ms at 1024 with no quadratic -scan; its profile is SipHash hashing and `Path` comparison, so check whether -a faster hasher for path-keyed maps pays off on both. +Measured with `session_join_broadcasts` (2026-09-25, one relay, Apple M4): +lite-06 join takes 128 µs with 1 announced broadcast, 317 µs with 64, and +5.0 ms with 1024, about 4.9 µs per route at the top; lite-07-wip matches. +IETF is 95 µs, 311 µs, and 4.05 ms, with no quadratic scan, so the scan is +the ~1 ms gap at 1024. An earlier Linux profile of the IETF path was SipHash +hashing and `Path` comparison, so check whether a faster hasher for +path-keyed maps pays off on both. Keep the replay's order and its last-update-wins semantics. diff --git a/quest/m1/perf/group-cost.md b/quest/m1/perf/group-cost.md index b558d76b62..cec74b3338 100644 --- a/quest/m1/perf/group-cost.md +++ b/quest/m1/perf/group-cost.md @@ -8,10 +8,11 @@ change to what is delivered. ## Plan -`session_delivery_viewers` (2026-09) spends about 32 µs per viewer per -4-frame, 64-byte group over the in-memory transport, through a publisher -session, a relay origin, and a viewer session. No single function dominates. -The profile spreads it over `kio` waiter registration and parking, +`session_delivery_viewers` spends about 26 µs per viewer per 4-frame, +64-byte group over the in-memory transport, through a publisher session, a +relay origin, and a viewer session: ~420 µs at 16 viewers on every version +(2026-09-25, Apple M4; the 256-viewer point is too noisy on that machine to +quote). No single function dominates. An earlier Linux profile spread it over `kio` waiter registration and parking, `TrackState::evict_expired_scan` (4-5%), `Waiter`'s lazily allocated shared waker (`Once::call`, about 3%, so waiters are created per poll), and malloc/free (10-12%). diff --git a/quest/m1/perf/lite-route-rescan.md b/quest/m1/perf/lite-route-rescan.md deleted file mode 100644 index 6591609912..0000000000 --- a/quest/m1/perf/lite-route-rescan.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] Lite subscriber polls only routes with a request - -## Goal - -A moq-lite session's per-group cost stops growing with the number of -broadcasts its peer has announced: delivery over a session holding thousands -of announced routes costs what it costs with a handful, as it already does -over moq-transport. - -## Plan - -`Announced::poll_serve` (`rs/moq-net/src/lite/subscriber.rs`) polls every -attached route's `Dynamic::poll_requested_broadcast` on every driver wake, so -each incoming group pays for every route, and each pending poll registers the -driver's waiter on every idle route's list. The IETF subscriber runs one task -per route and only wakes the one that got a request. - -Measured with `cargo bench -p moq-net --bench session` (2026-09, one relay, -16 viewers each watching one broadcast): - -- `session_delivery_broadcasts`: lite 445 µs at 16 announced, 15.2 ms at - 4096; IETF flat around 458 µs. 57% of lite CPU is `WaiterList::register` - under `poll_requested_broadcast`. -- `session_delivery_scale` at 256 publishers x 256 viewers: lite 40 ms, IETF - 16.5 ms, since every viewer session holds all 256 routes. - -Wake only routes with a pending request. A task per route like IETF's or a -ready set both fit; pick by what keeps the driver's fairness rules. Land -with the before/after of those two groups. diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index 42e65e2fd2..b093dc6db1 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -28,8 +28,9 @@ use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_m use moq_net::{Hop, Timestamp, Version, broadcast, cache, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; -/// Newest lite draft (`moq-lite-07-wip`, opt-in) and newest IETF draft. -const VERSIONS: [&str; 2] = ["moq-lite-07-wip", "moq-transport-22"]; +/// The lite draft production negotiates, the next one (opt-in), and the newest +/// IETF draft. +const VERSIONS: [&str; 3] = ["moq-lite-06", "moq-lite-07-wip", "moq-transport-22"]; /// Frames per group, so per-frame and per-group costs both appear. const FRAMES: usize = 4; From b33e926cad01acfea9b44717043dad6724ca7809 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 20:30:10 -0700 Subject: [PATCH 30/40] chore: bump package versions Patch bumps for unreleased behavior since #3879. Rust stays with release-plz. Go stays at 0.7 because the wrapper additions are compatible and CI owns that patch. Co-Authored-By: Grok --- bun.lock | 20 ++++++++++---------- js/auth/package.json | 2 +- js/binary/package.json | 2 +- js/hang/package.json | 2 +- js/json/package.json | 2 +- js/moq-boy/package.json | 2 +- js/net/package.json | 2 +- js/publish/package.json | 2 +- js/room/package.json | 2 +- js/signals/package.json | 2 +- js/watch/package.json | 2 +- kt/README.md | 2 +- kt/gradle.properties | 2 +- py/moq-rs/pyproject.toml | 2 +- swift/README.md | 2 +- swift/VERSION | 2 +- uv.lock | 2 +- 17 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bun.lock b/bun.lock index 49a1ec1627..4005df50c8 100644 --- a/bun.lock +++ b/bun.lock @@ -74,7 +74,7 @@ }, "js/auth": { "name": "@moq/auth", - "version": "0.2.0", + "version": "0.2.1", "bin": { "moq-auth": "./src/cli.ts", }, @@ -95,7 +95,7 @@ }, "js/binary": { "name": "@moq/binary", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@moq/flate": "workspace:^", "@moq/net": "workspace:^", @@ -138,7 +138,7 @@ }, "js/hang": { "name": "@moq/hang", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.10.9", @@ -162,7 +162,7 @@ }, "js/json": { "name": "@moq/json", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@moq/flate": "workspace:^", "@moq/net": "workspace:^", @@ -192,7 +192,7 @@ }, "js/moq-boy": { "name": "@moq/boy", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@moq/json": "workspace:^", "@moq/net": "workspace:^", @@ -226,7 +226,7 @@ }, "js/net": { "name": "@moq/net", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@moq/pattern": "workspace:^", "@moq/qmux": "^0.3.3", @@ -258,7 +258,7 @@ }, "js/publish": { "name": "@moq/publish", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "@moq/hang": "workspace:^", "@moq/json": "workspace:^", @@ -278,7 +278,7 @@ }, "js/room": { "name": "@moq/room", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@moq/hang": "workspace:^", "@moq/json": "workspace:^", @@ -298,7 +298,7 @@ }, "js/signals": { "name": "@moq/signals", - "version": "0.2.4", + "version": "0.2.5", "devDependencies": { "@types/bun": "^1.4.2", "@types/react": "^19.3.0", @@ -324,7 +324,7 @@ }, "js/watch": { "name": "@moq/watch", - "version": "0.6.0", + "version": "0.6.1", "dependencies": { "@moq/hang": "workspace:^", "@moq/json": "workspace:^", diff --git a/js/auth/package.json b/js/auth/package.json index 45b8056bda..60f2a21c2d 100644 --- a/js/auth/package.json +++ b/js/auth/package.json @@ -1,7 +1,7 @@ { "name": "@moq/auth", "type": "module", - "version": "0.2.0", + "version": "0.2.1", "description": "Media over QUIC - the authorization contract: requests, grants, and the JWT that rides them", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/binary/package.json b/js/binary/package.json index 849e5f012a..fa1f4487fb 100644 --- a/js/binary/package.json +++ b/js/binary/package.json @@ -1,7 +1,7 @@ { "name": "@moq/binary", "type": "module", - "version": "0.2.0", + "version": "0.2.1", "description": "Binary publishing over MoQ tracks: latest-value snapshots, or append-log streams.", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/hang/package.json b/js/hang/package.json index 5b38723514..51bcaf28d6 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -1,7 +1,7 @@ { "name": "@moq/hang", "type": "module", - "version": "0.5.0", + "version": "0.5.1", "description": "WebCodecs-based media format for MoQ", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/json/package.json b/js/json/package.json index 6bb51ecc61..989fa04116 100644 --- a/js/json/package.json +++ b/js/json/package.json @@ -1,7 +1,7 @@ { "name": "@moq/json", "type": "module", - "version": "0.4.0", + "version": "0.4.1", "description": "JSON publishing over MoQ tracks: snapshot/delta (RFC 7396 merge patch) objects, or append-log NDJSON streams.", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/moq-boy/package.json b/js/moq-boy/package.json index 6d29510d5e..51ad777bff 100644 --- a/js/moq-boy/package.json +++ b/js/moq-boy/package.json @@ -1,7 +1,7 @@ { "name": "@moq/boy", "type": "module", - "version": "0.4.0", + "version": "0.4.1", "jsr": false, "description": "MoQ Boy - Crowd-controlled Game Boy streaming via MoQ", "license": "(MIT OR Apache-2.0)", diff --git a/js/net/package.json b/js/net/package.json index bc5620c6f0..ce53b03637 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -1,7 +1,7 @@ { "name": "@moq/net", "type": "module", - "version": "0.4.0", + "version": "0.4.1", "description": "The networking layer for Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization.", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/publish/package.json b/js/publish/package.json index e9c38f3ea4..f1b4cd30a7 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -1,7 +1,7 @@ { "name": "@moq/publish", "type": "module", - "version": "0.5.0", + "version": "0.5.1", "jsr": false, "description": "Publish Media over QUIC broadcasts", "license": "(MIT OR Apache-2.0)", diff --git a/js/room/package.json b/js/room/package.json index 532c04390a..18381bf6fd 100644 --- a/js/room/package.json +++ b/js/room/package.json @@ -1,7 +1,7 @@ { "name": "@moq/room", "type": "module", - "version": "0.2.0", + "version": "0.2.1", "description": "Headless multi-participant rooms over MoQ: announce-derived roster, local publish, remote watch, and a chat track", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/signals/package.json b/js/signals/package.json index acc547b8f1..cd9387dee7 100644 --- a/js/signals/package.json +++ b/js/signals/package.json @@ -1,7 +1,7 @@ { "name": "@moq/signals", "type": "module", - "version": "0.2.4", + "version": "0.2.5", "description": "Reactive and safe signals", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/watch/package.json b/js/watch/package.json index 9b4a0016eb..7055fb40b0 100644 --- a/js/watch/package.json +++ b/js/watch/package.json @@ -1,7 +1,7 @@ { "name": "@moq/watch", "type": "module", - "version": "0.6.0", + "version": "0.6.1", "jsr": false, "description": "Watch Media over QUIC broadcasts", "license": "(MIT OR Apache-2.0)", diff --git a/kt/README.md b/kt/README.md index e9d4ec3de7..62c509e960 100644 --- a/kt/README.md +++ b/kt/README.md @@ -14,7 +14,7 @@ Most apps want `dev.moq:moq`. Reach for `dev.moq:moq-ffi` directly only if you w ```kotlin ignore // build.gradle.kts dependencies { - implementation("dev.moq:moq:0.5.0") + implementation("dev.moq:moq:0.5.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") } ``` diff --git a/kt/gradle.properties b/kt/gradle.properties index 94109cbaf9..8967643904 100644 --- a/kt/gradle.properties +++ b/kt/gradle.properties @@ -14,7 +14,7 @@ moqffi.version=0.0.0-dev # Wrapper (`dev.moq:moq`) version. Source of truth, bumped by hand. Independent # of the crate: release-kt-lib.yml publishes a new version only when this # changes. Must stay >= the last published wrapper version. -moq.version=0.5.0 +moq.version=0.5.1 # Publishing is disabled by default. CI flips this when MAVEN_CENTRAL_USERNAME # and SIGNING_KEY secrets are configured. See README.md for setup details. diff --git a/py/moq-rs/pyproject.toml b/py/moq-rs/pyproject.toml index 815d863c29..71c3999f46 100644 --- a/py/moq-rs/pyproject.toml +++ b/py/moq-rs/pyproject.toml @@ -14,7 +14,7 @@ name = "moq-rs" # isn't already there (no tag needed; the registry is the gate). # Starts at 0.3.0 to open a new line above the old lockstep 0.2.x releases that # bundled the bindings. -version = "0.5.0" +version = "0.5.1" description = "Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization, with a Pythonic API. Import as `moq`." readme = "README.md" license = "MIT OR Apache-2.0" diff --git a/swift/README.md b/swift/README.md index a953a0c648..766f1ff96e 100644 --- a/swift/README.md +++ b/swift/README.md @@ -16,7 +16,7 @@ The Swift integration ships as two SPM packages, each mirrored to its own repo: ## Install ```swift ignore -.package(url: "https://github.com/moq-dev/moq-swift", from: "0.5.0"), +.package(url: "https://github.com/moq-dev/moq-swift", from: "0.5.1"), ``` SPM resolves `MoqFFI` (and its prebuilt `MoqFFI.xcframework`, attached to the matching `moq-ffi-v*` GitHub Release) transitively. You only depend on `moq-swift`. diff --git a/swift/VERSION b/swift/VERSION index 8f0916f768..4b9fcbec10 100644 --- a/swift/VERSION +++ b/swift/VERSION @@ -1 +1 @@ -0.5.0 +0.5.1 diff --git a/uv.lock b/uv.lock index 5856edeab7..feb20b9b8a 100644 --- a/uv.lock +++ b/uv.lock @@ -509,7 +509,7 @@ source = { editable = "py/moq-ffi" } [[package]] name = "moq-rs" -version = "0.5.0" +version = "0.5.1" source = { editable = "py/moq-rs" } dependencies = [ { name = "moq-ffi" }, From bd0376f2d2e0de6a0c68fe601827ca48d1e8b7dc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 20:50:24 -0700 Subject: [PATCH 31/40] fix(net): await the publisher answer for lite fetches (#4221) Co-authored-by: GPT-6 Co-authored-by: Grok 4.7 --- doc/lib/js/net.md | 2 +- js/net/src/broadcast.ts | 5 +- js/net/src/integration.test.ts | 90 ++++++++++++++++++++++++++++++++++ js/net/src/lite/subscriber.ts | 45 ++++++++++------- quest/m1/README.md | 1 - quest/m1/js-fetch-answer.md | 32 ------------ 6 files changed, 120 insertions(+), 55 deletions(-) delete mode 100644 quest/m1/js-fetch-answer.md diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index f086c11cd9..833961ec74 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -53,7 +53,7 @@ for (;;) { - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. -- **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. +- **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. diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 41cbeca2ef..32bc42ad8e 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -4,6 +4,7 @@ * @module */ import { type GetPromise, Once, Signal } from "@moq/signals"; +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"; @@ -131,7 +132,7 @@ async function fetchGroup( try { for (;;) { const group = await subscriber.recvGroup(); - if (!group) throw new Error(`group not found: ${sequence}`); + if (!group) throw new NotFound(`group ${sequence}`); if (group.sequence === sequence) { // Close the subscription when the returned group finishes, not now: an // in-progress group must keep receiving frames for its lifetime (mirrors @@ -141,7 +142,7 @@ async function fetchGroup( } group.close(); - if (group.sequence > sequence) throw new Error(`group not found: ${sequence}`); + if (group.sequence > sequence) throw new NotFound(`group ${sequence}`); } } catch (err) { subscriber.close(); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index aaed493344..249f4b1bb9 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -10,6 +10,7 @@ import { type Established, } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, StreamError, TooFarBehind } from "./error.ts"; +import { Producer as GroupProducer } from "./group.ts"; import * as Ietf from "./ietf/index.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair } from "./mock.ts"; @@ -868,6 +869,95 @@ test("integration: lite draft-05 fetches a cached group", async () => { server.close(); }); +test.each(["gap", "end"])("integration: lite fetch rejects coalesced misses at %s with NotFound", async (missing) => { + const pair = createMockTransportPair(Lite.ALPN_05); + 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")); + let serving: Promise | undefined; + if (missing === "gap") { + const producer = broadcast.createTrack("video"); + const group = new GroupProducer(1); + producer.writeGroup(group); + group.close(); + } else { + serving = (async () => { + for (;;) { + const request = await wireOf(broadcast).requested(); + if (!request) return; + request.accept().close(); + } + })(); + } + const remote = wireOf(client).consume(Path.from("test")); + try { + const track = remote.track("video"); + const results = await Promise.allSettled([track.fetchGroup(0), track.fetchGroup(0)]); + for (const result of results) { + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") throw new Error("missing group was accepted"); + expect(result.reason).toBeInstanceOf(StreamError); + expect(result.reason.code).toBe(StreamCode.NotFound); + } + if (results[0].status === "rejected" && results[1].status === "rejected") { + expect(results[0].reason).toBe(results[1].reason); + } + } finally { + broadcast.close(); + await serving; + remote.close(); + client.close(); + server.close(); + origin.close(); + } +}); + +test.each(["frame", "FIN"])("integration: lite fetch waits for the publisher's first %s", async (answer) => { + const pair = createMockTransportPair(Lite.ALPN_05); + 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 producer = broadcast.createTrack("video"); + const group = producer.appendGroup(); + const remote = wireOf(client).consume(Path.from("test")); + try { + let settled = 0; + const fetch = () => + remote + .track("video") + .fetchGroup(0) + .then((consumer) => { + settled++; + return consumer; + }); + const a = fetch(); + const b = fetch(); + // Let the in-memory peer process the request with no response available yet. + await sleep(0); + expect(settled).toBe(0); + if (answer === "frame") group.writeString("accepted"); + else group.close(); + const consumers = await Promise.all([a, b]); + for (const consumer of consumers) { + expect(await consumer.readString()).toBe(answer === "frame" ? "accepted" : undefined); + consumer.close(); + } + } finally { + group.close(); + broadcast.close(); + remote.close(); + client.close(); + server.close(); + origin.close(); + } +}); + test("integration: lite draft-05 coalesces concurrent fetches of one group", async () => { const enc = new TextEncoder(); const dec = new TextDecoder(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index eb5aff5569..913abf2998 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -132,7 +132,7 @@ export class Subscriber { // Dedup in-flight one-shot fetches, keyed by [broadcast, track, sequence]. Concurrent (or // repeat, while still open) fetchGroup() calls for the same group share one FETCH stream and // each get an independent mirror; the entry is evicted once the group closes. - #fetches = new Map(); + #fetches = new Map }>(); // The peer's PROBE estimates, written as they arrive (Lite03+ only). #probe?: Signal; @@ -704,7 +704,7 @@ export class Subscriber { // Open a FETCH stream for one group and stream its bare frames into a group, for the // ConsumeBroadcast backing track.Consumer.fetchGroup() (lite-05+). - fetchGroup( + async fetchGroup( broadcast: Path.Valid, track: string, sequence: number, @@ -713,29 +713,37 @@ export class Subscriber { // Coalesce onto a still-open fetch of the same group so we don't open a second FETCH // stream (and re-download it); each caller reads an independent mirror. const key = JSON.stringify([broadcast, track, sequence]); - const existing = this.#fetches.get(key); - if (existing && !existing.isClosed) return Promise.resolve(existing.mirror()); - - // Create and cache the group synchronously (before any await) so a concurrent fetch for - // the same group finds it and coalesces rather than racing to open its own stream. - const group = new netGroup.Producer(sequence); - this.#fetches.set(key, group); - void group.closed.then(() => { - if (this.#fetches.get(key) === group) this.#fetches.delete(key); - }); + let entry = this.#fetches.get(key); + if (!entry || entry.group.isClosed) { + const group = new netGroup.Producer(sequence); + entry = { group, accepted: this.#runFetch(broadcast, track, sequence, options, group) }; + this.#fetches.set(key, entry); + void group.closed.then(() => { + if (this.#fetches.get(key)?.group === group) this.#fetches.delete(key); + }); + } - return this.#runFetch(broadcast, track, sequence, options, group); + // Reserve each caller's mirror before awaiting acceptance so the pump sees demand, + // and a fast FIN cannot discard frames before these callers receive their handles. + const consumer = entry.group.mirror(); + try { + await entry.accepted; + return consumer; + } catch (err) { + consumer.close(); + throw err; + } } // Open the FETCH stream and pump the response into the shared group. Setup errors close the - // group (so coalesced mirrors observe them and the entry evicts) and reject this caller. + // group, evict the entry, and reject every caller waiting for acceptance. async #runFetch( broadcast: Path.Valid, track: string, sequence: number, options: track.FetchGroupOptions, group: netGroup.Producer, - ): Promise { + ): Promise { try { if (!supportsTrackStream(this.version)) { throw new Error("fetch group requires moq-lite-05 or newer"); @@ -751,16 +759,15 @@ export class Subscriber { 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(); } catch (err: unknown) { stream.abort(error(err)); throw err; } - // Mint this caller's reader before starting the pump, so the group has demand when the - // pump begins watching it (an abandoned fetch cancels once every reader has left). - const consumer = group.mirror(); void this.#runFetchResponse(stream, group, Time.Timescale(info.timescale)); - return consumer; } catch (err: unknown) { group.close(error(err)); throw err; diff --git a/quest/m1/README.md b/quest/m1/README.md index 2077fb1231..5aef7064c5 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,7 +17,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests -- [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [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` diff --git a/quest/m1/js-fetch-answer.md b/quest/m1/js-fetch-answer.md deleted file mode 100644 index 0e9bc28b3b..0000000000 --- a/quest/m1/js-fetch-answer.md +++ /dev/null @@ -1,32 +0,0 @@ -# [S] JS lite fetch waits for the publisher's answer - -## Goal - -`js/net`'s lite `fetchGroup` resolves only once the publisher has answered: -the first response byte, or a FIN for an empty group. A missing group rejects -the fetch itself, and every coalesced caller sees the same rejection, instead -of receiving a group whose first `readFrame()` fails. A JS publisher that -cannot serve a group resets the stream with `NotFound`, not a generic error, -so a Rust or JS subscriber can tell a miss from a failure. - -## Plan - -- Rust already behaves this way since #4164, which waits in the lite - subscriber before accepting and rejects on reset. Mirror it: resolve after - the first response byte or an empty-group FIN, and reject on reset. Today - the fetch path returns its mirror before the response arrives; the stream - reader can already block until data or FIN and throw on reset. -- The publisher side throws a plain error for a local miss, which reaches the - wire as a generic reset code. Give it the `NotFound` code the Rust side uses. -- The IETF JS path refuses `fetchGroup` outright and is out of scope. -- Tests in the lite integration suite: a missing group rejects the fetch, a - coalesced second caller rejects too, an existing group is unchanged, and a - JS publisher's miss reaches a subscriber as `NotFound`. - -Public API: none; a behavior change in when `fetchGroup` settles. Wire: no -format change; a miss resets with the existing `NotFound` code instead of a -generic one. - -## Related - -- [#4164](https://github.com/moq-dev/moq/pull/4164) - the same fix in Rust From c81aaae1eb1a4df836593cd681c2dc33b9dda321 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 21:04:32 -0700 Subject: [PATCH 32/40] fix(cli): keep delayed playback at the live edge (#4241) Co-authored-by: GPT-6 --- doc/bin/cli.md | 8 + quest/m1/README.md | 1 - quest/m1/play-tunein-backpressure.md | 44 --- rs/moq-cli/Cargo.toml | 4 + rs/moq-cli/benches/play_buffer.rs | 38 +++ rs/moq-cli/src/play/buffer.rs | 148 ++++++++++ rs/moq-cli/src/play/fake.rs | 18 ++ rs/moq-cli/src/play/media.rs | 179 ++++++++---- rs/moq-cli/src/play/mod.rs | 3 + rs/moq-cli/src/play/timeline.rs | 7 + rs/moq-cli/src/play/video.rs | 405 +++++++++++++++++++++++++++ 11 files changed, 749 insertions(+), 106 deletions(-) delete mode 100644 quest/m1/play-tunein-backpressure.md create mode 100644 rs/moq-cli/benches/play_buffer.rs create mode 100644 rs/moq-cli/src/play/buffer.rs create mode 100644 rs/moq-cli/src/play/video.rs diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 8aca668760..290e9fd853 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -106,6 +106,14 @@ actually is. While video owns the clock, a frame arriving earlier than predicted pulls playback forward, so a late start catches up to live instead of staying 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. + 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 does not replay the retained backlog. A publisher that retires the rendition diff --git a/quest/m1/README.md b/quest/m1/README.md index 5aef7064c5..a0ff60bb93 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -42,7 +42,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Jitter clock](/quest/m1/jitter-flush-clock.md) - renditions advertise `delay` (lag behind the earliest track) and `jitter` (spread), measured at encoder flush, never lowered; js/watch sizes playout over what it subscribes - [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` -- [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md) - moq play: a tune-in burst larger than the video queue parks the decoder, so the clock never reaches live at a wide `--delay` - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers - [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 diff --git a/quest/m1/play-tunein-backpressure.md b/quest/m1/play-tunein-backpressure.md deleted file mode 100644 index b19a7bc2fd..0000000000 --- a/quest/m1/play-tunein-backpressure.md +++ /dev/null @@ -1,44 +0,0 @@ -# [M] play: a tune-in burst must not stall behind the video queue - -## Goal - -`moq play --delay` above roughly a second reaches the live edge as quickly as -the default does. Today the picture can settle a further delay behind live and -stay there for the session. - -`play_video` in `rs/moq-cli/src/play/media.rs` folds each frame into the playout -clock as it is read, then parks on `drained` once the queue holds -`MAX_VIDEO_FRAMES` (30). The clock only moves toward live when a frame arrives -earlier than the anchor predicted, so a parked decoder cannot observe the rest -of a tune-in burst. With `--delay 2s` at 30 fps the queue is a second of media, -the window presents nothing for two seconds, and by the time it drains the -remaining burst frames are late under the old anchor, which `Pacer` deliberately -refuses to re-anchor on. The anchor stays pinned wherever the first frame landed. - -## Plan - -The frames are raw decoded surfaces, so a queue sized by the delay is not the -answer: 30 frames of 1080p NV12 is already ~90 MB. - -Chosen A/V policy: buffer encoded frames for the whole `max_age` window, -decode only a few ahead of presentation, and evict the oldest decoded frame -when that small queue fills. Video keeps observing the live edge without moving -the anchor while audio owns it. `moq_video::decode::Consumer` owns both the -container reader and the native decoder today, so split or compose them without -duplicating the subscription and codec rules. Bound the encoded buffer by media -age and account for its bytes; `--delay` allows 10s, which as raw 1080p frames -would be ~900 MB. - -The regression lives in #3946 (`refs/pull/3946/head`, `play::media::tests`): -61 frames at 30fps, a 2s delay, and no window drain park the decoder at frame 31, leaving the newest frame due 990ms late. Port it onto -the harness, and also cover video-only and speaker-owned anchors, delayed -drains, reordering, discontinuity, and the decoder's tail flush. - -The harness (`play::fake::Recorder`, driving `Media` on a paused tokio clock) -records the speaker and the window's wakes, but nothing presents video yet: a -test drains the shared queue itself, so add a fake presenter that pops frames -as `Presentation::due` allows, mirroring `window.rs`. - -## Related - -- [Playout clock](https://github.com/moq-dev/moq/pull/3528) - added the clock this bounds diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index a1739c6a86..6065077ddd 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -134,3 +134,7 @@ tempfile = { workspace = true } # `test-util` enables `tokio::time::pause` so the TS round-trip test simulates the # exporter drain timeouts instantly instead of waiting on the wall clock. tokio = { workspace = true, features = ["test-util"] } + +[[bench]] +name = "play_buffer" +harness = false diff --git a/rs/moq-cli/benches/play_buffer.rs b/rs/moq-cli/benches/play_buffer.rs new file mode 100644 index 0000000000..758c6ea827 --- /dev/null +++ b/rs/moq-cli/benches/play_buffer.rs @@ -0,0 +1,38 @@ +//! Encoded retention cost and peak memory, swept over delay and bitrate. +//! Run with `cargo bench -p moq-cli --bench play_buffer`. + +#[allow(dead_code)] +#[path = "../src/play/buffer.rs"] +mod buffer; + +use std::hint::black_box; +use std::time::{Duration, Instant}; + +fn main() { + for delay_ms in [100, 2_000, 10_000] { + for payload_bytes in [1_024, 65_536] { + let frames: Vec<_> = (0..300) + .map(|index| moq_mux::container::Frame { + timestamp: hang::moq_net::Timestamp::from_millis(index * 33).unwrap(), + duration: None, + payload: bytes::Bytes::from(vec![128; payload_bytes]), + keyframe: index % 30 == 0, + }) + .collect(); + let start = Instant::now(); + let mut peak = 0; + for _ in 0..1_000 { + let mut buffer = buffer::Buffer::default(); + for frame in &frames { + buffer.push(black_box(frame.clone()), Duration::from_millis(delay_ms)); + peak = peak.max(buffer.bytes); + } + black_box(buffer); + } + println!( + "delay={delay_ms}ms payload={payload_bytes}B: {:.1} ns/frame, peak={peak}B", + start.elapsed().as_nanos() as f64 / 300_000.0 + ); + } + } +} diff --git a/rs/moq-cli/src/play/buffer.rs b/rs/moq-cli/src/play/buffer.rs new file mode 100644 index 0000000000..3c9e9599c0 --- /dev/null +++ b/rs/moq-cli/src/play/buffer.rs @@ -0,0 +1,148 @@ +//! Encoded playback retention in media time. + +use hang::moq_net; +use moq_mux::container::Frame; +use std::collections::VecDeque; +use std::time::Duration; + +/// Encoded access units stay in decode order, even when their PTS reorders. +#[derive(Default)] +pub(super) struct Buffer { + pub frames: VecDeque, + pub bytes: usize, + edge: Option, + // Monotone minimum: PTS can reorder behind the front of decode order. + oldest: VecDeque, + pub generation: u64, + pub floor: Option, + waiting_keyframe: bool, + pub ended: bool, +} + +impl Buffer { + pub fn clear(&mut self) { + self.frames.clear(); + self.oldest.clear(); + self.bytes = 0; + self.edge = None; + self.generation += 1; + self.waiting_keyframe = true; + } + + pub fn pop(&mut self) -> Option { + let frame = self.frames.pop_front()?; + self.bytes -= frame.payload.len(); + if self.oldest.front() == Some(&frame.timestamp) { + self.oldest.pop_front(); + } + Some(frame) + } + + pub fn push(&mut self, frame: Frame, max_age: Duration) { + let edge = *self.edge.get_or_insert(frame.timestamp); + self.edge = Some(edge.max(frame.timestamp)); + if frame.keyframe { + if self.waiting_keyframe { + self.floor = Some(frame.timestamp); + } + self.waiting_keyframe = false; + } + if self.waiting_keyframe { + return; + } + while self.oldest.back().is_some_and(|at| *at > frame.timestamp) { + self.oldest.pop_back(); + } + self.oldest.push_back(frame.timestamp); + self.bytes += frame.payload.len(); + self.frames.push_back(frame); + let edge = self.edge.unwrap(); + let stale = + |oldest: &moq_net::Timestamp| edge.as_micros().saturating_sub(oldest.as_micros()) > max_age.as_micros(); + if self.oldest.front().is_some_and(stale) { + // Dropping a reference picture invalidates the rest of its GOP. Resume + // only at a retained keyframe, never feed a broken chain to the codec. + self.pop(); + while self.frames.front().is_some_and(|frame| !frame.keyframe) || self.oldest.front().is_some_and(stale) { + self.pop(); + } + self.generation += 1; + self.waiting_keyframe = self.frames.is_empty(); + self.floor = self.frames.front().map(|frame| frame.timestamp); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(ms: u64, keyframe: bool) -> Frame { + Frame { + timestamp: moq_net::Timestamp::from_millis(ms).unwrap(), + duration: None, + payload: bytes::Bytes::from_static(b"encoded"), + keyframe, + } + } + + #[test] + fn eviction_keeps_only_decodable_media_inside_the_age_window() { + let mut buffer = Buffer::default(); + let age = Duration::from_millis(100); + buffer.push(frame(0, true), age); + buffer.push(frame(50, false), age); + buffer.push(frame(100, true), age); + buffer.push(frame(150, false), age); + assert_eq!(buffer.frames.front().unwrap().timestamp.as_millis(), 100); + assert_eq!(buffer.bytes, 14); + buffer.pop(); + assert_eq!(buffer.bytes, 7); + buffer.push(frame(260, false), age); + assert!(buffer.frames.is_empty(), "no retained keyframe"); + assert_eq!(buffer.bytes, 0); + buffer.push(frame(280, false), age); + assert!(buffer.frames.is_empty(), "cannot resume on a dependent picture"); + buffer.push(frame(300, true), age); + assert_eq!(buffer.bytes, 7); + } + + #[test] + fn reordered_timestamps_keep_decode_order_and_the_newest_edge() { + let mut buffer = Buffer::default(); + for (ms, keyframe) in [(0, true), (99, false), (33, false), (66, false)] { + buffer.push(frame(ms, keyframe), Duration::from_millis(100)); + } + assert_eq!( + buffer + .frames + .iter() + .map(|f| f.timestamp.as_millis()) + .collect::>(), + [0, 99, 33, 66] + ); + assert_eq!(buffer.edge.unwrap().as_millis(), 99); + buffer.clear(); + assert_eq!(buffer.bytes, 0); + assert!(buffer.frames.is_empty()); + buffer.push(frame(1, true), Duration::ZERO); + assert_eq!(buffer.edge.unwrap().as_millis(), 1); + } + #[test] + fn age_expiry_finds_a_reordered_picture_behind_the_front() { + let mut buffer = Buffer::default(); + let age = Duration::from_millis(100); + for (ms, keyframe) in [(0, true), (99, false), (33, false)] { + buffer.push(frame(ms, keyframe), age); + } + buffer.pop(); + buffer.push(frame(150, false), age); + assert!( + buffer.frames.is_empty(), + "the 33ms picture expired behind the 99ms reference" + ); + assert_eq!(buffer.bytes, 0); + buffer.push(frame(160, true), age); + assert_eq!(buffer.frames.len(), 1); + } +} diff --git a/rs/moq-cli/src/play/fake.rs b/rs/moq-cli/src/play/fake.rs index ba30cc198d..856a62dab8 100644 --- a/rs/moq-cli/src/play/fake.rs +++ b/rs/moq-cli/src/play/fake.rs @@ -39,6 +39,24 @@ struct State { } impl Recorder { + /// Present the newest due frame, just as the window does on a redraw. + pub fn present(&self, media: &super::media::Media) -> Option { + let mut video = media.video.lock().unwrap(); + let presentation = media.presentation.lock().unwrap(); + let now = Instant::now().into_std(); + let mut shown = None; + while video + .front() + .is_some_and(|frame| presentation.due(frame.timestamp).is_none_or(|at| at <= now)) + { + shown = video.pop_front().map(|frame| frame.timestamp); + } + if shown.is_some() { + media.drained.notify_one(); + } + shown + } + /// Everything the speaker played, in write order. pub fn played(&self) -> Vec { self.state.lock().unwrap().played.clone() diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index 70428163ee..fec8b15c9f 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -17,13 +17,9 @@ use super::output::{Output, Sink, Speaker}; use super::playback::{Kind, Playback, joined}; use super::source::subscribe; use super::timeline::{AudioTimeline, Presentation, timestamp}; +use super::video::Video; use super::window::Event; -/// Decoded frames held for presentation, and the point at which the decoder is -/// made to wait. About a second at 30fps: enough to absorb a burst, few enough -/// that raw frames can't run away with memory. -const MAX_VIDEO_FRAMES: usize = 30; - /// The floor on the speaker's buffer, whatever delay was asked for. The device /// pulls on a fixed clock, so a ring shallower than this drops out on ordinary /// network jitter, and one with no depth at all can never be read from. @@ -147,6 +143,7 @@ impl Media { // any more, and video takes the anchor back. if !playback.playing(Kind::Audio) && tails.is_empty() && speaker.take().is_some() { self.presentation.lock().unwrap().stopped(); + self.drained.notify_one(); } // Start whatever isn't playing from the newest snapshot, which is not @@ -176,26 +173,37 @@ impl Media { continue; } }; - let mut decode = moq_video::decode::Options::new(); - decode.start = moq_video::decode::Start::Latest; - // Nothing older than the playhead is worth presenting, so the delay - // doubles as the staleness budget on the wire. - decode.max_age = self.args.delay.into_std(); - match moq_video::decode::Consumer::new(&rendition, &config, &name, decode).await { - Ok(consumer) => { - tracing::info!(track = name, decoder = consumer.name(), "playing video rendition"); - let presentation = self.presentation.clone(); - let video = self.video.clone(); - let drained = self.drained.clone(); - let output = self.output.clone(); - tasks.spawn(async move { - ( - Kind::Video, - play_video(consumer, presentation, video, drained, output) - .await - .map(|()| None), - ) - }); + let max_age = self.args.delay.into_std(); + let opened = async { + let decoder = moq_video::decode::Sink::open(&config, &Default::default()).await?; + let track = rendition.track(&name)?; + let mut subscriber = track + .subscribe( + moq_net::track::Subscription::default() + .with_priority(hang::catalog::PRIORITY.video) + .with_max_age(max_age), + ) + .await?; + // Start at the local live edge without asking the shared publisher + // subscription to rewind to a cached sequence. + if let Some(latest) = track.latest() { + subscriber.set_groups(latest..); + } + let format = catalog::hang::Container::try_from(&config)?; + Ok::<_, anyhow::Error>((moq_mux::container::Consumer::new(subscriber, format), decoder)) + } + .await; + match opened { + Ok((track, decoder)) => { + tracing::info!(track = name, decoder = decoder.name(), "playing video rendition"); + let video = Video { + presentation: self.presentation.clone(), + frames: self.video.clone(), + changed: self.drained.clone(), + output: self.output.clone(), + max_age, + }; + tasks.spawn(async move { (Kind::Video, video.run(track, decoder).await.map(|()| None)) }); playback.started(Kind::Video); break; } @@ -234,6 +242,7 @@ impl Media { speaker: speaker.clone().expect("opened above"), presentation: self.presentation.clone(), depth, + changed: self.drained.clone(), output: self.output.clone(), }; tasks.spawn(async move { (Kind::Audio, play_audio(consumer, audio).await.map(Some)) }); @@ -259,43 +268,8 @@ impl Media { } } -async fn play_video( - mut consumer: moq_video::decode::Consumer, - presentation: Arc>, - video: Arc>>, - drained: Arc, - output: O, -) -> anyhow::Result<()> { - while let Some(frame) = consumer.read().await? { - // Fold the arrival into the playout clock before queueing it, so the window - // always has a deadline for whatever it finds in the queue. A move has to - // wake it before the wait below, not after: the window is asleep on the old - // anchor's deadline, and it is the only thing that drains the queue this - // task is about to block on. - if presentation - .lock() - .unwrap() - .video(frame.timestamp, Instant::now().into_std()) - { - output.send(Event::Wake); - } - - // Wait for room rather than dropping the oldest. Audio is paced to real - // time, so during a catch-up burst the frames at the front are still ahead - // of the clock, and dropping them would blank the window until the clock - // reached whatever survived. The playout clock is anchored to the wall - // clock, so the queue always drains and this always clears. - while video.lock().unwrap().len() >= MAX_VIDEO_FRAMES { - drained.notified().await; - } - - video.lock().unwrap().push_back(frame); - output.send(Event::Wake); - } - Ok(()) -} - struct AudioPlayback { + changed: Arc, speaker: O::Speaker, presentation: Arc>, depth: Duration, @@ -309,6 +283,7 @@ async fn play_audio( playback: AudioPlayback, ) -> anyhow::Result<::Sink> { let AudioPlayback { + changed, speaker, presentation, depth, @@ -419,6 +394,7 @@ async fn play_audio( .unwrap() .audio(timing.end, sink.buffered(), Instant::now().into_std()); if moved { + changed.notify_one(); output.send(Event::Wake); } } @@ -569,4 +545,85 @@ mod tests { let gap = new_start.saturating_duration_since(old_end); assert!(gap < AUDIO_CHUNK, "the switch went silent for {gap:?}"); } + /// The 61-frame tune-in burst from #3946 must reach the clock before the + /// window drains its first picture, regardless of the raw queue's capacity. + #[tokio::test] + async fn a_wide_delay_observes_the_whole_tune_in_burst() { + tokio::time::pause(); + let delay = Duration::from_secs(2); + let origin = moq_tokio::origin::spawn(); + let broadcast = origin.create_broadcast("room").unwrap(); + let track = broadcast + .create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video)) + .unwrap(); + let mut producer = moq_mux::container::Producer::new(track, Container::Legacy(moq_mux::container::Kind::Data)); + let mut config = moq_video::encode::Config::new(64, 64, moq_video::Rate::new(30, 1).unwrap()); + config.kind = moq_video::encode::Kind::Software; + config.gop = moq_video::encode::Gop::Keyframe { interval: 120 }; + let mut encoder = moq_video::encode::Encoder::new(&config).unwrap(); + for index in 0..=60 { + let surface = moq_video::Surface::rgba(&vec![128; 64 * 64 * 4], moq_video::Size::new(64, 64)).unwrap(); + let frame = moq_video::Frame::new(surface, moq_net::Timestamp::from_millis(index * 33).unwrap()); + for encoded in encoder.encode(&frame).unwrap() { + producer + .write(moq_mux::container::Frame { + timestamp: encoded.timestamp, + duration: None, + payload: encoded.payload, + keyframe: index == 0, + }) + .unwrap(); + } + } + producer.finish().unwrap(); + let catalog = hang::catalog::VideoConfig::new(hang::catalog::H264 { + inline: true, + profile: 0x42, + constraints: 0, + level: 30, + }); + let mut options = moq_video::decode::Options::new(); + options.decoder.kind = moq_video::decode::Kind::Software; + options.max_age = delay; + let decoder = moq_video::decode::Sink::open(&catalog, &options.decoder).await.unwrap(); + let subscriber = broadcast + .consume() + .track("video") + .unwrap() + .subscribe(moq_net::track::Subscription::default().with_max_age(delay)) + .await + .unwrap(); + let track = moq_mux::container::Consumer::new(subscriber, Container::try_from(&catalog).unwrap()); + let recorder = Recorder::default(); + let media = media(&origin, delay, recorder.clone()); + let now = Instant::now(); + let last = moq_net::Timestamp::from_millis(60 * 33).unwrap(); + let task = tokio::spawn( + Video { + presentation: media.presentation.clone(), + frames: media.video.clone(), + changed: media.drained.clone(), + output: recorder.clone(), + max_age: delay, + } + .run(track, decoder), + ); + loop { + tokio::task::yield_now().await; + if media.video.lock().unwrap().len() == 30 + || media.presentation.lock().unwrap().due(last) == Some((now + delay).into_std()) + { + break; + } + } + let due = media.presentation.lock().unwrap().due(last); + task.abort(); + let _ = task.await; + assert_eq!( + due, + Some((now + delay).into_std()), + "the decoder did not observe the live edge" + ); + assert!(recorder.present(&media).is_none(), "nothing is due before its delay"); + } } diff --git a/rs/moq-cli/src/play/mod.rs b/rs/moq-cli/src/play/mod.rs index 74ce9d499c..c455dd1e8d 100644 --- a/rs/moq-cli/src/play/mod.rs +++ b/rs/moq-cli/src/play/mod.rs @@ -15,6 +15,7 @@ #![cfg_attr(not(feature = "play"), allow(dead_code))] mod args; +mod buffer; mod layout; mod playback; mod source; @@ -27,6 +28,8 @@ mod media; #[cfg(feature = "play")] mod output; #[cfg(feature = "play")] +mod video; +#[cfg(feature = "play")] mod window; #[cfg(feature = "play")] diff --git a/rs/moq-cli/src/play/timeline.rs b/rs/moq-cli/src/play/timeline.rs index 60b97e7aa3..5082120f6c 100644 --- a/rs/moq-cli/src/play/timeline.rs +++ b/rs/moq-cli/src/play/timeline.rs @@ -54,6 +54,13 @@ impl Presentation { before != Some(self.pacer.pace(timestamp, now)) } + /// Start a new video timeline without taking ownership from a speaker. + pub(super) fn video_restarted(&mut self) { + if !self.speaker { + self.pacer = moq_mux::Pacer::default().with_delay(self.delay); + } + } + /// Fold the speaker's position into the anchor, reporting whether that moved /// the schedule. /// diff --git a/rs/moq-cli/src/play/video.rs b/rs/moq-cli/src/play/video.rs new file mode 100644 index 0000000000..51a6efb441 --- /dev/null +++ b/rs/moq-cli/src/play/video.rs @@ -0,0 +1,405 @@ +//! Receive encoded video independently of the paced decoder and window. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use hang::moq_net; +use moq_mux::container::Frame; +use tokio::time::Instant; + +use super::buffer::Buffer; +use super::output::Output; +use super::timeline::Presentation; +use super::window::Event; + +/// A few surfaces, independent of the requested playout delay. +pub(super) const MAX_FRAMES: usize = 3; +const DECODE_AHEAD: Duration = Duration::from_millis(100); + +type Track = moq_mux::container::Consumer; + +/// The window's shared state and the subscription's encoded retention budget. +pub(super) struct Video { + pub presentation: Arc>, + pub frames: Arc>>, + pub changed: Arc, + pub output: O, + pub max_age: Duration, +} + +/// The production sink and the deterministic buffered decoder used by tests. +pub(super) trait Decoder { + async fn decode(&mut self, frame: Frame) -> anyhow::Result>; + async fn flush(&mut self) -> anyhow::Result>; +} + +impl Decoder for moq_video::decode::Sink { + async fn decode(&mut self, frame: Frame) -> anyhow::Result> { + Ok(self.decode(frame.payload, frame.timestamp, frame.keyframe).await?) + } + + async fn flush(&mut self) -> anyhow::Result> { + Ok(self.flush().await?) + } +} + +impl Video { + pub async fn run(self, mut track: Track, mut decoder: impl Decoder) -> anyhow::Result<()> { + let buffer = Mutex::new(Buffer::default()); + let receive = async { + let mut discontinuity = track.discontinuity(); + loop { + let frame = track.read().await?; + let mut buffer = buffer.lock().unwrap(); + let before = buffer.generation; + if track.discontinuity() != discontinuity { + discontinuity = track.discontinuity(); + buffer.clear(); + self.presentation.lock().unwrap().video_restarted(); + } + let ended = frame.is_none(); + if let Some(frame) = frame { + if self + .presentation + .lock() + .unwrap() + .video(frame.timestamp, Instant::now().into_std()) + { + self.output.send(Event::Wake); + } + buffer.push(frame, self.max_age); + } else { + buffer.ended = true; + } + if buffer.generation != before { + self.frames.lock().unwrap().clear(); + self.output.send(Event::Wake); + } + tracing::trace!( + bytes = buffer.bytes, + frames = buffer.frames.len(), + "buffered encoded video" + ); + self.changed.notify_one(); + if ended { + break; + } + } + Ok::<_, anyhow::Error>(()) + }; + let decode = async { + let mut generation = None; + loop { + let next = { + let buffer = buffer.lock().unwrap(); + buffer.frames.front().map(|frame| frame.timestamp) + }; + let Some(timestamp) = next else { + if buffer.lock().unwrap().ended { + 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))); + } + at + }; + if let Some(at) = at.filter(|at| *at > Instant::now().into_std()) { + tokio::select! { + _ = tokio::time::sleep_until(at.into()) => {}, + _ = self.changed.notified() => {}, + } + 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); + } + } + let tail = decoder.flush().await?; + if generation == Some(buffer.lock().unwrap().generation) { + self.decoded(tail, buffer.lock().unwrap().floor); + } + 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::*; + + #[derive(Default)] + struct Buffered { + pending: Option, + decoded: Arc>>, + flushed: Arc>, + } + + 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()) + } + + async fn flush(&mut self) -> anyhow::Result> { + *self.flushed.lock().unwrap() += 1; + Ok(self.pending.take().into_iter().collect()) + } + } + + fn media(delay: Duration) -> Media { + Media { + origin: moq_tokio::origin::spawn().consume(), + broadcast: "room".into(), + args: Args { + delay: delay.into(), + catalog_format: None, + select: Default::default(), + }, + video: Default::default(), + presentation: Arc::new(Mutex::new(Presentation::new(delay))), + drained: Default::default(), + output: Recorder::default(), + } + } + + fn playback(media: &Media, max_age: Duration) -> Video { + Video { + presentation: media.presentation.clone(), + frames: media.video.clone(), + changed: media.drained.clone(), + output: media.output.clone(), + max_age, + } + } + + fn frame(ms: u64, keyframe: bool) -> Frame { + Frame { + timestamp: moq_net::Timestamp::from_millis(ms).unwrap(), + duration: None, + keyframe, + payload: bytes::Bytes::from_static(b"encoded"), + } + } + + async fn track(frames: impl IntoIterator, delay: Duration) -> Track { + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast + .create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video)) + .unwrap(); + let subscriber = track + .consume() + .subscribe(moq_net::track::Subscription::default().with_max_age(delay)) + .await + .unwrap(); + let format = moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data); + let mut producer = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), + ); + for frame in frames { + producer.write(frame).unwrap(); + } + producer.finish().unwrap(); + moq_mux::container::Consumer::new(subscriber, format) + } + + async fn burst(speaker: bool, paused_window: bool) { + tokio::time::pause(); + let delay = Duration::from_secs(2); + let media = media(delay); + let start = Instant::now(); + let last = moq_net::Timestamp::from_millis(1_980).unwrap(); + if speaker { + media + .presentation + .lock() + .unwrap() + .audio(Duration::ZERO, delay, start.into_std()); + } + let decoder = Buffered::default(); + let decoded = decoder.decoded.clone(); + let flushed = decoder.flushed.clone(); + let track = track((0..=60).map(|index| frame(index * 33, index == 0)), delay).await; + let task = tokio::spawn(playback(&media, delay).run(track, decoder)); + tokio::task::yield_now().await; + let last_due = start + + delay + if speaker { + Duration::from_millis(1_980) + } else { + Duration::ZERO + }; + assert_eq!(media.presentation.lock().unwrap().due(last), Some(last_due.into_std())); + assert!(media.video.lock().unwrap().len() <= MAX_FRAMES); + if speaker { + assert!( + decoded.lock().unwrap().is_empty(), + "the delay was buffered as raw pictures" + ); + } + let mut shown = Vec::new(); + while Instant::now() <= last_due + Duration::from_millis(10) { + if (!paused_window || Instant::now() >= start + Duration::from_secs(1)) + && let Some(timestamp) = media.output.present(&media) + { + shown.push((timestamp, Instant::now())); + } + assert!(media.video.lock().unwrap().len() <= MAX_FRAMES, "unbounded raw queue"); + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + } + task.await.unwrap().unwrap(); + assert_eq!(*flushed.lock().unwrap(), 1, "decoder tail was not flushed exactly once"); + let &(timestamp, at) = shown.last().expect("presented video"); + assert_eq!(timestamp.as_micros(), last.as_micros(), "decoder tail was lost"); + assert!( + at.max(last_due).duration_since(at.min(last_due)) <= Duration::from_millis(1), + "live edge remained late: {at:?}, expected {last_due:?}" + ); + assert!(shown.windows(2).all(|pair| pair[0].0 < pair[1].0)); + assert_eq!(decoded.lock().unwrap().len(), 61); + } + + #[tokio::test] + async fn video_only_burst_reaches_live_and_flushes_its_tail() { + burst(false, false).await; + } + + #[tokio::test] + async fn a_stalled_presenter_does_not_stall_arrivals_or_decode() { + burst(false, true).await; + } + + #[tokio::test] + async fn a_video_burst_follows_the_speakers_clock() { + burst(true, false).await; + } + + #[tokio::test] + async fn reordered_decoder_output_is_presented_in_timestamp_order() { + tokio::time::pause(); + let delay = Duration::from_millis(100); + let media = media(delay); + let track = track( + [frame(0, true), frame(99, false), frame(33, false), frame(66, false)], + delay, + ) + .await; + let task = tokio::spawn(playback(&media, delay).run(track, Buffered::default())); + let mut shown = Vec::new(); + for _ in 0..110 { + tokio::task::yield_now().await; + if let Some(frame) = media.output.present(&media) { + shown.push(frame.as_millis()); + } + 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" + ); + } + #[tokio::test] + async fn a_discontinuity_discards_old_pictures_and_restarts_the_delay() { + tokio::time::pause(); + let delay = Duration::from_secs(2); + let media = media(delay); + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast + .create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video)) + .unwrap(); + let subscriber = track + .consume() + .subscribe(moq_net::track::Subscription::default().with_max_age(delay)) + .await + .unwrap(); + let mut producer = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video), + ); + let track = moq_mux::container::Consumer::new( + subscriber, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video), + ); + producer.write(frame(0, true)).unwrap(); + let decoder = Buffered::default(); + let decoded = decoder.decoded.clone(); + let task = tokio::spawn(playback(&media, delay).run(track, decoder)); + tokio::task::yield_now().await; + tokio::time::advance(delay).await; + tokio::task::yield_now().await; + assert_eq!(decoded.lock().unwrap().len(), 1, "old picture held inside decoder"); + producer.discontinuity().unwrap(); + producer.write(frame(500, true)).unwrap(); + producer.finish().unwrap(); + let restarted = Instant::now(); + tokio::task::yield_now().await; + assert_eq!( + media + .presentation + .lock() + .unwrap() + .due(moq_net::Timestamp::from_millis(500).unwrap()), + Some((restarted + delay).into_std()) + ); + assert!(media.output.present(&media).is_none()); + tokio::time::advance(delay + Duration::from_millis(1)).await; + task.await.unwrap().unwrap(); + let queued = media + .video + .lock() + .unwrap() + .iter() + .map(|frame| frame.timestamp.as_millis()) + .collect::>(); + assert_eq!(queued, [500], "old decoder output crossed the discontinuity"); + assert_eq!(media.output.present(&media).unwrap().as_millis(), 500); + } +} From ce123c825b5333c03250cdcfa3af82377bf331e4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 21:24:43 -0700 Subject: [PATCH 33/40] feat(moq-mux): catalog delay measures cross-rendition encoder lateness (#4170) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- doc/bin/gstreamer.md | 3 +- doc/bin/obs.md | 3 +- doc/concept/audio-jitter.md | 33 ++++ doc/concept/hang.md | 1 + doc/lib/rs/moq-mux.md | 8 +- drafts/draft-lcurley-moq-hang.md | 15 ++ js/hang/src/catalog/audio.ts | 11 ++ js/hang/src/catalog/root.test.ts | 24 +++ js/hang/src/catalog/text.ts | 11 ++ js/hang/src/catalog/video.ts | 11 ++ js/msf/src/catalog.test.ts | 24 +++ js/msf/src/catalog.ts | 4 + js/publish/src/catalog.test.ts | 114 +++++++------- js/publish/src/catalog.ts | 27 ++-- js/watch/src/audio/config.test.ts | 5 + js/watch/src/audio/config.ts | 7 +- js/watch/src/msf.test.ts | 60 ++++++++ js/watch/src/msf.ts | 9 ++ js/watch/src/sync.test.ts | 20 +++ js/watch/src/video/decoder.test.ts | 149 ++++++++++++++++++ js/watch/src/video/decoder.ts | 32 +++- js/watch/src/video/playhead.test.ts | 8 +- js/watch/src/video/playhead.ts | 18 ++- quest/m0/audio-jitter-target/README.md | 1 - quest/m1/README.md | 2 +- quest/m1/data-jitter.md | 7 +- quest/m1/jitter-flush-clock.md | 113 -------------- quest/m1/publish-delay.md | 27 ++++ quest/m2/watch-data-sync.md | 1 - rs/hang/src/catalog/audio/mod.rs | 12 ++ rs/hang/src/catalog/root.rs | 9 +- rs/hang/src/catalog/text/mod.rs | 12 ++ rs/hang/src/catalog/video/mod.rs | 12 ++ rs/moq-hls/src/export/rendition.rs | 2 + rs/moq-msf/src/lib.rs | 29 ++++ rs/moq-mux/src/catalog/estimate.rs | 205 +++++++++++++++++++------ rs/moq-mux/src/catalog/msf/consumer.rs | 26 ++++ rs/moq-mux/src/catalog/producer.rs | 16 ++ rs/moq-mux/src/catalog/tracks.rs | 94 ++++++++++-- rs/moq-mux/src/container/producer.rs | 10 +- rs/moq-mux/src/error.rs | 4 + rs/moq-video/src/encode/producer.rs | 6 +- 42 files changed, 907 insertions(+), 278 deletions(-) create mode 100644 js/watch/src/video/decoder.test.ts delete mode 100644 quest/m1/jitter-flush-clock.md create mode 100644 quest/m1/publish-delay.md diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index 99bc063da5..c3695b214d 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -77,7 +77,8 @@ is at least 1, and a rate is the delta over any window you sample. Unlike Set `encoder=true` on audio and video pads a local encoder feeds (`x264enc`, `opusenc`, ...). The pad then measures how late each frame reaches the sink behind its running time and raises the catalog `jitter` by the spread, -so players buffer for an encoder that delivers irregularly. Leave it off, the +and `delay` by how far it trails the earliest such pad, so players buffer for an +encoder that delivers irregularly or behind the others. Leave it off, the default, for file, demuxed, and network media: their arrival reflects the disk or the network, not the original encoder, and a GStreamer segment cannot tell the two apart. Text and opaque pads refuse it. diff --git a/doc/bin/obs.md b/doc/bin/obs.md index e22801d357..a9733f27d1 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -35,7 +35,8 @@ OBS Studio install. OBS reports each locally encoded packet's handoff to libmoq against the shared broadcast media clock. Each track's catalog `jitter` is the largest measured -delay above that track's own recent minimum, rounded up to milliseconds. +delay above that track's own recent minimum, and its `delay` is how far that +minimum trails the earliest track, both rounded up to milliseconds. ## Source quality and moq-transcode diff --git a/doc/concept/audio-jitter.md b/doc/concept/audio-jitter.md index eb4c490763..f300399fba 100644 --- a/doc/concept/audio-jitter.md +++ b/doc/concept/audio-jitter.md @@ -271,6 +271,39 @@ is the one thing a measurement cannot be. Note that `js/watch` today adds the two rather than taking the maximum, which over-buffers a bursty publisher by its own flush span. +## Across renditions + +Every track plays from one clock, anchored on the earliest lateness any track +has shown. The estimator measures each track against its own fastest frame, so a +constant offset between tracks cancels out of `measured`: a video encoder that +flushes 200 ms behind the audio encoder produces the same video target as one +that does not, and its frames would all arrive 200 ms late for the shared clock. + +The catalog `delay` field carries that offset: how far a rendition's minimum +flush lateness trails the broadcast's earliest rendition, measured by the +publisher and never lowered. It is an addend, not a floor, because nothing on +this page contains it. The shared playout is the largest requirement among the +renditions actually subscribed: + +``` +playout = max over subscribed renditions of (delay + target) +``` + +A receiver **must not** subtract one rendition's `delay` from another's. Each is +a lifetime maximum taken against a sliding baseline, so two values need not +share an origin: if the earliest rendition later drifts behind another, their +difference understates the real spread. The maximum never under-buffers, +because the subscribed renditions' spread is measured from an earliest baseline +no earlier than the broadcast's, so it never exceeds the largest `delay` among +them. The cost is over-buffering by the smallest subscribed `delay` when the +broadcast's earliest rendition is not subscribed. + +The playout is recomputed when a rendition is subscribed, unsubscribed, or its +catalog entry rises, so dropping a slow rendition lowers latency. The shared +reference itself only ever moves earlier, so once the earliest subscribed track +leaves, the clock stays anchored to it until playback re-anchors; that errs on +the safe side. + ## Rise, fall, startup, and the ceiling There is **no separate rise or fall limiter**. The histogram is the only diff --git a/doc/concept/hang.md b/doc/concept/hang.md index c95fa3f2e4..20f3bfc0ed 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -55,6 +55,7 @@ A few things the catalog can express beyond decoder config: - **Labels.** Any rendition may carry a human-readable `label` for a track picker. The map key stays the track name used to subscribe, so labels need not be unique and renaming one doesn't rename the track. - **Renditions in another broadcast.** A rendition may point at a relative broadcast path, so a transcoder can publish a ladder that adds low rungs and references the source's original rendition without re-publishing its bytes. The path resolves against where the consumer found the catalog, so a reference that escapes above the root names nothing and the catalog is rejected. - **Jitter.** A rendition can say how far its frames fell behind the media clock before the publisher flushed them, in whole milliseconds rounded up. Encoders report the spread of lateness above each rendition's own recent minimum, so a constant encoder delay is not jitter; container imports estimate batch spans without counting ingest delay. It describes the publisher, never the network, only grows over the life of a stream, and a player sizes its buffer to at least this much. A `0` is read as absent. +- **Delay.** A rendition can also say how far its frames reach the transport behind the broadcast's earliest rendition, measured the same way from each rendition's minimum lateness, so a video encoder running 200 ms behind audio advertises `delay: 200` on video. It follows the same rules as jitter. A player holds the largest `delay + jitter` among the renditions it subscribes to, and never subtracts one rendition's `delay` from another's. - **Stalled renditions.** A publisher can flag a rendition as temporarily bad so players prefer another one without the track disappearing. First-party video publishers set this flag after more than three frame intervals of source silence or encoding lag while subscribed, and clear it after three on-time completed frames or when idle. Browser and native capture poll while waiting; FLV and MPEG-TS importers observe video silence as container data arrives. The shared detector is `hang::catalog::stalled::Detector` in Rust and `Catalog.Stalled.Detector` in JavaScript. It is a playback diagnostic, not an authorization or routing signal. - **Archive.** A broadcast may advertise an `archive` entry naming its timeline track (a small index of each complete aligned segment) and, if recorded, the replay MoQ path, object-store URL, and format version. The timeline is what lets the [HLS gateway](/bin/hls) build playlists without subscribing to media. - **Clock.** The optional root `clock` maps PTS zero to wall time so every media track and the archive index share one fixed epoch after timescale conversion. It is independent of `archive`, so a live-only publisher can expose wall-clock timing without creating a segment index. diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index 5fe596a4fd..7be69723be 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -41,9 +41,11 @@ retires the entry. Calling `modify` before the first `set` returns `Error::NotPublished`. Container writes measure bitrate; importers can also measure batch span or reorder delay for jitter. Locally encoded frames call `container::Producer::flush(timestamp, Instant::now())`; jitter is the spread -above that track's own recent minimum lateness, published as soon as it rises. -Generic imports remain clock-free. Invalid or decreasing jitter is rejected -before the edit is retained, including while the initial catalog is reserved. +above that track's own recent minimum lateness, and delay is how far that +minimum trails the earliest track on the same catalog. Both are published as +soon as they rise. Generic imports remain clock-free. Invalid or decreasing +jitter or delay is rejected before the edit is retained, including while the +initial catalog is reserved. Codec importers propagate catalog and media errors through their configuration and frame-writing methods. diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 30d711e3c9..04fffe9264 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -481,6 +481,7 @@ type CommonExtensions = { "label": string | undefined, "container": Container, "jitter": number | undefined, + "delay": number | undefined, } ~~~ @@ -532,6 +533,19 @@ For example: - A fragment or packet batch contributes the media span between its earliest timestamp and flush point. - Reordered frames contribute the delay they were held before flushing, without treating a decode-order presentation timestamp gap as delay by itself. +### delay {#field-delay} +The maximum amount, in milliseconds, by which a rendition's minimum flush lateness ({{field-jitter}}) has trailed the smallest minimum among the broadcast's renditions that measure it. +If absent, a consumer SHOULD assume the rendition does not trail the others. + +A publisher measures each rendition's minimum over the same recent window it uses for `jitter`, from one clock shared by every rendition, and advertises the largest difference observed. +A container importer does not measure `delay`. +The rounding, `0`, and never-lower rules of `jitter` apply unchanged. + +A consumer SHOULD hold at least the largest `delay` plus `jitter` among the renditions it plays together. +A consumer MUST NOT subtract one rendition's `delay` from another's: each is a maximum over the life of the stream, so two values need not share an origin. + +For example, a video encoder that flushes 200 milliseconds after the audio encoder for the same media time advertises a video `delay` of 200 and no audio `delay`. + # Container {#container} Audio, video, and text tracks use a container to encapsulate the media payload. A rendition declares its container via the `container` field of its catalog entry ({{common}}): @@ -1085,6 +1099,7 @@ A publisher MAY estimate an unknown final duration from the frame cadence, but M - An audio endpoint bounds only the terminal packets that follow it in its own group. - Replaced the archive timeline `wall` field with a root `clock` section (`wall` plus `timescale`): one fixed broadcast mapping every track and the archive index convert into, independent of any archive. Zero timescales and walls past the JSON-safe integer range are refused. - Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. +- 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. # Acknowledgments diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index ec7039eedb..f897c9125a 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -59,6 +59,17 @@ export const AudioConfigSchema = z.object({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this rendition's frames reach the transport behind the broadcast's earliest + // rendition, in whole milliseconds rounded up. A player holds `delay + jitter` for it and never + // subtracts one rendition's `delay` from another's. Absent on the earliest rendition. It only + // ever grows over the life of a stream. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** Schema for the catalog audio section: a map of track name to rendition config. */ diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index a62dbe1181..c37154805c 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import * as z from "@zod/mini"; import { ARCHIVE_VERSION } from "./archive.ts"; +import { u53 } from "./integers.ts"; import type { RelativeBroadcast } from "./path.ts"; import { RootSchema } from "./root.ts"; @@ -95,6 +96,29 @@ test("legacy zero jitter is absent for audio and video", () => { expect(JSON.stringify(parsed)).not.toContain('"jitter"'); }); +test("delay parses beside jitter and zero is absent", () => { + const parsed = RootSchema.parse({ + audio: { + renditions: { + audio: { + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48000, + numberOfChannels: 2, + delay: 0, + }, + }, + }, + video: { + renditions: { video: { codec: "avc1.64001f", container: { kind: "legacy" }, jitter: 34, delay: 200 } }, + }, + text: { renditions: { captions: { format: "vtt", container: { kind: "legacy" }, delay: 120 } } }, + }); + 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)); +}); + test("clock round-trips at the root", () => { const parsed = RootSchema.parse({ clock: { wall: 1_751_846_400_000_000, timescale: 1_000_000 }, diff --git a/js/hang/src/catalog/text.ts b/js/hang/src/catalog/text.ts index 3bd4e3dc21..5be0bee11b 100644 --- a/js/hang/src/catalog/text.ts +++ b/js/hang/src/catalog/text.ts @@ -58,6 +58,17 @@ export const TextConfigSchema = z.object({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this rendition's frames reach the transport behind the broadcast's earliest + // rendition, in whole milliseconds rounded up. A player holds `delay + jitter` for it and never + // subtracts one rendition's `delay` from another's. Absent on the earliest rendition. It only + // ever grows over the life of a stream. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** Schema for the catalog text section: a map of track name to rendition config. */ diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index 2d173e1758..53c9946a7f 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -73,6 +73,17 @@ export const VideoConfigSchema = z.object({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this rendition's frames reach the transport behind the broadcast's earliest + // rendition, in whole milliseconds rounded up. A player holds `delay + jitter` for it and never + // subtracts one rendition's `delay` from another's. Absent on the earliest rendition. It only + // ever grows over the life of a stream. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/msf/src/catalog.test.ts b/js/msf/src/catalog.test.ts index 749b18819d..a6e16a222b 100644 --- a/js/msf/src/catalog.test.ts +++ b/js/msf/src/catalog.test.ts @@ -185,6 +185,30 @@ test("preserves SAP fields through decode and encode", () => { expect(wireTracks[0].jitter).toBe(15); }); +test("preserves delay through decode and encode", () => { + const catalog = decode( + encodeJson({ + version: "draft-01", + tracks: [ + { + name: "video0", + packaging: "loc", + isLive: true, + role: "video", + codec: "avc1.640028", + delay: 200, + }, + ], + }), + ); + + expect(catalog.tracks[0].delay).toBe(200); + + const wire = decodeJson(encode(catalog)); + const wireTracks = wire.tracks as { delay?: number }[]; + expect(wireTracks[0].delay).toBe(200); +}); + test.each([ ["omitted", undefined], ["false", false], diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index a11cbca452..9596adbee0 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -48,6 +48,10 @@ const trackShape = { // The player's buffer must be at least this large to avoid underruns. // Mirrors the `jitter` field in the hang catalog. jitter: z.optional(z.number()), + + // Non-standard: how far this rendition trails the broadcast's earliest, in milliseconds. + // Mirrors hang `delay`. A player holds `delay + jitter` and does not subtract across renditions. + delay: z.optional(z.number()), }; /** Zod schema describing a single track entry in an MSF catalog. */ diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index cd76a88781..215da4f89c 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -117,10 +117,37 @@ test("a reconnecting subscriber is seeded with the full current catalog", async effect.close(); }); -test("catalog producer refuses zero jitter before retaining an edit", () => { - const catalog = new CatalogProducer(); +for (const field of ["jitter", "delay"] as const) { + test(`catalog producer refuses zero ${field} before retaining an edit`, () => { + const catalog = new CatalogProducer(); + for (const section of ["audio", "video", "text"] as const) { + expect(() => + catalog.mutate((value) => { + Object.assign(value, { + [section]: { + renditions: { + media: { + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48000, + numberOfChannels: 2, + [field]: 0, + }, + }, + }, + }); + }), + ).toThrow(`omit ${field}`); + } + catalog.mutate((value) => { + expect(value.audio).toBeUndefined(); + expect(value.video).toBeUndefined(); + }); + }); + for (const section of ["audio", "video", "text"] as const) { - expect(() => + test(`catalog refuses ${section} ${field} decreases without retaining them`, () => { + const catalog = new CatalogProducer(); catalog.mutate((value) => { Object.assign(value, { [section]: { @@ -130,70 +157,45 @@ test("catalog producer refuses zero jitter before retaining an edit", () => { container: { kind: "legacy" }, sampleRate: 48000, numberOfChannels: 2, - jitter: 0, + [field]: 100, }, }, }, }); - }), - ).toThrow("omit jitter"); - } - catalog.mutate((value) => { - expect(value.audio).toBeUndefined(); - expect(value.video).toBeUndefined(); - }); -}); - -for (const section of ["audio", "video", "text"] as const) { - test(`catalog refuses ${section} jitter decreases without retaining them`, () => { - const catalog = new CatalogProducer(); - catalog.mutate((value) => { - Object.assign(value, { - [section]: { - renditions: { - media: { - codec: "opus", - container: { kind: "legacy" }, - sampleRate: 48000, - numberOfChannels: 2, - jitter: 100, - }, - }, - }, }); - }); - // The section is optional on the loose root type, so re-read it through a guard. - const retained = (value: Catalog.Root) => { - const sectionValue = value[section]; - if (!sectionValue) throw new Error(`expected a retained ${section} section`); - return sectionValue; - }; - for (const jitter of [Catalog.u53(50), undefined]) { - expect(() => + // The section is optional on the loose root type, so re-read it through a guard. + const retained = (value: Catalog.Root) => { + const sectionValue = value[section]; + if (!sectionValue) throw new Error(`expected a retained ${section} section`); + return sectionValue; + }; + for (const estimate of [Catalog.u53(50), undefined]) { + expect(() => + catalog.mutate((value) => { + retained(value).renditions.media[field] = estimate; + }), + ).toThrow(`${field} cannot decrease`); catalog.mutate((value) => { - retained(value).renditions.media.jitter = jitter; - }), - ).toThrow("jitter cannot decrease"); + expect(retained(value).renditions.media[field]).toBe(Catalog.u53(100)); + }); + } catalog.mutate((value) => { - expect(retained(value).renditions.media.jitter).toBe(Catalog.u53(100)); + delete retained(value).renditions.media; }); - } - catalog.mutate((value) => { - delete retained(value).renditions.media; - }); - catalog.mutate((value) => { - Object.assign(retained(value).renditions, { - media: { - codec: "opus", - container: { kind: "legacy" }, - sampleRate: 48000, - numberOfChannels: 2, - jitter: 50, - }, + catalog.mutate((value) => { + Object.assign(retained(value).renditions, { + media: { + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48000, + numberOfChannels: 2, + [field]: 50, + }, + }); }); }); - }); + } } for (const section of ["json", "binary"] as const) { diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 3a40e3d1ae..225a4e4715 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -24,13 +24,15 @@ export class CatalogProducer { mutate(fn: (catalog: Catalog.Root) => void): void { const value = structuredClone(this.#value); fn(value); - for (const [section, next] of Object.entries(jitters(value))) { - const previous = jitters(this.#value)[section]; - for (const [name, jitter] of Object.entries(next)) { - if (jitter === 0) throw new Error("omit jitter for a track flushed immediately"); - const before = previous?.[name]; - if (before !== undefined && (jitter === undefined || jitter < before)) { - throw new Error("jitter cannot decrease for an existing track"); + for (const field of ["jitter", "delay"] as const) { + const previous = advertised(this.#value, field); + for (const [section, next] of Object.entries(advertised(value, field))) { + for (const [name, estimate] of Object.entries(next)) { + if (estimate === 0) throw new Error(`omit ${field} rather than advertising 0`); + const before = previous[section]?.[name]; + if (before !== undefined && (estimate === undefined || estimate < before)) { + throw new Error(`${field} cannot decrease for an existing track`); + } } } } @@ -60,10 +62,13 @@ export class CatalogProducer { } } -/** Every track's advertised jitter, by section and then track name. */ -function jitters(catalog: Catalog.Root): Record> { - const pick = (tracks: Record | undefined) => - Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config.jitter])); +/** Every track's advertised `field`, by section and then track name. */ +function advertised( + catalog: Catalog.Root, + field: "jitter" | "delay", +): Record> { + const pick = (tracks: Record | undefined) => + Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config[field]])); return { audio: pick(catalog.audio?.renditions), video: pick(catalog.video?.renditions), diff --git a/js/watch/src/audio/config.test.ts b/js/watch/src/audio/config.test.ts index 36ee450983..ca5dee3a23 100644 --- a/js/watch/src/audio/config.test.ts +++ b/js/watch/src/audio/config.test.ts @@ -68,6 +68,11 @@ test("an advertised jitter of zero falls back to the codec frame duration", () = expect(playbackJitter(config({ jitter: 60 }))).toBe(Time.Milli(63)); }); +test("a rendition's delay adds to its jitter", () => { + expect(playbackJitter(config({ delay: 200 }))).toBe(Time.Milli(223)); + expect(playbackJitter(config({ delay: 200, jitter: 60 }))).toBe(Time.Milli(263)); +}); + test("AAC and MP3 jitter follows their codec frame sizes", () => { expect(playbackJitter(config({ codec: "mp4a.40.2", sampleRate: 48000 }))).toBe(Time.Milli(25)); expect(playbackJitter(config({ codec: "mp4a.40.2", sampleRate: 24000 }))).toBe(Time.Milli(49)); diff --git a/js/watch/src/audio/config.ts b/js/watch/src/audio/config.ts index e5ae6d5fdc..2665ce83d8 100644 --- a/js/watch/src/audio/config.ts +++ b/js/watch/src/audio/config.ts @@ -40,7 +40,10 @@ export function playbackIdentity(config: Catalog.AudioConfig): PlaybackIdentity }; } -/** The jitter to add to the sync buffer for a rendition, in milliseconds. */ +/** + * The sync buffer a rendition needs, in milliseconds: its catalog `delay` behind the broadcast's + * earliest rendition plus its own jitter. + */ export function playbackJitter(config: Catalog.AudioConfig): Time.Milli { // A publisher advertising 0 is claiming frames are never delayed, which no encoder can do, so // fall back to the codec's frame duration the same way an absent field does. @@ -48,7 +51,7 @@ export function playbackJitter(config: Catalog.AudioConfig): Time.Milli { // Add the worklet render quantum so the ring buffer has margin between frame arrivals. const overhead = Math.ceil((WORKLET_QUANTUM / config.sampleRate) * 1000); - return Time.Milli(codecJitter + overhead); + return Time.Milli((config.delay ?? 0) + codecJitter + overhead); } // Estimate the minimum jitter (frame duration) based on the audio codec. diff --git a/js/watch/src/msf.test.ts b/js/watch/src/msf.test.ts index dd2e930259..2a58e4d815 100644 --- a/js/watch/src/msf.test.ts +++ b/js/watch/src/msf.test.ts @@ -1,7 +1,67 @@ import { expect, test } from "bun:test"; +import { u53 } from "@moq/hang/catalog"; import type * as Msf from "@moq/msf"; import { toHang } from "./msf"; +test("copies delay onto the hang rendition", () => { + const catalog: Msf.Catalog = { + tracks: [ + { + name: "video", + packaging: "loc", + role: "video", + codec: "vp09.00.10.08", + delay: 200, + jitter: 40, + }, + { + name: "audio", + packaging: "loc", + role: "audio", + codec: "opus", + delay: 80, + }, + ], + }; + + expect(toHang(catalog).video?.renditions.video?.delay).toBe(u53(200)); + expect(toHang(catalog).video?.renditions.video?.jitter).toBe(u53(40)); + expect(toHang(catalog).audio?.renditions.audio?.delay).toBe(u53(80)); +}); + +test("rounds a fractional MSF delay up, and drops zero", () => { + const catalog: Msf.Catalog = { + tracks: [ + { + name: "video", + packaging: "loc", + role: "video", + codec: "vp09.00.10.08", + delay: 200.2, + }, + { + name: "audio", + packaging: "loc", + role: "audio", + codec: "opus", + delay: 0.2, + }, + { + name: "early", + packaging: "loc", + role: "audio", + codec: "opus", + delay: 0, + }, + ], + }; + + const hang = toHang(catalog); + expect(hang.video?.renditions.video?.delay).toBe(u53(201)); + expect(hang.audio?.renditions.audio?.delay).toBe(u53(1)); + expect(hang.audio?.renditions.early?.delay).toBeUndefined(); +}); + test("preserves stalled video renditions", () => { const catalog: Msf.Catalog = { tracks: [ diff --git a/js/watch/src/msf.ts b/js/watch/src/msf.ts index cc7271ef66..a78a396ec5 100644 --- a/js/watch/src/msf.ts +++ b/js/watch/src/msf.ts @@ -46,6 +46,13 @@ function toContainer(track: Msf.Track): ContainerInfo | undefined { } } +// Hang stores delay as whole milliseconds rounded up, and zero means the rendition is not behind. +// MSF writes a fractional millisecond, which u53 rejects. +function delayMillis(value: number): ReturnType | undefined { + if (value <= 0) return undefined; + return u53(Math.ceil(value)); +} + function toVideoConfig(track: Msf.Track): Catalog.VideoConfig | undefined { if (!track.codec) return undefined; @@ -62,6 +69,7 @@ function toVideoConfig(track: Msf.Track): Catalog.VideoConfig | undefined { bitrate: track.bitrate != null ? u53(track.bitrate) : undefined, stalled: track.stalled, jitter: track.jitter != null ? u53(track.jitter) : undefined, + delay: track.delay != null ? delayMillis(track.delay) : undefined, }; } @@ -85,6 +93,7 @@ function toAudioConfig(track: Msf.Track): Catalog.AudioConfig | undefined { numberOfChannels: u53(channels), bitrate: track.bitrate != null ? u53(track.bitrate) : undefined, jitter: track.jitter != null ? u53(track.jitter) : undefined, + delay: track.delay != null ? delayMillis(track.delay) : undefined, }; } diff --git a/js/watch/src/sync.test.ts b/js/watch/src/sync.test.ts index d8d18764b8..f0c068ebdd 100644 --- a/js/watch/src/sync.test.ts +++ b/js/watch/src/sync.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test"; +import type * as Catalog from "@moq/hang/catalog"; import { Time } from "@moq/net"; import { Signal } from "@moq/signals"; +import { playbackJitter } from "./audio/config"; import { type Delay, Sync } from "./sync"; +import { renditionJitter } from "./video/playhead"; // Effects in @moq/signals flush on a microtask, so let pending updates drain before asserting. const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -80,6 +83,23 @@ describe("delay and buffer", () => { sync.close(); }); + it("holds the largest delay plus jitter among subscribed renditions", async () => { + const audio = { codec: "opus", container: { kind: "legacy" }, sampleRate: 48000, numberOfChannels: 2 }; + const video = { codec: "avc1.640028", container: { kind: "legacy" }, jitter: 34, delay: 200 }; + const sync = new Sync({ delay: 100 as Time.Milli }); + sync.register(new Signal(playbackJitter(audio as Catalog.AudioConfig))); + const unsubscribe = sync.register(new Signal(renditionJitter(video as Catalog.VideoConfig))); + await flush(); + // 100 ms of network jitter over the video's 200 ms delay and 34 ms spread. + expect(sync.out.delay.peek()).toBe(334 as Time.Milli); + + // Dropping the slow rendition lowers latency to the audio's own 20 ms frame and 3 ms quantum. + unsubscribe(); + await flush(); + expect(sync.out.delay.peek()).toBe(123 as Time.Milli); + sync.close(); + }); + it("unregisters duplicate jitter inputs independently", async () => { const media = new Signal(20 as Time.Milli); const sync = new Sync({ delay: 100 as Time.Milli }); diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts new file mode 100644 index 0000000000..fac3a7bbde --- /dev/null +++ b/js/watch/src/video/decoder.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "bun:test"; +import * as Catalog from "@moq/hang/catalog"; +import type * as Moq from "@moq/net"; +import { Time } from "@moq/net"; +import { Signal } from "@moq/signals"; +import type { Broadcast } from "../broadcast"; +import { Sync } from "../sync"; +import { Decoder } from "./decoder"; +import { Source } from "./source"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function settle(): Promise { + for (let i = 0; i < 8; i++) await flush(); +} + +function config(fields: Record): Catalog.VideoConfig { + return Catalog.VideoConfigSchema.parse({ + codec: "avc1.640028", + container: { kind: "legacy" }, + ...fields, + }); +} + +// The subscription is already closed, so the track never reaches VideoDecoder. +function closedConsumer(): Moq.Broadcast.Consumer { + return { closed: new Signal("ended") } as unknown as Moq.Broadcast.Consumer; +} + +function watchBroadcast(catalog: Signal): Broadcast { + return { + out: { catalog }, + relativeBroadcast: () => closedConsumer(), + } as unknown as Broadcast; +} + +async function play(catalog: Signal): Promise<{ + broadcast: Signal; + source: Source; + sync: Sync; + decoder: Decoder; +}> { + const broadcast = new Signal(watchBroadcast(catalog)); + const source = new Source({ + broadcast, + supported: async () => true, + }); + const sync = new Sync({ delay: Time.Milli(0) }); + await settle(); + const decoder = new Decoder({ source, sync }); + await settle(); + return { broadcast, source, sync, decoder }; +} + +describe("Decoder jitter across a source switch", () => { + it("keeps the outgoing floor when the next source reuses the track name", async () => { + const outgoing = new Signal({ + video: { renditions: { video: config({ delay: 200, jitter: 60 }) } }, + }); + const { broadcast, source, sync, decoder } = await play(outgoing); + try { + expect(decoder.out.jitter.peek()).toBe(Time.Milli(260)); + + // A rise on the catalog this rendition subscribed to still resizes the floor. + outgoing.set({ + video: { renditions: { video: config({ delay: 300, jitter: 60 }) } }, + }); + await settle(); + expect(decoder.out.jitter.peek()).toBe(Time.Milli(360)); + + broadcast.set( + watchBroadcast( + new Signal({ + video: { renditions: { video: config({ jitter: 20 }) } }, + }), + ), + ); + await settle(); + // The old frames are still on screen. The new catalog's same name is not their floor. + expect(decoder.out.jitter.peek()).toBe(Time.Milli(360)); + } finally { + decoder.close(); + source.close(); + sync.close(); + } + }); + + it("follows a newer pending catalog when the track name stays the same", async () => { + const outgoing = new Signal({ + video: { renditions: { video: config({ jitter: 20 }) } }, + }); + const { broadcast, source, sync, decoder } = await play(outgoing); + try { + expect(decoder.out.jitter.peek()).toBe(Time.Milli(20)); + + broadcast.set( + watchBroadcast( + new Signal({ + video: { renditions: { video: config({ delay: 200 }) } }, + }), + ), + ); + await settle(); + expect(decoder.out.jitter.peek()).toBe(Time.Milli(200)); + + const newest = new Signal({ + video: { renditions: { video: config({ delay: 500 }) } }, + }); + broadcast.set(watchBroadcast(newest)); + await settle(); + // The first switch is still pending, so the name did not change. The new catalog still has to win. + expect(decoder.out.jitter.peek()).toBe(Time.Milli(500)); + + newest.set({ + video: { renditions: { video: config({ delay: 700 }) } }, + }); + await settle(); + expect(decoder.out.jitter.peek()).toBe(Time.Milli(700)); + } finally { + decoder.close(); + source.close(); + sync.close(); + } + }); + + it("keeps the outgoing floor when the next source uses a different name", async () => { + const outgoing = new Signal({ + video: { renditions: { hd: config({ delay: 200, jitter: 60 }) } }, + }); + const { broadcast, source, sync, decoder } = await play(outgoing); + try { + expect(decoder.out.jitter.peek()).toBe(Time.Milli(260)); + + broadcast.set( + watchBroadcast( + new Signal({ + video: { renditions: { sd: config({ jitter: 20 }) } }, + }), + ), + ); + await settle(); + expect(decoder.out.jitter.peek()).toBe(Time.Milli(260)); + } finally { + decoder.close(); + source.close(); + sync.close(); + } + }); +}); diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index c6319ff5e9..592eb898d3 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -92,7 +92,10 @@ export class Decoder { // The current track running, held so we can cancel it when the new track is ready. #active = new Signal(undefined); - #pendingJitter = new Signal(undefined); + // The rendition preparing to replace the active one, with the catalog of the broadcast it + // subscribed to. One value so a later source that reuses the track name still notifies: + // the name alone would compare equal, and the jitter effect would keep the previous catalog. + #pending = new Signal<{ track: string; catalog: Getter } | undefined>(undefined); readonly #identity: Computed; #signals = new Effect(); @@ -125,9 +128,20 @@ export class Decoder { this.#signals.run(this.#runBuffering.bind(this)); } + // Read from the full catalog entry, which the decoder config omits, so a rising entry resizes Sync. + // Each in-flight rendition stays tied to the catalog it subscribed to: during a source switch the + // shared catalog already describes the next broadcast, and the same track name there is a + // different rendition. #runJitter(effect: Effect): void { - const active = effect.get(this.#active)?.jitter; - const pending = effect.get(this.#pendingJitter); + const floor = (track: string | undefined, catalog: Getter | undefined) => { + if (track === undefined || catalog === undefined) return undefined; + const config = effect.get(catalog)?.video?.renditions?.[track]; + return config && renditionJitter(config); + }; + const activeTrack = effect.get(this.#active); + const active = floor(activeTrack?.track, activeTrack?.catalog); + const pendingTrack = effect.get(this.#pending); + const pending = floor(pendingTrack?.track, pendingTrack?.catalog); effect.set(this.#out.jitter, switchJitter({ active, pending })); } @@ -164,8 +178,9 @@ export class Decoder { track, config: identity.decoder, stats: this.#out.stats, + catalog: broadcast.out.catalog, }); - effect.set(this.#pendingJitter, pending.jitter); + effect.set(this.#pending, { track, catalog: broadcast.out.catalog }); effect.cleanup(() => pending?.close()); @@ -185,7 +200,7 @@ export class Decoder { // Upgrade the pending track to active. // #runActive will be in charge of it now. this.#active.set(pending); - this.#pendingJitter.set(undefined); + this.#pending.set(undefined); pending = undefined; // This effect is done; close it to avoid a useless re-run. @@ -267,6 +282,9 @@ interface DecoderTrackProps { broadcast: Moq.Broadcast.Consumer; track: string; config: DecoderConfig; + // The broadcast catalog this subscription started from. Updates on that broadcast still apply; + // a later source does not. + catalog: Getter; stats: Signal; } @@ -276,8 +294,8 @@ class DecoderTrack { broadcast: Moq.Broadcast.Consumer; track: string; config: DecoderConfig; + catalog: Getter; stats: Signal; - jitter: Time.Milli | undefined; timestamp = new Signal(undefined); frame = new Signal(undefined); @@ -299,8 +317,8 @@ class DecoderTrack { this.broadcast = props.broadcast; this.track = props.track; this.config = props.config; + this.catalog = props.catalog; this.stats = props.stats; - this.jitter = renditionJitter(props.config); this.#signals.run(this.#run.bind(this)); } diff --git a/js/watch/src/video/playhead.test.ts b/js/watch/src/video/playhead.test.ts index 7c701a46a0..08c5fef605 100644 --- a/js/watch/src/video/playhead.test.ts +++ b/js/watch/src/video/playhead.test.ts @@ -4,7 +4,7 @@ import type { Time } from "@moq/net"; import { caughtUp, renditionJitter, switchJitter } from "./playhead"; // `jitter` is a branded u53 in the catalog schema, so build the config through a cast. -function config(props: { jitter?: number; framerate?: number }): Catalog.VideoConfig { +function config(props: { jitter?: number; delay?: number; framerate?: number }): Catalog.VideoConfig { return { codec: "avc1.640028", container: { kind: "legacy" }, ...props } as Catalog.VideoConfig; } @@ -26,6 +26,12 @@ describe("renditionJitter", () => { it("is undefined when the catalog declares neither", () => { expect(renditionJitter(config({}))).toBeUndefined(); }); + + it("adds the delay behind the earliest rendition", () => { + expect(renditionJitter(config({ delay: 200, jitter: 60 }))).toBe(ms(260)); + expect(renditionJitter(config({ delay: 200, framerate: 30 }))).toBe(ms(234)); + expect(renditionJitter(config({ delay: 200 }))).toBe(ms(200)); + }); }); describe("caughtUp", () => { diff --git a/js/watch/src/video/playhead.ts b/js/watch/src/video/playhead.ts index 7f341d3c36..851f645190 100644 --- a/js/watch/src/video/playhead.ts +++ b/js/watch/src/video/playhead.ts @@ -9,15 +9,19 @@ const SLACK = Time.Milli(100); /** * How far behind live a rendition's playhead can sit while still being at its own live edge. * - * Frames arrive one group at a time, so this is the rendition's group cadence: the sync buffer - * has to cover it or playback starves between groups. The catalog value wins. Otherwise assume - * the publisher flushes each frame as it's encoded, so a frame interval is the longest we wait. - * Undefined when the catalog declares neither. + * Its catalog `delay` behind the broadcast's earliest rendition, plus its own spread: frames arrive + * one group at a time, so the sync buffer has to cover the group cadence or playback starves + * between groups. The catalog `jitter` wins. Otherwise assume the publisher flushes each frame as + * it's encoded, so a frame interval is the longest we wait. Undefined when the catalog declares + * none of them. */ export function renditionJitter(config: Catalog.VideoConfig): Time.Milli | undefined { - if (config.jitter !== undefined) return Time.Milli(config.jitter); - if (config.framerate) return Time.Milli(Math.ceil(1000 / config.framerate)); - return undefined; + let spread: Time.Milli | undefined; + if (config.jitter !== undefined) spread = Time.Milli(config.jitter); + else if (config.framerate) spread = Time.Milli(Math.ceil(1000 / config.framerate)); + + if (config.delay === undefined) return spread; + return Time.Milli.add(Time.Milli(config.delay), spread ?? Time.Milli.zero); } /** The playheads involved in promoting a new rendition. */ diff --git a/quest/m0/audio-jitter-target/README.md b/quest/m0/audio-jitter-target/README.md index cf59623b4b..9f9600f3e1 100644 --- a/quest/m0/audio-jitter-target/README.md +++ b/quest/m0/audio-jitter-target/README.md @@ -71,7 +71,6 @@ buffer against uneven arrivals. ## Related -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - the advertised jitter (#3513 landed the flush span), which `doc/concept/audio-jitter.md` settles as a floor on the measured target - [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - the automated proof, built on its own schedule - [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/m1/README.md b/quest/m1/README.md index a0ff60bb93..787191075c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -39,7 +39,7 @@ 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 -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - renditions advertise `delay` (lag behind the earliest track) and `jitter` (spread), measured at encoder flush, never lowered; js/watch sizes playout over what it subscribes +- [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux - [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass diff --git a/quest/m1/data-jitter.md b/quest/m1/data-jitter.md index e21e11ca3b..3ccf0386a4 100644 --- a/quest/m1/data-jitter.md +++ b/quest/m1/data-jitter.md @@ -5,8 +5,8 @@ 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 -[jitter clock](/quest/m1/jitter-flush-clock.md) defines for encoders. A +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. @@ -45,6 +45,3 @@ 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. -## Required - -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - defines the flush-lateness measurement and `delay` diff --git a/quest/m1/jitter-flush-clock.md b/quest/m1/jitter-flush-clock.md deleted file mode 100644 index e83e361955..0000000000 --- a/quest/m1/jitter-flush-clock.md +++ /dev/null @@ -1,113 +0,0 @@ -# [L] moq-mux: catalog delay and jitter measure how far behind the media clock an encoder flushes - -## Goal - -An original publisher advertises two numbers per rendition, both measured -from the gap between a frame's media timestamp and the wall-clock moment it -handed that frame to the transport (its lateness), the same lateness -`js/watch` `sync.ts` computes on receive, measured one hop earlier: - -- `delay`: the rendition's minimum lateness behind the broadcast's earliest - rendition. A video encoder running 200 ms behind the audio encoder - advertises `delay: 200` on video and none on audio. -- `jitter`: the spread of the rendition's lateness above its own minimum. A - fragment flushed at its end, a B-frame held for reordering, and a - latency-buffered batch all raise it without being special cases. - -`delay + jitter` is the worst-case lateness, and neither value is ever -lowered once advertised. `js/watch` sizes playout over the renditions it -subscribes to as `max(delay + jitter) - min(delay)` plus network jitter, and -resizes when that set changes, so dropping a slow track lowers latency. - -Only encoders feed the clock; file and pipe imports and the RTMP, SRT, and TS -gateways never do, so an unstable ingest link cannot inflate the catalog. TS -and fragmented MP4 imports retain their clock-free jitter estimates from the -media span of each emitted batch and advertise no `delay`. - -## Plan - -- **Landed (#3940):** `jitter` measured at encoder flush. `Estimator::flush`, - `container::Producer::flush`, and codec importer forwarding; each rendition - keeps its own 10 s sliding minimum and advertises the lifetime maximum spread - above it. The `moq-video` and `moq-audio` encoders (so `moq import capture`), - libmoq (so OBS), moq-ffi and its wrappers, and the `js/publish` encoders call - it. The provisional PTS-gap floor is gone, and `moq_mux::Error::JitterDecreased` - plus zero-as-absent text jitter enforce never-lower in Rust and JS. - `moq-gst` pads opt in with `encoder=true`; imports stay clock-free. - What remains below is `delay` and the player. A seek or pause resetting the - baseline from moqsink and the bindings is - [Import discontinuity](/quest/m1/import-discontinuity.md), including a - `moq-gst` encoder pad across a `PLAYING -> PAUSED -> PLAYING` cycle (running - time stops, the wall clock does not) and a flushing seek. -- **Measurement.** Lateness is `now - timestamp`, observed by the existing - `flush` calls, so no call site changes. Each rendition keeps its own - baseline, the minimum lateness over a sliding window (about 10 s), so a media - clock that drifts slower than wall time does not ratchet forever. The - broadcast baseline, on `catalog::Producer` and shared by every rendition - that flushes, is the minimum of those; the per-rendition `Baseline` epochs - must become one shared epoch for the subtraction to mean anything. `delay` is - the rendition baseline minus the broadcast baseline, reported as its lifetime - maximum. Renditions that never flush advertise no `delay`. -- **Open:** lifetime maxima taken against a sliding baseline stop sharing an - origin when the earliest rendition changes. If A starts at 0 and B at - 200 ms, B keeps `delay: 200`; if A then drifts to 500 ms, A advertises 300 - and `Sync` computes `300 - 200 = 100` while the tracks are 300 ms apart. - Options: - - *No subtraction (recommended).* Keep the sliding baselines and never-lower, - and change the player rule to `max(delay + jitter)` over the subscribed - renditions, dropping `- min(delay)`. The subscribed renditions' true spread - is measured from an earliest subscribed baseline no earlier than the - broadcast baseline, so it never exceeds the largest advertised `delay`, - and the catalog alone can never under-buffer. The cost is over-buffering by - `min(delay)` when the broadcast's earliest rendition is not subscribed - (a video-only viewer of a broadcast whose audio leads by 200 ms pays - 200 ms). Dropping a slow track still lowers latency. The draft says a - consumer MUST NOT subtract `delay` values across renditions. - - *Fixed common origin.* Measure every `delay` from the broadcast's first - lateness. Exact subtraction, but common drift raises every rendition - together, so values grow without bound and the catalog republishes for the - life of the broadcast; the problem the sliding window exists to avoid. - - *Coordinated rebasing.* Advertise each `delay` as its current value against - the current broadcast baseline and let it fall. Exact, but `delay` gives up - never-lower, the catalog churns as baselines move, and the player must - shrink safely. - - *No `delay` field.* The player measures each subscribed track's own - arrival baseline and sizes by their spread. No wire change, and it also - covers gateway and ingest offsets, but a track's offset is unknown until its - first frames arrive, so subscribing to a slower rendition glitches once. - Every option also needs the player's reference to follow the earliest - subscribed track's current arrival rather than its lifetime minimum, since - `Sync.received` only ever lowers it; that is receiver-side and belongs with - the arrival minimum the [audio jitter target](/quest/m0/audio-jitter-target/README.md) - already expires. -- A faster-than-real-time source flushes early; each frame becomes the new - minimum and both stay at zero, which is correct for something that is not - live. -- **Wire.** Add optional `delay` beside `jitter` on video, audio, and text - renditions in `rs/hang`, `js/hang`, and `drafts/draft-lcurley-moq-hang.md`, - serialized with `MillisCeil` and zero-as-absent like `jitter`. Additive: - today's `jitter` never included a cross-track offset. Extend the draft's - never-lower rule to `delay` (unless rebasing wins), along with - `moq_mux::Error::JitterDecreased` and the `js/publish/src/catalog.ts` check. -- **Player.** `Sync` registers each subscribed rendition's `delay` and - `jitter` and recomputes when a rendition registers, unregisters, or its - catalog entry rises. Rename `Sync`'s own `delay` output (the resolved - playout total) so it does not collide with the field. #3954 on the - [audio jitter target](/quest/m0/audio-jitter-target/README.md) line changes - what `register()` takes, so build on whichever lands first. -- **Docs.** Update `doc/concept/audio-jitter.md`, the normative playout page, - with the cross-track rule and the catalog floor it reads. -- **Tests** inject the clock. Cover: a constant offset reports as `delay` on - the slower track and nothing on the faster, a slow common drift stays - bounded, the earliest rendition changing does not under-buffer, a decrease is - refused on every site, and `Sync` resizes on a subscription change. - -Public API: additive `delay` field in `hang` and `@moq/hang`, the -decreased-estimate error extended to `delay`, `Sync` output rename in -`@moq/watch`. Wire: one optional catalog field. - -## Related - -- [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - reads `jitter` as its floor, which is now the spread alone -- [Audio time-stretch](/quest/m1/watch-audio-time-stretch.md) - converges audio to a resized target without skipping -- [Data jitter](/quest/m1/data-jitter.md) - feeds the same measurement from JSON and binary tracks diff --git a/quest/m1/publish-delay.md b/quest/m1/publish-delay.md new file mode 100644 index 0000000000..aeb4209d69 --- /dev/null +++ b/quest/m1/publish-delay.md @@ -0,0 +1,27 @@ +# [S] js/publish: encoders advertise catalog delay + +## Goal + +The browser publisher advertises `delay` the way `moq-mux` does: each rendition +reports how far its minimum flush lateness trails the broadcast's earliest +rendition, as a lifetime maximum that is never lowered. A browser video encoder +running behind its audio encoder advertises that offset on video, so +`js/watch` holds it instead of playing video late. + +## Plan + +- `js/publish/src/jitter.ts` already measures each rendition's lateness on + `performance.now()`, and every js/publish timestamp shares that clock, so a + broadcast-wide sliding minimum beside the per-rendition one is enough. Mirror + `moq_mux::catalog::Estimator`: one window per rendition, one shared by the + broadcast, and `delay` is the gap between their minima. +- The shared minimum belongs to the `Broadcast` the encoders register on, not to + the encoders, so a swapped broadcast starts fresh. Keep it off the public API. +- The draft's rule stands: a consumer never subtracts `delay` across + renditions and holds the largest `delay + jitter` it subscribes to, so the + publisher reports its sliding-baseline maximum as is. +- `CatalogProducer` already refuses a lowered or zero `delay`. + +## Related + +- [Data jitter](/quest/m1/data-jitter.md) - the same measurement for JSON and binary tracks in Rust diff --git a/quest/m2/watch-data-sync.md b/quest/m2/watch-data-sync.md index e0d3d6ae8b..19b0b1f440 100644 --- a/quest/m2/watch-data-sync.md +++ b/quest/m2/watch-data-sync.md @@ -21,7 +21,6 @@ releases it. ## Required -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - the `delay` field and `Sync` sizing this registers into - [Data jitter](/quest/m1/data-jitter.md) - data tracks advertise the `delay` and `jitter` this reads ## Related diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index 6afa7cef18..7c013c94a9 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -124,6 +124,17 @@ pub struct AudioConfig { #[serde_as(as = "MillisCeil")] #[serde(default)] pub jitter: Option, + + /// How far this rendition's frames reach the transport behind the broadcast's earliest + /// rendition, measured at the publisher from each rendition's minimum flush lateness. + /// Absent on the earliest rendition and on any rendition the publisher did not measure. + /// + /// A consumer holds `delay + jitter` for this rendition and MUST NOT subtract one rendition's + /// `delay` from another's: each is a lifetime maximum, so two need not share an origin. It only + /// ever grows over the life of a stream, and is serialized like [`jitter`](Self::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, } impl AudioConfig { @@ -144,6 +155,7 @@ impl AudioConfig { description: None, container: Container::default(), jitter: None, + delay: None, } } } diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 21c1e11e85..8941e3835f 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -301,18 +301,19 @@ mod test { assert_eq!(encoded, output, "wrong encoded output"); } - /// Lock in the on-wire shape of the jitter field: a bare integer number + /// Lock in the on-wire shape of the jitter and delay fields: a bare integer number /// of milliseconds. If `Option` ever loses the `duration_millis` /// serde adapter, this regresses to serde's default `{secs, nanos}` shape. #[test] - fn jitter_serialized_as_millis() { + fn jitter_and_delay_serialized_as_millis() { let mut encoded = r#"{ "video": { "renditions": { "video": { "codec": "avc1.64001f", "container": {"kind": "legacy"}, - "jitter": 100 + "jitter": 100, + "delay": 200 } } }, @@ -355,6 +356,7 @@ mod test { optimize_for_latency: None, container: Container::Legacy, jitter: Some(std::time::Duration::from_millis(100)), + delay: Some(std::time::Duration::from_millis(200)), }, ); @@ -371,6 +373,7 @@ mod test { description: None, container: Container::Legacy, jitter: Some(std::time::Duration::from_millis(40)), + delay: None, }, ); diff --git a/rs/hang/src/catalog/text/mod.rs b/rs/hang/src/catalog/text/mod.rs index 29e089e590..496b016af2 100644 --- a/rs/hang/src/catalog/text/mod.rs +++ b/rs/hang/src/catalog/text/mod.rs @@ -107,6 +107,17 @@ pub struct TextConfig { #[serde_as(as = "MillisCeil")] #[serde(default)] pub jitter: Option, + + /// How far this rendition's frames reach the transport behind the broadcast's earliest + /// rendition, measured at the publisher from each rendition's minimum flush lateness. + /// Absent on the earliest rendition and on any rendition the publisher did not measure. + /// + /// A consumer holds `delay + jitter` for this rendition and MUST NOT subtract one rendition's + /// `delay` from another's: each is a lifetime maximum, so two need not share an origin. It only + /// ever grows over the life of a stream, and is serialized like [`jitter`](Self::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, } impl TextConfig { @@ -125,6 +136,7 @@ impl TextConfig { label: None, container: Container::default(), jitter: None, + delay: None, } } } diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 1c75126d0e..db43d6c3eb 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -235,6 +235,17 @@ pub struct VideoConfig { #[serde_as(as = "MillisCeil")] #[serde(default)] pub jitter: Option, + + /// How far this rendition's frames reach the transport behind the broadcast's earliest + /// rendition, measured at the publisher from each rendition's minimum flush lateness. + /// Absent on the earliest rendition and on any rendition the publisher did not measure. + /// + /// A consumer holds `delay + jitter` for this rendition and MUST NOT subtract one rendition's + /// `delay` from another's: each is a lifetime maximum, so two need not share an origin. It only + /// ever grows over the life of a stream, and is serialized like [`jitter`](Self::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, } impl VideoConfig { @@ -260,6 +271,7 @@ impl VideoConfig { optimize_for_latency: None, container: Container::default(), jitter: None, + delay: None, } } } diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index f1f1a03691..5832ae9375 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -190,6 +190,7 @@ fn normalize_video(config: &VideoConfig) -> VideoConfig { let mut config = config.clone(); config.bitrate = None; config.jitter = None; + config.delay = None; config.label = None; config.stalled = None; // The muxer ignores a non-finite framerate, and NaN never equals itself, so a catalog @@ -203,6 +204,7 @@ fn normalize_audio(config: &AudioConfig) -> AudioConfig { let mut config = config.clone(); config.bitrate = None; config.jitter = None; + config.delay = None; config.label = None; config } diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index 200aa204f2..83087ca201 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -184,6 +184,15 @@ pub struct Track { /// Serialized as a JSON number of milliseconds, matching the hang catalog. #[serde_as(as = "Option")] pub jitter: Option, + + /// How far this rendition trails the broadcast's earliest rendition (non-standard + /// extension; not in the MSF/CMSF drafts). + /// + /// Serialized as a JSON number of milliseconds, matching [`jitter`](Self::jitter). Absent on + /// the earliest rendition. A consumer holds `delay + jitter` and does not subtract one + /// rendition's `delay` from another's. + #[serde_as(as = "Option")] + pub delay: Option, } impl Catalog<()> { @@ -480,6 +489,7 @@ impl Track { max_grp_sap_starting_type: None, max_obj_sap_starting_type: None, jitter: None, + delay: None, } } } @@ -631,6 +641,7 @@ mod test { max_grp_sap_starting_type: None, max_obj_sap_starting_type: None, jitter: None, + delay: None, } } @@ -655,6 +666,7 @@ mod test { max_grp_sap_starting_type: None, max_obj_sap_starting_type: None, jitter: None, + delay: None, } } @@ -679,6 +691,7 @@ mod test { max_grp_sap_starting_type: Some(1), max_obj_sap_starting_type: Some(2), jitter: Some(Duration::from_millis(15)), + delay: None, } } @@ -700,6 +713,7 @@ mod test { assert!(track.get("maxGrpSapStartingType").is_none()); assert!(track.get("maxObjSapStartingType").is_none()); assert!(track.get("jitter").is_none()); + assert!(track.get("delay").is_none()); assert_eq!(track["stalled"], true); } @@ -823,6 +837,7 @@ mod test { assert_eq!(track.max_grp_sap_starting_type, None); assert_eq!(track.max_obj_sap_starting_type, None); assert_eq!(track.jitter, None); + assert_eq!(track.delay, None); } #[test] @@ -858,6 +873,20 @@ mod test { assert_eq!(value["tracks"][0]["jitter"].as_f64(), Some(15.0)); } + #[test] + fn delay_roundtrips() { + let mut track = track_with_sap_and_jitter(); + track.delay = Some(Duration::from_millis(200)); + let original = Catalog::new(vec![track]); + + let json = original.to_json().unwrap(); + let parsed = Catalog::<()>::from_str(&json).unwrap(); + assert_eq!(parsed.tracks[0].delay, Some(Duration::from_millis(200))); + + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["tracks"][0]["delay"].as_f64(), Some(200.0)); + } + #[test] fn serialize_emits_draft01_version() { // Callers never set a version; we always emit the newest draft string. diff --git a/rs/moq-mux/src/catalog/estimate.rs b/rs/moq-mux/src/catalog/estimate.rs index 8ac0378131..9455626547 100644 --- a/rs/moq-mux/src/catalog/estimate.rs +++ b/rs/moq-mux/src/catalog/estimate.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; use moq_net::Timestamp; @@ -19,6 +20,8 @@ pub struct Estimate { pub jitter: Option, /// The maximum bitrate in bits per second. pub bitrate: Option, + /// The most this track's minimum flush lateness has trailed the broadcast's earliest track. + pub delay: Option, } impl Estimate { @@ -33,6 +36,12 @@ impl Estimate { self.bitrate = bitrate.into(); self } + + /// Set the delay behind the broadcast's earliest track (or clear it with `None`). + pub fn with_delay(mut self, delay: impl Into>) -> Self { + self.delay = delay.into(); + self + } } /// Measures the catalog jitter and bitrate of one track from the frames written to it. @@ -76,7 +85,13 @@ impl Estimate { pub struct Estimator { jitter: Jitter, bitrate: Bitrate, - baseline: Baseline, + /// This track's recent minimum flush lateness. + baseline: Window, + /// The recent minimum across every track sharing it. A standalone estimator's is its own, so + /// it never measures a delay. + broadcast: Baseline, + /// The largest amount [`baseline`](Self::baseline) has trailed [`broadcast`](Self::broadcast). + delay: Duration, } impl Estimator { @@ -85,6 +100,14 @@ impl Estimator { Self::default() } + /// Measure `delay` against the tracks sharing `broadcast`. + pub(crate) fn with_broadcast(broadcast: Baseline) -> Self { + Self { + broadcast, + ..Self::default() + } + } + /// Observe a frame of `bytes` encoded bytes at presentation time `timestamp`, as written by /// [`container::Producer::write`](crate::container::Producer::write). pub fn write(&mut self, timestamp: Timestamp, bytes: usize) { @@ -94,10 +117,13 @@ impl Estimator { /// Measure when an encoder handed a frame to the transport. Only locally encoded frames should /// call this; imports keep clock-free batch and reorder estimates. Jitter is the spread above - /// this track's own recent minimum lateness, so a constant encoder delay is not jitter. + /// this track's own recent minimum lateness, so a constant encoder delay is not jitter. Delay + /// is how far that minimum trails the earliest track on the same catalog. pub fn flush(&mut self, timestamp: Timestamp, now: Instant) { - let spread = self.baseline.observe(timestamp, now); - self.jitter.max = self.jitter.max.max(spread); + let (lateness, earliest) = self.broadcast.observe(timestamp, now); + let minimum = self.baseline.observe(now, lateness); + self.jitter.max = self.jitter.max.max(nanos_between(minimum, lateness)); + self.delay = self.delay.max(nanos_between(earliest, minimum)); } /// Close the current span at `end`, as [`container::Producer::cut`](crate::container::Producer::cut) @@ -110,11 +136,15 @@ impl Estimator { self.bitrate.cut(end.map(nanos)); } - /// Discard the open bitrate span and the flush baseline, so nothing is measured across a break + /// Discard the open bitrate span and the flush baselines, so nothing is measured across a break /// in the timeline. See [`container::Producer::discontinuity`](crate::container::Producer::discontinuity). + /// + /// The broadcast baseline is cleared too: a pause usually stops every track, and the others' + /// pre-pause minimum would otherwise read as a delay until it left the window. pub fn discontinuity(&mut self) { self.bitrate.discontinuity(); - self.baseline = Baseline::default(); + self.baseline = Window::default(); + self.broadcast.discontinuity(); } /// Observe a frame's reorder delay (`PTS - DTS`), which raises the jitter to the decode buffer a @@ -137,6 +167,7 @@ impl Estimator { Estimate { jitter: self.jitter.current(), bitrate: self.bitrate.current(), + delay: (!self.delay.is_zero()).then_some(self.delay), } } } @@ -258,14 +289,43 @@ impl Jitter { } } -/// One track's minimum encode lateness over the recent window. +/// The flush clock shared by every track on one catalog: a common epoch, so lateness compares +/// across tracks, and the minimum lateness any of them flushed with over the recent window. /// /// An `Instant` has no public mapping to the broadcast's media epoch. The first observation -/// chooses a local origin; its unknown offset cancels when subtracting the recent minimum. A -/// monotonic deque makes insertion and expiry amortized O(1). +/// chooses a local origin; its unknown offset cancels when subtracting one minimum from another. +#[derive(Clone, Default)] +pub(crate) struct Baseline(Arc>); + #[derive(Default)] -struct Baseline { +struct Shared { epoch: Option, + window: Window, +} + +impl Baseline { + /// Return the frame's lateness on the shared epoch and the broadcast's minimum including it. + fn observe(&self, timestamp: Timestamp, now: Instant) -> (i128, i128) { + let mut shared = self.0.lock().unwrap_or_else(PoisonError::into_inner); + let epoch = *shared.epoch.get_or_insert(now); + let elapsed = match now.checked_duration_since(epoch) { + Some(duration) => duration.as_nanos() as i128, + None => -(epoch.duration_since(now).as_nanos() as i128), + }; + let lateness = elapsed - timestamp.as_nanos() as i128; + (lateness, shared.window.observe(now, lateness)) + } + + fn discontinuity(&self) { + self.0.lock().unwrap_or_else(PoisonError::into_inner).window = Window::default(); + } +} + +/// The minimum lateness over the last [`JITTER_WINDOW`], so a media clock drifting against the +/// wall clock does not ratchet forever. A monotonic deque makes insertion and expiry amortized +/// O(1). +#[derive(Default)] +struct Window { samples: VecDeque, } @@ -274,32 +334,29 @@ struct Sample { lateness: i128, } -impl Baseline { - fn observe(&mut self, timestamp: Timestamp, now: Instant) -> Duration { - let epoch = *self.epoch.get_or_insert(now); +impl Window { + /// Insert a lateness observed at `now` and return the window's minimum. + fn observe(&mut self, now: Instant, lateness: i128) -> i128 { while self .samples .front() - .is_some_and(|sample| now.duration_since(sample.at) > JITTER_WINDOW) + .is_some_and(|sample| now.saturating_duration_since(sample.at) > JITTER_WINDOW) { self.samples.pop_front(); } - - let elapsed = match now.checked_duration_since(epoch) { - Some(duration) => duration.as_nanos() as i128, - None => -(epoch.duration_since(now).as_nanos() as i128), - }; - let lateness = elapsed - timestamp.as_nanos() as i128; while self.samples.back().is_some_and(|sample| sample.lateness >= lateness) { self.samples.pop_back(); } self.samples.push_back(Sample { at: now, lateness }); - let minimum = self.samples.front().expect("the current sample was inserted").lateness; - let spread = u64::try_from(lateness - minimum).unwrap_or(u64::MAX); - Duration::from_nanos(spread) + self.samples.front().expect("the current sample was inserted").lateness } } +/// `to - from` as a duration, clamped at zero. +fn nanos_between(from: i128, to: i128) -> Duration { + Duration::from_nanos(u64::try_from(to - from).unwrap_or(if to > from { u64::MAX } else { 0 })) +} + #[cfg(test)] mod tests { use super::*; @@ -355,34 +412,94 @@ mod tests { #[test] fn baseline_expires_drift() { + let mut estimator = Estimator::new(); let anchor = Instant::now(); - - let mut drift = Baseline::default(); - let maximum = (0..100u128) - .map(|second| { - drift.observe( - micros((second * 1_000_000) as u64), - anchor + Duration::from_millis((second * 1_001) as u64), - ) - }) - .max() - .unwrap(); - assert!(maximum <= Duration::from_millis(10), "{maximum:?}"); + for second in 0..100u64 { + estimator.flush( + micros(second * 1_000_000), + anchor + Duration::from_millis(second * 1_001), + ); + } + let jitter = estimator.estimate().jitter.unwrap(); + assert!(jitter <= Duration::from_millis(10), "{jitter:?}"); } #[test] fn early_flush_keeps_lowering_the_baseline() { - let mut baseline = Baseline::default(); + let mut estimator = Estimator::new(); let anchor = Instant::now(); - for second in 0..100u128 { - assert_eq!( - baseline.observe( - micros((second * 2_000_000) as u64), - anchor + Duration::from_secs(second as u64) - ), - Duration::ZERO - ); + for second in 0..100u64 { + estimator.flush(micros(second * 2_000_000), anchor + Duration::from_secs(second)); } + assert_eq!(estimator.estimate(), Estimate::default()); + } + + /// Two tracks on one broadcast baseline, flushed every 100 ms for `seconds` with the lateness + /// each closure returns (in ms) at a given frame's media time. + fn pair(seconds: u64, fast: impl Fn(u64) -> u64, slow: impl Fn(u64) -> u64) -> (Estimate, Estimate) { + let broadcast = Baseline::default(); + let mut estimators = [ + Estimator::with_broadcast(broadcast.clone()), + Estimator::with_broadcast(broadcast), + ]; + let anchor = Instant::now(); + for frame in 0..seconds * 10 { + let pts = frame * 100; + for (estimator, lateness) in estimators.iter_mut().zip([fast(pts), slow(pts)]) { + estimator.flush(micros(pts * 1_000), anchor + Duration::from_millis(pts + lateness)); + } + } + let [fast, slow] = estimators.map(|estimator| estimator.estimate()); + (fast, slow) + } + + #[test] + fn a_constant_offset_is_delay_on_the_slower_track() { + let (fast, slow) = pair(5, |_| 0, |_| 200); + assert_eq!(fast, Estimate::default()); + assert_eq!(slow, Estimate::default().with_delay(Duration::from_millis(200))); + } + + #[test] + fn a_common_drift_stays_bounded() { + // Both media clocks run 0.1% slow for 100 s, so each lateness climbs 100 ms together. + let (fast, slow) = pair(100, |pts| pts / 1_000, |pts| 200 + pts / 1_000); + assert_eq!(fast.delay, None); + assert_eq!(slow.delay, Some(Duration::from_millis(200))); + for jitter in [fast.jitter, slow.jitter] { + assert!(jitter.unwrap() <= Duration::from_millis(10), "{jitter:?}"); + } + } + + /// The earliest track starts at 0 and the other at 200 ms, then the first drifts to 500 ms. The + /// delays no longer share an origin, but the drifting track's own `delay` covers the new offset, + /// so the largest `delay + jitter` never falls below the real spread. + #[test] + fn the_earliest_track_changing_does_not_under_buffer() { + let drift = |pts: u64| pts.saturating_sub(10_000).min(50_000) / 100; + let (drifted, steady) = pair(80, drift, |_| 200); + assert_eq!(steady.delay, Some(Duration::from_millis(200))); + assert_eq!(drifted.delay, Some(Duration::from_millis(300))); + } + + #[test] + fn a_discontinuity_clears_the_broadcast_baseline() { + let broadcast = Baseline::default(); + let mut audio = Estimator::with_broadcast(broadcast.clone()); + let mut video = Estimator::with_broadcast(broadcast); + let anchor = Instant::now(); + audio.flush(micros(0), anchor); + video.flush(micros(0), anchor + Duration::from_millis(50)); + assert_eq!(video.estimate().delay, Some(Duration::from_millis(50))); + + // Both resume at the previous live edge after a 2 s pause: the pause is not a delay. + audio.discontinuity(); + video.discontinuity(); + video.flush(micros(40_000), anchor + Duration::from_millis(2_090)); + audio.flush(micros(40_000), anchor + Duration::from_millis(2_040)); + video.flush(micros(80_000), anchor + Duration::from_millis(2_130)); + assert_eq!(video.estimate().delay, Some(Duration::from_millis(50))); + assert_eq!(audio.estimate().delay, None); } #[test] diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 2d49b61510..c8e2a256a0 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -237,6 +237,7 @@ fn video_config_from_msf(track: &moq_msf::Track) -> Result> config.framerate = track.framerate; config.container = container; config.jitter = track.jitter; + config.delay = track.delay; Ok(Some(config)) } @@ -276,6 +277,7 @@ fn audio_config_from_msf(track: &moq_msf::Track) -> Result> config.description = legacy_description(track)?; config.container = container; config.jitter = track.jitter; + config.delay = track.delay; Ok(Some(config)) } @@ -446,6 +448,30 @@ mod test { assert_eq!(video.stalled, Some(true)); } + #[test] + fn delay_round_trips_onto_hang() { + let mut video = video_track("video0", moq_msf::Packaging::Legacy, None); + video.jitter = Some(std::time::Duration::from_millis(40)); + video.delay = Some(std::time::Duration::from_millis(200)); + + let mut audio = audio_track("audio0", moq_msf::Packaging::Loc); + audio.delay = Some(std::time::Duration::from_millis(80)); + + let catalog = from_msf::<()>(&moq_msf::Catalog::new(vec![video, audio])).expect("delay should convert"); + assert_eq!( + catalog.video.renditions["video0"].delay, + Some(std::time::Duration::from_millis(200)) + ); + assert_eq!( + catalog.video.renditions["video0"].jitter, + Some(std::time::Duration::from_millis(40)) + ); + assert_eq!( + catalog.audio.renditions["audio0"].delay, + Some(std::time::Duration::from_millis(80)) + ); + } + #[test] fn loc_audio_yields_loc_container() { let msf = moq_msf::Catalog::new(vec![audio_track("audio0", moq_msf::Packaging::Loc)]); diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 0d0958a123..0811704b1b 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -182,6 +182,9 @@ pub struct Producer { /// Connection allocator passthrough tracks claim their peak-hold bitrate on. /// See [`Config::with_bandwidth`]. bandwidth: moq_net::bandwidth::Allocator, + /// The minimum flush lateness across this catalog's renditions, which each rendition's + /// advertised `delay` is measured against. + baseline: super::estimate::Baseline, } // Manual Clone so a producer is cheaply clonable regardless of whether `E` is. @@ -194,6 +197,7 @@ impl Clone for Producer { timeline: self.timeline.clone(), max_age: self.max_age, bandwidth: self.bandwidth.clone(), + baseline: self.baseline.clone(), } } } @@ -342,6 +346,7 @@ impl Producer { timeline, max_age: config.max_age, bandwidth: config.bandwidth, + baseline: Default::default(), }) } @@ -615,6 +620,11 @@ impl Producer { .with_bandwidth(self.bandwidth.clone())) } + /// A fresh estimator whose `delay` is measured against this catalog's other renditions. + pub(crate) fn estimator(&self) -> super::Estimator { + super::Estimator::with_broadcast(self.baseline.clone()) + } + /// The allocator passthrough tracks claim on. fMP4 writes groups by hand, so it reads this itself. pub(crate) fn bandwidth(&self) -> moq_net::bandwidth::Allocator { self.bandwidth.clone() @@ -941,6 +951,7 @@ fn to_msf_media(catalog: &hang::Catalog) -> moq_msf::Catalog { track.max_grp_sap_starting_type = sap_type; track.max_obj_sap_starting_type = sap_type; track.jitter = config.jitter; + track.delay = config.delay; tracks.push(track); } @@ -971,6 +982,7 @@ fn to_msf_media(catalog: &hang::Catalog) -> moq_msf::Catalog { track.max_grp_sap_starting_type = Some(1); track.max_obj_sap_starting_type = Some(1); track.jitter = config.jitter; + track.delay = config.delay; tracks.push(track); } @@ -1608,6 +1620,7 @@ mod test { video_config.framerate = Some(30.0); video_config.container = Container::Legacy; video_config.jitter = Some(std::time::Duration::from_millis(100)); + video_config.delay = Some(std::time::Duration::from_millis(200)); let mut video_renditions = BTreeMap::new(); video_renditions.insert("video0".to_string(), video_config); @@ -1615,6 +1628,7 @@ mod test { let mut audio_config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); audio_config.container = Container::Legacy; audio_config.jitter = Some(std::time::Duration::from_millis(40)); + audio_config.delay = Some(std::time::Duration::from_millis(80)); let mut audio_renditions = BTreeMap::new(); audio_renditions.insert("audio0".to_string(), audio_config); @@ -1631,12 +1645,14 @@ mod test { assert_eq!(video.max_grp_sap_starting_type, Some(2)); assert_eq!(video.max_obj_sap_starting_type, Some(2)); assert_eq!(video.jitter, Some(std::time::Duration::from_millis(100))); + assert_eq!(video.delay, Some(std::time::Duration::from_millis(200))); let audio = &msf.tracks[1]; assert_eq!(audio.role, Some(moq_msf::Role::Audio)); assert_eq!(audio.max_grp_sap_starting_type, Some(1)); assert_eq!(audio.max_obj_sap_starting_type, Some(1)); assert_eq!(audio.jitter, Some(std::time::Duration::from_millis(40))); + assert_eq!(audio.delay, Some(std::time::Duration::from_millis(80))); } #[test] diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 6c2c349fdb..91c0670fcc 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -190,6 +190,8 @@ pub struct VideoHint { pub optimize_for_latency: Option, /// The maximum jitter before the next frame is emitted. pub jitter: Option, + /// How far this rendition trails the broadcast's earliest rendition. + pub delay: Option, /// The container wrapping each frame on the wire. /// /// Unlike the other fields this is a choice, not a hint: the bitstream never reveals a @@ -224,6 +226,7 @@ impl From for VideoHint { framerate: config.framerate, optimize_for_latency: config.optimize_for_latency, jitter: config.jitter, + delay: config.delay, container: config.container, } } @@ -247,6 +250,7 @@ impl VideoHint { fill(&mut config.framerate, self.framerate); fill(&mut config.optimize_for_latency, self.optimize_for_latency); fill(&mut config.jitter, self.jitter); + fill(&mut config.delay, self.delay); config.container = self.container.clone(); } @@ -276,11 +280,15 @@ impl RenditionConfig for hang::catalog::VideoConfig { } 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; } } @@ -300,11 +308,15 @@ impl RenditionConfig for hang::catalog::AudioConfig { } 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; } } @@ -319,10 +331,11 @@ impl RenditionConfig for hang::catalog::TextConfig { catalog.text.renditions.remove(name); } fn estimate(&self) -> Estimate { - Estimate::default().with_jitter(self.jitter) + Estimate::default().with_jitter(self.jitter).with_delay(self.delay) } fn set_estimate(&mut self, estimate: Estimate) { self.jitter = estimate.jitter; + self.delay = estimate.delay; } } @@ -518,6 +531,11 @@ impl> Rendition { &self.name } + /// A fresh estimator measuring `delay` against the catalog's other renditions. + pub(crate) fn estimator(&self) -> super::Estimator { + self.catalog.estimator() + } + /// Resolve a timestamp on the broadcast's shared clock (see [`Producer::timestamp`]). pub fn timestamp(&self, hint: Option) -> crate::Result { self.catalog.timestamp(hint) @@ -533,7 +551,7 @@ impl> Rendition { pub(crate) fn set(&mut self, mut config: C) -> crate::Result<()> { let supplied = config.estimate(); let resolved = Self::resolved(&supplied, &self.detected); - self.check_jitter(&resolved)?; + self.check_decrease(&resolved)?; config.set_estimate(resolved.clone()); { let mut guard = self.catalog.modify()?; @@ -560,6 +578,9 @@ impl> Rendition { if estimate.bitrate.is_none() { estimate.bitrate = detected.bitrate; } + if estimate.delay.is_none() { + estimate.delay = detected.delay; + } estimate } @@ -581,10 +602,11 @@ impl> Rendition { return Ok(()); } let mut resolved = Self::resolved(&self.supplied, &estimate); - // A measurement never lowers the published jitter, including one raised through `modify`. - if let Some(published) = self.config()?.estimate().jitter { - resolved.jitter = Some(resolved.jitter.map_or(published, |jitter| jitter.max(published))); - } + // A measurement never lowers the published jitter or delay, including one raised through + // `modify`. + let published = self.config()?.estimate(); + resolved.jitter = resolved.jitter.max(published.jitter); + resolved.delay = resolved.delay.max(published.delay); self.detected = estimate; if self.published.as_ref() != Some(&resolved) { let mut config = self.config()?; @@ -595,13 +617,18 @@ impl> Rendition { Ok(()) } - fn check_jitter(&self, next: &Estimate) -> crate::Result<()> { - if self.present - && let Some(previous) = self.config()?.estimate().jitter - && next.jitter.is_none_or(|jitter| jitter < previous) - { + /// Refuse a config that lowers the jitter or delay already advertised to subscribers. + fn check_decrease(&self, next: &Estimate) -> crate::Result<()> { + if !self.present { + return Ok(()); + } + let previous = self.config()?.estimate(); + if next.jitter < previous.jitter { return Err(crate::Error::JitterDecreased); } + if next.delay < previous.delay { + return Err(crate::Error::DelayDecreased); + } Ok(()) } @@ -620,7 +647,7 @@ impl> Rendition { if !self.present { return Err(crate::Error::NotPublished); } - self.check_jitter(&config.estimate())?; + self.check_decrease(&config.estimate())?; let mut guard = self.catalog.modify()?; let mut next = (*guard).clone(); config.insert(&mut next, &self.name); @@ -723,6 +750,45 @@ mod tests { ); } + #[test] + fn published_delay_never_decreases() { + let (_broadcast, catalog, mut rendition) = video_track(); + let delayed = |delay| { + let mut config = config(None, None); + config.delay = delay; + config + }; + rendition.set(delayed(Some(Duration::from_millis(200)))).unwrap(); + for smaller in [Some(Duration::from_millis(100)), None] { + assert!(matches!( + rendition.set(delayed(smaller)), + Err(crate::Error::DelayDecreased) + )); + assert!(matches!( + rendition.replace(delayed(smaller)), + Err(crate::Error::DelayDecreased) + )); + } + assert_eq!( + catalog.snapshot().video.renditions["v"].delay, + Some(Duration::from_millis(200)) + ); + + let (_broadcast, catalog, mut detected) = video_track(); + detected.set(config(None, None)).unwrap(); + detected + .estimate(Estimate::default().with_delay(Duration::from_millis(200))) + .unwrap(); + // A lower measurement holds the published value rather than failing the write path. + detected + .estimate(Estimate::default().with_delay(Duration::from_millis(100))) + .unwrap(); + assert_eq!( + catalog.snapshot().video.renditions["v"].delay, + Some(Duration::from_millis(200)) + ); + } + #[test] fn measurement_keeps_a_jitter_raised_by_modify() { let (_broadcast, catalog, mut rendition) = video_track(); diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index fa18792c30..abdd85fd72 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -187,7 +187,7 @@ where previous_timestamp: None, cadence: None, reordered: false, - estimator: crate::catalog::Estimator::new(), + estimator: rendition.estimator(), bandwidth: None, rendition: Some(Box::new(rendition)), } @@ -202,7 +202,7 @@ where /// /// Estimate fields the config left to detection at [`set`](Self::set) stay owned by detection: /// an edit to them here is published but replaced by the next measurement, except that jitter - /// never drops below the published value. Call `set` with the field filled in to pin it. + /// and delay never drop below the published value. Call `set` with the field filled in to pin it. pub fn modify(&mut self) -> crate::Result> { let rendition = self.rendition.as_mut().ok_or(crate::Error::NotPublished)?; let config = rendition.config()?; @@ -794,7 +794,7 @@ mod tests { } #[test] - fn catalog_flush_measures_each_rendition_against_its_own_minimum() { + fn catalog_flush_measures_delay_across_renditions_and_jitter_within_each() { let mut broadcast = moq_net::broadcast::Info::new().produce(); let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); let mut tracks = Vec::new(); @@ -814,9 +814,11 @@ mod tests { let ms = std::time::Duration::from_millis; let pts = |millis: u64| Timestamp::from_micros(millis * 1_000).unwrap(); tracks[0].flush(pts(0), anchor).unwrap(); - // A constant 200ms offset behind the other rendition is not jitter. + // A constant 200ms offset behind the other rendition is delay, not jitter. tracks[1].flush(pts(0), anchor + ms(200)).unwrap(); assert_eq!(catalog.snapshot().video.renditions["slow"].jitter, None); + assert_eq!(catalog.snapshot().video.renditions["slow"].delay, Some(ms(200))); + assert_eq!(catalog.snapshot().video.renditions["fast"].delay, None); // A frame flushed 60ms later than the slow rendition's own minimum is. tracks[1].flush(pts(40), anchor + ms(300)).unwrap(); diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index a0a50989da..9603dfa5e5 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -241,6 +241,10 @@ pub enum Error { /// A rendition tried to lower jitter already advertised to subscribers. #[error("catalog jitter cannot decrease for a published rendition")] JitterDecreased, + + /// A rendition tried to lower delay already advertised to subscribers. + #[error("catalog delay cannot decrease for a published rendition")] + DelayDecreased, } impl Error { diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index dd76386bcd..998e7dbf1c 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -663,10 +663,10 @@ mod tests { producer.publish(&encoder.finish().unwrap()).unwrap(); let (name, resolved) = rendition(&catalog).expect("the importer should have registered a video rendition"); - // Jitter aside, which is measured from the frames rather than declared by either. + // Jitter and delay aside, which are measured from the frames rather than declared by either. let (mut before, mut after) = (advertised, resolved.clone()); - before.jitter = None; - after.jitter = None; + (before.jitter, before.delay) = (None, None); + (after.jitter, after.delay) = (None, None); assert_eq!( before, after, "the first keyframe should confirm the advertised rendition, not correct it" From c280683e9985644bdf9d053d5b3b713ee8c99d21 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 21:30:44 -0700 Subject: [PATCH 34/40] chore(claude): share skills through kixelated/skills, tighten quest skills (#4227) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- .claude/hooks/submodule.sh | 20 +++++++++++++ .claude/settings.json | 4 +++ .claude/shared | 1 + .claude/skills/bump | 1 + .claude/skills/clean | 1 + .claude/skills/close | 1 + .claude/skills/close/SKILL.md | 15 ---------- .claude/skills/close/agents/openai.yaml | 4 --- .claude/skills/grill | 1 + .claude/skills/merge | 1 + .claude/skills/merge/SKILL.md | 18 ------------ .claude/skills/merge/agents/openai.yaml | 4 --- .claude/skills/peer-review | 1 + .claude/skills/plan-quests/SKILL.md | 3 +- .claude/skills/recommended | 1 + .claude/skills/spawn-merge | 1 + .claude/skills/spawn-merge/SKILL.md | 28 ------------------- .claude/skills/spawn-merge/agents/openai.yaml | 4 --- .claude/skills/spawn-quests/SKILL.md | 9 ++++-- .claude/skills/takeover | 1 + .claude/skills/takeover/SKILL.md | 16 ----------- .claude/skills/takeover/agents/openai.yaml | 4 --- .gitmodules | 3 ++ .remarkignore | 7 +++++ 24 files changed, 52 insertions(+), 97 deletions(-) create mode 100755 .claude/hooks/submodule.sh create mode 160000 .claude/shared create mode 120000 .claude/skills/bump create mode 120000 .claude/skills/clean create mode 120000 .claude/skills/close delete mode 100644 .claude/skills/close/SKILL.md delete mode 100644 .claude/skills/close/agents/openai.yaml create mode 120000 .claude/skills/grill create mode 120000 .claude/skills/merge delete mode 100644 .claude/skills/merge/SKILL.md delete mode 100644 .claude/skills/merge/agents/openai.yaml create mode 120000 .claude/skills/peer-review create mode 120000 .claude/skills/recommended create mode 120000 .claude/skills/spawn-merge delete mode 100644 .claude/skills/spawn-merge/SKILL.md delete mode 100644 .claude/skills/spawn-merge/agents/openai.yaml create mode 120000 .claude/skills/takeover delete mode 100644 .claude/skills/takeover/SKILL.md delete mode 100644 .claude/skills/takeover/agents/openai.yaml create mode 100644 .gitmodules diff --git a/.claude/hooks/submodule.sh b/.claude/hooks/submodule.sh new file mode 100755 index 0000000000..cda3fd4ace --- /dev/null +++ b/.claude/hooks/submodule.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# The shared skills in .claude/skills are symlinks into the .claude/shared +# submodule. git leaves that unpopulated in a new worktree, and a dangling +# symlink is silently skipped. A checkout on the wrong commit stays stale too. + +set -eu + +# Require an explicit project dir so we never touch a stray checkout. +[ -n "${CLAUDE_PROJECT_DIR:-}" ] || exit 0 +cd "$CLAUDE_PROJECT_DIR" || exit 0 + +# '-' is uninitialized. '+' is populated at a commit other than the gitlink. +# A matching checkout has no prefix. +case "$(git submodule status .claude/shared 2>/dev/null)" in + -* | +*) ;; + *) exit 0 ;; +esac + +echo "submodule hook: updating .claude/shared to the pinned skills." >&2 +git submodule update --init .claude/shared >&2 diff --git a/.claude/settings.json b/.claude/settings.json index 954f580759..29502e7553 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,6 +4,10 @@ "SessionStart": [ { "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/submodule.sh" + }, { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/direnv.sh" diff --git a/.claude/shared b/.claude/shared new file mode 160000 index 0000000000..763fb4c986 --- /dev/null +++ b/.claude/shared @@ -0,0 +1 @@ +Subproject commit 763fb4c9865a0625716de8cde80d4af1b5edcef7 diff --git a/.claude/skills/bump b/.claude/skills/bump new file mode 120000 index 0000000000..c99bb07978 --- /dev/null +++ b/.claude/skills/bump @@ -0,0 +1 @@ +../shared/bump \ No newline at end of file diff --git a/.claude/skills/clean b/.claude/skills/clean new file mode 120000 index 0000000000..8bf0f1985a --- /dev/null +++ b/.claude/skills/clean @@ -0,0 +1 @@ +../shared/clean \ No newline at end of file diff --git a/.claude/skills/close b/.claude/skills/close new file mode 120000 index 0000000000..31284b7508 --- /dev/null +++ b/.claude/skills/close @@ -0,0 +1 @@ +../shared/close \ No newline at end of file diff --git a/.claude/skills/close/SKILL.md b/.claude/skills/close/SKILL.md deleted file mode 100644 index 24cb8a7619..0000000000 --- a/.claude/skills/close/SKILL.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: close -description: Close a GitHub PR. ---- - -Abandon the specified PR. - -If this PR was completing a quest, takeover the PR to delete the quest instead (without the other changes). -If this PR was not completing a quest, close the PR as-is. - -After GitHub confirms the PR is merged or closed, run `just clean` in its local -checkout if that recipe exists, once this task's checks and builds have stopped. -Enabling auto-merge is not completion. Preserve another task's active processes; -report a refused or failed cleanup without changing the PR outcome. Leave the -checkout itself for the host's worktree cleanup timer to retire after you exit. diff --git a/.claude/skills/close/agents/openai.yaml b/.claude/skills/close/agents/openai.yaml deleted file mode 100644 index 8b363520a1..0000000000 --- a/.claude/skills/close/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Close PR" - short_description: "Close a GitHub PR" - default_prompt: "Use $close to close this PR." diff --git a/.claude/skills/grill b/.claude/skills/grill new file mode 120000 index 0000000000..a27dcd72e5 --- /dev/null +++ b/.claude/skills/grill @@ -0,0 +1 @@ +../shared/grill \ No newline at end of file diff --git a/.claude/skills/merge b/.claude/skills/merge new file mode 120000 index 0000000000..4735484baf --- /dev/null +++ b/.claude/skills/merge @@ -0,0 +1 @@ +../shared/merge \ No newline at end of file diff --git a/.claude/skills/merge/SKILL.md b/.claude/skills/merge/SKILL.md deleted file mode 100644 index aab5a0dda6..0000000000 --- a/.claude/skills/merge/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: merge -description: Merge a GitHub PR once reviews and CI pass. ---- - -Land a pull request. -If unsure about any course of action, pause and interactively prompt the user for guidance. - -- Parse the arguments to determine the PR number, otherwise resolve it from the current context/branch. -- Fix any minor issues with the PR, such as merge conflicts and failing CI checks. -- If a draft, flip it to "Ready for Review" then wait for at least one automated review to complete. -- Fix any review feedback you agree with, and leave a comment if you disagree. - -If everything looks good, enable auto-merge and post a summary of the changes made. -You don't need to address everything. - -You may repeat any of the above steps on new CI results or review feedback. -Abort on any major issues or blockers, or if little progress is being made. diff --git a/.claude/skills/merge/agents/openai.yaml b/.claude/skills/merge/agents/openai.yaml deleted file mode 100644 index 44df0dd02d..0000000000 --- a/.claude/skills/merge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Merge" - short_description: "Land a GitHub PR once reviews and CI pass" - default_prompt: "Use $merge to review and land this pull request." diff --git a/.claude/skills/peer-review b/.claude/skills/peer-review new file mode 120000 index 0000000000..7abac91d3d --- /dev/null +++ b/.claude/skills/peer-review @@ -0,0 +1 @@ +../shared/peer-review \ No newline at end of file diff --git a/.claude/skills/plan-quests/SKILL.md b/.claude/skills/plan-quests/SKILL.md index 887a0691c7..0f55d0a72f 100644 --- a/.claude/skills/plan-quests/SKILL.md +++ b/.claude/skills/plan-quests/SKILL.md @@ -26,7 +26,7 @@ When the work changes what a user sees (a wire, an API, a flag, a dashboard), as 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. -Do not move on to implementation decisions until the goal is settled. +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. @@ -34,6 +34,7 @@ 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`. diff --git a/.claude/skills/recommended b/.claude/skills/recommended new file mode 120000 index 0000000000..440251611f --- /dev/null +++ b/.claude/skills/recommended @@ -0,0 +1 @@ +../shared/recommended \ No newline at end of file diff --git a/.claude/skills/spawn-merge b/.claude/skills/spawn-merge new file mode 120000 index 0000000000..669e016de4 --- /dev/null +++ b/.claude/skills/spawn-merge @@ -0,0 +1 @@ +../shared/spawn-merge \ No newline at end of file diff --git a/.claude/skills/spawn-merge/SKILL.md b/.claude/skills/spawn-merge/SKILL.md deleted file mode 100644 index 94e4ede39b..0000000000 --- a/.claude/skills/spawn-merge/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: spawn-merge -description: Decide and merge GitHub PRs in parallel. ---- - -Read the /merge, /takeover, and /close skills before starting. - -The goal is to evaluate the open PRs in the repository and decide which ones to merge. -Each merge is performed in parallel by a sub-agent. - -Start by listing all open PRs that are ready for review (skip drafts). -An argument can be used to filter the PRs in scope. - -One at a time, for each PR, interactively prompt the user if we should /merge, skip, or /close. -Include a short summary and your recommendation. - -If the user chooses to merge the PR, run the /merge command using a sub-agent. -There can be at most N concurrent merge operations, where N is half the number of physical CPU cores. -If ordering matters, then queue the PRs in order. - -If the user chooses to close the PR, run the /close command using a sub-agent. - -Keep going until all PRs have been decided then wait for all spawned sub-agents to finish. -Before finishing, refresh the open PR list and process any newly opened PRs that match the original scope. - -Summarize the results when done. -Include all of the issues encountered and suggested follow-ups. -Repeat the process for any newly opened PRs that match the original scope. diff --git a/.claude/skills/spawn-merge/agents/openai.yaml b/.claude/skills/spawn-merge/agents/openai.yaml deleted file mode 100644 index 7ad171c371..0000000000 --- a/.claude/skills/spawn-merge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Spawn Merge" - short_description: "Land multiple GitHub PRs once reviews and CI pass" - default_prompt: "Use $spawn_merge to review and land these pull requests." diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md index 6ade2b5d88..d8782dd12b 100644 --- a/.claude/skills/spawn-quests/SKILL.md +++ b/.claude/skills/spawn-quests/SKILL.md @@ -15,15 +15,18 @@ Judge readiness from each line branch's own quest directory, and treat a quest d 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. -One at a time, for each each quest, interactively prompt the user if we should /start-quest, /plan-quests, skip, or delete. -Include a short summary and your recommendation. +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. -Monitor the sub-agents and report their final status, but do not monitor their PRs. +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. diff --git a/.claude/skills/takeover b/.claude/skills/takeover new file mode 120000 index 0000000000..f004a334ef --- /dev/null +++ b/.claude/skills/takeover @@ -0,0 +1 @@ +../shared/takeover \ No newline at end of file diff --git a/.claude/skills/takeover/SKILL.md b/.claude/skills/takeover/SKILL.md deleted file mode 100644 index 1ad2afced6..0000000000 --- a/.claude/skills/takeover/SKILL.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: takeover -description: Adopt someone else's open PR and drive it to landable. ---- - -Someone else opened this PR; you are now responsible for it. -If unsure about any course of action, prompt the user for guidance. - -- Parse the arguments to determine the PR number. -- Read the PR, any linked issues, reviews/comments posted on the PR, and the surrounding code. -- Judge the approach before touching anything. Would you take a different approach? -- Fix any issues with the PR, such as merge conflicts and failing CI checks. -- Address any automated review findings (Codex/CodeRabbit) you agree with. Turn down any you disagree with with a comment. One round only; never request a review. -- Push any changes you made to the PR, updating the summary if needed. -- Summarize the changes made and any potential follow-up actions. -- Do not merge the PR; that is the `merge` skill's job, and only once the user approves. diff --git a/.claude/skills/takeover/agents/openai.yaml b/.claude/skills/takeover/agents/openai.yaml deleted file mode 100644 index 8f684a245b..0000000000 --- a/.claude/skills/takeover/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Takeover" - short_description: "Adopt someone else's PR and drive it to landable" - default_prompt: "Use $takeover to adopt this pull request and make it landable." diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..25cd0e0737 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".claude/shared"] + path = .claude/shared + url = https://github.com/kixelated/skills diff --git a/.remarkignore b/.remarkignore index 62cd171e1f..5716b01a44 100644 --- a/.remarkignore +++ b/.remarkignore @@ -15,3 +15,10 @@ doc/draft/ # Quests use a constrained Markdown dialect checked by the quest validator. quest/ + +# Skills are symlinks into the kixelated/skills submodule, which formats its +# own files; remark would follow them (and .agents, Codex's link to the same +# directory) and rewrite the pinned checkout. +.claude/shared/ +.claude/skills/ +.agents/ From ea7db013bc91272d699148d1c0a957e96081a7c4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 22:14:11 -0700 Subject: [PATCH 35/40] perf(kio): keep a parked waiter that quiet lists still hold (#4240) Co-authored-by: Claude Opus 5.5 --- quest/m1/perf/group-cost.md | 10 +- rs/kio/Cargo.toml | 2 +- rs/kio/benches/waiter.rs | 4 +- rs/kio/src/loom.rs | 5 +- rs/kio/src/waiter.rs | 260 +++++++++++++++++++++++++++++--- rs/kio/tests/waiter_allocs.rs | 45 +++++- rs/moq-net/benches/origin.rs | 7 +- rs/moq-net/benches/session.rs | 159 ++++++++++++++++++- rs/moq-net/src/coding/reader.rs | 2 +- 9 files changed, 445 insertions(+), 49 deletions(-) diff --git a/quest/m1/perf/group-cost.md b/quest/m1/perf/group-cost.md index cec74b3338..a716f8889e 100644 --- a/quest/m1/perf/group-cost.md +++ b/quest/m1/perf/group-cost.md @@ -17,9 +17,13 @@ quote). No single function dominates. An earlier Linux profile spread it over `k waker (`Once::call`, about 3%, so waiters are created per poll), and malloc/free (10-12%). -Count allocations per viewer-group first (a counting allocator in the bench, -as `moq-json`'s allocation bench does), then remove the largest sources. A -measured no-win abandons the quest, per this line's rules. +`SESSION_ALLOCS=1` makes the session bench print allocations per +viewer-group. The largest source, a fresh `Arc` whenever a partial +wake made `kio::Park` retire a still-registered waiter, is gone: kio now +keeps that waiter and skips re-registering on lists that still hold it +(2026-09: 228 to 122 allocations per viewer-group, 352 to 118 paced). Find +the next largest source from there. A measured no-win abandons the quest, +per this line's rules. ## Related diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index ae815a85d6..aed9d6849d 100644 --- a/rs/kio/Cargo.toml +++ b/rs/kio/Cargo.toml @@ -13,7 +13,7 @@ keywords = ["async", "producer", "consumer", "state", "sync"] categories = ["asynchronous", "concurrency"] [dependencies] -smallvec = "1.15" +smallvec = { version = "1.15", features = ["union"] } # Loom swaps in its own Arc/Mutex/atomics so it can permute every interleaving. # It's a normal (not dev) dependency because `sync.rs` re-exports it from the diff --git a/rs/kio/benches/waiter.rs b/rs/kio/benches/waiter.rs index 821dbc2d0b..9fb08a8cd4 100644 --- a/rs/kio/benches/waiter.rs +++ b/rs/kio/benches/waiter.rs @@ -70,7 +70,7 @@ fn bench_register(c: &mut Criterion) { g.finish(); } -/// Re-poll with a still-registered waiter, including retirement and slot reuse. +/// Re-poll with a still-registered waiter, which the list recognizes and skips. fn bench_reregister(c: &mut Criterion) { let mut g = c.benchmark_group("waiter_reregister"); for &n in &SIZES { @@ -118,7 +118,7 @@ fn bench_cancel(c: &mut Criterion) { /// The cycle a poll function actually drives: hold the park, register with L lists, /// then one list wakes. The re-poll arrives with live registrations still parked on -/// the other lists, requiring retirement before re-registration. +/// the other lists, which it keeps, re-registering only on the list that woke. fn bench_park_cycle(c: &mut Criterion) { let mut g = c.benchmark_group("waiter_park_cycle"); // Include broad polls that remain parked on many quiet lists. diff --git a/rs/kio/src/loom.rs b/rs/kio/src/loom.rs index 9695abbc5d..67dcdc2bce 100644 --- a/rs/kio/src/loom.rs +++ b/rs/kio/src/loom.rs @@ -289,8 +289,9 @@ fn queue_close_wakes_a_parked_pop() { }); } -/// A partial wake retires the waiter still parked on the quiet list. The new -/// registration must hear a terminal wake on the drained list or the task hangs. +/// A partial wake re-polls a waiter still parked on the quiet list. Its registration +/// on the drained list must be real, not skipped as a duplicate, or the terminal +/// wake is lost and the task hangs. #[test] fn a_replaced_waiter_hears_the_drained_list_again() { loom::model(|| { diff --git a/rs/kio/src/waiter.rs b/rs/kio/src/waiter.rs index 81a66ba48e..1d3755b138 100644 --- a/rs/kio/src/waiter.rs +++ b/rs/kio/src/waiter.rs @@ -5,7 +5,10 @@ use std::{ pin::Pin, // std, not `crate::sync`: loom's Arc has no `downgrade`, and `Waker::from` takes // std's. See `sync.rs`. - sync::{Arc, OnceLock, Weak}, + sync::{ + Arc, OnceLock, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, task::{Context, Poll, Wake, Waker}, }; @@ -37,14 +40,28 @@ pub struct Waiter { // first `register` (a poll that never parks never allocates it), then reused so multiple // lists in one poll share a single allocation whose `Weak`s die together when the waiter drops. shared: OnceLock>, + + // The list tag each registration was made under, so registering on a list that + // still holds this waiter is a no-op. Filled from the front, zero past the end, + // and cleared all at once. + parked: [AtomicU64; PARKED], + + // A registration found no free slot in `parked`, so a list may hold this waiter + // unrecorded and a later registration there would stack a duplicate. + lost: AtomicBool, } +/// Lists a waiter remembers being parked on. A relay's busiest tasks park on up to 8. +const PARKED: usize = 8; + impl Waiter { /// Create a new waiter from an async [`Waker`]. pub fn new(waker: Waker) -> Self { Self { waker, shared: OnceLock::new(), + parked: Default::default(), + lost: AtomicBool::new(false), } } @@ -55,7 +72,7 @@ impl Waiter { /// Register this waiter with a [`WaiterList`] for future notification. /// - /// Delegates to [`WaiterList::register`], which is not idempotent. + /// Delegates to [`WaiterList::register`], a no-op while the list still holds it. pub fn register(&self, list: &mut WaiterList) { list.register(self); } @@ -72,6 +89,63 @@ impl Waiter { self.shared.get_or_init(|| Arc::new(self.waker.clone())) } + /// Record a registration under `tag`, or report one already recorded there. + /// + /// Returns whether the list needs an entry: false only when a record proves it + /// still holds this waiter. A record of an older round of the same list is + /// replaced, since the drain that ended that round took the entry with it. + fn record(&self, tag: u64) -> bool { + // Retiring anyway, so skip the bookkeeping: duplicates die with the waiter. + if self.lost.load(Ordering::Relaxed) { + return true; + } + + // The common case, kept a tight loop of its own. + if self.parked.iter().any(|slot| slot.load(Ordering::Relaxed) == tag) { + return false; + } + + let serial = tag >> ROUND_BITS; + for slot in &self.parked { + let old = slot.load(Ordering::Relaxed); + // The first empty slot ends the records. A record of the same list in an + // older round is stale: the drain that ended that round took the entry. + if old >> ROUND_BITS == serial { + // Only a registration on this list, under its lock, writes this slot. + slot.store(tag, Ordering::Relaxed); + return true; + } + // Claimed, not stored: another thread registering this waiter on another + // list may be taking the same empty slot. + if old == 0 + && slot + .compare_exchange(0, tag, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + return true; + } + } + self.lost.store(true, Ordering::Relaxed); + true + } + + /// Whether every list holding this waiter is recorded, so registering it again + /// cannot stack a duplicate. + fn reusable(&self) -> bool { + if !self.lost.load(Ordering::Relaxed) { + return true; + } + if self.shared.get().is_some_and(|shared| Arc::weak_count(shared) > 0) { + return false; + } + // Every list let go, so every record is stale: start over. + for slot in &self.parked { + slot.store(0, Ordering::Relaxed); + } + self.lost.store(false, Ordering::Relaxed); + true + } + /// Poll a foreign [`Future`] against this waiter, so it re-wakes the enclosing /// `poll_*` step when it is ready. pub fn poll_future(&self, future: Pin<&mut F>) -> Poll { @@ -88,6 +162,8 @@ impl Clone for Waiter { Self { waker: self.waker.clone(), shared: OnceLock::from(shared), + parked: std::array::from_fn(|i| AtomicU64::new(self.parked[i].load(Ordering::Relaxed))), + lost: AtomicBool::new(self.lost.load(Ordering::Relaxed)), } } } @@ -102,6 +178,37 @@ pub struct WaiterList { entries: SmallVec<[Weak; INLINE_WAITERS]>, /// Rotating cursor for opportunistic GC on `register`. cursor: usize, + /// A serial unique to this list in the high bits and a drain count in the low + /// [`ROUND_BITS`], so an unchanged tag proves an entry made under it is still + /// here. Zero until the first registration. + tag: u64, +} + +/// Low bits of a list tag that count drains. A wrap takes a fresh serial instead, +/// so no tag is ever reused. +const ROUND_BITS: u32 = 16; + +/// A serial for a list tag, unique for the life of the process. Handed out in +/// per-thread blocks so lists created on many threads don't share a cache line. +fn serial() -> u64 { + use std::cell::Cell; + + const BLOCK: u64 = 1024; + static NEXT: AtomicU64 = AtomicU64::new(1); + thread_local! { + static RANGE: Cell<(u64, u64)> = const { Cell::new((0, 0)) }; + } + + RANGE.with(|range| { + let (mut next, mut end) = range.get(); + if next == end { + next = NEXT.fetch_add(BLOCK, Ordering::Relaxed); + end = next + BLOCK; + assert!(end < 1 << (64 - ROUND_BITS), "waiter list serials exhausted"); + } + range.set((next + 1, end)); + next << ROUND_BITS + }) } impl WaiterList { @@ -110,22 +217,28 @@ impl WaiterList { Self { entries: SmallVec::new(), cursor: 0, + tag: 0, } } - /// Register a waiter. + /// Register a waiter, a no-op while this list still holds it. /// - /// Not idempotent: a waiter already in the list is appended again. - /// [`Park::hold`] retires any waiter that still has live registrations - /// before the next poll, so the in-tree poll path never stacks - /// duplicates. Callers that retain a waiter across polls must drop it - /// before registering again. + /// The list's tag changes on every drain and the waiter records the tag it + /// joined under, so a matching record proves the entry is still here and a + /// waiter kept across polls re-registers for free. /// /// Each call probes at most two slots at the rotating cursor and reuses /// a dead one in place. The cursor advances on each live probe so the /// window covers the list over time. A list about to grow sweeps every /// dead slot first. pub fn register(&mut self, waiter: &Waiter) { + if self.tag == 0 { + self.tag = serial(); + } + if !waiter.record(self.tag) { + return; + } + let new_weak = Arc::downgrade(waiter.shared()); for _ in 0..self.entries.len().min(2) { @@ -152,18 +265,34 @@ impl WaiterList { self.entries.push(new_weak); } + /// Start a new round, so no record of an entry made in the last one matches. + fn drained(&mut self) { + self.cursor = 0; + if self.tag == 0 || self.entries.is_empty() { + // No serial yet (a `take` snapshot, say) or no entry: no record can match + // the current tag, and a zero tag must stay zero to draw a fresh serial. + return; + } + self.tag += 1; + if self.tag & ((1 << ROUND_BITS) - 1) == 0 { + // The round wrapped: take a fresh serial rather than reuse a tag. + self.tag = 0; + } + } + /// Drain all entries into a new [`WaiterList`], leaving this one empty. pub fn take(&mut self) -> Self { - self.cursor = 0; + self.drained(); Self { entries: std::mem::take(&mut self.entries), cursor: 0, + tag: 0, } } /// Wake all live waiters, draining the list. pub fn wake(&mut self) { - self.cursor = 0; + self.drained(); for waker in self.entries.drain(..).filter_map(|w| w.upgrade()) { waker.wake_by_ref(); } @@ -232,16 +361,21 @@ impl Park { /// (the usual `ready!` on a nested poll) still leaves its registrations live. /// There is no second call to forget. /// - /// The held waiter is reused when it would wake the same task *and* has no live - /// list registrations (the usual case after a wakeup, which drains every entry), - /// so a steady-state park allocates nothing. Otherwise it is retired for a fresh - /// one: a still-registered waiter must not be registered again, because - /// [`WaiterList`] reclaims a slot only once its `Arc` dies, so reusing one with - /// live entries would stack duplicates the list could never collect. + /// The held waiter is reused when it would wake the same task, so a steady-state + /// park allocates nothing even when one list woke it and others still hold it: + /// registering again on those is a no-op (see [`WaiterList::register`]). It is + /// retired for a fresh one when the task changed, or when it parked on more lists + /// than it can record while some still hold it, since re-registering there could + /// stack duplicates a list never collects: it reclaims a slot only once its `Arc` + /// dies. + /// + /// A reused waiter keeps the registrations its next poll does not renew, so a + /// list it has moved on from may wake it once more, spuriously. pub fn hold(&mut self, cx: &Context<'_>) -> &Waiter { - let reuse = self.0.as_ref().is_some_and(|waiter| { - cx.waker().will_wake(&waiter.waker) && waiter.shared.get().is_none_or(|shared| Arc::weak_count(shared) == 0) - }); + let reuse = self + .0 + .as_ref() + .is_some_and(|waiter| cx.waker().will_wake(&waiter.waker) && waiter.reusable()); if !reuse { // The outgoing waiter drops here, killing its registrations so the lists // can reclaim those slots. @@ -721,10 +855,8 @@ mod tests { /// anything ever parks. Growing either is invisible at the call site, so bound /// them: a diff that has to raise these numbers should say why. /// - /// A bound and not an equality, because `SmallVec` stores its inline array in a - /// union or a tagged enum depending on whether anything else in the build graph - /// enabled `smallvec/union` (glib and wgpu-hal both do). That moves the list - /// between 48 B and 56 B for reasons that have nothing to do with kio. + /// kio enables `smallvec/union`, which drops the inline array's enum tag, so the + /// list costs the same whatever else is in the build graph. #[test] #[cfg(target_pointer_width = "64")] fn the_list_stays_small() { @@ -831,12 +963,90 @@ mod tests { } #[test] - fn register_appends_a_live_waiter() { + fn register_is_idempotent_until_the_list_drains() { let mut list = WaiterList::new(); let waiter = Waiter::new(Waker::noop().clone()); waiter.register(&mut list); waiter.register(&mut list); - assert_eq!(list.entries.len(), 2, "a live waiter must not dedup"); + assert_eq!(list.entries.len(), 1, "a waiter the list holds must not stack"); + + list.wake(); + waiter.register(&mut list); + assert_eq!(list.entries.len(), 1, "a drained waiter must register again"); + } + + /// The relay's shape: a task parks on a few lists, one of them wakes it, and the + /// rest stay quiet. Retiring the waiter there cost an `Arc` per poll. + #[test] + fn partial_wakes_reuse_the_waiter() { + let waker = Waker::from(Arc::new(Flag::default())); + let cx = Context::from_waker(&waker); + let mut park = Park::default(); + let mut lists: Vec<_> = (0..PARKED).map(|_| WaiterList::new()).collect(); + let first = park.hold(&cx).shared().clone(); + for round in 0..100 { + let waiter = park.hold(&cx); + assert!(Arc::ptr_eq(&first, waiter.shared()), "retired a recorded waiter"); + for list in &mut lists { + waiter.register(list); + } + lists[round % PARKED].wake(); + } + for list in &lists { + assert!(list.entries.len() <= 1, "re-registration stacked a duplicate"); + } + } + + /// The tag moves with the list, so a list that moved still recognizes its waiter. + #[test] + fn a_moved_list_still_holds_its_waiter() { + let waiter = Waiter::new(Waker::noop().clone()); + let mut list = WaiterList::new(); + waiter.register(&mut list); + + let mut moved = std::mem::take(&mut list); + waiter.register(&mut moved); + assert_eq!(moved.entries.len(), 1); + + // The emptied original is a different list now. + waiter.register(&mut list); + assert_eq!(list.entries.len(), 1); + } + + /// A `take` snapshot has no serial, and waking it must not invent one: every + /// snapshot would share it, and a reused one would skip a real registration. + #[test] + fn a_woken_snapshot_draws_a_fresh_serial() { + let waiter = Waiter::new(Waker::noop().clone()); + let mut snapshots = [WaiterList::new(), WaiterList::new()].map(|mut list| { + Waiter::new(Waker::noop().clone()).register(&mut list); + let mut snapshot = list.take(); + snapshot.wake(); + snapshot + }); + for snapshot in &mut snapshots { + waiter.register(snapshot); + assert_eq!(snapshot.entries.len(), 1, "a snapshot skipped a real registration"); + } + } + + /// A tag must never repeat, or a stale record would skip a registration the list + /// no longer holds and the wakeup would be lost. + #[test] + fn a_wrapped_round_takes_a_fresh_serial() { + let stale = Waiter::new(Waker::noop().clone()); + let mut list = WaiterList::new(); + stale.register(&mut list); + list.wake(); + + // Skip ahead to the last round, then drain once more. + list.tag |= (1 << ROUND_BITS) - 1; + let other = Waiter::new(Waker::noop().clone()); + other.register(&mut list); + list.wake(); + + stale.register(&mut list); + assert_eq!(list.entries.len(), 1, "a wrapped round matched a stale record"); } #[test] diff --git a/rs/kio/tests/waiter_allocs.rs b/rs/kio/tests/waiter_allocs.rs index 4f37ba0cb3..32d4f01e9c 100644 --- a/rs/kio/tests/waiter_allocs.rs +++ b/rs/kio/tests/waiter_allocs.rs @@ -7,9 +7,10 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; -use std::task::Waker; +use std::sync::Arc; +use std::task::{Context, Wake, Waker}; -use kio::{Waiter, WaiterList}; +use kio::{Park, Waiter, WaiterList}; /// Inline slots in a `WaiterList`, which is private, so this mirrors it. A lower /// real value fails the test rather than passing it quietly. @@ -89,3 +90,43 @@ fn a_small_list_cycles_without_allocating() { fn a_spilled_list_allocates_once_per_wake() { assert_eq!(cycle_allocs(&waiters(INLINE_WAITERS + 1), 100), 100); } + +/// A task parked on several lists that only one of them wakes, as a relay's group +/// stream parks on its frames, its priority, and its subscription. Its park re-polls +/// with the same waiter, so a poll that re-registers everywhere allocates nothing. +#[test] +fn a_partial_wake_reuses_the_parked_waiter() { + // A waker of its own, since `Park` reuses a waiter only for a waker that + // `will_wake` matches, and `Waker::noop()` need not match itself. + struct Task; + #[expect(clippy::manual_noop_waker, reason = "the waker must match itself under will_wake")] + impl Wake for Task { + fn wake(self: Arc) {} + } + + let waker = Waker::from(Arc::new(Task)); + let cx = Context::from_waker(&waker); + let mut park = Park::default(); + let mut woken = WaiterList::new(); + let mut quiet = [WaiterList::new(), WaiterList::new()]; + + let mut poll = || { + let waiter = park.hold(&cx); + woken.register(waiter); + for list in &mut quiet { + list.register(waiter); + } + woken.wake(); + }; + + poll(); + let before = ALLOCS.with(Cell::get); + for _ in 0..100 { + poll(); + } + assert_eq!( + ALLOCS.with(Cell::get) - before, + 0, + "a partial wake re-allocated the waiter" + ); +} diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index ae998dd904..b2cef774cb 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -242,11 +242,8 @@ fn bench_serve_idle(c: &mut Criterion) { }) .collect(); b.iter(|| { - // A fresh waiter per sweep. A live registration keeps the waiter's - // `Weak` in each route's list until it drops, so a waiter reused - // across sweeps would stack one per route per iteration. Production - // retires the parked waiter the same way: `Park::hold` drops a - // still-registered waiter before the next poll registers again. + // A fresh waiter per sweep, so every poll pays a real registration: a + // reused one would find itself still parked on each route and skip it. let waiter = kio::Waiter::noop(); // Nothing is queued, so every poll parks again: the idle sweep. for dynamic in &dynamics { diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index b093dc6db1..947fa10727 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -10,6 +10,8 @@ //! as a slope. Unwatched broadcasts stay announced and silent: their cost is the //! route table they occupy, not a publisher writing into its own cache. //! +//! `SESSION_ALLOCS=1` prints allocations per viewer-group instead of timing. +//! //! `session_join_*` times a new viewer connecting, resolving a broadcast, and //! receiving its latest group, swept over what the relays already announce. //! @@ -21,11 +23,13 @@ #[path = "../tests/support/mod.rs"] mod support; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use bytes::Bytes; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; -use moq_net::{Hop, Timestamp, Version, broadcast, cache, origin, track}; +use moq_net::{Hop, Timestamp, Version, broadcast, cache, group, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// The lite draft production negotiates, the next one (opt-in), and the newest @@ -59,6 +63,8 @@ struct Shape { watch: usize, /// Payload bytes per frame. frame: usize, + /// Write one frame per round, as a live source does, instead of a whole group. + paced: bool, } impl Shape { @@ -71,25 +77,73 @@ impl Shape { viewers: 16, watch: 1, frame: 64, + paced: false, }; fn total(&self) -> usize { self.publishers * self.broadcasts } + /// Frames each group gets per round. + fn frames(&self) -> usize { + if self.paced { 1 } else { FRAMES } + } + /// Payload bytes every viewer reads in one round. fn expected(&self) -> usize { - self.viewers * self.watch * FRAMES * self.frame + self.viewers * self.watch * self.frames() * self.frame } fn id(&self, version: &str) -> String { format!( - "{version}/relays={}/publishers={}/broadcasts={}/viewers={}/watch={}/frame={}", - self.relays, self.publishers, self.broadcasts, self.viewers, self.watch, self.frame + "{version}/relays={}/publishers={}/broadcasts={}/viewers={}/watch={}/frame={}{}", + self.relays, + self.publishers, + self.broadcasts, + self.viewers, + self.watch, + self.frame, + if self.paced { "/paced" } else { "" }, ) } } +struct Counter; + +static COUNTING: AtomicBool = AtomicBool::new(false); +static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0); + +#[global_allocator] +static ALLOCATOR: Counter = Counter; + +// Counting only. Every call forwards to the system allocator unchanged. +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + fn runtime() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_current_thread() .enable_time() @@ -229,7 +283,13 @@ struct Room { /// Broadcasts at least one viewer watches; only these write each round. watched: Vec, payload: Bytes, - expected: usize, + shape: Shape, + /// Frames written so far, so paced rounds know where each group starts and ends. + written: usize, + /// The group each watched broadcast is writing, when paced. + writing: Vec>, + /// The group each viewer's subscriber is reading, when paced, flattened in viewer order. + reading: Vec>, } impl Room { @@ -267,14 +327,24 @@ impl Room { .map(|(i, _)| i) .collect(), payload: Bytes::from(vec![0; shape.frame]), - expected: shape.expected(), + shape, + written: 0, + writing: Vec::new(), + reading: Vec::new(), }; + room.writing.resize_with(room.watched.len(), || None); + room.reading.resize_with(shape.viewers * shape.watch, || None); // Warm every path so the timed rounds skip first-group setup. - room.round().await; + for _ in 0..FRAMES / shape.frames() { + room.round().await; + } room } async fn round(&mut self) { + if self.shape.paced { + return self.paced_round().await; + } for &broadcast in &self.watched { write_group(&self.cluster.tracks[broadcast], &self.payload); } @@ -284,7 +354,38 @@ impl Room { bytes += read_group(subscriber).await; } } - assert_eq!(bytes, self.expected); + assert_eq!(bytes, self.shape.expected()); + } + + /// One frame into every watched group and one frame out to every viewer, so each + /// hop serves a group frame by frame across [`FRAMES`] rounds. + async fn paced_round(&mut self) { + let last = self.written % FRAMES == FRAMES - 1; + self.written += 1; + + for (writing, &broadcast) in self.writing.iter_mut().zip(&self.watched) { + let group = writing.get_or_insert_with(|| self.cluster.tracks[broadcast].append_group().unwrap()); + group.write_frame(Timestamp::ZERO, self.payload.clone()).unwrap(); + if last { + group.finish().unwrap(); + *writing = None; + } + } + + let mut bytes = 0; + let subscribers = self.viewers.iter_mut().flat_map(|viewer| &mut viewer.subscribers); + for (subscriber, reading) in subscribers.zip(&mut self.reading) { + if reading.is_none() { + *reading = Some(subscriber.recv_group().await.unwrap().expect("track ended")); + } + let frame = reading.as_mut().unwrap().read_frame().await.unwrap(); + bytes += frame.expect("group ended early").payload.len(); + if last { + let end = reading.take().unwrap().read_frame().await.unwrap(); + assert!(end.is_none(), "group did not end after its last frame"); + } + } + assert_eq!(bytes, self.shape.expected()); } async fn measure(&mut self, iters: u64) -> Duration { @@ -362,7 +463,39 @@ fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) group.finish(); } +/// Print allocations per viewer-group instead of timing, to compare across revisions. +fn allocations() { + const ROUNDS: usize = 256; + + let rt = runtime(); + let fanout = Shape { + publishers: 1, + viewers: 256, + ..Shape::BASE + }; + for version in VERSIONS { + for shape in [Shape::BASE, fanout] { + for paced in [false, true] { + let shape = Shape { paced, ..shape }; + let mut room = rt.block_on(Room::new(version, shape)); + ALLOCATIONS.store(0, Ordering::Relaxed); + COUNTING.store(true, Ordering::Relaxed); + rt.block_on(room.measure(ROUNDS as u64)); + COUNTING.store(false, Ordering::Relaxed); + + let groups = ROUNDS * shape.viewers * shape.watch * shape.frames() / FRAMES; + let allocations = ALLOCATIONS.load(Ordering::Relaxed) as f64 / groups as f64; + println!("{}: {allocations:.1} allocations per viewer-group", shape.id(version)); + } + } + } +} + fn session(c: &mut Criterion) { + if std::env::var_os("SESSION_ALLOCS").is_some() { + allocations(); + return; + } let base = Shape::BASE; delivery( @@ -371,6 +504,16 @@ fn session(c: &mut Criterion) { [1, 16, 256].map(|publishers| Shape { publishers, ..base }), ); delivery(c, "viewers", [1, 16, 256].map(|viewers| Shape { viewers, ..base })); + // Live media: every hop serves each group frame by frame. + delivery( + c, + "paced", + [1, 16, 256].map(|viewers| Shape { + viewers, + paced: true, + ..base + }), + ); delivery( c, "scale", diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 12bf76bf5f..434e962551 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -460,7 +460,7 @@ mod tests { type Error = crate::lite::test_transport::SinkError; fn poll_read(&mut self, cx: &mut Context<'_>, dst: &mut [u8]) -> Poll, Self::Error>> { - // Like an executor poll, retire the previous turn's registrations first. + // Start each turn with none of the last turn's registrations left. self.waiter = kio::Waiter::new(self.waiter.waker().clone()); while self.payload.poll_read_chunk(&self.waiter).is_ready() {} self.chunks.poll_read(cx, dst) From d2d5d7876a18cc7b94676e73e0a60b9bf20ebcb6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:35:13 -0700 Subject: [PATCH 36/40] fix(auth): keep accepted grants on fixed expiry deadlines (#4237) Co-authored-by: GPT-6 --- doc/bin/relay/auth.md | 5 +- doc/lib/rs/moq-auth.md | 2 +- quest/m1/README.md | 1 - quest/m1/auth-expiry-clock.md | 24 ---------- rs/moq-auth/Cargo.toml | 4 +- rs/moq-auth/src/client.rs | 87 ++++++++++++++++++++++++----------- rs/moq-auth/src/grant.rs | 8 +++- rs/moq-relay/src/auth.rs | 35 ++++++++++---- 8 files changed, 99 insertions(+), 67 deletions(-) delete mode 100644 quest/m1/auth-expiry-clock.md diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 2cb99cc912..f9289e3f61 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -49,7 +49,10 @@ 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 few seconds of clock skew are tolerated on `expires`. +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. +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 diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index 8f3ea78286..ddab0dc5a9 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -14,7 +14,7 @@ answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. - **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. -- **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. +- **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. - **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/quest/m1/README.md b/quest/m1/README.md index 787191075c..022226b808 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -25,7 +25,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for -- [Auth expiry clock](/quest/m1/auth-expiry-clock.md) - moq-auth and the relay hold one fixed expiry deadline and honour the same skew allowance - [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone diff --git a/quest/m1/auth-expiry-clock.md b/quest/m1/auth-expiry-clock.md deleted file mode 100644 index c2a069c104..0000000000 --- a/quest/m1/auth-expiry-clock.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] Grant expiry is one fixed deadline everywhere - -## Goal - -A grant's expiry is a deadline fixed when the grant arrives, in both -`moq-auth`'s `Client::drive` and the relay, and both honour `moq-auth`'s 5 s -clock-skew allowance. Today `Client::drive` recomputes the time left from the -wall clock on every re-check, so the countdown restarts, and the relay ignores -the skew allowance, so a grant just past expiry stays live in the client but -ends immediately in the relay. - -## Plan - -- Build on the lease clock `lease::Producer` owns (#3943): it sets - its deadline once per grant and measures it on the tokio clock, so the - client's outage tests run on a paused clock like the relay's (#3969 fixed - the relay side). -- Apply the skew allowance in one place both sides share. -- Test: re-polling keeps the deadline; a grant within the skew window is live - on both sides; the client outage tests run on a paused clock. - -## Required - -- The relay's fixed expiry deadline (#3969) has merged diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 9c65a292ea..4ca32b17dc 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -12,9 +12,9 @@ rust-version.workspace = true [features] default = ["client"] # The HTTP client that drives a lease against an auth server. -client = ["dep:reqwest", "dep:rustls", "dep:tokio", "dep:tracing", "dep:rand"] +client = ["dep:reqwest", "dep:rustls", "tokio", "dep:tracing", "dep:rand"] # The reference auth server behind `moq auth serve`: the policy a relay held, on axum. -serve = ["dep:axum", "dep:tokio", "dep:tracing", "tokio/net"] +serve = ["dep:axum", "tokio", "dep:tracing", "tokio/net"] tokio = ["dep:tokio"] [dependencies] diff --git a/rs/moq-auth/src/client.rs b/rs/moq-auth/src/client.rs index 020358d65c..8b8fc0359f 100644 --- a/rs/moq-auth/src/client.rs +++ b/rs/moq-auth/src/client.rs @@ -80,6 +80,7 @@ impl Client { client: self.clone(), request, producer: Some(producer), + expires: grant.deadline(), started: Instant::now(), }; tokio::spawn(driver.run(grant)); @@ -115,6 +116,7 @@ struct Driver { request: Request, producer: Option, started: Instant, + expires: Option, } impl Driver { @@ -140,7 +142,7 @@ impl Driver { .take() .expect("the driver owns the producer until it ends"); let mut failures = 0u32; - let mut next = grant.revalidate.map(|cadence| Instant::now() + cadence); + let mut next = grant.revalidate.map(|cadence| tokio::time::Instant::now() + cadence); // The re-check in flight, kept out of the select so expiry and the session's // close are still polled while a stalled server holds the reply. let mut inflight: Option> + Send>>> = None; @@ -149,16 +151,15 @@ impl Driver { let mut pending = false; loop { - let expires = grant.expires.map(crate::grant::until); let revalidate = async { match next { - Some(at) => tokio::time::sleep_until(at.into()).await, + Some(at) => tokio::time::sleep_until(at).await, None => std::future::pending().await, } }; let expire = async { - match expires { - Some(after) => tokio::time::sleep(after).await, + match self.expires { + Some(at) => tokio::time::sleep_until(at).await, None => std::future::pending().await, } }; @@ -177,7 +178,8 @@ impl Driver { match result { Ok(fresh) => { failures = 0; - next = fresh.revalidate.map(|cadence| Instant::now() + cadence); + self.expires = fresh.deadline(); + next = fresh.revalidate.map(|cadence| tokio::time::Instant::now() + cadence); producer.update(fresh.clone()); grant = fresh; } @@ -190,7 +192,7 @@ impl Driver { failures += 1; let delay = backoff(failures, grant.revalidate.unwrap_or(BACKOFF_MAX)); tracing::warn!(id = %self.request.id, %err, ?delay, "auth revalidation failed; retrying"); - next = Some(Instant::now() + delay); + next = Some(tokio::time::Instant::now() + delay); } } if pending { @@ -314,6 +316,37 @@ mod tests { server } + /// Keep HTTP handling on the paused test clock instead of wiremock's separate runtime. + async fn clock_server(log: Log, grant: Grant, stall: bool) -> Client { + use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; + let router = Router::new() + .route( + "/", + post( + |State((log, grant, stall)): State<(Log, Grant, bool)>, Json(request): Json| async move { + let event = request.event.clone(); + log.0.lock().push(request); + match event { + Event::Connect => Json(grant).into_response(), + Event::Revalidate if stall => std::future::pending().await, + Event::Revalidate => StatusCode::SERVICE_UNAVAILABLE.into_response(), + Event::End { .. } => StatusCode::OK.into_response(), + } + }, + ), + ) + .with_state((log, grant, stall)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + // Only the lease clock is under test; an HTTP timeout can auto-advance + // Tokio's paused clock before loopback I/O gets its first reactor turn. + Client { + http: reqwest::Client::builder().no_proxy().build().unwrap(), + url: url.parse().unwrap(), + } + } + fn client(server: &MockServer) -> Client { Client::new(server.uri().parse().unwrap(), None).unwrap() } @@ -461,14 +494,12 @@ mod tests { #[tokio::test] async fn a_grant_within_clock_skew_stays_live() { - let server = server(Log::default(), |_| { - let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); - grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); - ResponseTemplate::new(200).set_body_json(grant) - }) - .await; + 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; + let consumer = client.connect(request()).await.unwrap(); - let consumer = client(&server).connect(request()).await.unwrap(); tokio::time::sleep(Duration::from_millis(500)).await; assert!( tokio::time::timeout(Duration::from_millis(100), consumer.closed()) @@ -485,15 +516,16 @@ mod tests { #[tokio::test] async fn an_outage_keeps_the_grant_until_expires() { + tokio::time::pause(); let log = Log::default(); - let server = server(log.clone(), |request| match request.event { - Event::Connect => ResponseTemplate::new(200) - .set_body_json(grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1)))), - _ => ResponseTemplate::new(503), - }) + let client = clock_server( + log.clone(), + grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))), + false, + ) .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"); assert_eq!( @@ -517,16 +549,15 @@ mod tests { #[tokio::test] async fn expiry_fires_while_a_recheck_is_stalled() { - let server = server(Log::default(), |request| match request.event { - Event::Connect => ResponseTemplate::new(200) - .set_body_json(grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1)))), - _ => ResponseTemplate::new(200) - .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(60)))) - .set_delay(Duration::from_secs(30)), - }) + tokio::time::pause(); + let client = clock_server( + Log::default(), + grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))), + true, + ) .await; + let consumer = client.connect(request()).await.unwrap(); - let consumer = client(&server).connect(request()).await.unwrap(); let reason = tokio::time::timeout(Duration::from_secs(5), consumer.closed()) .await .expect("expired while the re-check was in flight"); diff --git a/rs/moq-auth/src/grant.rs b/rs/moq-auth/src/grant.rs index 9ae14ae375..954b60aae1 100644 --- a/rs/moq-auth/src/grant.rs +++ b/rs/moq-auth/src/grant.rs @@ -9,7 +9,7 @@ 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. -pub(crate) fn until(at: SystemTime) -> Duration { +fn until(at: SystemTime) -> Duration { match at.duration_since(SystemTime::now()) { Ok(remaining) => remaining, Err(late) => CLOCK_SKEW.saturating_sub(late.duration()), @@ -67,6 +67,12 @@ impl Grant { } } + /// Snapshot the expiry on Tokio's clock, allowing five seconds of past clock skew. + #[cfg(feature = "tokio")] + pub fn deadline(&self) -> Option { + self.expires.map(|at| tokio::time::Instant::now() + until(at)) + } + /// 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. diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 35c663301c..a3eb5dda48 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -4,7 +4,6 @@ //! certificate is reported to whoever decides as a fact. use std::sync::Arc; -use std::time::SystemTime; use axum::http; use moq_auth::{Bytes, Grant, Request, lease}; @@ -248,7 +247,7 @@ impl Lease { let grant = consumer.grant(); Self { token: Token::new(path, &grant), - expires: deadline(&grant), + expires: grant.deadline(), consumer, stats: Default::default(), } @@ -306,7 +305,7 @@ impl Lease { self.stats.set_tier(fresh.tier.clone()); self.token.tier = fresh.tier; } - self.expires = deadline(&grant); + self.expires = grant.deadline(); }, Err(reason) => return reason, }, @@ -323,12 +322,6 @@ impl Lease { } } -/// The grant's `expires` as a deadline on tokio's clock, counted from now. -fn deadline(grant: &Grant) -> Option { - let at = grant.expires?; - Some(tokio::time::Instant::now() + at.duration_since(SystemTime::now()).unwrap_or_default()) -} - enum Decider { Server(moq_auth::Client), Public(Grant), @@ -509,6 +502,7 @@ pub(crate) fn peer(identity: &moq_tokio::tls::PeerIdentity) -> Option Patterns { texts.iter().map(|text| text.parse().unwrap()).collect() @@ -643,6 +637,29 @@ mod tests { assert_eq!(reason, lease::Reason::Expired); } + #[tokio::test] + async fn a_grant_within_clock_skew_stays_live() { + tokio::time::pause(); + 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"); + 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 + ); + } + #[test] fn token_keeps_every_grant_pattern() { let mut grant = Grant::new(patterns(&["alice/**"]), patterns(&["**"])); From f63b295f23784dbf3e63a9a371e2181403398061 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:35:55 -0700 Subject: [PATCH 37/40] fix(net): retain retired counters in host snapshots (#4238) Co-authored-by: GPT-6 --- doc/bin/relay/http.md | 4 ++ rs/moq-net/src/stats.rs | 124 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md index 80460efb30..dcf73a4636 100644 --- a/doc/bin/relay/http.md +++ b/doc/bin/relay/http.md @@ -54,6 +54,10 @@ process ran out of a resource `accept` needs. Content dropped for drifting past a subscriber's budget is counted separately as `moq_relay_stale_bytes_total` and friends. Host CPU and memory belong to a node exporter. +Traffic and session counters accumulate for the node's lifetime, including +broadcasts and sessions that have ended. The stats publishing prefix (normally +`.stats`) is excluded to avoid counting the feed's own traffic. + With `--runtime-io-uring`, each QUIC worker thread also reports its own `moq_relay_uring_*` counters under a `worker` label: datagrams and syscalls (the ratios are the GRO/GSO batching and the syscall amortization the runtime diff --git a/rs/moq-net/src/stats.rs b/rs/moq-net/src/stats.rs index 7f84c93a37..d6cf90368e 100644 --- a/rs/moq-net/src/stats.rs +++ b/rs/moq-net/src/stats.rs @@ -794,6 +794,9 @@ pub struct Registry { /// State shared by every clone of a [`Registry`]. struct Shared { + /// Completed entries folded by tier before pruning. Lock before either map + /// so a snapshot sees each entry either here or live, never both or neither. + retired: Lock, entries: Lock>>, /// Connected-session gauges keyed by `(tier, auth root)`. Independent of any /// broadcast; surfaced on the per-tier session tracks. A tier's inner map is @@ -844,6 +847,7 @@ impl Registry { Self { exclude, shared: Some(Arc::new(Shared { + retired: Lock::default(), entries: Lock::default(), sessions: Default::default(), })), @@ -922,12 +926,14 @@ impl Registry { /// all-zero snapshot for a disabled registry. /// /// Unlike [`Registry::report`], this collapses per-broadcast detail into - /// node totals (what a `/metrics`-style scrape wants) and never prunes. + /// node lifetime totals (what a `/metrics`-style scrape wants) and never prunes. + /// Retired entries remain in these totals after [`Self::report`] prunes them. pub fn snapshot(&self) -> Snapshot { - let mut snap = Snapshot::default(); let Some(shared) = self.shared.as_ref() else { - return snap; + return Snapshot::default(); }; + let retired = shared.retired.lock(); + let mut snap = retired.clone(); { let entries = shared.entries.lock(); for entry in entries.values() { @@ -967,6 +973,7 @@ impl Registry { let Some(shared) = self.shared.as_ref() else { return; }; + let mut retired = shared.retired.lock(); { let mut entries = shared.entries.lock(); for (path, entry) in entries.iter() { @@ -989,7 +996,15 @@ impl Registry { return true; } let mut tiers = entry.tiers.lock().expect("stats tiers poisoned"); - tiers.retain(|_, counters| Arc::strong_count(counters) > 1); + tiers.retain(|tier, counters| { + if Arc::strong_count(counters) > 1 { + return true; + } + let totals = retired.traffic.entry(tier.clone()).or_default(); + totals[Role::Publisher.idx()].add(counters.publisher.snapshot()); + totals[Role::Subscriber.idx()].add(counters.subscriber.snapshot()); + false + }); !tiers.is_empty() }); } @@ -1004,8 +1019,18 @@ impl Registry { }); } } - for roots in sessions.values_mut() { - roots.retain(|_, counters| Arc::strong_count(counters) > 1); + for (tier, roots) in sessions.iter_mut() { + roots.retain(|_, counters| { + if Arc::strong_count(counters) > 1 { + return true; + } + retired + .sessions + .entry(tier.clone()) + .or_default() + .add(counters.snapshot()); + false + }); } sessions.retain(|_, roots| !roots.is_empty()); } @@ -1735,6 +1760,93 @@ mod tests { assert!(session_snapshot(&stats, &Tier::default(), "acme").is_none()); } + #[test] + fn snapshot_preserves_retired_counters() { + let stats = test_stats(); + let tier = Tier::default(); + let live = stats.tier(tier.clone()).session("live"); + let scope = live.egress("live/video"); + let _live_sub = scope.subscribe(); + for _ in 0..2 { + let session = stats.tier(tier.clone()).session("retired"); + let scope = session.egress("retired/video"); + let sub = scope.subscribe(); + scope.meter().bytes(100); + drop(sub); + drop(scope); + drop(session); + let before = stats.snapshot(); + drain(&stats); + assert_eq!(stats.snapshot(), before, "pruning must not reset host counters"); + } + let snap = stats.snapshot(); + let traffic = snap + .traffic() + .into_iter() + .find(|(_, role, _)| *role == Role::Publisher) + .unwrap() + .2; + assert_eq!(traffic.bytes, 200); + assert_eq!(traffic.subscriptions_started, 3); + assert_eq!(traffic.subscriptions_ended, 2); + let sessions = snap.sessions().into_iter().find(|(label, _)| label == &tier).unwrap().1; + assert_eq!(sessions.sessions_started, 3); + assert_eq!(sessions.sessions_ended, 2); + assert_eq!(stats.shared().entries.lock().len(), 1, "retired paths are still pruned"); + assert_eq!( + stats.shared().sessions.lock()[&tier].len(), + 1, + "retired roots are still pruned" + ); + let retired = stats.shared().retired.lock(); + assert_eq!(retired.traffic.len(), 1, "retain only a total per tier"); + assert_eq!(retired.sessions.len(), 1); + } + + #[cfg(not(target_family = "wasm"))] + #[test] + fn snapshot_and_report_transfer_counters_once() { + let stats = test_stats(); + let worker_stats = stats.clone(); + let worker = std::thread::spawn(move || { + for i in 0..256 { + let path = format!("root/{i}"); + let session = worker_stats.tier(Tier::default()).session(path.as_str()); + session.ingress(path.as_str()).meter().bytes(1); + drop(session); + drain(&worker_stats); + } + }); + let mut previous = 0; + while !worker.is_finished() { + let bytes: u64 = stats + .snapshot() + .traffic() + .iter() + .map(|(_, _, traffic)| traffic.bytes) + .sum(); + assert!( + bytes >= previous, + "retiring an entry must not double-count or lose its bytes" + ); + assert!(bytes <= 256); + previous = bytes; + } + worker.join().unwrap(); + let snap = stats.snapshot(); + assert_eq!( + snap.traffic().iter().map(|(_, _, traffic)| traffic.bytes).sum::(), + 256 + ); + assert_eq!(snap.sessions()[0].1.sessions_started, 256); + assert_eq!(snap.sessions()[0].1.sessions_ended, 256); + assert!(stats.shared().entries.lock().is_empty()); + assert!(stats.shared().sessions.lock().is_empty()); + let retired = stats.shared().retired.lock(); + assert_eq!(retired.traffic.len(), 1); + assert_eq!(retired.sessions.len(), 1); + } + #[test] fn paths_under_exclude_are_no_op() { // Our own stats broadcasts (and any sibling category under the same From 3431e3dac76602953a1af09125637ef1027c1131 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:36:49 -0700 Subject: [PATCH 38/40] fix(net): make JS origin refusals terminal to match moq-net (#4230) Co-authored-by: Claude Opus 5.5 --- doc/lib/js/net.md | 2 +- js/net/src/origin.test.ts | 73 ++++++++++++++++++-- js/net/src/origin.ts | 140 +++++++++++++++++++++++++------------- 3 files changed, 159 insertions(+), 56 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 833961ec74..daef5e58f3 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -47,7 +47,7 @@ for (;;) { } ``` -- **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` resolves an announced local broadcast with no round trip, so a page that watches what it publishes reads its own copy, unless a cheaper route announces the same path. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and nobody, local or remote, can see or reach a broadcast until it announces. +- **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` resolves an announced local broadcast with no round trip, so a page that watches what it publishes reads its own copy, unless a cheaper route announces the same path. A refusal from the most specific route ends the request: `request.closed` settles with the handler's error and no broader route is asked. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and nobody, local or remote, can see or reach a broadcast until it announces. - **Connections** race WebTransport against WebSocket. `new Connection({ url })` pools one connection per relay URL and reconnects with backoff, which the elements use. Supplying WebTransport/WebSocket options, discovery, delay, or a caller-owned origin selects a private loop; explicit `share: true` refuses those options. `closed` settles when the handle is released (`null` on a clean close); the failure that stopped retrying the current URL is `error`, and a new URL recovers the same handle. A connection owns one send-rate sampler and one `Bandwidth.Allocator`; publishers reserve against it so their encoder targets sum to the estimate instead of each matching it. - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 47d044158e..488e41e273 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1300,31 +1300,92 @@ test("a rejected request is not asked of the same route again", async () => { origin.close(); }); -test("a rejected request falls through to the next-best route", async () => { +test("a refusal is terminal, never falling through to a broader route", async () => { const origin = new Producer(); const consumer = origin.consume(); const narrow = origin.dynamic(Path.from("live")); + const wide = origin.dynamic(Path.from("")); + const wideRequests = wide.requested(); + const wideAsked = wideRequests.next(); + + const request = consumer.request(Path.from("live/cam")); + const { value: req } = await narrow.requested().next(); + const err = new Error("unserved"); + req?.reject(err); + await settle(); + + // The narrow route spoke for the path, so the broader one is never asked. + expect(await request.closed).toBe(err); + expect(request.active.peek()).toBeUndefined(); + expect(request.unroutable.peek()).toBe(true); + expect(await Promise.race([wideAsked.then(() => "asked"), settle().then(() => "idle")])).toBe("idle"); + + request.close(); + void wideRequests.return?.(); + narrow.close(); + wide.close(); + origin.close(); +}); + +test("a better route's refusal leaves the serving route in place", async () => { + const origin = new Producer(); + const consumer = origin.consume(); const served = new BroadcastProducer(); - served.createTrack("video"); const disposeWide = serve(origin, Path.from(""), provider(served)); const request = consumer.request(Path.from("live/cam")); + await settle(); + const before = request.active.peek(); + expect(before).toBeDefined(); + + // The narrower route is asked while the broad one keeps serving, then says no. + const narrow = origin.dynamic(Path.from("live")); const { value: req } = await narrow.requested().next(); + expect(request.active.peek()).toBe(before); req?.reject(new Error("unserved")); await settle(); - // The narrow route refused, so the broader one answers instead of the path black-holing. - const track = request.active.peek()?.track("video").subscribe(); - expect(track).toBeDefined(); - track?.close(); + expect(request.active.peek()).toBe(before); + expect(before?.closed.peek()).toBeUndefined(); + expect(request.closed.peek()).toBeUndefined(); request.close(); + expect(request.closed.peek()).toBeNull(); narrow.close(); disposeWide(); served.close(); origin.close(); }); +test("a refusal from a superseded route does not end the request", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const wide = origin.dynamic(Path.from("")); + const wideRequests = wide.requested(); + + const request = consumer.request(Path.from("live/cam")); + const { value: stale } = await wideRequests.next(); + + // A narrower route takes over the pending request before the broad one answers. + const narrow = origin.dynamic(Path.from("live")); + const { value: req } = await narrow.requested().next(); + stale?.reject(new Error("unserved")); + await settle(); + expect(request.closed.peek()).toBeUndefined(); + + const produced = new BroadcastProducer(); + req?.accept(produced); + await settle(); + expect(request.active.peek()).toBeDefined(); + + request.close(); + void wideRequests.return?.(); + narrow.close(); + wide.close(); + produced.close(); + origin.close(); +}); + test("close rejects queued requests with NoCapacity", async () => { const origin = new Producer(); const handle = origin.dynamic(Path.from("live")); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 28dd06e62d..3e9641828b 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -34,18 +34,21 @@ export { isAnonymous } from "./hop.ts"; * `answer` outlives a session: the answering session clears it when it dies and the next one * answers again, which is what makes a request span reconnects. * - * `refused` holds the route entries whose handler rejected the path. A refusal is - * authoritative: the entry is never asked again while it stands (a reconnect is a fresh - * entry), and the path resolves through the next-best route or to nothing. Without it a - * rejection would refresh the path straight back onto the same queue, and a handler that - * keeps saying no would be asked forever. + * `handles` holds the `closed` of each open {@link Requesting} on the path. + * + * A refusal is terminal. With nothing serving, the slot ends: every handle closes with the + * handler's error and the slot leaves the table, so the next request asks afresh. While + * another source still serves (a better route was asked and said no), the refuser joins + * `refused` and is skipped for as long as it stands (a reconnect is a fresh entry), so the + * current source carries on. A refusal never falls through to a broader prefix or another + * advertiser. * * @internal */ export interface RequestSlot { - count: number; blind: number; answer?: broadcast.Consumer; + readonly handles: Set>; readonly refused: Set; readonly route: Signal; } @@ -115,7 +118,7 @@ class ServeState { closed = new Once(); settled = new Signal(0); onChange: (path: Path.Valid) => void = () => {}; - onReject: (path: Path.Valid) => void = () => {}; + onReject: (path: Path.Valid, err: Error) => void = () => {}; enqueue(path: Path.Valid): void { if (this.closed.peek() !== undefined) return; @@ -157,7 +160,7 @@ class ServeState { if (this.pending.get(request.path) !== request) return; this.pending.delete(request.path); if (this.demanding.has(request.path)) this.rejected.set(request.path, err); - this.onReject(request.path); + this.onReject(request.path, err); this.settled.update((n) => n + 1); } @@ -303,31 +306,42 @@ class OriginState { slot.route.set(this.route(path, slot)); } - /** Record that `entry` refused `path` and re-route the requests watching it. */ - refuse(path: Path.Valid, entry: RouteEntry): void { + /** + * `entry` refused `path` with `err`. A request still serving another source skips the + * refuser; one with nothing serving ends with `err`. + */ + refuse(path: Path.Valid, entry: RouteEntry, err: Error): void { const slot = this.requests.peek()?.get(path); if (!slot) return; - slot.refused.add(entry); - slot.route.set(this.route(path, slot)); + // Only the route the request is waiting on speaks for it; a superseded one's answer is moot. + if (this.bestEntry(path, (candidate) => slot.refused.has(candidate)) !== entry) return; + + const serving = slot.route.peek(); + if (serving && serving.closed.peek() === undefined) { + slot.refused.add(entry); + slot.route.set(this.route(path, slot)); + return; + } + + this.requests.mutate((map) => { + if (map?.get(path) === slot) map.delete(path); + }); + slot.answer?.close(); + slot.answer = undefined; + slot.route.set(undefined); + this.releaseMaterialized(path); + for (const closed of slot.handles) closed.set(err); + slot.handles.clear(); } /** * Recompute every open request covered by `prefix`, after a route was inserted or * removed there: a route covers many paths, so a single-path refresh is not enough. - * Materialized broadcasts whose provider changed are released here too, so a - * retracted route's session subscription closes even when nothing reads it again. + * Every materialized broadcast belongs to an open request, so rerouting them also + * releases a retracted route's session subscription even when nothing reads it again. */ refreshPrefix(prefix: Path.Valid): void { - const requests = this.requests.peek(); - for (const [path, cached] of [...this.materialized]) { - if (!Path.hasPrefix(prefix, path)) continue; - const refused = requests?.get(path)?.refused; - if (cached.entry !== this.bestEntry(path, (entry) => refused?.has(entry) ?? false)) { - this.materialized.delete(path); - cached.front.close(); - } - } - for (const [path, slot] of requests ?? []) { + for (const [path, slot] of this.requests.peek() ?? []) { if (Path.hasPrefix(prefix, path)) slot.route.set(this.route(path, slot)); } } @@ -404,8 +418,9 @@ class OriginState { * the best covering route, or the blind answer. * * Materialization is lazy and cached per path: the first request under a route opens - * the providing session's subscription, repeats share it, and a provider change (the - * route retracting, a better session taking over) swaps it out. + * the providing session's subscription and repeats share it. A better route is made + * before the old one breaks: the current front keeps serving until the new route + * answers (then swaps) or refuses (then is skipped). A retracted route swaps at once. */ route(path: Path.Valid, slot: Pick): broadcast.Consumer | undefined { const entry = this.bestEntry(path, (candidate) => slot.refused.has(candidate)); @@ -416,24 +431,26 @@ class OriginState { return local; } - const cached = this.materialized.get(path); - if (cached && cached.entry === entry) { - if (cached.front.closed.peek() === undefined) return cached.front; + let cached = this.materialized.get(path); + if (cached && cached.front.closed.peek() !== undefined) { this.materialized.delete(path); - } else if (cached) { - this.materialized.delete(path); - cached.front.close(); + cached = undefined; + } + if (cached && cached.entry === entry) return cached.front; + if (!entry?.server) { + this.releaseMaterialized(path); + return slot.answer; } - if (!entry?.server) return slot.answer; const served = entry.server.served.get(path); if (served && served.closed.peek() === undefined) { + cached?.front.close(); this.materialized.set(path, { entry, front: served }); return served; } entry.server.enqueue(path); - return undefined; + return cached?.front; } } @@ -629,7 +646,7 @@ export class Producer implements Table { originated, server, }; - server.onReject = (path) => this.#state.refuse(path, entry); + server.onReject = (path, err) => this.#state.refuse(path, entry, err); let closed = false; this.#state.routes.mutate((routes) => { @@ -830,6 +847,7 @@ let makeRequesting: ( path: Path.Valid, active: Getter, unroutable: Getter, + closed: Once, dispose: Dispose, ) => Requesting; @@ -870,33 +888,43 @@ export class Requesting { * set, and false while a connection is still coming up, so the ordinary page-load window * before the first handshake reads as pending rather than as a missing broadcast. Waiting * on this is futile by definition; wait for an announcement instead, via the origin's - * `announced`. + * `announced`. True once the request is refused. */ readonly unroutable: Getter; + /** + * Settles with the error a route's handler refused the path with, or `null` once you + * {@link close} the request. A refusal is final: no other route is asked, and a fresh + * request is needed to try again. + */ + readonly closed: GetPromise; + #dispose: Dispose; - #closed = false; + #disposed = false; private constructor( path: Path.Valid, active: Getter, unroutable: Getter, + closed: GetPromise, dispose: Dispose, ) { this.path = path; this.active = active; this.unroutable = unroutable; + this.closed = closed; this.#dispose = dispose; } static { - makeRequesting = (path, active, unroutable, dispose) => new Requesting(path, active, unroutable, dispose); + makeRequesting = (path, active, unroutable, closed, dispose) => + new Requesting(path, active, unroutable, closed, dispose); } /** Withdraw the request. The path stays routed for any other open request. Idempotent. */ close(): void { - if (this.#closed) return; - this.#closed = true; + if (this.#disposed) return; + this.#disposed = true; this.#dispose(); } } @@ -1000,7 +1028,14 @@ export class Consumer { const requests = this.#state.requests.peek(); if (!requests) { // Closed origin: a request that can never resolve, and says so. - return makeRequesting(path, new Signal(undefined), getter(true), () => {}); + const closed = new Once(); + return makeRequesting( + path, + new Signal(undefined), + getter(true), + closed, + () => closed.set(null), + ); } let slot = requests.get(path); @@ -1012,8 +1047,8 @@ export class Consumer { // notify nobody. const refused = new Set(); const created: RequestSlot = { - count: 0, blind: 0, + handles: new Set(), refused, route: new Signal(this.#state.route(path, { refused })), }; @@ -1022,7 +1057,8 @@ export class Consumer { map?.set(path, created); }); } - slot.count += 1; + const closed = new Once(); + slot.handles.add(closed); let blind = !options.announced || this.#discovery.peek() === false; if (blind) slot.blind += 1; this.#state.requests.mutate(() => {}); @@ -1075,9 +1111,12 @@ export class Consumer { // Only meaningful while nothing is routed, so it reads the route rather than `active`: // the two cannot disagree, since a routed path always has an answerer-independent // answer. - const unroutable = new Derived([route, this.#state.answerers], (front, answerers) => !front && answerers === 0); + const unroutable = new Derived( + [route, this.#state.answerers, closed], + (front, answerers, ended) => ended !== undefined || (!front && answerers === 0), + ); - return makeRequesting(path, active, unroutable, () => { + return makeRequesting(path, active, unroutable, closed, () => { // Releases this request's handle; the route itself belongs to the table. released = true; unsubscribeDiscovery(); @@ -1086,18 +1125,21 @@ export class Consumer { handle = undefined; source = undefined; - taken.count -= 1; + taken.handles.delete(closed); + if (closed.peek() === undefined) closed.set(null); if (blind) taken.blind -= 1; this.#state.requests.mutate(() => {}); - if (taken.count > 0) return; + if (taken.handles.size > 0) return; // Defer the teardown a microtask: an effect whose rerun was triggered by the // answer resolving closes its old request and takes a new one in the same tick, // and tearing down in between would drop the answer it is about to read. queueMicrotask(() => { - if (taken.count > 0) return; + if (taken.handles.size > 0) return; + // A refused slot already tore itself down, and the path may hold a newer one. + if (this.#state.requests.peek()?.get(path) !== taken) return; this.#state.requests.mutate((map) => { - if (map?.get(path) === taken) map.delete(path); + map?.delete(path); }); taken.answer?.close(); taken.answer = undefined; From ba989f182b5dff75916e9dc11f58975e5167fd12 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:05:32 -0700 Subject: [PATCH 39/40] test(tokio): wait past the Live marker in the max age relay test main's max age test took the first announce event as the publisher's broadcast; on this line an empty initial set yields `Live` first. Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/tests/max_age.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/rs/moq-tokio/tests/max_age.rs b/rs/moq-tokio/tests/max_age.rs index 3503058e75..f9c2fe05a7 100644 --- a/rs/moq-tokio/tests/max_age.rs +++ b/rs/moq-tokio/tests/max_age.rs @@ -95,10 +95,14 @@ async fn relay(version: moq_net::Version, javascript: Option) -> anyhow::R .established() .await?, ); - announced - .next() - .await - .ok_or_else(|| anyhow::anyhow!("announcements closed"))?; + // The `Live` marker can come first while the publisher is still connecting. + while !matches!( + announced + .next() + .await + .ok_or_else(|| anyhow::anyhow!("announcements closed"))?, + moq_net::announce::Event::Announced(_) + ) {} let front = consumer.request_broadcast("age").await?; for (i, age) in AGES.into_iter().enumerate() { let track = front.track(&i.to_string())?.subscribe(None).await?; From 9796b47029cacd9f054305d3b99689d23cfe7196 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:06:49 -0700 Subject: [PATCH 40/40] style: rustfmt the merged moq-auth and moq-cli sources Co-Authored-By: Claude Opus 5.5 --- rs/moq-auth/src/lease.rs | 2 +- rs/moq-cli/src/fetch.rs | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/rs/moq-auth/src/lease.rs b/rs/moq-auth/src/lease.rs index 84bcf5df3c..023ad7a0a5 100644 --- a/rs/moq-auth/src/lease.rs +++ b/rs/moq-auth/src/lease.rs @@ -397,10 +397,10 @@ fn ready_if(condition: bool) -> Poll<()> { #[cfg(test)] mod tests { use super::*; - use std::time::SystemTime; use std::future::Future; use std::pin::pin; use std::task::{Context, Waker}; + use std::time::SystemTime; fn grant(publish: &str) -> Grant { Grant::new([publish.parse().unwrap()].into_iter().collect(), Default::default()) diff --git a/rs/moq-cli/src/fetch.rs b/rs/moq-cli/src/fetch.rs index 86a145cdfa..5d3a6d7e4d 100644 --- a/rs/moq-cli/src/fetch.rs +++ b/rs/moq-cli/src/fetch.rs @@ -63,8 +63,7 @@ async fn fetch( // Scoped to the broadcast, so the relay announces it by name even when a hidden // segment such as `.stats` would keep it out of an unscoped listing. - let pattern = - moq_net::Pattern::subtree(&broadcast).with_context(|| format!("invalid broadcast `{broadcast}`"))?; + let pattern = moq_net::Pattern::subtree(&broadcast).with_context(|| format!("invalid broadcast `{broadcast}`"))?; let origin = moq_tokio::origin::spawn() .scope("", &moq_net::Patterns::from(pattern)) .with_context(|| format!("failed to scope to `{broadcast}`"))?; @@ -170,7 +169,8 @@ mod tests { hidden.announce(Default::default()).expect("announce hidden"); let secret = hidden.create_track("data", None).expect("hidden track"); let mut group = secret.append_group().expect("group"); - group.write_frame(moq_net::Timestamp::ZERO, b"secret".as_ref()) + group + .write_frame(moq_net::Timestamp::ZERO, b"secret".as_ref()) .expect("frame"); group.finish().expect("finish"); @@ -217,7 +217,12 @@ mod tests { Self { connect, http: fixture.http, - _publisher: (connection, vec![broadcast, hidden], vec![data, empty, live, secret], open), + _publisher: ( + connection, + vec![broadcast, hidden], + vec![data, empty, live, secret], + open, + ), } }