From d6dbe9b1ba9b10d26ed7cd504b3518b77a73de0a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 08:10:14 -0700 Subject: [PATCH 01/17] quest: claim m1/announce-live Co-Authored-By: Claude Opus 5.5 From d72f28bb8c56c069c3c153486575183d58ed9cb5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 08:31:32 -0700 Subject: [PATCH 02/17] feat(net): announce consumers report when they have caught up Sessions hold a snapshot on the origin from the moment they request announcements until the peer's initial set is in: ANNOUNCE_OK's count on lite-05+, ANNOUNCE_INIT on lite-01/02, MoQ Active Count on moq-transport, and the request or answer as a best effort elsewhere. An announce consumer is caught up once no snapshot overlapping its scope is in flight and it has read every event queued before the last one landed. A counted set cut short is a protocol violation. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/session.rs | 20 +- rs/moq-net/src/ietf/subscriber.rs | 198 +++++++++++++- rs/moq-net/src/lite/publisher.rs | 36 +++ rs/moq-net/src/lite/subscriber.rs | 226 +++++++++++++++- rs/moq-net/src/model/mod.rs | 4 + rs/moq-net/src/model/origin.rs | 416 +++++++++++++++++++++++++++++- rs/moq-net/tests/caught_up.rs | 122 +++++++++ 7 files changed, 994 insertions(+), 28 deletions(-) create mode 100644 rs/moq-net/tests/caught_up.rs diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index af9a871587a..f353867c34f 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -155,7 +155,17 @@ where // 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(); + // Each holds a snapshot from now, so the origin's announce consumers never read as + // caught up before the request goes out. + let namespaces: Vec<_> = subscribe + .as_ref() + .map(|origin| { + subscribe_prefixes(origin) + .into_iter() + .map(|prefix| (origin.snapshot(&prefix), prefix)) + .collect() + }) + .unwrap_or_default(); let withdrawal = crate::session::Withdrawal::default(); let withdrawing = withdrawal.clone(); @@ -310,7 +320,7 @@ where let mut pub_ns_run = std::pin::pin!(err_only(publisher.clone().run_publish_namespaces())); let mut sub_ns_run = std::pin::pin!(err_only(async { let mut prefixes = futures::stream::FuturesUnordered::new(); - for prefix in namespaces { + for (snapshot, prefix) in namespaces { let mut sub_ns = sub_ns.clone(); let sub_ns_adapter = sub_ns_adapter.clone(); prefixes.push(async move { @@ -325,7 +335,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, snapshot).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) { @@ -521,13 +531,13 @@ where let mut pub_ns_run = std::pin::pin!(err_only(publisher.clone().run_publish_namespaces())); let mut sub_ns_run = std::pin::pin!(err_only(async { let mut prefixes = futures::stream::FuturesUnordered::new(); - for prefix in namespaces { + for (snapshot, prefix) 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, snapshot).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 4f5cbc8b59d..eb262ab9935 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -704,6 +704,14 @@ pub(super) fn subscribe_prefixes(origin: &origin::Producer) -> Vec { crate::model::interest_prefixes(&origin.allowed()) } +/// The initial set of a SUBSCRIBE_NAMESPACE still to read, under MoQ Active Count. +struct Initial { + /// NAMESPACE messages still owed before the first change. + initial: u64, + /// Released once `initial` reaches zero. + snapshot: Option, +} + /// Resolve the subscription a data stream belongs to. /// /// SUBSCRIBE_OK can be reordered behind the stream it describes, so an alias we have not @@ -995,10 +1003,15 @@ where /// A failure here is per-prefix, so the caller decides what it means for the /// session: [`is_protocol_violation`] separates the peer's fault (fatal) from a /// stream of ours that simply died (survivable). + /// + /// `snapshot` holds the origin's announce consumers under the prefix short of + /// caught up. It drops once the NAMESPACE messages MoQ Active Count promised are + /// in, on REQUEST_OK without the extension, or when no request goes out at all. pub async fn run_subscribe_namespace( &mut self, mut stream: Stream, prefix: PathOwned, + snapshot: crate::model::Snapshot, ) -> Result<(), Error> { // Hidden namespaces are requested too, as on moq-lite: the session mirrors the // peer into the origin and each local reader opts in on its own. The parameter @@ -1075,13 +1088,14 @@ where tracing::debug!(%prefix, ?count, "subscribe_namespace ok"); - // MoQ Active Count says how many NAMESPACE messages make up the initial set; - // nothing here needs that boundary, so it is only checked. A count the - // negotiation did not promise, or a missing one it did, is the peer breaking - // the extension. + // MoQ Active Count says how many NAMESPACE messages make up the initial set. A + // count the negotiation did not promise, or a missing one it did, is the peer + // breaking the extension. Without it, the answer is the best boundary there is. if declared.active_count != count.is_some() { return Err(Error::ProtocolViolation); } + let initial = count.unwrap_or(0); + let snapshot = (initial > 0).then_some(snapshot); // The extension changes the NAMESPACE encoding, so we can't parse one until // the peer's SETUP says whether it negotiated. @@ -1097,7 +1111,9 @@ where // // 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, Initial { initial, snapshot }) + .await; for path in live { let _ = self.stop_announce(path, false); } @@ -1115,11 +1131,18 @@ where prefix: &PathOwned, peer: &cluster::Peer, live: &mut std::collections::HashSet, + mut initial: Initial, ) -> Result<(), Error> { loop { + // Every counted NAMESPACE is in the origin by now, dropped ones included. + if initial.initial == 0 { + initial.snapshot = None; + } let type_id = match stream.reader.varint_maybe().await? { Some(id) => id, - None => break, // Stream closed + // A stream ending inside the counted set cut it short. + None if initial.initial > 0 => return Err(Error::ProtocolViolation), + None => break, }; let body: ietf::Body = stream.reader.decode().await?; let mut data = body.decoder(self.version); @@ -1133,6 +1156,7 @@ where if !data.is_empty() { return Err(Error::WrongSize); } + initial.initial = initial.initial.saturating_sub(1); let path = prefix.join(&msg.suffix); let Some(advert) = self.route(msg.cluster.as_ref(), peer) else { // Looped back through us: forwarding it would extend the loop and @@ -1168,6 +1192,8 @@ where } } } + // Only NAMESPACE may come before the counted set is in. + ietf::NamespaceDone::ID if initial.initial > 0 => return Err(Error::ProtocolViolation), ietf::NamespaceDone::ID => { let msg = ietf::NamespaceDone::decode_msg(&mut data, self.version)?; let path = prefix.join(&msg.suffix); @@ -5543,7 +5569,8 @@ mod tests { let prefix = 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, prefix)); + let mut run = + std::pin::pin!(subscriber.run_subscribe_namespace(stream, prefix, crate::model::Snapshot::landed())); // Parks awaiting the peer's response; the request is already on the wire. assert!(futures::poll!(run.as_mut()).is_pending()); @@ -5617,7 +5644,8 @@ mod tests { let prefix = 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, crate::model::Snapshot::landed())); 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 @@ -5684,7 +5712,11 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let res = subscriber - .run_subscribe_namespace(stream, crate::Path::new("").to_owned()) + .run_subscribe_namespace( + stream, + crate::Path::new("").to_owned(), + crate::model::Snapshot::landed(), + ) .await; assert!( matches!(res, Err(Error::ProtocolViolation)), @@ -5693,6 +5725,142 @@ mod tests { } } + /// The peer's REQUEST_OK carrying `active`, then a NAMESPACE per suffix, or a + /// NAMESPACE_DONE for one prefixed with `-`. + async fn counted_response(version: Version, active: Option, entries: &[&str]) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: None, + active, + }) + .await + .unwrap(); + for entry in entries { + match entry.strip_prefix('-') { + Some(suffix) => { + writer.varint(ietf::NamespaceDone::ID).await.unwrap(); + writer + .encode(&ietf::NamespaceDone { + suffix: crate::Path::new(suffix), + }) + .await + .unwrap(); + } + None => { + writer.varint(ietf::Namespace::ID).await.unwrap(); + writer + .encode(&ietf::Namespace { + suffix: crate::Path::new(entry), + cluster: None, + }) + .await + .unwrap(); + } + } + } + let writes = log.writes.lock().unwrap(); + writes.clone() + } + + /// A root SUBSCRIBE_NAMESPACE on a fresh origin whose peer replays `session`, holding + /// the origin's snapshot as the session does. + fn counted_subscriber( + version: Version, + active_count: bool, + session: crate::lite::test_transport::ScriptedSession, + ) -> ( + Subscriber, + crate::origin::Consumer, + crate::model::Snapshot, + ) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let snapshot = origin.snapshot(""); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + active_count, + ..Default::default() + }); + let subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + origin, + Control::new(None, false), + None, + peer_setup, + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ); + (subscriber, consumer, snapshot) + } + + /// With MoQ Active Count, the origin catches up once the counted NAMESPACEs are in. + #[moq_net_sim::test] + async fn counted_namespaces_catch_up() { + const VERSION: Version = Version::Draft18; + for (entries, caught_up) in [(&["a"][..], false), (&["a", "b"][..], true)] { + let session = + crate::lite::test_transport::ScriptedSession::new(counted_response(VERSION, Some(2), entries).await); + let (mut subscriber, consumer, snapshot) = counted_subscriber(VERSION, true, session.clone()); + let mut cursor = consumer.announced(); + 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(), snapshot)); + for _ in 0..10 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + for entry in entries { + cursor.assert_next_active(*entry); + } + assert_eq!(cursor.is_caught_up(), caught_up, "{entries:?}"); + } + } + + /// Without MoQ Active Count, REQUEST_OK is the best boundary there is. + #[moq_net_sim::test] + async fn an_uncounted_answer_catches_up_on_the_ok() { + const VERSION: Version = Version::Draft18; + let session = crate::lite::test_transport::ScriptedSession::new(counted_response(VERSION, None, &[]).await); + let (mut subscriber, consumer, snapshot) = counted_subscriber(VERSION, false, session.clone()); + let cursor = consumer.announced(); + assert!(!cursor.is_caught_up()); + 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(), snapshot)); + for _ in 0..10 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert!(cursor.is_caught_up()); + } + + /// A counted set cut short, by a NAMESPACE_DONE or the stream ending, is the peer's fault. + #[moq_net_sim::test] + async fn a_counted_set_cut_short_is_a_violation() { + const VERSION: Version = Version::Draft18; + for (entries, fin) in [(&["a", "-a"][..], false), (&["a"][..], true)] { + let session = + crate::lite::test_transport::ScriptedSession::new(counted_response(VERSION, Some(2), entries).await); + if fin { + session.close(crate::lite::test_transport::Close::Fin); + } + let (mut subscriber, _consumer, snapshot) = counted_subscriber(VERSION, true, session.clone()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let res = subscriber + .run_subscribe_namespace(stream, crate::Path::new("").to_owned(), snapshot) + .await; + assert!(matches!(res, Err(Error::ProtocolViolation)), "{entries:?}: {res:?}"); + } + } + #[test] fn retiring_old_track_does_not_retire_reused_alias() { let aliases = TrackAliases::default(); @@ -7242,7 +7410,11 @@ 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(), + crate::model::Snapshot::landed(), + ) .await .expect("a clean FIN is not an error"); settle().await; @@ -7318,7 +7490,11 @@ 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 mut run = std::pin::pin!(subscriber.run_subscribe_namespace( + stream, + crate::Path::new("").to_owned(), + crate::model::Snapshot::landed() + )); 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 1ab54f6a7d8..dc82dbf7f0f 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -2588,6 +2588,42 @@ mod announce_test { harness_with(version, None).await } + /// A relay answers with what it holds now: an upstream still delivering its own + /// initial set never delays the ANNOUNCE_OK, so a peer catches up with this relay. + #[moq_net_sim::test] + async fn a_silent_upstream_never_delays_the_answer() { + let origin = Hop::new(1).unwrap().produce(); + let _route = origin.announce("cam", crate::origin::Route::default()).unwrap(); + // An upstream that never answers its announce request. + let _silent = origin.clone().upstream().snapshot(""); + + let log = Log::default(); + let writes = log.writes.clone(); + let consumer = origin.consume(); + assert!( + !consumer.announced().is_caught_up(), + "the relay itself is still catching up" + ); + let mut stream = Stream:: { + writer: Writer::new(SinkSend::new(log), VERSION), + reader: Reader::new(PendingRecv, VERSION), + }; + let _task = moq_net_sim::spawn(async move { + let mut announced = consumer.announced(); + let self_origin = consumer.hop(); + TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, VERSION, None).await + }); + settle().await; + + let mut wire = Wire { + writes, + cursor: 0, + version: VERSION, + }; + assert_eq!(wire.take_ok().active, 1, "answered with the current view"); + assert_eq!(wire.take_announces().len(), 1); + } + /// [`harness_on`], announcing under `auth`'s grant. async fn harness_with(version: Version, auth: Option) -> Harness { let origin = Hop::new(1).unwrap().produce(); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 82536cd993f..0cc09afb8ad 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1285,6 +1285,10 @@ struct AnnouncePrefix { subscriber: Subscriber, prefix: PathOwned, state: PrefixState, + /// Holds the origin's announce consumers under the prefix short of caught up + /// until the peer's initial set is in. Dropped at the boundary the version + /// marks, or with the stream. + snapshot: Option, } enum PrefixState { @@ -1298,6 +1302,8 @@ enum PrefixState { Cost { stream: Stream, responder_origin: Option, + /// Lite05+: the ANNOUNCE_STARTs the publisher counted into its initial set. + initial: u64, }, /// Lite01/02: reading the ANNOUNCE_INIT set. ReadInit { stream: Stream, run: PrefixRun }, @@ -1320,14 +1326,21 @@ struct PrefixRun { decoder: lite::AnnounceDecoder, /// The auth epoch last applied, so a new limit holds back or brings back routes. auth_epoch: u64, + /// Lite05+: the ANNOUNCE_STARTs of the initial set still to read. Anything else + /// before them, or the stream ending, cuts the counted set short. + initial: u64, } impl AnnouncePrefix { fn new(subscriber: Subscriber, prefix: PathOwned) -> Self { + // Taken now, while the session is being set up, so a consumer of the origin never + // reads as caught up before the request even goes out. + let snapshot = Some(subscriber.origin.snapshot(&prefix)); Self { subscriber, prefix, state: PrefixState::Open, + snapshot, } } @@ -1369,20 +1382,29 @@ impl AnnouncePrefix { let PrefixState::Send { stream } = std::mem::replace(&mut self.state, PrefixState::Open) else { unreachable!() }; - self.state = match self.subscriber.version.has_announce_ok() { + let version = self.subscriber.version; + // Lite03/04 mark no initial set, so the request going out is the best + // boundary. Lite01/02 mark it with ANNOUNCE_INIT, newer ones with a count. + if !version.has_announce_ok() && !matches!(version, Version::Lite01 | Version::Lite02) { + self.snapshot = None; + } + self.state = match version.has_announce_ok() { true => PrefixState::ReadOk { stream }, false => PrefixState::Cost { stream, responder_origin: None, + initial: 0, }, }; } 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. + // Its `active` count marks where the initial set ends. let ok = ready!(stream.reader.poll_decode::(&mut cx))?; + if ok.active == 0 { + self.snapshot = None; + } // A peer may legally report id 0 (no identity). Keep it: the assigned // identity stays on `via` and is never forwarded as a hop. let origin = ok.origin; @@ -1392,6 +1414,7 @@ impl AnnouncePrefix { self.state = PrefixState::Cost { stream, responder_origin: Some(origin), + initial: ok.active, }; } PrefixState::Cost { .. } => { @@ -1399,6 +1422,7 @@ impl AnnouncePrefix { let PrefixState::Cost { stream, responder_origin, + initial, } = std::mem::replace(&mut self.state, PrefixState::Open) else { unreachable!() @@ -1410,6 +1434,7 @@ impl AnnouncePrefix { announced: Announced::new(self.subscriber.announces.clone()), decoder: lite::AnnounceDecoder::default(), auth_epoch: 0, + initial, }; // Lite01/02 send the initial set as one ANNOUNCE_INIT message, so they @@ -1441,6 +1466,7 @@ impl AnnouncePrefix { else { unreachable!() }; + self.snapshot = None; self.state = PrefixState::Run { stream, run }; } PrefixState::Run { stream, run } => { @@ -1464,7 +1490,22 @@ impl AnnouncePrefix { loop { match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(announce))) => { + if run.initial > 0 { + match announce { + lite::AnnounceBroadcast::Active { .. } => run.initial -= 1, + // An unknown type is no change, so it neither counts nor cuts. + lite::AnnounceBroadcast::Skipped => {} + _ => return Poll::Ready(Err(Error::ProtocolViolation)), + } + } self.subscriber.handle_announce(&self.prefix, announce, run)?; + // Released only once the last counted route is in the origin. + if run.initial == 0 { + self.snapshot = None; + } + } + Poll::Ready(Ok(None)) if run.initial > 0 => { + return Poll::Ready(Err(Error::ProtocolViolation)); } Poll::Ready(Ok(None)) => { // The publisher FINed: it has nothing (more) to announce for this @@ -4097,6 +4138,185 @@ mod tests { cursor.assert_next_active("b"); } + /// A peer's announce stream for the root, encoded for `version`. + fn announce_script(version: Version, messages: &[&dyn Fn(&mut crate::coding::Encoder<'_>)]) -> Vec { + let mut script = Vec::new(); + for message in messages { + message(&mut crate::coding::Encoder::new(&mut script, version.into())); + } + script + } + + /// An ANNOUNCE_OK counting `active` starts into the initial set. + fn announce_ok(version: Version, active: u64) -> impl Fn(&mut crate::coding::Encoder<'_>) { + move |w| { + lite::AnnounceOk { + origin: crate::Hop::new(9).unwrap(), + active, + } + .encode(w, version) + .unwrap() + } + } + + /// An ANNOUNCE_START for `suffix`. + fn announce_start(version: Version, suffix: &'static str) -> impl Fn(&mut crate::coding::Encoder<'_>) { + move |w| { + lite::AnnounceBroadcast::Active { + epoch: None, + suffix: lite::PathRef::literal(Path::new(suffix)), + hops: lite::HopsRef::literal(crate::Hops::new()), + cost: crate::origin::Cost::default(), + } + .encode(w, version) + .unwrap() + } + } + + /// The announce stream for the root of a fresh origin, its peer replaying `session`. + fn root_prefix( + version: Version, + session: crate::lite::test_transport::ScriptedSession, + ) -> ( + AnnouncePrefix, + origin::Consumer, + ) { + let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::sim(), + session, + origin, + recv_bandwidth: None, + version, + peer_setup: crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand), + cost: Some(1), + peer_hop: None, + going_away: Default::default(), + auth: crate::auth::Handle::new(false), + }); + (AnnouncePrefix::new(subscriber, Path::new("").to_owned()), consumer) + } + + /// The origin's consumers catch up once the counted starts are in and read, never before. + #[moq_net_sim::test] + async fn a_counted_initial_set_catches_up() { + const VERSION: Version = Version::Lite06; + let script = announce_script( + VERSION, + &[ + &announce_ok(VERSION, 2), + &announce_start(VERSION, "a"), + &announce_start(VERSION, "b"), + ], + ); + let (mut prefix, consumer) = root_prefix(VERSION, crate::lite::test_transport::ScriptedSession::new(script)); + let mut cursor = consumer.announced(); + assert!(!cursor.is_caught_up(), "held from the request on"); + + 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"); + assert!(!cursor.is_caught_up(), "never before an earlier event is read"); + cursor.assert_next_active("b"); + assert!(cursor.is_caught_up()); + // A consumer created after the boundary catches up on its replay. + let mut late = consumer.announced(); + late.assert_next_active("a"); + late.assert_next_active("b"); + assert!(late.is_caught_up()); + } + + /// Part of a counted set is not caught up, however much of it was read. + #[moq_net_sim::test] + async fn a_partial_initial_set_holds() { + const VERSION: Version = Version::Lite06; + let script = announce_script(VERSION, &[&announce_ok(VERSION, 2), &announce_start(VERSION, "a")]); + let (mut prefix, consumer) = root_prefix(VERSION, crate::lite::test_transport::ScriptedSession::new(script)); + 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"); + assert!(!cursor.is_caught_up()); + } + + /// A count of zero is caught up on the ANNOUNCE_OK. + #[moq_net_sim::test] + async fn an_empty_initial_set_catches_up_at_once() { + const VERSION: Version = Version::Lite07; + let script = announce_script(VERSION, &[&announce_ok(VERSION, 0)]); + let (mut prefix, consumer) = root_prefix(VERSION, crate::lite::test_transport::ScriptedSession::new(script)); + let cursor = consumer.announced(); + let mut run = std::pin::pin!(kio::wait(|waiter| prefix.poll(waiter))); + assert!(futures::poll!(run.as_mut()).is_pending()); + assert!(cursor.is_caught_up()); + } + + /// Anything but a start inside the counted set cuts it short: a protocol violation. + #[moq_net_sim::test] + async fn an_end_inside_the_initial_set_is_refused() { + const VERSION: Version = Version::Lite06; + let end = + |w: &mut crate::coding::Encoder<'_>| lite::AnnounceBroadcast::EndedId { id: 0 }.encode(w, VERSION).unwrap(); + let script = announce_script( + VERSION, + &[&announce_ok(VERSION, 2), &announce_start(VERSION, "a"), &end], + ); + let (mut prefix, _consumer) = root_prefix(VERSION, crate::lite::test_transport::ScriptedSession::new(script)); + let err = kio::wait(|waiter| prefix.poll_run(waiter)).await.unwrap_err(); + assert!(matches!(err, Error::ProtocolViolation), "{err}"); + } + + /// The stream ending inside the counted set cuts it short too. + #[moq_net_sim::test] + async fn a_fin_inside_the_initial_set_is_refused() { + const VERSION: Version = Version::Lite06; + let script = announce_script(VERSION, &[&announce_ok(VERSION, 2), &announce_start(VERSION, "a")]); + let session = crate::lite::test_transport::ScriptedSession::new(script); + session.close(crate::lite::test_transport::Close::Fin); + let (mut prefix, _consumer) = root_prefix(VERSION, session); + let err = kio::wait(|waiter| prefix.poll_run(waiter)).await.unwrap_err(); + assert!(matches!(err, Error::ProtocolViolation), "{err}"); + } + + /// Lite03/04 mark no initial set: caught up, best effort, once the request is sent. + #[moq_net_sim::test] + async fn an_unmarked_version_catches_up_on_the_request() { + const VERSION: Version = Version::Lite04; + let (mut prefix, consumer) = + root_prefix(VERSION, crate::lite::test_transport::ScriptedSession::new(Vec::new())); + let cursor = consumer.announced(); + assert!(!cursor.is_caught_up()); + let mut run = std::pin::pin!(kio::wait(|waiter| prefix.poll(waiter))); + assert!(futures::poll!(run.as_mut()).is_pending()); + assert!(cursor.is_caught_up()); + } + + /// Lite01/02 mark the initial set with ANNOUNCE_INIT. + #[moq_net_sim::test] + async fn announce_init_is_the_initial_set() { + const VERSION: Version = Version::Lite02; + let init = |w: &mut crate::coding::Encoder<'_>| { + lite::AnnounceInit { + suffixes: vec![Path::new("a")], + } + .encode(w, VERSION) + .unwrap() + }; + let (mut prefix, consumer) = root_prefix( + VERSION, + crate::lite::test_transport::ScriptedSession::new(announce_script(VERSION, &[&init])), + ); + 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()); + assert!(!cursor.is_caught_up()); + cursor.assert_next_active("a"); + assert!(cursor.is_caught_up()); + } + /// An announce past the session's cap fails the session with TOO_MANY_REQUESTS, and /// the stream gives its slots back once the session drops it. #[moq_net_sim::test] diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index eba4e141de1..daa80126e7c 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -67,6 +67,10 @@ pub use origin_impl::{Consume, Hop, Hops, InvalidHop}; #[cfg(feature = "async")] pub(crate) use origin_impl::interest_prefixes; +// Held by a session while its peer's initial announcements arrive. +#[cfg(feature = "async")] +pub(crate) use origin_impl::Snapshot; + // The advertise-only route guard, for tests shaping the route table. #[cfg(all(test, feature = "async"))] 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 89c87a1ae82..8197c5b2428 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -795,9 +795,47 @@ 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, + /// Snapshots in flight that can present on this consumer's cursors, counted + /// once per cursor they touch. See [`AnnounceConsumer::poll_caught_up`]. + waiting: usize, + /// The pending prefixes the consumer still owes reading from before the last + /// snapshot landed. Caught up once empty and nothing is `waiting`. + owed: BTreeSet, } impl OriginConsumerState { + /// Whether the consumer has read everything its sources announced before they + /// caught up. A torn-down origin has no sources left to wait on. + fn caught_up(&self) -> bool { + (self.waiting == 0 || self.ended) && self.owed.is_empty() + } + + /// A snapshot that can present here landed. + fn landed(&mut self) { + self.waiting -= 1; + if self.waiting == 0 { + self.owe(); + } + } + + /// Owe every pending update that changes which prefixes are announced: a start + /// the consumer has not seen, or an end. A re-price or restart of a delivered + /// route leaves the set as it is, and may be held, so it is not owed. + fn owe(&mut self) { + self.owed = self + .pending + .iter() + .filter(|(prefix, update)| { + !self.delivered.contains(*prefix) + || matches!( + update, + PendingUpdate::Unannounce(_) | PendingUpdate::UnannounceAnnounce { .. } + ) + }) + .map(|(prefix, _)| prefix.clone()) + .collect(); + } + fn apply_announce(&mut self, prefix: PathOwned, meta: RouteMeta, captures: Option>) { let meta = (meta, captures); let new = match self.pending.remove(&prefix) { @@ -842,8 +880,10 @@ impl OriginConsumerState { let last = (last, captures); 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) => {} + // one, so the pair cancels entirely, and nothing is owed for it. + Some(PendingUpdate::Announce(_)) if !self.delivered.contains(&prefix) => { + self.owed.remove(&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::Restart(..) | PendingUpdate::Unannounce(_)) => { @@ -924,6 +964,19 @@ impl OriginConsumerState { /// Take the pending update at `prefix` for delivery. fn take_prefix(&mut self, prefix: PathOwned) -> AnnounceEvent { + let event = self.take_update(prefix); + let (AnnounceEvent::Start(announce) + | AnnounceEvent::Update(announce) + | AnnounceEvent::Restart(announce) + | AnnounceEvent::End(announce)) = &event; + // A trailing announce left pending is still owed. + if !self.pending.contains_key(&announce.prefix) { + self.owed.remove(&announce.prefix); + } + event + } + + fn take_update(&mut self, prefix: PathOwned) -> AnnounceEvent { 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. @@ -1147,9 +1200,12 @@ struct Horizon { impl Horizon { /// Whether `entry` may be observed or served through this horizon. fn admits(&self, entry: &RouteEntry) -> bool { - !(self.local && entry.link != Link::Local) - && !(self.upstream && entry.link == Link::Upstream) - && entry.visible_to(self.exclude) + self.admits_link(entry.link) && entry.visible_to(self.exclude) + } + + /// Whether routes arriving on `link` may be observed through this horizon. + fn admits_link(&self, link: Link) -> bool { + !(self.local && link != Link::Local) && !(self.upstream && link == Link::Upstream) } /// This horizon as it applies to the routes covering `path`: an excluded peer @@ -1278,6 +1334,11 @@ impl TableCursor { .map(|(_, captures)| captures) } + /// Whether this cursor waits on `snapshot`: what it delivers could present here. + fn awaits(&self, snapshot: &SnapshotEntry) -> bool { + self.horizon.admits_link(snapshot.link) && self.allowed.overlaps(&snapshot.claim) + } + /// Whether this cursor may observe `entry` at all: advertised, not behind /// the excluded peer (split horizon), within the cursor's patterns, and /// nameable through its mount. @@ -1908,6 +1969,37 @@ impl Producer { ) } + /// Start a [`Snapshot`] under `prefix`: the announce consumers it overlaps are not + /// caught up until the returned guard drops. Taken before the request goes out, so + /// nothing reads as caught up in between. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn snapshot(&self, prefix: impl AsPath) -> Snapshot { + let prefix = self.root.join(prefix.as_path()).to_owned(); + // A prefix past the depth limit can carry no route, so nothing waits on it. + let Ok(claim) = prefix_claim(&prefix) else { + return Snapshot::landed(); + }; + let mut state = self.shared.lock(); + if state.closed { + return Snapshot::landed(); + } + let id = state.next_snapshot; + state.next_snapshot += 1; + state.add_snapshot( + &prefix, + SnapshotEntry { + id, + link: self.link, + claim, + }, + ); + Snapshot { + shared: Some(self.shared.clone()), + prefix, + 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 @@ -3432,6 +3524,11 @@ struct RouteNode { /// Watches at this node or below, so a route change walks only the subtrees /// holding one. watches_below: usize, + /// Snapshots in flight at exactly this prefix; see [`Snapshot`]. + snapshots: Vec, + /// Snapshots at this node or below, so a cursor registering walks only the + /// subtrees holding one. + snapshots_below: usize, children: HashMap, } @@ -3587,23 +3684,85 @@ impl Drop for Watch { } } +/// A snapshot in flight, as the route table holds it. +struct SnapshotEntry { + id: u64, + /// The link its routes arrive on, so a horizon hiding them never waits on it. + link: Link, + /// The prefix's [`prefix_claim`], so a cursor whose patterns miss it never waits on it. + claim: Pattern, +} + +/// A session still delivering its peer's initial announcements under a prefix. +/// +/// Every announce consumer whose scope the prefix overlaps is short of caught up +/// (see [`AnnounceConsumer::poll_caught_up`]) until this drops: once the session +/// has inserted the announcements its peer counted, or it gave up on them. +pub(crate) struct Snapshot { + shared: Option>, + prefix: PathOwned, + id: u64, +} + +impl Snapshot { + /// A snapshot nothing waits on. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn landed() -> Self { + Self { + shared: None, + prefix: PathOwned::default(), + id: 0, + } + } +} + +impl Drop for Snapshot { + fn drop(&mut self) { + if let Some(shared) = self.shared.take() { + shared.lock().remove_snapshot(&self.prefix, self.id); + } + } +} + /// What a registration adds to the subtree counts on its walk. #[derive(Clone, Copy)] struct Below { cursors: usize, watches: usize, + snapshots: usize, } impl Below { - const NONE: Self = Self { cursors: 0, watches: 0 }; - const CURSOR: Self = Self { cursors: 1, watches: 0 }; - const WATCH: Self = Self { cursors: 0, watches: 1 }; + const NONE: Self = Self { + cursors: 0, + watches: 0, + snapshots: 0, + }; + const CURSOR: Self = Self { + cursors: 1, + watches: 0, + snapshots: 0, + }; + const WATCH: Self = Self { + cursors: 0, + watches: 1, + snapshots: 0, + }; + const SNAPSHOT: Self = Self { + cursors: 0, + watches: 0, + snapshots: 1, + }; } impl RouteNode { /// Nothing here and nothing below: the node can be pruned. fn is_empty(&self) -> bool { - self.entries.is_empty() && self.cursors.is_empty() && self.watches.is_empty() && self.children.is_empty() + self.entries.is_empty() + && self.cursors.is_empty() + && self.watches.is_empty() + && self.snapshots.is_empty() + && self.children.is_empty() } /// The node `parts` below this one, if the table has it. @@ -3619,6 +3778,7 @@ impl RouteNode { fn reach<'a>(&mut self, mut parts: impl Iterator, below: Below) -> &mut Self { self.cursors_below += below.cursors; self.watches_below += below.watches; + self.snapshots_below += below.snapshots; match parts.next() { None => self, Some(part) => self.children.entry(part.to_string()).or_default().reach(parts, below), @@ -3647,6 +3807,7 @@ impl RouteNode { }; self.cursors_below -= below.cursors; self.watches_below -= below.watches; + self.snapshots_below -= below.snapshots; Some(result) } @@ -3706,6 +3867,17 @@ impl RouteNode { } } + /// Collect the snapshots at this node and below, skipping subtrees with none. + fn collect_snapshots<'a>(&'a self, out: &mut Vec<&'a SnapshotEntry>) { + if self.snapshots_below == 0 { + return; + } + out.extend(&self.snapshots); + for child in self.children.values() { + child.collect_snapshots(out); + } + } + /// Collect the cursors at this node and below, skipping subtrees with none. fn collect_cursors(&self, out: &mut Vec) { if self.cursors_below == 0 { @@ -3914,6 +4086,7 @@ struct OriginState { // read. A join takes a value of its own, so a start before it reads older. next_route: u64, next_watch: u64, + next_snapshot: u64, // The registered announce cursors, each with its own coalescing buffer. Each // also hangs in the route table at its heads, which is how an announcement @@ -3943,6 +4116,7 @@ impl OriginState { routes: RouteTable::default(), next_route: 0, next_watch: 0, + next_snapshot: 0, cursors: HashMap::new(), fronts: WeakCache::default(), withdrawn: HashMap::new(), @@ -4142,9 +4316,64 @@ impl OriginState { for head in &cursor.heads { self.routes.add_cursor(head, id); } + + // The snapshots in flight it can see sit where its routes would, each counted once. + let mut snapshots = Vec::new(); + for head in &cursor.heads { + let (above, at) = self.routes.split(head); + snapshots.extend(above.into_iter().flat_map(|node| node.snapshots.iter())); + if let Some(node) = at { + node.collect_snapshots(&mut snapshots); + } + } + snapshots.retain(|snapshot| cursor.awaits(snapshot)); + snapshots.sort_unstable_by_key(|snapshot| snapshot.id); + snapshots.dedup_by_key(|snapshot| snapshot.id); + if !snapshots.is_empty() + && let Ok(mut state) = cursor.state.write() + { + state.waiting += snapshots.len(); + } + self.cursors.insert(id, cursor); } + /// Hold every cursor `snapshot` can present on short of caught up until it lands. + fn add_snapshot(&mut self, prefix: &Path, snapshot: SnapshotEntry) { + for id in self.routes.cursors_touching(prefix) { + if let Some(cursor) = self.cursors.get(&id) + && cursor.awaits(&snapshot) + && let Ok(mut state) = cursor.state.write() + { + state.waiting += 1; + } + } + self.routes + .root + .reach(prefix.parts(), Below::SNAPSHOT) + .snapshots + .push(snapshot); + } + + /// The snapshot `id` at `prefix` landed: release the cursors it held. + fn remove_snapshot(&mut self, prefix: &Path, id: u64) { + let removed = self.routes.root.edit(prefix.parts(), Below::SNAPSHOT, |node| { + let index = node.snapshots.iter().position(|snapshot| snapshot.id == id)?; + Some(node.snapshots.swap_remove(index)) + }); + let Some(Some(snapshot)) = removed else { + return; + }; + for id in self.routes.cursors_touching(prefix) { + if let Some(cursor) = self.cursors.get(&id) + && cursor.awaits(&snapshot) + && let Ok(mut state) = cursor.state.write() + { + state.landed(); + } + } + } + /// The best served route covering `path` (absolute) for a requester seeing /// `horizon`, skipping the `excluded` entry ids. /// @@ -5435,6 +5664,12 @@ impl AnnounceConsumer { ids.push(id); } } + // Under the table lock, so no snapshot lands between the count and this. + if let Ok(mut state) = state.write() + && state.waiting == 0 + { + state.owe(); + } } Self { @@ -5645,6 +5880,42 @@ impl AnnounceConsumer { state.is_closed() || state.ended } + /// Whether this consumer has caught up; see [`Self::poll_caught_up`]. + pub fn is_caught_up(&self) -> bool { + let state = self.state.read(); + state.is_closed() || state.caught_up() + } + + /// Wait until this consumer has caught up with what its sources announced. + /// + /// Caught up means every session feeding this scope has delivered its peer's + /// announcements as of when it answered, and this consumer has read every event + /// queued before then. A prefix not announced by then is not announced, rather + /// than not heard yet. A session joining the scope later, such as a reconnect, + /// takes this back until it catches up too; routes published on this origin + /// itself never wait. + /// + /// Caught up is relative to the session's peer, never global: a relay answers with + /// what it holds without waiting on its own upstreams. Versions that cannot mark + /// the boundary (moq-lite-03/04, and moq-transport without MoQ Active Count) catch + /// up once the peer accepts the request, or once it is sent when nothing answers. + /// + /// Level-triggered: ready whenever caught up, and once the cursor is closed. + pub fn poll_caught_up(&self, waiter: &kio::Waiter) -> Poll<()> { + self.state + .poll(waiter, |state| match state.caught_up() { + true => Poll::Ready(()), + false => Poll::Pending, + }) + .map(|_| ()) + } + + /// Wait until this consumer has caught up; see [`Self::poll_caught_up`]. + #[cfg(feature = "async")] + pub async fn caught_up(&self) { + kio::wait(|waiter| self.poll_caught_up(waiter)).await + } + /// Returns the root that is automatically stripped from emitted prefixes. pub fn root(&self) -> &Path<'_> { &self.root @@ -5934,6 +6205,133 @@ mod tests { announced.assert_next_wait(); } + /// A consumer is caught up once a snapshot lands and it has read what came before. + #[test] + fn caught_up_after_the_snapshot_and_its_events() { + let producer = origin(1).produce(); + let snapshot = producer.snapshot("room"); + let mut announced = producer.consume().announced(); + assert!(!announced.is_caught_up(), "a snapshot in flight holds it"); + + let _alice = producer.announce("room/alice", Route::default()).unwrap(); + drop(snapshot); + assert!(!announced.is_caught_up(), "never before an earlier event is read"); + + announced.assert_next_active("room/alice"); + assert!(announced.is_caught_up()); + + // A later announcement is news, not part of what it caught up with. + let _bob = producer.announce("room/bob", Route::default()).unwrap(); + assert!(announced.is_caught_up()); + } + + /// Without any session feeding it, a consumer catches up on its own replay. + #[test] + fn a_local_origin_catches_up_on_its_replay() { + let producer = origin(1).produce(); + let mut empty = producer.consume().announced(); + assert!(empty.is_caught_up(), "nothing to wait on"); + empty.assert_next_wait(); + + let _alice = producer.announce("room/alice", Route::default()).unwrap(); + let mut announced = producer.consume().announced(); + assert!(!announced.is_caught_up(), "the replay is owed"); + announced.assert_next_active("room/alice"); + assert!(announced.is_caught_up()); + } + + /// A start and end that cancel before being read are owed nothing. + #[test] + fn a_cancelled_start_still_catches_up() { + let producer = origin(1).produce(); + let snapshot = producer.snapshot(""); + let mut announced = producer.consume().announced(); + + let alice = producer.announce("room/alice", Route::default()).unwrap(); + drop(snapshot); + assert!(!announced.is_caught_up()); + drop(alice); + assert!(announced.is_caught_up(), "the pair cancelled"); + announced.assert_next_wait(); + } + + /// A re-price of a route already read is not owed: the set of prefixes is unchanged. + #[test] + fn an_update_is_not_owed() { + let producer = origin(1).produce(); + let mut announced = producer.consume().announced(); + let alice = producer.announce("room/alice", Route::default()).unwrap(); + announced.assert_next_active("room/alice"); + + let snapshot = producer.snapshot(""); + alice.update(Route::default().with_cost(5)).unwrap(); + drop(snapshot); + assert!(announced.is_caught_up()); + } + + /// A snapshot taken after catching up (a reconnect) holds it again until it lands. + #[test] + fn a_new_snapshot_takes_caught_up_back() { + let producer = origin(1).produce(); + let announced = producer.consume().announced(); + assert!(announced.is_caught_up()); + + let snapshot = producer.snapshot("room/alice"); + assert!(!announced.is_caught_up(), "a snapshot beneath the scope holds it"); + let second = producer.snapshot(""); + drop(snapshot); + assert!(!announced.is_caught_up(), "every current snapshot must land"); + drop(second); + assert!(announced.is_caught_up()); + } + + /// Only snapshots that could present on a consumer hold it. + #[test] + fn only_overlapping_snapshots_hold() { + let producer = origin(1).produce(); + let room = producer + .consume() + .scope("room", &Patterns::from(Pattern::all())) + .unwrap(); + + let other = producer.snapshot("other"); + assert!(room.announced().is_caught_up(), "a disjoint prefix"); + + let above = producer.snapshot(""); + let announced = room.announced(); + assert!(!announced.is_caught_up(), "a snapshot above the root covers it"); + drop(above); + assert!(announced.is_caught_up()); + + drop(other); + + // A local view never waits on a peer's routes. + let _peer = producer.clone().peer().snapshot("room"); + assert!(producer.consume().local().announced().is_caught_up()); + assert!(!producer.consume().announced().is_caught_up()); + } + + /// A waiter parked on catching up is woken when it does. + #[test] + fn catching_up_wakes_the_waiter() { + struct Woken(AtomicBool); + impl Wake for Woken { + fn wake(self: Arc) { + self.0.store(true, Ordering::SeqCst); + } + } + + let producer = origin(1).produce(); + let snapshot = producer.snapshot(""); + let announced = producer.consume().announced(); + let woken = Arc::new(Woken(AtomicBool::new(false))); + let waiter = kio::Waiter::new(std::task::Waker::from(woken.clone())); + assert!(announced.poll_caught_up(&waiter).is_pending()); + drop(snapshot); + assert!(woken.0.load(Ordering::SeqCst), "not woken"); + assert!(announced.poll_caught_up(&waiter).is_ready()); + } + /// A `.`-prefixed segment below the requested prefix hides a route from /// discovery unless the reader opts in; one inside the prefix does not. #[test] diff --git a/rs/moq-net/tests/caught_up.rs b/rs/moq-net/tests/caught_up.rs new file mode 100644 index 00000000000..47a64bd08cc --- /dev/null +++ b/rs/moq-net/tests/caught_up.rs @@ -0,0 +1,122 @@ +#![cfg(feature = "async")] + +//! A subscriber's announce consumers catch up once the session has delivered its +//! peer's initial announcements: exactly on versions that mark the boundary, and +//! on the answer, as a best effort, on the rest. + +mod support; + +use std::collections::BTreeSet; +use std::time::Duration; + +use moq_net::{Hop, Version, announce, origin}; +use support::harness::{MockConnectOptions, connect_mock}; + +/// Long enough, in virtual time, for anything in flight to reach the far side. +const SETTLE: Duration = Duration::from_secs(1); + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + support::harness::spawn(driver); + producer +} + +/// Read events until the consumer catches up, returning the prefixes then announced. +async fn read_until_caught_up(announced: &mut announce::Consumer) -> BTreeSet { + let mut seen = BTreeSet::new(); + while !announced.is_caught_up() { + let event = moq_net_sim::timeout(SETTLE, announced.next()) + .await + .expect("caught up in time") + .expect("the cursor stays open"); + match event { + announce::Event::Start(announce) | announce::Event::Restart(announce) => { + seen.insert(announce.prefix.to_string()); + } + announce::Event::End(announce) => { + seen.remove(announce.prefix.as_str()); + } + announce::Event::Update(_) => {} + } + } + seen +} + +/// Connect a subscriber to a publisher holding two broadcasts, and return what its +/// origin's consumer had read when it caught up. +async fn caught_up(version: &str) -> BTreeSet { + let publisher = produce_origin(1); + let a = publisher.create_broadcast("a").unwrap(); + a.announce(Default::default()).unwrap(); + let b = publisher.create_broadcast("b").unwrap(); + b.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 _pair = connect_mock(options).await; + + let mut announced = subscriber.consume().announced(); + read_until_caught_up(&mut announced).await +} + +fn both() -> BTreeSet { + ["a", "b"].into_iter().map(String::from).collect() +} + +/// Every version that marks the boundary catches up with the whole initial set. +#[moq_net_sim::test] +async fn an_exact_boundary_catches_up_with_the_initial_set() { + for version in [ + "moq-lite-05", + "moq-lite-06", + "moq-lite-07-wip", + "moq-transport-16", + "moq-transport-17", + "moq-transport-18", + "moq-transport-19", + "moq-transport-20", + "moq-transport-21", + "moq-transport-22", + ] { + assert_eq!(caught_up(version).await, both(), "{version}"); + } +} + +/// Versions without a boundary still catch up, so nothing waiting on it hangs. +#[moq_net_sim::test] +async fn a_best_effort_boundary_still_catches_up() { + for version in ["moq-lite-03", "moq-lite-04", "moq-transport-14", "moq-transport-15"] { + caught_up(version).await; + } +} + +/// A second session into the same origin, as a reconnect makes, catches up too: the +/// consumer waits on every current session. +#[moq_net_sim::test] +async fn a_second_session_catches_up() { + let publisher = produce_origin(1); + let a = publisher.create_broadcast("a").unwrap(); + a.announce(Default::default()).unwrap(); + + let subscriber = produce_origin(2); + let connect = || { + let mut options = MockConnectOptions::new("moq-lite-07-wip".parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(subscriber.clone()); + connect_mock(options) + }; + let first = connect().await; + let mut announced = subscriber.consume().announced(); + assert_eq!( + read_until_caught_up(&mut announced).await, + ["a"].into_iter().map(String::from).collect() + ); + + let second = connect().await; + drop(first); + moq_net_sim::sleep(SETTLE).await; + read_until_caught_up(&mut announced).await; + drop(second); +} From 0648ab43e9b34833f50bc08f6b4159d6fcc7680b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 08:33:18 -0700 Subject: [PATCH 03/17] bench(net): sweep caught-up tracking over announce streams and consumers Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/Cargo.toml | 6 +++ rs/moq-net/benches/caught_up.rs | 90 +++++++++++++++++++++++++++++++++ rs/moq-net/src/fuzz.rs | 9 ++++ 3 files changed, 105 insertions(+) create mode 100644 rs/moq-net/benches/caught_up.rs diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index f39304639e7..efb0d63a9de 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -98,6 +98,12 @@ name = "announce" harness = false required-features = ["fuzz"] +# Reaches the crate-private session snapshot through the hidden `fuzz` module. +[[bench]] +name = "caught_up" +harness = false +required-features = ["fuzz"] + # Reaches the private message codecs through the hidden `fuzz` module. [[bench]] name = "codec" diff --git a/rs/moq-net/benches/caught_up.rs b/rs/moq-net/benches/caught_up.rs new file mode 100644 index 00000000000..0613c0f9710 --- /dev/null +++ b/rs/moq-net/benches/caught_up.rs @@ -0,0 +1,90 @@ +//! What it costs an origin to track whether its announce consumers have caught up. +//! +//! Every session requesting announcements holds a snapshot on the origin until its +//! peer's initial set is in, and the announce consumers its prefix overlaps wait on +//! it. Both shapes are swept over the snapshots in flight (announce streams) and the +//! consumers watching elsewhere, so a cost that grows with the table rather than the +//! touched path shows up as a slope. +//! +//! - land: a snapshot starts and lands under one prefix, releasing its one consumer. +//! - register: a consumer of one prefix registers, counting the snapshots it waits on. +//! +//! The snapshot is crate-private, so this reaches it through the hidden `fuzz` module. +//! +//! Run with `cargo bench -p moq-net --features fuzz --bench caught_up`. + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use moq_net::{Hop, Pattern, Patterns, announce, fuzz, origin}; + +/// `(streams, consumers)` shapes. +const SHAPES: [(usize, usize); 4] = [(10, 10), (1_000, 10), (10, 1_000), (1_000, 1_000)]; + +/// An origin with `streams` snapshots in flight under `room/` and `consumers` cursors +/// on prefixes of their own under `other/`. The handles are held for the whole run. +struct Table { + producer: origin::Producer, + _driver: origin::Driver, + _streams: Vec, + _consumers: Vec, +} + +fn table(streams: usize, consumers: usize) -> Table { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(1).unwrap())); + let streams = (0..streams) + .map(|i| fuzz::snapshot(&producer, &format!("room/{i}"))) + .collect(); + let consumers = (0..consumers) + .map(|i| scoped(&producer, &format!("other/{i}")).announced()) + .collect(); + Table { + producer, + _driver: driver, + _streams: streams, + _consumers: consumers, + } +} + +fn scoped(producer: &origin::Producer, prefix: &str) -> origin::Consumer { + producer + .consume() + .scope(prefix, &Patterns::from(Pattern::all())) + .unwrap() +} + +fn bench_land(c: &mut Criterion) { + let mut group = c.benchmark_group("caught_up/land"); + for (streams, consumers) in SHAPES { + let id = BenchmarkId::from_parameter(format!("{streams}st_{consumers}c")); + group.bench_function(id, |b| { + let table = table(streams, consumers); + let watcher = scoped(&table.producer, "room/incoming").announced(); + b.iter(|| { + let snapshot = fuzz::snapshot(&table.producer, "room/incoming"); + assert!(!watcher.is_caught_up()); + drop(snapshot); + assert!(watcher.is_caught_up()); + }); + }); + } + group.finish(); +} + +fn bench_register(c: &mut Criterion) { + let mut group = c.benchmark_group("caught_up/register"); + for (streams, consumers) in SHAPES { + let id = BenchmarkId::from_parameter(format!("{streams}st_{consumers}c")); + group.bench_function(id, |b| { + let table = table(streams, consumers); + let incoming = scoped(&table.producer, "room/incoming"); + let _held = fuzz::snapshot(&table.producer, "room/incoming"); + b.iter(|| { + let watcher = incoming.announced(); + assert!(!watcher.is_caught_up()); + }); + }); + } + group.finish(); +} + +criterion_group!(benches, bench_land, bench_register); +criterion_main!(benches); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index ced17bfc30a..85b0c9de221 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -268,6 +268,15 @@ pub fn excluding(origin: crate::origin::Consumer, peer: crate::Hop) -> crate::or origin.excluding(peer) } +/// A session's snapshot of its peer's initial announcements, for the caught-up bench. +/// Drop it to land the snapshot. +pub struct Snapshot(#[allow(dead_code)] crate::model::Snapshot); + +/// Start a snapshot under `prefix`, as a session requesting announcements there does. +pub fn snapshot(origin: &crate::origin::Producer, prefix: &str) -> Snapshot { + Snapshot(origin.snapshot(prefix)) +} + /// Add `n` publisher-side payload bytes on `(path, tier)`, creating the entry /// when the path is tracked. The stats producer bench marks a share of held /// paths changed this way, without a media frame. A later [`crate::stats::Registry::report`] From f30543f9f3921872df14b51cba7573cadca33d2f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 08:50:45 -0700 Subject: [PATCH 04/17] feat(js): announce consumers report when they have caught up announce.Consumer.caughtUp flips once the source's initial announcements are read: counted on lite-05+ (ANNOUNCE_OK active) and on moq-transport with MoQ Active Count, which JS now declares, counts as a publisher, and validates. A counted set cut short is a protocol violation. Older versions catch up on the answer or the request, as a best effort. An origin stream waits on every session still delivering an overlapping initial set, and a Reload holds it back while no session is established, so a reconnect takes caught up back until the new set lands. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 10 +++ js/net/src/announced.test.ts | 115 ++++++++++++++++++++++++++ js/net/src/announced.ts | 67 ++++++++++++++- js/net/src/connection/accept.ts | 7 +- js/net/src/connection/connect.ts | 5 +- js/net/src/connection/forward.test.ts | 44 ++++++++++ js/net/src/connection/forward.ts | 11 +++ js/net/src/connection/handshake.ts | 5 ++ js/net/src/connection/reload.test.ts | 62 ++++++++++++++ js/net/src/connection/reload.ts | 18 +++- js/net/src/ietf/active_count.ts | 38 +++++++++ js/net/src/ietf/connection.ts | 5 ++ js/net/src/ietf/index.ts | 1 + js/net/src/ietf/parameters.ts | 15 ++++ js/net/src/ietf/publisher.test.ts | 58 ++++++++++++- js/net/src/ietf/publisher.ts | 53 +++++++++--- js/net/src/ietf/subscriber.test.ts | 105 +++++++++++++++++++++++ js/net/src/ietf/subscriber.ts | 47 +++++++++-- js/net/src/lite/subscriber.test.ts | 89 ++++++++++++++++++++ js/net/src/lite/subscriber.ts | 18 ++++ js/net/src/origin.ts | 39 ++++++++- js/net/src/wire.ts | 5 ++ 22 files changed, 792 insertions(+), 25 deletions(-) create mode 100644 js/net/src/ietf/active_count.ts diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 0810b45e59b..89d03934997 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -37,6 +37,16 @@ how a conference room learns who joined, how a player learns a stream came online without polling, and how [relay clusters](/bin/relay/cluster) discover each other. +Once that first reply is read, the reader has **caught up**: a broadcast not +announced by then is absent rather than late, so a player can say "offline" +instead of waiting. A relay replies from what it holds now and never waits on +its own upstreams. moq-lite 05 and later, and moq-transport with the +[active-count](/draft/moq-active-count) extension, mark exactly where the first +reply ends. Older versions catch up on the peer's answer, or as soon as the +request is sent, as a best effort. Reading an origin fed by sessions, a reader +has caught up once every current session's first reply is in, so a reconnect +takes it back until the new reply lands. + An announcement is a **route**: a claim that broadcasts at a path prefix, and every path beneath it, can be served. By convention a publisher announces each broadcast's exact path, so subscribers enumerate broadcasts by listing routes. diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 2142abff528..675de6eb613 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -128,3 +128,118 @@ test("the local route wins over a blind request on a no-discovery origin", async broadcast.close(); origin.close(); }); + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const start = (prefix: string): Announce.Event => ({ + prefix: p(prefix), + captures: undefined, + kind: "start", + route: Route.default, +}); + +test("a stream catches up once the reader is past what was queued at the boundary", async () => { + const producer = new Announce.Producer(); + const consumer = producer.consume(); + producer.append(start("a")); + producer.append(start("b")); + expect(consumer.caughtUp.peek()).toBe(false); + + producer.caughtUp(); + // Appended after the boundary, so not owed. + producer.append(start("c")); + expect(consumer.caughtUp.peek()).toBe(false); + + await consumer.next(); + await consumer.next(); + // Still handling b. + expect(consumer.caughtUp.peek()).toBe(false); + expect(await consumer.next()).toMatchObject({ prefix: p("c") }); + expect(consumer.caughtUp.peek()).toBe(true); +}); + +test("an event taken before the boundary is handled before catching up", async () => { + const producer = new Announce.Producer(); + const consumer = producer.consume(); + producer.append(start("a")); + await consumer.next(); + producer.caughtUp(); + expect(consumer.caughtUp.peek()).toBe(false); + + void consumer.next(); + expect(consumer.caughtUp.peek()).toBe(true); + producer.close(); +}); + +test("behind takes caught up back, and closing hands it back for good", () => { + const producer = new Announce.Producer(); + const consumer = producer.consume(); + producer.caughtUp(); + expect(consumer.caughtUp.peek()).toBe(true); + producer.behind(); + expect(consumer.caughtUp.peek()).toBe(false); + producer.close(); + expect(consumer.caughtUp.peek()).toBe(true); + producer.behind(); + expect(consumer.caughtUp.peek()).toBe(true); +}); + +test("an origin with no session delivering catches up on its own replay", async () => { + const origin = new OriginProducer(); + const room = publish(origin, p("room")); + const announced = origin.consume().announced(); + expect(await announced.next()).toMatchObject({ prefix: p("room"), kind: "start" }); + void announced.next(); + await tick(); + expect(announced.caughtUp.peek()).toBe(true); + room.close(); + announced.close(); +}); + +test("an origin stream waits on every overlapping initial set in flight", async () => { + const origin = new OriginProducer(); + const wire = wireOf(origin); + const room = wire.snapshot(p("room")); + const other = wire.snapshot(p("other")); + + const scoped = origin.consume().announced(Path.Pattern.subtree(p("room"))); + const all = origin.consume().announced(); + await tick(); + expect(scoped.caughtUp.peek()).toBe(false); + expect(all.caughtUp.peek()).toBe(false); + + other(); + await tick(); + // Still held by the room snapshot; the other one never held the scoped stream. + expect(scoped.caughtUp.peek()).toBe(false); + expect(all.caughtUp.peek()).toBe(false); + + // The initial set lands, and so does its snapshot. + const route = wire.receive(p("room/cam")); + room(); + expect(await scoped.next()).toMatchObject({ prefix: p("room/cam"), kind: "start" }); + void scoped.next(); + await tick(); + expect(scoped.caughtUp.peek()).toBe(true); + + // A new session, as a reconnect makes, takes it back until its own set lands. + const second = wire.snapshot(p("")); + await tick(); + expect(scoped.caughtUp.peek()).toBe(false); + second(); + await tick(); + expect(scoped.caughtUp.peek()).toBe(true); + + route.close(); + scoped.close(); + all.close(); +}); + +test("a snapshot elsewhere never holds a scoped stream", async () => { + const origin = new OriginProducer(); + const other = wireOf(origin).snapshot(p("other")); + const scoped = origin.consume().announced(Path.Pattern.subtree(p("room"))); + await tick(); + expect(scoped.caughtUp.peek()).toBe(true); + other(); + scoped.close(); +}); diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index f85d83911d1..2a68bd7a371 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -3,7 +3,7 @@ * * @module */ -import { type GetPromise, Once, Signal } from "@moq/signals"; +import { type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import type { Route } from "./hop.js"; import type * as Path from "./path.js"; @@ -61,6 +61,18 @@ export interface Options { class AnnounceState { queue = new Signal([]); closed = new Once(); + caughtUp = new Signal(false); + // The queued events still to read before the stream counts as caught up, or undefined + // while the initial set is still arriving. + owed: number | undefined; + // A reader holds the last event it took before catching up until it asks for the next + // one, so whatever it does with that event lands before caught up flips. + reading = false; +} + +// Caught up once nothing is owed and the reader has come back for more. +function settle(state: AnnounceState) { + if (state.owed === 0 && !state.reading) state.caughtUp.set(true); } // Once.set throws on a second settle, and both ends of a stream can close independently. @@ -70,6 +82,10 @@ function closeState(state: AnnounceState, abort?: Error) { state.queue.mutate((queue) => { queue.length = 0; }); + // Nothing more is coming, so nothing waiting on the boundary should hang. + state.owed = 0; + state.reading = false; + state.caughtUp.set(true); } /** @@ -101,6 +117,31 @@ export class Producer { }); } + /** + * Marks the initial set as delivered: the stream catches up once the reader has taken + * everything queued so far. A no-op until {@link behind} once marked. + * + * @internal + */ + caughtUp() { + const state = this.#state; + if (state.owed !== undefined) return; + state.owed = state.queue.peek().length; + settle(state); + } + + /** + * Takes caught up back until the next {@link caughtUp}, as when another initial set starts. + * + * @internal + */ + behind() { + const state = this.#state; + if (state.closed.peek() !== undefined) return; + state.owed = undefined; + state.caughtUp.set(false); + } + /** Closes the writer. Idempotent. */ close(abort?: Error) { closeState(this.#state, abort); @@ -135,6 +176,19 @@ export class Consumer { makeConsumer = (state) => new Consumer(state); } + /** + * Whether the stream has delivered its source's initial announcements and the reader has + * taken them, so a prefix not announced by now is absent rather than not yet heard of. + * + * Flips once the reader asks for the event after the last of them, and is true once + * closed. Through a session, the initial set is the peer's view when it answered: exact + * on versions that count it, best-effort on the rest. Reading an origin, it is every + * attached session's initial set, so a reconnect takes it back until the new one lands. + */ + get caughtUp(): Getter { + return this.#state.caughtUp; + } + /** The events as they arrive, until the stream closes. */ async *[Symbol.asyncIterator](): AsyncGenerator { for (;;) { @@ -146,9 +200,16 @@ export class Consumer { /** Returns the next event, or undefined once the stream closes. */ async next(): Promise { + const state = this.#state; + state.reading = false; + settle(state); for (;;) { - const announce = this.#state.queue.peek().shift(); - if (announce) return announce; + const announce = state.queue.peek().shift(); + if (announce) { + if (state.owed) state.owed -= 1; + if (!state.caughtUp.peek()) state.reading = true; + return announce; + } const closed = this.#state.closed.peek(); if (closed instanceof Error) throw closed; diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 3cecd832bdb..ab89346fc6a 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -118,7 +118,7 @@ async function acceptAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup( + const { control, early, solicit, hidden, activeCount, cluster, auth } = await exchangeSetup( transport, version, "moq-lite-js", @@ -134,6 +134,7 @@ async function acceptAlpn( early, solicit, hidden, + activeCount, cluster, auth, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. @@ -171,6 +172,7 @@ async function acceptSetup( params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Extensions.intoSetup(params, wiring.extensions, version); Ietf.hiddenIntoSetup(params); + Ietf.activeCountIntoSetup(params, version); const server = new Ietf.ServerSetup({ version, parameters: params }); await server.encode(stream.writer, version); @@ -187,6 +189,7 @@ async function acceptSetup( version, solicit: Ietf.solicitFromSetup(client.parameters), hidden: Ietf.hiddenFromSetup(client.parameters), + activeCount: Ietf.activeCountFromSetup(client.parameters, version), }); } @@ -231,6 +234,7 @@ async function acceptNegotiated( params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Extensions.intoSetup(params, wiring.extensions, setupVersion); Ietf.hiddenIntoSetup(params); + Ietf.activeCountIntoSetup(params, setupVersion); const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params }); await server.encode(stream.writer, setupVersion); @@ -255,6 +259,7 @@ async function acceptNegotiated( version: selectedVersion as Ietf.IetfVersion, solicit: Ietf.solicitFromSetup(client.parameters), hidden: Ietf.hiddenFromSetup(client.parameters), + activeCount: Ietf.activeCountFromSetup(client.parameters, selectedVersion as Ietf.IetfVersion), }); } else { throw new Error(`unsupported version: ${selectedVersion.toString(16)}`); diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index ff07a31c128..c99207be8ec 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -311,6 +311,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Extensions.intoSetup(params, wiring.extensions, setupVersion); Ietf.hiddenIntoSetup(params); + Ietf.activeCountIntoSetup(params, setupVersion); const client = new Ietf.ClientSetup({ versions: @@ -350,6 +351,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): version: server.version as Ietf.IetfVersion, solicit: Ietf.solicitFromSetup(server.parameters), hidden: Ietf.hiddenFromSetup(server.parameters), + activeCount: Ietf.activeCountFromSetup(server.parameters, server.version as Ietf.IetfVersion), }); } else { throw new Error(`unsupported server version: ${server.version.toString()}`); @@ -366,7 +368,7 @@ async function handshakeAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup( + const { control, early, solicit, hidden, activeCount, cluster, auth } = await exchangeSetup( session, version, "moq-lite-js", @@ -382,6 +384,7 @@ async function handshakeAlpn( early, solicit, hidden, + activeCount, cluster, auth, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 1a5bba0fc8a..44964e24b51 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -610,3 +610,47 @@ test("one failed request leaves the other routes live until the session ends", a b.close(); origin.close(); }); + +test("an origin watcher catches up with the session's initial set, not before", async () => { + const origin = new OriginProducer(); + const session = new FakeSession(); + forwardAnnounced(session.session, origin); + + const watch = origin.announced(Path.Pattern.subtree(Path.from("room"))); + const events = record(watch); + await settle(); + expect(watch.caughtUp.peek()).toBe(false); + + const stream = session.stream("room/**"); + start(stream, "room/cam"); + await settle(); + // The route is in, but the peer has not said the set is complete. + expect(events).toEqual(["start room/cam"]); + expect(watch.caughtUp.peek()).toBe(false); + + stream.caughtUp(); + await settle(); + expect(watch.caughtUp.peek()).toBe(true); + + watch.close(); + session.die(); + origin.close(); +}); + +test("a session ending before its initial set lands stops holding the watcher", async () => { + const origin = new OriginProducer(); + const session = new FakeSession(); + forwardAnnounced(session.session, origin); + + const watch = origin.announced(); + record(watch); + await settle(); + expect(watch.caughtUp.peek()).toBe(false); + + session.die(); + await settle(); + expect(watch.caughtUp.peek()).toBe(true); + + watch.close(); + origin.close(); +}); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index d237bd9fe03..59f304aa8c8 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -164,6 +164,14 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi }; interests.set(prefix, interest); + // The origin's readers under the prefix wait on this request's initial set. Released + // once the session stream has caught up, which it reports only after the last of those + // events is reported here. + const snapshot = originWire.snapshot(prefix); + const caughtUp = interest.announced.caughtUp.subscribe((caughtUp) => { + if (caughtUp) snapshot(); + }); + void (async () => { let failure: unknown; try { @@ -176,7 +184,10 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi // The session died mid-stream, or the peer refused or reset the request. failure = err; } finally { + caughtUp(); + snapshot(); for (const reported of [...interest.reported]) unreport(interest, reported); + interest.announced.close(); interest.linger?.(); if (interests.get(prefix) === interest) interests.delete(prefix); diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index 2903de9da6e..bcffd7e6d0a 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -29,6 +29,7 @@ export async function exchangeSetup( early: Reader[]; solicit: boolean | undefined; hidden: boolean; + activeCount: boolean; cluster: Ietf.Cluster.Hops; auth: boolean; }> { @@ -37,6 +38,7 @@ export async function exchangeSetup( params.setBytes(Ietf.SetupOption.Implementation, encoder.encode(implementation)); Extensions.intoSetup(params, extensions, version); Ietf.hiddenIntoSetup(params); + Ietf.activeCountIntoSetup(params, version); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. @@ -55,6 +57,7 @@ export async function exchangeSetup( early: received.early, solicit: received.solicit, hidden: received.hidden, + activeCount: received.activeCount, cluster: { self, peer: received.cluster }, // Negotiated only when both sides offer it. auth: received.auth && extensions.auth, @@ -84,6 +87,7 @@ async function receiveSetup( early: Reader[]; solicit: boolean | undefined; hidden: boolean; + activeCount: boolean; cluster: Hop | undefined; auth: boolean; }> { @@ -126,6 +130,7 @@ async function receiveSetup( early, solicit: Ietf.solicitFromSetup(setup.parameters), hidden: Ietf.hiddenFromSetup(setup.parameters), + activeCount: Ietf.activeCountFromSetup(setup.parameters, version), cluster: Ietf.Cluster.fromSetup(setup.parameters, version), auth: Ietf.Auth.fromSetup(setup.parameters, version) === true, }; diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 19ce8d11038..23f37263545 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -4,6 +4,7 @@ import { Effect, Signal } from "@moq/signals"; import type { Producer as BroadcastProducer } from "../broadcast.ts"; import { SessionCode, SessionError, StreamCode, toTransport } from "../error.ts"; import { Route } from "../hop.ts"; +import { ALPN as IetfALPN } from "../ietf/version.ts"; import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; @@ -600,3 +601,64 @@ test("announced accepts a non-prefix scope before any session exists", () => { reload.close(); } }); + +/** + * A watcher on the consume origin catches up only once the session has delivered the + * server's initial set, exactly on versions that count it (lite-05+, MoQ Active Count), and + * a reconnect takes it back until the next session's set lands. + */ +test("a watcher catches up with the server's initial set, and again after a reconnect", async () => { + for (const alpn of [Lite.ALPN_06, IetfALPN.DRAFT_19]) { + const original = globalThis.WebTransport; + const url = new URL("https://example.com/"); + + const serverOrigin = new OriginProducer(); + const published = [publish(serverOrigin, Path.from("a")), publish(serverOrigin, Path.from("b"))]; + const sessions: { abort: () => void }[] = []; + const stub = function StubWebTransport() { + const pair = createMockTransportPair(alpn); + void accept(pair.server, url, { publish: serverOrigin.consume() }).then((server) => { + sessions.push(server); + }); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + const clientOrigin = new OriginProducer(); + const reload = new Reload({ + enabled: true, + url, + websocket: { enabled: false }, + delay: { initial: Time.Milli(10), multiplier: 1, max: Time.Milli(10) }, + consume: clientOrigin, + }); + const watching = clientOrigin.announced(); + const seen = new Set(); + void (async () => { + for await (const event of watching) { + if (event.kind === "end") seen.delete(event.prefix); + else seen.add(event.prefix); + } + })(); + + try { + // Nothing is connected yet, which is not the same as nothing being announced. + expect(watching.caughtUp.peek()).toBe(false); + await waitUntil(() => watching.caughtUp.peek()); + expect([...seen].sort()).toEqual(["a", "b"]); + + sessions[0]?.abort(); + await waitUntil(() => !watching.caughtUp.peek()); + await waitUntil(() => sessions.length > 1 && watching.caughtUp.peek()); + expect([...seen].sort()).toEqual(["a", "b"]); + } finally { + watching.close(); + reload.close(); + clientOrigin.close(); + for (const broadcast of published) broadcast.close(); + serverOrigin.close(); + for (const session of sessions) session.abort(); + globalThis.WebTransport = original; + } + } +}); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 1a52747966e..40f8d7df5a1 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -243,7 +243,23 @@ export class Reload { // is recoverable (a new URL or a disable/re-enable starts another sequence), so a // request must keep waiting rather than go unroutable in the gap. if (this.consume) { - this.#signals.cleanup(wireOf(this.consume).expect()); + const consume = wireOf(this.consume); + this.#signals.cleanup(consume.expect()); + // Likewise, its announcement streams are not caught up while no session is there to + // deliver an initial set, so an empty table before the first connect, or during a + // reconnect, never reads as nothing being announced. The next session takes its own + // hold before this one is released. Taken now rather than in the effect, which runs + // a microtask later, after a watcher created alongside would already have caught up. + let hold = this.enabled.peek() ? consume.snapshot(Path.empty()) : undefined; + this.#signals.run((effect) => { + if (effect.get(this.enabled) && !effect.get(this.established)) { + hold ??= consume.snapshot(Path.empty()); + } else { + hold?.(); + hold = undefined; + } + }); + this.#signals.cleanup(() => hold?.()); } this.error = this.#error; diff --git a/js/net/src/ietf/active_count.ts b/js/net/src/ietf/active_count.ts new file mode 100644 index 00000000000..7baf9799a5d --- /dev/null +++ b/js/net/src/ietf/active_count.ts @@ -0,0 +1,38 @@ +import { SetupOption, type SetupOptions } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +/** + * The MoQ Active Count extension (draft-lcurley-moq-active-count-00). + * + * A publisher answers SUBSCRIBE_NAMESPACE with a NAMESPACE for every suffix it already + * advertises, then reports changes on the same stream. Nothing in moq-transport says where + * the first part ends, so when both endpoints declare the ACTIVE_COUNT option, the + * publisher puts on its REQUEST_OK how many NAMESPACE messages come before the + * subscription is caught up. + * + * @module + * @internal + */ + +// Draft-14/15 answer SUBSCRIBE_NAMESPACE with PUBLISH_NAMESPACE requests, which the stream cannot count. +function supported(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15; +} + +/** + * Whether the extension is negotiated. We always declare it, so the peer's SETUP decides. + * + * @internal + */ +export function activeCountFromSetup(params: SetupOptions, version: IetfVersion): boolean { + return supported(version) && params.getVarint(SetupOption.ActiveCount) !== undefined; +} + +/** + * Declare that we count and read the peer's count. + * + * @internal + */ +export function activeCountIntoSetup(params: SetupOptions, version: IetfVersion) { + if (supported(version)) params.setVarint(SetupOption.ActiveCount, 1n); +} diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index f8ca1449add..bff7b107f75 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -122,6 +122,7 @@ export class Connection implements Established { publish, solicit, hidden = false, + activeCount = false, cluster, auth = false, early = [], @@ -144,6 +145,8 @@ export class Connection implements Established { solicit?: boolean; /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ hidden?: boolean; + /** Whether both SETUPs declared MoQ Active Count. */ + activeCount?: boolean; /** * The Hop IDs this session declared (MoQ Cluster). `undefined` on a version that * cannot negotiate the extension, as is a `peer` the peer never declared. @@ -208,6 +211,7 @@ export class Connection implements Established { publish, requiresSolicitation: solicit ?? false, hidden, + activeCount, cluster, grant: this.#auth.grant, ready: this.#auth.setupAnswered(), @@ -220,6 +224,7 @@ export class Connection implements Established { quic, cluster, hidden, + activeCount, solicit, goaway: this.#goaway, grant: this.#auth.grant, diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index 269f01b1b9c..784a064100d 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -1,3 +1,4 @@ +export * from "./active_count.ts"; export * from "./adapter.ts"; export * as Auth from "./auth.ts"; export * as Cluster from "./cluster.ts"; diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index dd51b8f3877..0482731d172 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -25,6 +25,8 @@ export const SetupOption = { Hidden: 0x40b5cn, /** AUTH, from the MoQ Auth extension. See `auth.ts`. */ Auth: 0x40b60n, + /** ACTIVE_COUNT, from the MoQ Active Count extension. See `active_count.ts`. */ + ActiveCount: 0x40b64n, } as const; // Unknown SETUP options may repeat, including GREASE (draft-21 section 9.1). @@ -552,6 +554,19 @@ export class Parameters { else this.vars.delete(MSG_PARAM_HIDDEN); } + /** How many NAMESPACE messages follow a REQUEST_OK before the subscription is caught up (MoQ Active Count). */ + get activeCount(): number | undefined { + const v = this.vars.get(MSG_PARAM_ACTIVE_COUNT); + if (v === undefined) return undefined; + if (v > BigInt(Number.MAX_SAFE_INTEGER)) throw new ProtocolViolation(`ACTIVE_COUNT too large: ${v}`); + return Number(v); + } + + set activeCount(v: number | undefined) { + if (v === undefined) this.vars.delete(MSG_PARAM_ACTIVE_COUNT); + else this.vars.set(MSG_PARAM_ACTIVE_COUNT, BigInt(v)); + } + // --- Bytes accessors --- get largest(): MessageLocation | undefined { diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 234dfbfcb5e..37df583349d 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1,4 +1,4 @@ -import { expect, jest, spyOn, test } from "bun:test"; +import { expect, jest, onTestFinished, spyOn, test } from "bun:test"; import { Milli } from "@moq/clock"; import { Signal } from "@moq/signals"; import type { Grant } from "../auth.ts"; @@ -19,6 +19,7 @@ import type * as Cluster from "./cluster.ts"; import { toRequestCode } from "./error.ts"; import { Fetch, FetchHeader } from "./fetch.ts"; import { Frame, Group as GroupMessage } from "./object.ts"; +import { Parameters } from "./parameters.ts"; import { toWire } from "./priority.ts"; import { PublishDone } from "./publish.ts"; import { PublishNamespace, PublishNamespaceUpdate } from "./publish_namespace.ts"; @@ -120,11 +121,13 @@ function publisher( session, cluster, grant, + activeCount, }: { requiresSolicitation?: boolean; session?: Session; cluster?: Cluster.Hops; grant?: Signal; + activeCount?: boolean; } = {}, ): { pub: Publisher; origin: OriginProducer } { const origin = new OriginProducer(); @@ -137,6 +140,7 @@ function publisher( requiresSolicitation, cluster, grant, + activeCount, }), origin, }; @@ -3154,3 +3158,55 @@ test("namespace updates move the prefix after acknowledgement", async () => { } } }); + +/** MoQ Active Count: the REQUEST_OK counts the NAMESPACEs sent ahead of any change. */ +test("a counted answer names the NAMESPACEs that follow it", async () => { + for (const activeCount of [true, false]) { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: true, activeCount }); + publish(origin, Path.from("a")); + publish(origin, Path.from("b")); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace(new SubscribeNamespace({ requestId: 0n, namespace: Path.empty() }), accepted); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + const ok = await RequestOk.decode(subscription.reader, VERSION); + expect(ok.parameters.activeCount).toBe(activeCount ? 2 : undefined); + const suffixes = []; + for (let i = 0; i < 2; i++) { + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + suffixes.push((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix); + } + expect(suffixes.sort()).toEqual([Path.from("a"), Path.from("b")]); + + subscription.close(); + origin.close(); + } +}); + +/** ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE, so one on any other REQUEST_OK is a violation. */ +test("an ACTIVE_COUNT answering a PUBLISH_NAMESPACE closes the session", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { activeCount: true }); + const warn = spyOn(console, "warn").mockImplementation(() => void 0); + onTestFinished(() => warn.mockRestore()); + void pub.runPublishNamespaces(); + publish(origin, Path.from("a")); + + const stream = await nextStream(pair.client); + if (!stream) throw new Error("no PUBLISH_NAMESPACE was sent"); + expect(await readPublishNamespace(stream)).toBe(Path.from("a")); + const parameters = new Parameters(); + parameters.activeCount = 0; + await stream.writer.u53(RequestOk.id); + await new RequestOk({ requestId: undefined, parameters }).encode(stream.writer, VERSION); + + await Promise.race([ + pair.server.closed, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("session stayed up")), 500)), + ]); + origin.close(); +}); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 0dc1360a0c9..249e1a9c5f3 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -266,6 +266,8 @@ export class Publisher { // turns them off: there is no stream fallback. Released in close(). #datagramWriter?: WritableStreamDefaultWriter; #hidden: boolean; + // Whether a SUBSCRIBE_NAMESPACE's REQUEST_OK counts the NAMESPACEs that follow it (MoQ Active Count). + #activeCount: boolean; // The origin this session serves, borrowed: it outlives the session, and closing the // session leaves its broadcasts alone. Namespaces go out as an unsolicited @@ -300,6 +302,7 @@ export class Publisher { publish, requiresSolicitation, hidden = false, + activeCount = false, cluster, grant = new Signal(undefined), ready = Promise.resolve(), @@ -315,6 +318,8 @@ export class Publisher { requiresSolicitation: boolean; /** Whether the peer declared MoQ Hidden. */ hidden?: boolean; + /** Whether both SETUPs declared MoQ Active Count. */ + activeCount?: boolean; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; /** The union of our tokens' grants (MoQ Auth), which bounds what we publish. */ @@ -335,6 +340,7 @@ export class Publisher { this.#publish = publish; this.#requiresSolicitation = requiresSolicitation; this.#hidden = hidden; + this.#activeCount = activeCount; this.#advert = Cluster.advertise(cluster); this.#datagramWriter = DatagramStream.datagramWriter(quic); } @@ -1166,18 +1172,27 @@ export class Publisher { ); let updating = version >= Version.DRAFT_18 ? readUpdate() : undefined; try { - // Send OK response - if (version === Version.DRAFT_14) { - await stream.writer.u53(SubscribeNamespaceOk.id); - const ok = new SubscribeNamespaceOk({ requestId: msg.requestId }); - await ok.encode(stream.writer, version); - } else { + const answer = async (activeCount?: number) => { + if (version === Version.DRAFT_14) { + await stream.writer.u53(SubscribeNamespaceOk.id); + const ok = new SubscribeNamespaceOk({ requestId: msg.requestId }); + await ok.encode(stream.writer, version); + return; + } + const parameters = new Parameters(); + parameters.activeCount = activeCount; await stream.writer.u53(RequestOk.id); const ok = new RequestOk({ requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, + parameters, }); await ok.encode(stream.writer, version); - } + }; + + // MoQ Active Count: the OK counts what the first pass below sends, so it waits for + // that pass. Otherwise answer now. + let answered = !this.#activeCount; + if (answered) await answer(); // Hidden namespaces are left out unless the peer opted in (MoQ Hidden). Draft-16 // and later carry every remaining match. Draft-14/15 only add what the @@ -1239,12 +1254,19 @@ export class Publisher { }); const advertised = this.#advertised.peek(); + const updated = advertised ? presented(prefix, advertised, carries) : new Map(); + // A fresh pass sends a NAMESPACE for every entry, ahead of any change. + if (!answered) { + answered = true; + await answer(updated.size); + } if (!advertised) { dispose(); break; } - const outstanding = await this.#reconcile(ns, presented(prefix, advertised, carries), target); + const outstanding = await this.#reconcile(ns, updated, target); + retry = outstanding ? Math.min(retry ? retry * 2 : RETRY_BASE, RETRY_MAX) : 0; // Wait for the next change, or for the peer to unsubscribe. @@ -1587,7 +1609,7 @@ export class Publisher { if (this.#session.version === Version.DRAFT_14) { await PublishNamespaceOk.decode(stream.reader, this.#session.version); } else { - await RequestOk.decode(stream.reader, this.#session.version); + this.#answered(await RequestOk.decode(stream.reader, this.#session.version)); } return "held"; })(), @@ -1606,6 +1628,17 @@ export class Publisher { } } + /** + * Check a REQUEST_OK answering a PUBLISH_NAMESPACE. ACTIVE_COUNT only answers + * SUBSCRIBE_NAMESPACE (MoQ Active Count), and the session closes over it here since the + * caller only drops the advertisement. + */ + #answered(ok: RequestOk) { + if (ok.parameters.activeCount === undefined) return; + this.#session.close(); + throw new ProtocolViolation("ACTIVE_COUNT on a PUBLISH_NAMESPACE answer"); + } + /** * Update a namespace the peer holds: REQUEST_UPDATE on the request that carries it, * with only the parameters that changed, then its answer. @@ -1650,7 +1683,7 @@ export class Publisher { if (typeId !== RequestOk.id) { throw new Error(`unexpected answer to the update: typeId=0x${typeId.toString(16)}`); } - await RequestOk.decode(reader, version); + this.#answered(await RequestOk.decode(reader, version)); return "held"; })(), ADVERTISE_TIMEOUT, diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index fa27644447b..90c57360ef9 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -16,6 +16,7 @@ import type * as Cluster from "./cluster.ts"; import { Connection } from "./connection.ts"; import { ObjectDatagram } from "./datagram.ts"; import { EndOfTrackProperties, encodeObjectExtensions, type GroupFlags, Group as GroupMessage } from "./object.ts"; +import { Parameters } from "./parameters.ts"; import type { Properties } from "./properties.ts"; import { PublishNamespace, PublishNamespaceCancel, PublishNamespaceUpdate } from "./publish_namespace.ts"; import { MaxRequestId, RequestError, RequestOk } from "./request.ts"; @@ -2075,3 +2076,107 @@ test("an announce consumer closed while waiting on request-ID credit takes no ID session.close(); void running; }); + +/** Answer the next SUBSCRIBE_NAMESPACE with REQUEST_OK, carrying `activeCount` when given. */ +async function answerCounted(transport: WebTransport, activeCount: number | undefined): Promise { + const stream = await nextStream(transport); + if (!stream) throw new Error("no SUBSCRIBE_NAMESPACE was sent"); + await stream.reader.u53(); + await SubscribeNamespace.decode(stream.reader, VERSION); + const parameters = new Parameters(); + parameters.activeCount = activeCount; + await stream.writer.u53(RequestOk.id); + await new RequestOk({ requestId: undefined, parameters }).encode(stream.writer, VERSION); + return stream; +} + +const sessionClosed = (pair: ReturnType) => + Promise.race([ + pair.server.closed, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("session stayed up")), STREAM_WAIT)), + ]); + +const yieldTask = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** MoQ Active Count: the consumer catches up once the counted NAMESPACEs are read. */ +test("counted NAMESPACEs catch up", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session, activeCount: true }); + + const announced = subscriber.announced(); + const subscription = await answerCounted(pair.client, 2); + await inlineNamespace(subscription, Path.from("a")); + expect(await announced.next()).toMatchObject({ prefix: Path.from("a"), kind: "start" }); + const b = announced.next(); + await yieldTask(); + expect(announced.caughtUp.peek()).toBe(false); + + await inlineNamespace(subscription, Path.from("b")); + expect(await b).toMatchObject({ prefix: Path.from("b"), kind: "start" }); + await yieldTask(); + // Still handling the last of them. + expect(announced.caughtUp.peek()).toBe(false); + void announced.next(); + await yieldTask(); + expect(announced.caughtUp.peek()).toBe(true); + announced.close(); +}); + +/** Without MoQ Active Count, REQUEST_OK is the best boundary there is. */ +test("an uncounted answer catches up on the OK", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session }); + + const announced = subscriber.announced(); + await yieldTask(); + expect(announced.caughtUp.peek()).toBe(false); + await acceptSubscribeNamespace(pair.client); + await yieldTask(); + expect(announced.caughtUp.peek()).toBe(true); + announced.close(); +}); + +/** A count the negotiation did not promise, or a missing one it did, breaks the extension. */ +test("an ACTIVE_COUNT that does not match the negotiation closes the session", async () => { + for (const [negotiated, count] of [ + [true, undefined], + [false, 0], + ] as const) { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session, activeCount: negotiated }); + const warn = spyOn(console, "warn").mockImplementation(() => void 0); + onTestFinished(() => warn.mockRestore()); + + subscriber.announced(); + await answerCounted(pair.client, count); + await sessionClosed(pair); + } +}); + +/** A counted set cut short, by a NAMESPACE_DONE or the stream ending, is the peer's fault. */ +test.each(["done", "fin"] as const)("a counted set cut short by %s closes the session", async (cut) => { + { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session, activeCount: true }); + const warn = spyOn(console, "warn").mockImplementation(() => void 0); + onTestFinished(() => warn.mockRestore()); + + subscriber.announced(); + const subscription = await answerCounted(pair.client, 2); + await inlineNamespace(subscription, Path.from("a")); + if (cut === "done") { + // The type alone is the violation, so the session may close before the rest is written. + await subscription.writer.u53(SubscribeNamespaceEntryDone.id); + await new SubscribeNamespaceEntryDone({ suffix: Path.from("a") }) + .encode(subscription.writer, VERSION) + .catch(() => undefined); + } else { + subscription.writer.close(); + } + await sessionClosed(pair); + } +}); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 9d7592f26bf..90eab824173 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -174,6 +174,9 @@ export class Subscriber { // Whether the peer understands the HIDDEN parameter (MoQ Hidden). #hidden: boolean; + // Whether a SUBSCRIBE_NAMESPACE's REQUEST_OK counts the NAMESPACEs that follow it (MoQ Active Count). + #activeCount: boolean; + // What the peer's SETUP declared about being solicited (MoQ Solicit), `undefined` // when it declared nothing. #solicit?: boolean; @@ -199,6 +202,7 @@ export class Subscriber { quic, cluster, hidden = false, + activeCount = false, solicit, goaway, grant, @@ -212,6 +216,8 @@ export class Subscriber { cluster?: Cluster.Hops; /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ hidden?: boolean; + /** Whether both SETUPs declared MoQ Active Count. */ + activeCount?: boolean; /** What the peer's SETUP declared about being solicited (MoQ Solicit). */ solicit?: boolean; /** Settles when the peer sends GOAWAY. */ @@ -226,6 +232,7 @@ export class Subscriber { this.#announced = new SlotMap(new Slots(limits.announces, { quic, reason: "too many announcements" })); this.#cluster = cluster; this.#hidden = hidden; + this.#activeCount = activeCount; this.#solicit = solicit; this.#goaway = goaway; this.#grant = grant; @@ -407,6 +414,8 @@ export class Subscriber { // could land. A peer that declared Solicit only tells when asked, so it still is. const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15; if (legacy && prefix.length === 0 && this.#solicit === undefined) { + // Nothing marks where what it tells unasked starts or ends, so there is nothing to wait for. + announced.caughtUp(); // No request stream ends this wait, so the session's end has to. const ends: PromiseLike[] = [announced.closed]; if (this.#quic) ends.push(this.#quic.closed.catch(() => undefined)); @@ -485,39 +494,63 @@ export class Subscriber { stream.close(); }; + // Resolves to how many NAMESPACEs make up the initial set (MoQ Active Count). const response = (async () => { const respTypeId = await stream.reader.u53(); if (respTypeId === RequestOk.id) { - await RequestOk.decode(stream.reader, version); - } else if (respTypeId === SubscribeNamespaceOk.id) { + const ok = await RequestOk.decode(stream.reader, version); + // A count the negotiation did not promise, or a missing one it did, is + // the peer breaking the extension. Without it, the answer is the best + // boundary there is. + const count = ok.parameters.activeCount; + if (this.#activeCount !== (count !== undefined)) { + throw new ProtocolViolation("ACTIVE_COUNT does not match the negotiation"); + } + return count ?? 0; + } + if (respTypeId === SubscribeNamespaceOk.id) { // v14: SubscribeNamespaceOk const size = await stream.reader.u16(); await stream.reader.read(size); - } else { - throw new Error(`SubscribeNamespace rejected: typeId=0x${respTypeId.toString(16)}`); + return 0; } - return true; + throw new Error(`SubscribeNamespace rejected: typeId=0x${respTypeId.toString(16)}`); })(); // A consumer that leaves before the peer answers ends the request now, not on the answer. - if (!(await race([response, announced.closed.then(() => false)]))) { + const answered = await race([response, announced.closed.then(() => undefined)]); + if (answered === undefined) { await end(); return; } + let initial = answered; // Loop reading Namespace/NamespaceDone entries const readLoop = (async () => { for (;;) { + if (initial === 0) announced.caughtUp(); + const done = await stream.reader.done(); - if (done) break; + if (done) { + if (initial > 0) + throw new ProtocolViolation("namespace stream ended inside the initial set"); + break; + } const msgType = await stream.reader.u53(); + // The counted initial set is NAMESPACEs only, so anything else cuts it short. + if (initial > 0 && msgType !== SubscribeNamespaceEntry.id) { + throw new ProtocolViolation(`message 0x${msgType.toString(16)} inside the initial set`); + } if (msgType === SubscribeNamespaceEntry.id) { const entry = await SubscribeNamespaceEntry.decode( stream.reader, version, Cluster.negotiated(this.#cluster), ); + // Counted even when discarded below. + if (initial > 0) initial -= 1; if (released) break; + const path = Path.join(prefix, entry.suffix); // A repeat updates the advertisement in place, so one that now diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index 24e7affa1b2..38ffafc5e0a 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -123,6 +123,8 @@ function announceHarness(version: Version, origin = 1n) { return { subscriber, send, + // The peer FINs the announce stream. + finish: () => inbound.close(), abortReason, sessionEnded, settle: () => new Promise((resolve) => setTimeout(resolve, 0)), @@ -1742,3 +1744,90 @@ test("closing a broadcast while TRACK_INFO is pending leaves no unhandled reject subscriber.close(); } }); + +const announceStart = (suffix: string) => (w: Writer) => + encodeAnnounceBroadcast(w, { status: "active", suffix: Path.from(suffix), hops: [PUBLISHER_A] }, Version.DRAFT_06); + +test("a counted initial set catches up once the reader is past its last announce", async () => { + const { subscriber, send, settle } = announceHarness(Version.DRAFT_06); + const announced = subscriber.announced(); + await settle(); + + await send((w) => new AnnounceOk(PEER, 2).encode(w, Version.DRAFT_06)); + await send(announceStart("a")); + expect(await announced.next()).toMatchObject({ prefix: Path.from("a"), kind: "start" }); + expect(announced.caughtUp.peek()).toBe(false); + + await send(announceStart("b")); + expect(await announced.next()).toMatchObject({ prefix: Path.from("b"), kind: "start" }); + await settle(); + // Still handling the last of them. + expect(announced.caughtUp.peek()).toBe(false); + + void announced.next(); + await settle(); + expect(announced.caughtUp.peek()).toBe(true); + + announced.close(); + subscriber.close(); +}); + +test("an empty initial set catches up on the answer", async () => { + const { subscriber, send, settle } = announceHarness(Version.DRAFT_06); + const announced = subscriber.announced(); + await settle(); + expect(announced.caughtUp.peek()).toBe(false); + + await send((w) => new AnnounceOk(PEER, 0).encode(w, Version.DRAFT_06)); + await settle(); + expect(announced.caughtUp.peek()).toBe(true); + + announced.close(); + subscriber.close(); +}); + +test("a counted initial set cut short is a protocol violation", async () => { + for (const cut of ["ended", "fin"] as const) { + const { subscriber, send, finish, sessionEnded, settle } = announceHarness(Version.DRAFT_06); + const announced = subscriber.announced(); + await settle(); + + await send((w) => new AnnounceOk(PEER, 2).encode(w, Version.DRAFT_06)); + await send(announceStart("a")); + if (cut === "ended") { + await send((w) => encodeAnnounceBroadcast(w, { status: "endedId", id: 0n }, Version.DRAFT_06)); + } else { + finish(); + } + + expect(await announced.next()).toMatchObject({ prefix: Path.from("a"), kind: "start" }); + await expect(announced.next()).rejects.toThrow("inside the initial set"); + expect((await sessionEnded())?.closeCode).toBe(SessionCode.ProtocolViolation); + subscriber.close(); + } +}); + +test("a version without a boundary catches up once the request is out", async () => { + const { subscriber, settle } = announceHarness(Version.DRAFT_03); + const announced = subscriber.announced(); + await settle(); + expect(announced.caughtUp.peek()).toBe(true); + + announced.close(); + subscriber.close(); +}); + +test("announce init is the initial set", async () => { + const { subscriber, send, settle } = announceHarness(Version.DRAFT_02); + const announced = subscriber.announced(); + await settle(); + + await send((w) => new AnnounceInit([Path.from("room")]).encode(w, Version.DRAFT_02)); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "start" }); + void announced.next(); + await settle(); + expect(announced.caughtUp.peek()).toBe(true); + + announced.close(); + subscriber.close(); +}); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 797b686627f..057c40cb0ea 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -316,6 +316,9 @@ export class Subscriber { // It no longer stamps itself onto each hop chain, so we append it here to // keep the reflected-announce loop check seeing the full chain. let responderOrigin: Hop | undefined; + // The ANNOUNCEs still owed of the peer's initial set (lite-05+). Older versions + // mark no boundary, so they catch up once the request is out, as a best effort. + let initial = 0; if (hasAnnounceOk(this.version)) { const ok = await race([AnnounceOk.decode(stream.reader, this.version), announced.closed]); // A consumer that leaves before the peer answers ends the request now, not on the answer. @@ -327,6 +330,7 @@ export class Subscriber { // anonymous mark and must travel the reconstructed chain. Assigned identities // stay off this hop and are never forwarded. responderOrigin = ok.hop; + initial = ok.active; } switch (this.version) { @@ -390,14 +394,28 @@ export class Subscriber { // Receive announce updates (for Draft03+, this includes initial state) for (;;) { + if (initial === 0) announced.caughtUp(); + const announce = await race([ decodeAnnounceBroadcastMaybe(stream.reader, this.version), announced.closed, ]); // undefined: the stream ended. null: the consumer closed cleanly. + if (announce === undefined && initial > 0) { + throw new ProtocolViolation("announce stream ended inside the initial set"); + } if (!announce) break; if (announce instanceof Error) throw announce; + // The counted initial set is ANNOUNCEs only, so anything else cuts it short. An + // unknown message counts for nothing either way. + if (initial > 0 && announce.status !== "skipped") { + if (announce.status !== "active") { + throw new ProtocolViolation(`${announce.status} inside the initial set`); + } + initial -= 1; + } + let path: Path.Valid; let active: boolean; // Present on active/update/restart; ended messages never carry hops worth checking. diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 1061cfcd81c..70fa7095bef 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -609,6 +609,27 @@ class OriginState { // What backs the public `discovery` getter. sessions = new Signal({ total: 0, discovery: 0 }); + // The prefixes whose initial announcements a session is still delivering, counted. An + // announcement stream overlapping one has not caught up yet. + snapshots = new Signal(new Map()); + + /** Hold an initial set in flight under the absolute `prefix` until the returned dispose. */ + addSnapshot(prefix: Path.Valid): Dispose { + this.snapshots.mutate((counts) => { + counts.set(prefix, (counts.get(prefix) ?? 0) + 1); + }); + let landed = false; + return () => { + if (landed) return; + landed = true; + this.snapshots.mutate((counts) => { + const count = (counts.get(prefix) ?? 0) - 1; + if (count > 0) counts.set(prefix, count); + else counts.delete(prefix); + }); + }; + } + // The literal heads readers are watching, counted, as a trie: announcement streams and // announced requests. Sessions ask their peer for announcements under the broadest of // these and nothing else, and hear each change to that covering set as it happens, so a @@ -1127,6 +1148,7 @@ export class Producer implements Table { accepts: (prefix) => this.#scope.overlaps(Path.join(this.#scope.root, prefix)), attach: (discovery) => this.#attach(discovery), expect: () => this.#expect(), + snapshot: (prefix) => this.#state.addSnapshot(this.#scope.prefix(prefix)), get requests() { if (thisProducer.#scope === Scope.all) return thisProducer.#state.requests; thisProducer.#requests ??= new Derived([thisProducer.#state.requests], (requests) => @@ -2017,7 +2039,22 @@ export class Consumer { } active = next; - await Signal.race(this.#state.local, this.#state.advertisedLocal, this.#state.routes, producer.closed); + // Caught up once no session is still delivering an initial set this stream sees. + let behind = false; + for (const prefix of this.#state.snapshots.peek().keys()) { + if (patterns.overlaps(Path.Pattern.subtree(prefix))) behind = true; + } + if (behind) producer.behind(); + else producer.caughtUp(); + + await Signal.race( + this.#state.local, + this.#state.advertisedLocal, + this.#state.routes, + this.#state.snapshots, + producer.closed, + ); + if (producer.closed.peek() !== undefined) return; } } catch { diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 2afbfe51cd6..a1afebae626 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -53,6 +53,11 @@ export interface OriginProducer { ): origin.Dynamic; attach(discovery: boolean): Dispose; expect(): Dispose; + /** + * Hold the origin's announcement streams overlapping `prefix` short of caught up until + * the returned dispose: a session holds one while its peer's initial set is in flight. + */ + snapshot(prefix: Path.Valid): Dispose; readonly requests: Getter | undefined>; changed(): GetPromise; /** Whether sessions should answer the request with a blind subscription right now. */ From 5e402ffed2a602096b45d0b53b86f756942b4d86 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 09:06:12 -0700 Subject: [PATCH 05/17] quest: finish m1/announce-live Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/forward.ts | 4 +- quest/m1/README.md | 1 - quest/m1/announce-live.md | 103 ------------------------------- 3 files changed, 2 insertions(+), 106 deletions(-) delete mode 100644 quest/m1/announce-live.md diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 59f304aa8c8..bfb8646b298 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -53,8 +53,8 @@ interface Reported { * watches (`announced(scope)` and `request(path, { announced: true })`) that no open request * already covers, and closes {@link LINGER} after nothing at or beneath its prefix is watched. * A narrower watch rides an open broader request, and a narrower request already open stays - * open until the broader one covering it goes, so neither takes the other's routes over: - * nothing tells when a fresh request has caught up. A route both report is inserted once and + * open until the broader one covering it goes, so neither takes the other's routes over + * before it has caught up, which not every version can tell. A route both report is inserted once and * driven by its first reporter; if it changes hands anyway (a request failing), it restarts * unless one epoch proves both requests saw one instance. * diff --git a/quest/m1/README.md b/quest/m1/README.md index 5df3713e3e0..2db243f2ebf 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -73,7 +73,6 @@ lands before the cut. - [More tests under load](/quest/m1/test-flakes-2/README.md) - the second round of load-only failures, one quest per flake, fixed at the cause - [A public model](/quest/m1/public-model/README.md) - every model item a built-in session reaches is public, unnecessary, or marked deliberately private, so a custom publisher, subscriber, or relay builds from the public model alone - [Lazy discovery](/quest/m1/lazy-discovery/README.md) - a `consume` session requests announcements only for prefixes the origin is watching, in JS then Rust; `follow`, `broadcasts`, and the `discovery` option go away -- [Announce caught up](/quest/m1/announce-live.md) - Rust and JS announce consumers expose when a session's initial announce snapshot has arrived, from the counts the wire already carries where a version has them - [A spinning loop fails a sim test](/quest/m1/spinning-loops.md) - a sim test names any loop that holds a task poll too long, and each one yields through a budget - [Importers announce with a track](/quest/m1/import-declared-track.md) - every importer announces once its catalog names a servable track (a rendition or an extension track like `m2ts`), and input that ends with none exits non-zero unannounced - [Data consumer timestamps](/quest/m1/data-consumer-timestamps.md) - json and binary consumers return each value's timestamp, in Rust and every binding; snapshots add `latest()` beside an in-order `next()` diff --git a/quest/m1/announce-live.md b/quest/m1/announce-live.md deleted file mode 100644 index 9b04cfc8421..00000000000 --- a/quest/m1/announce-live.md +++ /dev/null @@ -1,103 +0,0 @@ -# [M] An announce consumer knows when it has caught up - -## Goal - -The Rust and JS announce consumers expose when a session's initial announce -snapshot has arrived. A consumer can tell "not announced" from "not heard -yet": a player shows offline instead of loading, and lazy discovery can hand -a watch from a broad announce request to a narrower one only once the -narrower one has caught up. - -Non-goals: global completeness across a cluster, and any new wire format. -Every version that can carry the boundary already does. - -## Plan - -Raised 2026-10-13 on [#5466](https://github.com/moq-dev/moq/pull/5466). -Hand-over between a broad and a narrower announce request had two gaps when -the narrower request answered slowly, so #5466 keeps the broad request open -while anything beneath it is watched. The maintainer suggested this signal -as what would make a narrower hand-over safe. - -The boundary is already on the wire, and the subscribers drop it: - -- lite-01/02: the initial set is the single ANNOUNCE_INIT message. -- lite-05+, including lite-07: ANNOUNCE_OK's `Active Count` is the number of - ANNOUNCE_START messages in the initial set. Publishers fill it from their - current view; the Rust lite subscriber reads and drops it. -- IETF: the MoQ Active Count extension - (`drafts/draft-lcurley-moq-active-count.md`) puts the count on the - REQUEST_OK answering SUBSCRIBE_NAMESPACE. The Rust IETF subscriber checks - it and drops it. JS only decodes the parameter: it neither declares the - setup option nor sends the count. -- lite-03/04, and IETF without the extension (always on draft-14/15): no - boundary. lite-03/04 have no OK either, and an unscoped draft-14/15 peer - without MoQ Solicit is sent no request at all. - -Decided with the maintainer, 2026-10-13: - -- ✅ The boundary plus a public API. Rejected: wire only; not now. The - maintainer chose a new lite-07 marker before the existing counts were - found; this plan reuses them instead. -- ✅ m1. Rejected: m2. -- ✅ A relay reports caught up once it has sent its own current view of the - prefix. It never waits for its upstream peers to catch up, so caught up - means "caught up with the sender", not global completeness. Rejected: - waiting for upstream markers. ANNOUNCE_OK's count already has this meaning. -- ✅ Where the session has no boundary, it reports caught up on the OK, as a - best effort. Document the gap. Rejected: never caught up, which hangs - consumers that wait on it. With MoQ Active Count negotiated, IETF waits - for the counted NAMESPACE messages like lite does. Where there is no OK - to wait on (lite-03/04, the unsolicited draft-14/15 case), it reports - caught up once the request is sent, or at once when none is. -- Open, for the implementer to bring back with a recommendation: the API - shape. The maintainer found an `Event::Live` enum variant ugly. The - candidate is separate `poll_next` (route events only) and `poll_live` - (ready once, then stays ready), with JS mirroring as `announced.live`. - To avoid a race, the consumer is caught up only after it has drained - every snapshot event before the boundary. Name the method by role. - The shape must also say what caught up means for a consumer that spans - sources (a reconnecting or pooled connection, an origin fed by several - sessions or prefix requests): reset per source generation, or wait for - every current source. "Ready once, then stays ready" is wrong after a - reconnect. Readiness is a watermark over the coalesced queue, since an - unseen start and end cancel out, and the origin keeps it so a consumer - created after the boundary is caught up once it drains its replay. - -Work: - -- Rust `rs/moq-net` and JS `js/net`: the lite and IETF subscribers keep the - count and mark the point where the initial set has been read, and the - origin's announce consumers expose it. A counted snapshot cut short, by - another message or the stream ending, is a protocol violation. Mirror - names across languages. -- JS MoQ Active Count: declare the setup option, record the negotiation, - validate the parameter against it (and refuse it on any REQUEST_OK but - SUBSCRIBE_NAMESPACE's), and send the count as a publisher, so JS IETF - sessions get the exact boundary too. -- `doc/concept`: what caught up means to a subscriber, including the - best-effort versions. -- A benchmark sweeping announce streams and announce consumers, so - propagating the boundary costs the touched path, not the table. -- Use it in lazy discovery: once both halves have landed, a narrower - request may take over from a broad one after it reports caught up. Only - an exact boundary qualifies; a best-effort session keeps the broad - request. Do that here or in a follow-up, as the implementer judges. -- Regressions with mocked time: caught up after the counted snapshot and - never before an earlier event is read; a count of 0 is caught up at once; - a snapshot cut short is refused; a start and end that cancel before the - poll still reach caught up; a consumer created after the boundary is - caught up after its replay; - a relay reports caught up without waiting on a silent upstream; IETF - waits for the counted NAMESPACE messages, or reports on the OK without - the extension; a session with no OK still reports caught up; a reconnect - and a second feed follow the chosen multi-source rule. Run - `just test interop --all`. - -Public API: new caught-up surface on Rust and JS announce consumers. Wire: -no new format or draft change; JS adopts the existing MoQ Active Count -extension. - -## Related - -- [Lazy discovery](/quest/m1/lazy-discovery/README.md) - its broad-request hold is what this lets a narrower request replace From fb9b9f01cc39854efaa8b082d5dde9764e6a24d9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 20:17:16 -0700 Subject: [PATCH 06/17] fix(net): follow main's integration test layout, SendCell, and @moq/clock Move the caught-up integration test into tests/integration (#5689), hold the snapshot's origin handle in a SendCell like the rest of the origin, and use @moq/clock's Milli in the Reload test. Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/reload.test.ts | 2 +- rs/moq-net/src/model/origin.rs | 2 +- rs/moq-net/tests/{ => integration}/caught_up.rs | 4 ++-- rs/moq-net/tests/integration/main.rs | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) rename rs/moq-net/tests/{ => integration}/caught_up.rs (99%) diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 23f37263545..8bf8a42fe9e 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -629,7 +629,7 @@ test("a watcher catches up with the server's initial set, and again after a reco enabled: true, url, websocket: { enabled: false }, - delay: { initial: Time.Milli(10), multiplier: 1, max: Time.Milli(10) }, + delay: { initial: Milli(10), multiplier: 1, max: Milli(10) }, consume: clientOrigin, }); const watching = clientOrigin.announced(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 8197c5b2428..1911031c2e8 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -3699,7 +3699,7 @@ struct SnapshotEntry { /// (see [`AnnounceConsumer::poll_caught_up`]) until this drops: once the session /// has inserted the announcements its peer counted, or it gave up on them. pub(crate) struct Snapshot { - shared: Option>, + shared: Option>>, prefix: PathOwned, id: u64, } diff --git a/rs/moq-net/tests/caught_up.rs b/rs/moq-net/tests/integration/caught_up.rs similarity index 99% rename from rs/moq-net/tests/caught_up.rs rename to rs/moq-net/tests/integration/caught_up.rs index 47a64bd08cc..404daae42b1 100644 --- a/rs/moq-net/tests/caught_up.rs +++ b/rs/moq-net/tests/integration/caught_up.rs @@ -4,12 +4,12 @@ //! peer's initial announcements: exactly on versions that mark the boundary, and //! on the answer, as a best effort, on the rest. -mod support; - use std::collections::BTreeSet; use std::time::Duration; use moq_net::{Hop, Version, announce, origin}; + +use crate::support; use support::harness::{MockConnectOptions, connect_mock}; /// Long enough, in virtual time, for anything in flight to reach the far side. diff --git a/rs/moq-net/tests/integration/main.rs b/rs/moq-net/tests/integration/main.rs index d155bb218f5..341acebfe44 100644 --- a/rs/moq-net/tests/integration/main.rs +++ b/rs/moq-net/tests/integration/main.rs @@ -11,6 +11,7 @@ mod announce_to_serve; mod auth; mod broadcast_close; mod catalog_acquisition; +mod caught_up; mod datagram; mod dial_split_horizon; mod drained_claim; From b21aa2abd304b5dcecbbb5a6b2993672d9d05129 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 21:41:04 -0700 Subject: [PATCH 07/17] fix(net): a session joining wakes no announce cursor Counting a snapshot in flight took the cursor state's write guard, which wakes every waiter on it. A relay therefore woke each overlapping announce feed twice per session that connected, and that perturbation made the lite cluster triangle in cluster_unknown flap (about half of runs, never on main). Count through a read guard instead and wake only on the last landing, the one change a caught-up waiter can see. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/origin.rs | 62 +++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 1911031c2e8..7a5ef4d668b 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -5,7 +5,7 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, fmt, sync::Arc, - sync::atomic::{AtomicBool, AtomicU64, Ordering}, + sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, task::{Context, Poll, Wake, Waker, ready}, time::Duration, }; @@ -797,7 +797,10 @@ struct OriginConsumerState { ended: bool, /// Snapshots in flight that can present on this consumer's cursors, counted /// once per cursor they touch. See [`AnnounceConsumer::poll_caught_up`]. - waiting: usize, + /// + /// Changed through a read guard, so a session joining wakes none of the cursors it + /// holds back: nothing waits on losing caught up. Only the table lock writes it. + waiting: AtomicUsize, /// The pending prefixes the consumer still owes reading from before the last /// snapshot landed. Caught up once empty and nothing is `waiting`. owed: BTreeSet, @@ -807,15 +810,7 @@ impl OriginConsumerState { /// Whether the consumer has read everything its sources announced before they /// caught up. A torn-down origin has no sources left to wait on. fn caught_up(&self) -> bool { - (self.waiting == 0 || self.ended) && self.owed.is_empty() - } - - /// A snapshot that can present here landed. - fn landed(&mut self) { - self.waiting -= 1; - if self.waiting == 0 { - self.owe(); - } + (self.waiting.load(Ordering::Relaxed) == 0 || self.ended) && self.owed.is_empty() } /// Owe every pending update that changes which prefixes are announced: a start @@ -4329,10 +4324,12 @@ impl OriginState { snapshots.retain(|snapshot| cursor.awaits(snapshot)); snapshots.sort_unstable_by_key(|snapshot| snapshot.id); snapshots.dedup_by_key(|snapshot| snapshot.id); - if !snapshots.is_empty() - && let Ok(mut state) = cursor.state.write() - { - state.waiting += snapshots.len(); + if !snapshots.is_empty() { + cursor + .state + .read() + .waiting + .fetch_add(snapshots.len(), Ordering::Relaxed); } self.cursors.insert(id, cursor); @@ -4343,9 +4340,8 @@ impl OriginState { for id in self.routes.cursors_touching(prefix) { if let Some(cursor) = self.cursors.get(&id) && cursor.awaits(&snapshot) - && let Ok(mut state) = cursor.state.write() { - state.waiting += 1; + cursor.state.read().waiting.fetch_add(1, Ordering::Relaxed); } } self.routes @@ -4367,9 +4363,11 @@ impl OriginState { for id in self.routes.cursors_touching(prefix) { if let Some(cursor) = self.cursors.get(&id) && cursor.awaits(&snapshot) + // Only the last landing changes what a caught-up waiter sees, so only it wakes. + && cursor.state.read().waiting.fetch_sub(1, Ordering::Relaxed) == 1 && let Ok(mut state) = cursor.state.write() { - state.landed(); + state.owe(); } } } @@ -5666,7 +5664,7 @@ impl AnnounceConsumer { } // Under the table lock, so no snapshot lands between the count and this. if let Ok(mut state) = state.write() - && state.waiting == 0 + && state.waiting.load(Ordering::Relaxed) == 0 { state.owe(); } @@ -6332,6 +6330,32 @@ mod tests { assert!(announced.poll_caught_up(&waiter).is_ready()); } + /// A session joining or landing short of the last one wakes no parked cursor, so a + /// relay's announce feeds stay idle while peers connect. + #[test] + fn holding_back_wakes_nobody() { + struct Woken(AtomicBool); + impl Wake for Woken { + fn wake(self: Arc) { + self.0.store(true, Ordering::SeqCst); + } + } + + let producer = origin(1).produce(); + let mut announced = producer.consume().announced(); + let woken = Arc::new(Woken(AtomicBool::new(false))); + let waiter = kio::Waiter::new(std::task::Waker::from(woken.clone())); + assert!(announced.poll_next(&waiter).is_pending()); + assert!(announced.poll_caught_up(&waiter).is_ready()); + + let first = producer.snapshot(""); + let second = producer.snapshot("room"); + drop(first); + assert!(!woken.0.load(Ordering::SeqCst), "woken while still held back"); + drop(second); + assert!(woken.0.load(Ordering::SeqCst), "the last landing wakes"); + } + /// A `.`-prefixed segment below the requested prefix hides a route from /// discovery unless the reader opts in; one inside the prefix does not. #[test] From 2b165a29320e27453324d4bd22e3f513a01e8223 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 22:28:04 -0700 Subject: [PATCH 08/17] fix(js): carry caught up through every announce wrapper A forwarded request already caught up as it opens (draft-14/15 at the root with no Solicit) never released its origin snapshot, since the listener only sees later changes. Connection.announced() and a Reload without a consume origin pumped events but never caught up. They now mirror their upstream, behind while enabled with nothing delivering, as the consume origin's hold is. Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/forward.test.ts | 21 +++++++++++++++++++++ js/net/src/connection/forward.ts | 2 ++ js/net/src/connection/pool.test.ts | 17 +++++++++++++++++ js/net/src/connection/pool.ts | 9 ++++++++- js/net/src/connection/reload.test.ts | 12 +++++++++--- js/net/src/connection/reload.ts | 9 ++++++++- 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 44964e24b51..10313563e99 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -42,6 +42,9 @@ class FakeSession { */ readonly consumed = new Map(); + /** Whether each request is caught up as it opens, as one with no initial set to wait for is. */ + caughtUpAtOnce = false; + closed: Promise; #die!: (reason: Error | null) => void; @@ -55,6 +58,7 @@ class FakeSession { announced(scope: Path.Pattern = Path.Pattern.all(), options?: Announce.Options): Announce.Consumer { const stream = new Announce.Producer(); this.requests.push({ scope: scope.text, hidden: options?.hidden, stream }); + if (this.caughtUpAtOnce) stream.caughtUp(); return stream.consume(); } @@ -654,3 +658,20 @@ test("a session ending before its initial set lands stops holding the watcher", watch.close(); origin.close(); }); + +test("a request caught up as it opens releases the watcher", async () => { + const origin = new OriginProducer(); + const session = new FakeSession(); + session.caughtUpAtOnce = true; + forwardAnnounced(session.session, origin); + + const watch = origin.announced(); + record(watch); + await settle(); + expect(session.requests).toHaveLength(1); + expect(watch.caughtUp.peek()).toBe(true); + + watch.close(); + session.die(); + origin.close(); +}); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index bfb8646b298..b9b20528419 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -171,6 +171,8 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi const caughtUp = interest.announced.caughtUp.subscribe((caughtUp) => { if (caughtUp) snapshot(); }); + // A request with no initial set to wait for is caught up before the listener exists. + if (interest.announced.caughtUp.peek()) snapshot(); void (async () => { let failure: unknown; diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts index 9a22eb8aee6..3f419a6db10 100644 --- a/js/net/src/connection/pool.test.ts +++ b/js/net/src/connection/pool.test.ts @@ -607,3 +607,20 @@ test("pooled probe readers share demand and carry it through reconnects", async await settle(); } }); + +test("a handle's announcements catch up with the shared origin", async () => { + stubTransports(); + + const handle = new Connection({ url }); + const watch = handle.announced(); + expect(watch.caughtUp.peek()).toBe(false); + + await advance(); + expect(handle.status.peek()).toBe("connected"); + // The session's announce request answers after it establishes. + await advance(); + expect(watch.caughtUp.peek()).toBe(true); + + watch.close(); + handle.close(); +}); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 252f1e60202..9ef588aecc0 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -293,10 +293,17 @@ export class Connection { // until the whole handle closes. const stop = this.#signals.run((effect) => { const origin = effect.get(this.#origin); - if (!origin) return; + if (!origin) { + // Like a reconnecting loop's origin: behind while enabled with nothing to deliver. + if (effect.get(this.enabled)) producer.behind(); + else producer.caughtUp(); + return; + } const upstream = origin.announced(scope, options); effect.cleanup(() => upstream.close()); + // Caught up when the origin is: the pump has appended everything it owed by then. + effect.subscribe(upstream.caughtUp, (caughtUp) => (caughtUp ? producer.caughtUp() : producer.behind())); // Track what this origin announced so a URL switch retracts it; the last // event rides along for the retraction. diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 8bf8a42fe9e..cd189c32414 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -608,7 +608,13 @@ test("announced accepts a non-prefix scope before any session exists", () => { * a reconnect takes it back until the next session's set lands. */ test("a watcher catches up with the server's initial set, and again after a reconnect", async () => { - for (const alpn of [Lite.ALPN_06, IetfALPN.DRAFT_19]) { + // Through the consume origin, and through the handle's own stream without one. + for (const [alpn, viaOrigin] of [ + [Lite.ALPN_06, true], + [IetfALPN.DRAFT_19, true], + [Lite.ALPN_06, false], + [IetfALPN.DRAFT_19, false], + ] as const) { const original = globalThis.WebTransport; const url = new URL("https://example.com/"); @@ -630,9 +636,9 @@ test("a watcher catches up with the server's initial set, and again after a reco url, websocket: { enabled: false }, delay: { initial: Milli(10), multiplier: 1, max: Milli(10) }, - consume: clientOrigin, + consume: viaOrigin ? clientOrigin : undefined, }); - const watching = clientOrigin.announced(); + const watching = viaOrigin ? clientOrigin.announced() : reload.announced(); const seen = new Set(); void (async () => { for await (const event of watching) { diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 40f8d7df5a1..34a5c0ac1bd 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -598,10 +598,17 @@ export class Reload { const pump = new Effect(); pump.run((effect) => { const conn = effect.get(this.established); - if (!conn) return; + if (!conn) { + // The same hold as a consume origin's: behind while enabled with no session. + if (effect.get(this.enabled)) producer.behind(); + else producer.caughtUp(); + return; + } const upstream = conn.announced(scope, options); effect.cleanup(() => upstream.close()); + // Caught up when the session's stream is: the pump has appended everything it owed by then. + effect.subscribe(upstream.caughtUp, (caughtUp) => (caughtUp ? producer.caughtUp() : producer.behind())); // Track what this connection announced so we can retract it if the connection // drops; the last event rides along for the retraction. From ba587064db8e7888c54d9b7993043380bbf6c056 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 22:28:04 -0700 Subject: [PATCH 09/17] fix(js): close with the violation code over a misplaced ACTIVE_COUNT The session closed cleanly and the advertiser swallowed the violation, so the peer never saw PROTOCOL_VIOLATION. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/publisher.test.ts | 5 +++-- js/net/src/ietf/publisher.ts | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 37df583349d..8672b462731 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -3204,9 +3204,10 @@ test("an ACTIVE_COUNT answering a PUBLISH_NAMESPACE closes the session", async ( await stream.writer.u53(RequestOk.id); await new RequestOk({ requestId: undefined, parameters }).encode(stream.writer, VERSION); - await Promise.race([ + const closed = await Promise.race([ pair.server.closed, - new Promise((_resolve, reject) => setTimeout(() => reject(new Error("session stayed up")), 500)), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("session stayed up")), 500)), ]); + expect(closed.closeCode).toBe(new ProtocolViolation("").code); origin.close(); }); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 249e1a9c5f3..a8f9fb6e154 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1630,13 +1630,15 @@ export class Publisher { /** * Check a REQUEST_OK answering a PUBLISH_NAMESPACE. ACTIVE_COUNT only answers - * SUBSCRIBE_NAMESPACE (MoQ Active Count), and the session closes over it here since the - * caller only drops the advertisement. + * SUBSCRIBE_NAMESPACE (MoQ Active Count), and the session closes over it here, with the + * violation's code, since the caller only drops the advertisement. */ #answered(ok: RequestOk) { if (ok.parameters.activeCount === undefined) return; + const violation = new ProtocolViolation("ACTIVE_COUNT on a PUBLISH_NAMESPACE answer"); + this.#quic.close({ closeCode: violation.code, reason: violation.message }); this.#session.close(); - throw new ProtocolViolation("ACTIVE_COUNT on a PUBLISH_NAMESPACE answer"); + throw violation; } /** From 1cf90169146fd76e5eebd729dae522a33aef2e58 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 23:01:12 -0700 Subject: [PATCH 10/17] fix(net): land the last snapshot in one critical section Zeroing the wait count under a read guard and owing the queue under a later write guard left a gap where another thread could read caught up before the owed set was in. The last landing now does both under the write guard; earlier landings still only decrement, waking nobody. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/origin.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 7a5ef4d668b..a36d428a209 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -799,7 +799,8 @@ struct OriginConsumerState { /// once per cursor they touch. See [`AnnounceConsumer::poll_caught_up`]. /// /// Changed through a read guard, so a session joining wakes none of the cursors it - /// holds back: nothing waits on losing caught up. Only the table lock writes it. + /// holds back (nothing waits on losing caught up), except the last landing, which + /// takes the write guard to owe the queue too. Only the table lock changes it. waiting: AtomicUsize, /// The pending prefixes the consumer still owes reading from before the last /// snapshot landed. Caught up once empty and nothing is `waiting`. @@ -4361,13 +4362,26 @@ impl OriginState { return; }; for id in self.routes.cursors_touching(prefix) { - if let Some(cursor) = self.cursors.get(&id) - && cursor.awaits(&snapshot) - // Only the last landing changes what a caught-up waiter sees, so only it wakes. - && cursor.state.read().waiting.fetch_sub(1, Ordering::Relaxed) == 1 - && let Ok(mut state) = cursor.state.write() + let Some(cursor) = self.cursors.get(&id).filter(|cursor| cursor.awaits(&snapshot)) else { + continue; + }; + // Only the last landing changes what a caught-up waiter sees, so only it takes the + // write guard and wakes. It zeroes the count and owes the queue in one critical + // section, so no poll sees nothing waiting before the owed set is in. The table + // lock keeps the count from moving between the two guards. { - state.owe(); + let state = cursor.state.read(); + if state.waiting.load(Ordering::Relaxed) > 1 { + state.waiting.fetch_sub(1, Ordering::Relaxed); + continue; + } + } + match cursor.state.write() { + Ok(mut state) => { + state.waiting.store(0, Ordering::Relaxed); + state.owe(); + } + Err(state) => state.waiting.store(0, Ordering::Relaxed), } } } From b78ed255e7b242a8a268e95e429838f3ceeb61d3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 23:01:12 -0700 Subject: [PATCH 11/17] fix(js): close SUBSCRIBE_NAMESPACE violations with the violation code A miscounted or cut-short Active Count set, like a malformed advertisement, closed the session cleanly, so the peer never saw PROTOCOL_VIOLATION. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/subscriber.test.ts | 15 ++++++++++----- js/net/src/ietf/subscriber.ts | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 90c57360ef9..1daa72c74cc 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -2093,9 +2093,14 @@ async function answerCounted(transport: WebTransport, activeCount: number | unde const sessionClosed = (pair: ReturnType) => Promise.race([ pair.server.closed, - new Promise((_resolve, reject) => setTimeout(() => reject(new Error("session stayed up")), STREAM_WAIT)), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("session stayed up")), STREAM_WAIT)), ]); +/** The session closes with PROTOCOL_VIOLATION, not a clean code. */ +async function violationClosed(pair: ReturnType) { + expect((await sessionClosed(pair)).closeCode).toBe(new ProtocolViolation("").code); +} + const yieldTask = () => new Promise((resolve) => setTimeout(resolve, 0)); /** MoQ Active Count: the consumer catches up once the counted NAMESPACEs are read. */ @@ -2146,13 +2151,13 @@ test("an ACTIVE_COUNT that does not match the negotiation closes the session", a ] as const) { const pair = createMockTransportPair(ALPN.DRAFT_19); const session = new NativeSession(pair.server, VERSION, true); - const subscriber = new Subscriber({ session, activeCount: negotiated }); + const subscriber = new Subscriber({ session, quic: pair.server, activeCount: negotiated }); const warn = spyOn(console, "warn").mockImplementation(() => void 0); onTestFinished(() => warn.mockRestore()); subscriber.announced(); await answerCounted(pair.client, count); - await sessionClosed(pair); + await violationClosed(pair); } }); @@ -2161,7 +2166,7 @@ test.each(["done", "fin"] as const)("a counted set cut short by %s closes the se { const pair = createMockTransportPair(ALPN.DRAFT_19); const session = new NativeSession(pair.server, VERSION, true); - const subscriber = new Subscriber({ session, activeCount: true }); + const subscriber = new Subscriber({ session, quic: pair.server, activeCount: true }); const warn = spyOn(console, "warn").mockImplementation(() => void 0); onTestFinished(() => warn.mockRestore()); @@ -2177,6 +2182,6 @@ test.each(["done", "fin"] as const)("a counted set cut short by %s closes the se } else { subscription.writer.close(); } - await sessionClosed(pair); + await violationClosed(pair); } }); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 90eab824173..1c13b985786 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -404,6 +404,17 @@ export class Subscriber { } } + // The peer broke the protocol: close with the violation's code before the session's own + // close, which would send a clean one, as the connection does for its own violations. + #violated(err: ProtocolViolation) { + try { + this.#quic?.close({ closeCode: err.code, reason: err.message }); + } catch { + // Already closed. + } + this.#session.close(); + } + async #runAnnounced(announced: announce.Producer, prefix: Path.Valid, hidden: boolean) { const version = this.#session.version; @@ -596,7 +607,7 @@ export class Subscriber { // rejection would go unhandled. Idempotent with the catch below, which is // what runs when the loop is the one that ends the race. readLoop.catch((err: unknown) => { - if (err instanceof ProtocolViolation) this.#session.close(); + if (err instanceof ProtocolViolation) this.#violated(err); }); // Wait for either the read loop or the announced to close @@ -614,7 +625,7 @@ export class Subscriber { // is the only place a malformed one surfaces. The cluster draft requires closing // the session over those, and the stream alone is not enough: the peer would // just repeat it on the next SUBSCRIBE_NAMESPACE. - if (e instanceof ProtocolViolation) this.#session.close(); + if (e instanceof ProtocolViolation) this.#violated(e); // Abort the stream rather than letting the caller's `finally` close it cleanly. // A rejected namespace subscription is a failure, and a consumer that can't tell From be5ee590645bea30ce72051980240c1d04444ae2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 23:27:38 -0700 Subject: [PATCH 12/17] fix(net): a snapshot under a mount hole never holds the handle A cursor waited on any snapshot its patterns overlapped, even one beneath a subtree a mount shadows, so an unrelated session could hold the handle back. It now judges a snapshot as it judges a route there: not in a hole, and nameable through its mount. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/origin.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a36d428a209..d9c37db8ef3 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1330,9 +1330,14 @@ impl TableCursor { .map(|(_, captures)| captures) } - /// Whether this cursor waits on `snapshot`: what it delivers could present here. + /// Whether this cursor waits on `snapshot`: what it delivers could present here, + /// as [`Self::visible`] judges a route at or beneath its prefix. fn awaits(&self, snapshot: &SnapshotEntry) -> bool { - self.horizon.admits_link(snapshot.link) && self.allowed.overlaps(&snapshot.claim) + self.horizon.admits_link(snapshot.link) + && self.allowed.overlaps(&snapshot.claim) + && !self.holes.iter().any(|hole| snapshot.prefix.has_prefix(hole)) + // Too deep for the mount to name means everything beneath is too. + && self.named.as_ref().is_none_or(|(mount, _)| mount.names(&snapshot.prefix)) } /// Whether this cursor may observe `entry` at all: advertised, not behind @@ -1987,6 +1992,7 @@ impl Producer { id, link: self.link, claim, + prefix: prefix.clone(), }, ); Snapshot { @@ -3687,6 +3693,8 @@ struct SnapshotEntry { link: Link, /// The prefix's [`prefix_claim`], so a cursor whose patterns miss it never waits on it. claim: Pattern, + /// The absolute prefix, so a cursor that could see nothing beneath it never waits on it. + prefix: PathOwned, } /// A session still delivering its peer's initial announcements under a prefix. @@ -6323,6 +6331,23 @@ mod tests { assert!(!producer.consume().announced().is_caught_up()); } + /// A source shadowed by a mount can present nothing on the handle, so it never holds it. + #[test] + fn a_shadowed_snapshot_never_holds() { + let producer = origin(1).produce(); + let project = mounted(&producer, "p1", &["**"]).with_hidden(true); + let announced = project.announced(); + assert!(announced.is_caught_up()); + + let shadowed = producer.snapshot("p1/.svc"); + assert!(announced.is_caught_up(), "a snapshot in flight under the hole"); + assert!(project.announced().is_caught_up(), "a reader registering past one"); + drop(shadowed); + + let _target = producer.snapshot(".svc/p1"); + assert!(!announced.is_caught_up(), "the mount's target does hold it"); + } + /// A waiter parked on catching up is woken when it does. #[test] fn catching_up_wakes_the_waiter() { From 19c1a8ce32f5110682654d5f7f43f52e40350eb2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 23:44:51 -0700 Subject: [PATCH 13/17] fix(net): a snapshot under a hidden segment holds only hidden readers A default reader waited on a session's initial set under a hidden segment, though it lists none of those routes. Rust and JS now rule such a snapshot out the way they hide its routes; a top-up feed's rule depends on the rest of the path, so it never rules a prefix out. Co-Authored-By: Claude Opus 5.5 --- js/net/src/announced.test.ts | 13 +++++++++++++ js/net/src/origin.ts | 8 ++++++-- rs/moq-net/src/model/origin.rs | 25 +++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 675de6eb613..a687858fbe1 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -243,3 +243,16 @@ test("a snapshot elsewhere never holds a scoped stream", async () => { other(); scoped.close(); }); + +test("a snapshot under a hidden segment holds only a reader that opts in", async () => { + const origin = new OriginProducer(); + const stats = wireOf(origin).snapshot(p(".stats")); + const all = origin.consume().announced(); + const hidden = origin.consume().announced(Path.Pattern.all(), { hidden: true }); + await tick(); + expect(all.caughtUp.peek()).toBe(true); + expect(hidden.caughtUp.peek()).toBe(false); + stats(); + all.close(); + hidden.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 70fa7095bef..56e39051986 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -2039,10 +2039,14 @@ export class Consumer { } active = next; - // Caught up once no session is still delivering an initial set this stream sees. + // Caught up once no session is still delivering an initial set this stream could + // list: one under a hidden segment holds only a reader that opts in. let behind = false; for (const prefix of this.#state.snapshots.peek().keys()) { - if (patterns.overlaps(Path.Pattern.subtree(prefix))) behind = true; + for (const pattern of patterns) { + if (!pattern.overlaps(Path.Pattern.subtree(prefix))) continue; + if (hidden || !hiddenBelow(scopeHead(pattern), prefix)) behind = true; + } } if (behind) producer.behind(); else producer.caughtUp(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index d9c37db8ef3..fe4523b34b0 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1335,6 +1335,7 @@ impl TableCursor { fn awaits(&self, snapshot: &SnapshotEntry) -> bool { self.horizon.admits_link(snapshot.link) && self.allowed.overlaps(&snapshot.claim) + && self.hidden.reaches(&self.heads, &snapshot.prefix) && !self.holes.iter().any(|hole| snapshot.prefix.has_prefix(hole)) // Too deep for the mount to name means everything beneath is too. && self.named.as_ref().is_none_or(|(mount, _)| mount.names(&snapshot.prefix)) @@ -1591,8 +1592,14 @@ pub(crate) struct Hidden { impl Hidden { /// Whether a cursor hanging at `heads` reports a route at `prefix`. fn discovers(&self, heads: &[PathOwned], prefix: &Path) -> bool { - (self.include || !hides(self.from.as_ref().map(std::slice::from_ref).unwrap_or(heads), prefix)) - && self.beyond.as_ref().is_none_or(|outer| hides(outer, prefix)) + self.reaches(heads, prefix) && self.beyond.as_ref().is_none_or(|outer| hides(outer, prefix)) + } + + /// Whether a cursor hanging at `heads` could report a route at or beneath `prefix`. + /// A hidden segment hides everything beneath it, while a top-up feed's rule turns + /// on the rest of the path, so only the first can rule a prefix out. + fn reaches(&self, heads: &[PathOwned], prefix: &Path) -> bool { + self.include || !hides(self.from.as_ref().map(std::slice::from_ref).unwrap_or(heads), prefix) } /// This rule for a cursor reading through `mount`, whose heads sit on the @@ -6348,6 +6355,20 @@ mod tests { assert!(!announced.is_caught_up(), "the mount's target does hold it"); } + /// A source under a hidden segment holds only the readers that opt into hidden routes. + #[test] + fn a_hidden_snapshot_holds_only_hidden_readers() { + let producer = origin(1).produce(); + let announced = producer.consume().announced(); + let _stats = producer.snapshot(".stats"); + assert!(announced.is_caught_up(), "a snapshot in flight under a hidden segment"); + assert!( + producer.consume().announced().is_caught_up(), + "a reader registering past one" + ); + assert!(!producer.consume().with_hidden(true).announced().is_caught_up()); + } + /// A waiter parked on catching up is woken when it does. #[test] fn catching_up_wakes_the_waiter() { From 5cd60c262f78104f9bdab7035ed402eb32512540 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 23:44:56 -0700 Subject: [PATCH 14/17] fix(js): an oversized ACTIVE_COUNT on an advertisement answer closes too Reading the count throws before the violation close ran, so the advertisement was dropped and the session stayed up. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/publisher.test.ts | 47 ++++++++++++++++--------------- js/net/src/ietf/publisher.ts | 16 +++++++---- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 8672b462731..861ea215885 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -3188,26 +3188,29 @@ test("a counted answer names the NAMESPACEs that follow it", async () => { }); /** ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE, so one on any other REQUEST_OK is a violation. */ -test("an ACTIVE_COUNT answering a PUBLISH_NAMESPACE closes the session", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_19); - const { pub, origin } = publisher(pair.server, { activeCount: true }); - const warn = spyOn(console, "warn").mockImplementation(() => void 0); - onTestFinished(() => warn.mockRestore()); - void pub.runPublishNamespaces(); - publish(origin, Path.from("a")); - - const stream = await nextStream(pair.client); - if (!stream) throw new Error("no PUBLISH_NAMESPACE was sent"); - expect(await readPublishNamespace(stream)).toBe(Path.from("a")); - const parameters = new Parameters(); - parameters.activeCount = 0; - await stream.writer.u53(RequestOk.id); - await new RequestOk({ requestId: undefined, parameters }).encode(stream.writer, VERSION); +test.each([0, Number.MAX_SAFE_INTEGER + 2])( + "an ACTIVE_COUNT of %p answering a PUBLISH_NAMESPACE closes the session", + async (count) => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { activeCount: true }); + const warn = spyOn(console, "warn").mockImplementation(() => void 0); + onTestFinished(() => warn.mockRestore()); + void pub.runPublishNamespaces(); + publish(origin, Path.from("a")); - const closed = await Promise.race([ - pair.server.closed, - new Promise((_resolve, reject) => setTimeout(() => reject(new Error("session stayed up")), 500)), - ]); - expect(closed.closeCode).toBe(new ProtocolViolation("").code); - origin.close(); -}); + const stream = await nextStream(pair.client); + if (!stream) throw new Error("no PUBLISH_NAMESPACE was sent"); + expect(await readPublishNamespace(stream)).toBe(Path.from("a")); + const parameters = new Parameters(); + parameters.activeCount = count; + await stream.writer.u53(RequestOk.id); + await new RequestOk({ requestId: undefined, parameters }).encode(stream.writer, VERSION); + + const closed = await Promise.race([ + pair.server.closed, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("session stayed up")), 500)), + ]); + expect(closed.closeCode).toBe(new ProtocolViolation("").code); + origin.close(); + }, +); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index a8f9fb6e154..4535653f9a2 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1634,11 +1634,17 @@ export class Publisher { * violation's code, since the caller only drops the advertisement. */ #answered(ok: RequestOk) { - if (ok.parameters.activeCount === undefined) return; - const violation = new ProtocolViolation("ACTIVE_COUNT on a PUBLISH_NAMESPACE answer"); - this.#quic.close({ closeCode: violation.code, reason: violation.message }); - this.#session.close(); - throw violation; + try { + // Reading it throws on a count too large to hold, a violation all the same. + if (ok.parameters.activeCount === undefined) return; + throw new ProtocolViolation("ACTIVE_COUNT on a PUBLISH_NAMESPACE answer"); + } catch (err) { + if (err instanceof ProtocolViolation) { + this.#quic.close({ closeCode: err.code, reason: err.message }); + this.#session.close(); + } + throw err; + } } /** From c9ec7e8c93ec28678fd0c4a51e62b3cd752bd1fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 17 Oct 2026 00:11:07 -0700 Subject: [PATCH 15/17] fix(js): a replaced announce upstream owes its retractions Switching a handle's upstream while caught up left the downstream caught up: the retractions landed after the new upstream was mirrored, and Producer's caughtUp() is a no-op until behind(). Pool and Reload now share one pump that retracts synchronously at teardown, goes behind first so even a parked reader takes the ends as owed, and mirrors caught up only from an open upstream. Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/pool.ts | 37 +----------- js/net/src/connection/reload.test.ts | 36 +++++++++++- js/net/src/connection/reload.ts | 87 ++++++++++++++++++---------- 3 files changed, 95 insertions(+), 65 deletions(-) diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 9ef588aecc0..5bfb72641fd 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -26,7 +26,7 @@ import type { Established as EstablishedType } from "./established.ts"; import type { Extensions as ExtensionsType } from "./extensions.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import type { GoawayProps, Redirect as RedirectType } from "./goaway.ts"; -import { Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; +import { pumpAnnounced, Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; import type { Transport as TransportType } from "./transport.ts"; @@ -281,12 +281,6 @@ export class Connection { const producer = new Announce.Producer(); const consumer = producer.consume(); - // Closing the consumer closes the shared state, so stop appending after that. - let closed = false; - void consumer.closed.then(() => { - closed = true; - }); - // A child of this handle's scope rather than a standalone Effect it merely cleans up // after: the disposer run() hands back also drops itself from the parent, so opening // and closing announcement streams repeatedly does not pile up dead pumps that live @@ -299,33 +293,8 @@ export class Connection { else producer.caughtUp(); return; } - - const upstream = origin.announced(scope, options); - effect.cleanup(() => upstream.close()); - // Caught up when the origin is: the pump has appended everything it owed by then. - effect.subscribe(upstream.caughtUp, (caughtUp) => (caughtUp ? producer.caughtUp() : producer.behind())); - - // Track what this origin announced so a URL switch retracts it; the last - // event rides along for the retraction. - const active = new Map(); - - effect.spawn(async () => { - try { - for (;;) { - const entry = await effect.race(upstream.next()); - if (!entry) break; - if (entry.kind === "end") active.delete(entry.prefix); - else active.set(entry.prefix, entry); - producer.append(entry); - } - } finally { - if (!closed) { - for (const entry of active.values()) { - producer.append({ ...entry, kind: "end" }); - } - } - } - }); + // A URL switch retracts what the old origin announced. + pumpAnnounced(effect, origin.announced(scope, options), producer); }); void consumer.closed.then(stop); diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index cd189c32414..09a8d029d52 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -1,6 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { Milli } from "@moq/clock"; import { Effect, Signal } from "@moq/signals"; +import * as Announce from "../announced.ts"; import type { Producer as BroadcastProducer } from "../broadcast.ts"; import { SessionCode, SessionError, StreamCode, toTransport } from "../error.ts"; import { Route } from "../hop.ts"; @@ -11,7 +12,7 @@ import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { wireOf } from "../wire.ts"; import { type AcceptProps, accept as acceptSession } from "./index.ts"; -import { Reload, type ReloadProps } from "./reload.ts"; +import { pumpAnnounced, Reload, type ReloadProps } from "./reload.ts"; function accept(transport: WebTransport, url: URL, props: Omit = {}) { return acceptSession({ transport, url, ...props }); @@ -668,3 +669,36 @@ test("a watcher catches up with the server's initial set, and again after a reco } } }); + +/** Replacing a caught-up upstream with one already caught up still owes the old routes' ends. */ +test("a pump owes the retractions of the upstream it replaces", async () => { + const tick = async () => { + for (let i = 0; i < 16; i++) await Promise.resolve(); + }; + const producer = new Announce.Producer(); + const consumer = producer.consume(); + + const first = new Announce.Producer(); + first.append({ prefix: Path.from("a"), captures: undefined, kind: "start", route: Route.default }); + first.caughtUp(); + const upstream = new Signal(first); + const pump = new Effect((effect) => pumpAnnounced(effect, effect.get(upstream).consume(), producer)); + + expect(await consumer.next()).toMatchObject({ prefix: Path.from("a"), kind: "start" }); + const parked = consumer.next(); + await tick(); + expect(consumer.caughtUp.peek()).toBe(true); + + const second = new Announce.Producer(); + second.caughtUp(); + upstream.set(second); + expect(await parked).toMatchObject({ prefix: Path.from("a"), kind: "end" }); + await tick(); + expect(consumer.caughtUp.peek()).toBe(false); + void consumer.next(); + await tick(); + expect(consumer.caughtUp.peek()).toBe(true); + + pump.close(); + producer.close(); +}); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 34a5c0ac1bd..69eb89ba94f 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -605,36 +605,9 @@ export class Reload { return; } - const upstream = conn.announced(scope, options); - effect.cleanup(() => upstream.close()); - // Caught up when the session's stream is: the pump has appended everything it owed by then. - effect.subscribe(upstream.caughtUp, (caughtUp) => (caughtUp ? producer.caughtUp() : producer.behind())); - - // Track what this connection announced so we can retract it if the connection - // drops; the last event rides along for the retraction. - const active = new Map(); - - effect.spawn(async () => { - try { - for (;;) { - const entry = await effect.race(upstream.next()); - if (!entry) break; - if (entry.kind === "end") active.delete(entry.prefix); - else active.set(entry.prefix, entry); - producer.append(entry); - } - } catch { - // A dropped connection resets the announce stream; the retractions below cover it. - } finally { - // Retract everything from the connection that just went away, so a per-broadcast - // watcher tears down instead of clinging to the dead route. - if (consumer.closed.peek() === undefined) { - for (const entry of active.values()) { - producer.append({ ...entry, kind: "end" }); - } - } - } - }); + // A dropped connection retracts what it announced, so a per-broadcast watcher tears + // down instead of clinging to the dead route. + pumpAnnounced(effect, conn.announced(scope, options), producer); }); this.#signals.cleanup(() => pump.close()); @@ -659,6 +632,60 @@ export class Reload { } } +/** + * Pump `upstream` into `producer` for the rest of `effect`'s run: its events, then an `end` + * for each route still active once it closes or the run ends, so nothing clings to a source + * that went away. Caught up follows the upstream's, and owes those retractions too. + * + * @internal + */ +export function pumpAnnounced(effect: Effect, upstream: Announce.Consumer, producer: Announce.Producer) { + // The last event per active route rides along for its retraction. + const active = new Map(); + let live = true; + const retract = () => { + live = false; + if (producer.closed.peek() !== undefined || active.size === 0) return; + // Behind first, so a reader parked on the stream takes these as owed, not as news. + producer.behind(); + for (const entry of active.values()) producer.append({ ...entry, kind: "end" }); + active.clear(); + }; + + // Torn down in reverse: the mirror stops, the retractions land, then the upstream closes, + // so its closing never reads as caught up. + effect.cleanup(() => upstream.close()); + effect.cleanup(retract); + // A new upstream owes its own initial set, and the retractions of the one before. + producer.behind(); + effect.subscribe(upstream.caughtUp, (caughtUp) => { + if (!caughtUp) producer.behind(); + else if (upstream.closed.peek() === undefined) producer.caughtUp(); + }); + + effect.spawn(async () => { + try { + for (;;) { + const entry = await effect.race(upstream.next()); + if (!entry || !live || producer.closed.peek() !== undefined) break; + if (entry.kind === "end") active.delete(entry.prefix); + else active.set(entry.prefix, entry); + producer.append(entry); + } + } catch { + // A dropped connection resets the announce stream; the retractions below cover it. + } finally { + if (live) { + // The upstream ended on its own: nothing more comes from it, so once its + // retractions are read there is nothing left to wait for. + retract(); + producer.behind(); + producer.caughtUp(); + } + } + }); +} + /** * A session the peer sent GOAWAY on, left serving so its groups in flight finish. It retires * when it closes on its own or overstays its handover window, and `onRetire` runs once either way. From 725257112e8cae2ce77ddb8ba9b9c73aeaaef777 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 17 Oct 2026 00:27:04 -0700 Subject: [PATCH 16/17] fix(net): a teardown with a snapshot in flight owes the queue Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/origin.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index fe4523b34b0..e9d01e091a5 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2584,10 +2584,14 @@ impl DriverState { } // End the announce cursors: each drains its pending updates, then reports - // the end. Registrations stay (the cursors remove themselves on drop). + // the end. Registrations stay (the cursors remove themselves on drop). A + // snapshot still in flight is released like a landing, owing what is queued. for state in cursors { if let Ok(mut state) = state.write() { state.ended = true; + if state.waiting.swap(0, Ordering::Relaxed) > 0 { + state.owe(); + } } } } @@ -9561,6 +9565,22 @@ mod tests { assert!(late.next().now_or_never().expect("ended").is_none()); } + /// A teardown with a snapshot in flight still owes what the cursor has not read. + #[test] + #[cfg(feature = "async")] + fn teardown_owes_what_a_snapshot_held() { + let (producer, driver) = Producer::new(Config::new(origin(1))); + let snapshot = producer.snapshot("room"); + let mut announced = producer.consume().announced(); + let _alice = producer.announce("room/alice", Route::default()).unwrap(); + + drop(driver); + assert!(!announced.is_caught_up(), "the queue is still owed"); + announced.assert_next_active("room/alice"); + assert!(announced.is_caught_up()); + drop(snapshot); + } + #[test] fn internal_route_requests_are_closed_on_teardown() { let (producer, mut driver) = Producer::new(Config::new(origin(1))); From 7b88bcd8f10dd4da574f3d471e2f1c15200c6db1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 17 Oct 2026 07:35:37 -0700 Subject: [PATCH 17/17] refactor(net): rename caught up to synced Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 6 +- doc/concept/standard.md | 2 +- drafts/draft-lcurley-moq-active-count.md | 6 +- js/net/src/announced.test.ts | 56 ++++---- js/net/src/announced.ts | 28 ++-- js/net/src/connection/forward.test.ts | 26 ++-- js/net/src/connection/forward.ts | 16 +-- js/net/src/connection/pool.test.ts | 6 +- js/net/src/connection/pool.ts | 2 +- js/net/src/connection/reload.test.ts | 24 ++-- js/net/src/connection/reload.ts | 18 +-- js/net/src/ietf/active_count.ts | 2 +- js/net/src/ietf/parameters.ts | 2 +- js/net/src/ietf/subscriber.test.ts | 16 +-- js/net/src/ietf/subscriber.ts | 4 +- js/net/src/lite/subscriber.test.ts | 20 +-- js/net/src/lite/subscriber.ts | 4 +- js/net/src/origin.ts | 6 +- js/net/src/wire.ts | 2 +- quest/m1/lazy-discovery/rust.md | 2 +- rs/moq-net/Cargo.toml | 2 +- .../benches/{caught_up.rs => synced.rs} | 14 +- rs/moq-net/src/fuzz.rs | 2 +- rs/moq-net/src/ietf/active_count.rs | 6 +- rs/moq-net/src/ietf/peer.rs | 2 +- rs/moq-net/src/ietf/request.rs | 2 +- rs/moq-net/src/ietf/session.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 12 +- rs/moq-net/src/lite/publisher.rs | 7 +- rs/moq-net/src/lite/subscriber.rs | 34 ++--- rs/moq-net/src/model/origin.rs | 124 +++++++++--------- rs/moq-net/tests/integration/main.rs | 2 +- .../integration/{caught_up.rs => synced.rs} | 30 ++--- 33 files changed, 242 insertions(+), 245 deletions(-) rename rs/moq-net/benches/{caught_up.rs => synced.rs} (90%) rename rs/moq-net/tests/integration/{caught_up.rs => synced.rs} (78%) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 666a837558a..acfce7a3f07 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -37,14 +37,14 @@ how a conference room learns who joined, how a player learns a stream came online without polling, and how [relay clusters](/bin/relay/cluster) discover each other. -Once that first reply is read, the reader has **caught up**: a broadcast not +Once that first reply is read, the reader has **synced**: a broadcast not announced by then is absent rather than late, so a player can say "offline" instead of waiting. A relay replies from what it holds now and never waits on its own upstreams. moq-lite 05 and later, and moq-transport with the [active-count](/draft/moq-active-count) extension, mark exactly where the first -reply ends. Older versions catch up on the peer's answer, or as soon as the +reply ends. Older versions sync on the peer's answer, or as soon as the request is sent, as a best effort. Reading an origin fed by sessions, a reader -has caught up once every current session's first reply is in, so a reconnect +has synced once every current session's first reply is in, so a reconnect takes it back until the new reply lands. An announcement is a **route**: a claim that broadcasts at a path prefix, and diff --git a/doc/concept/standard.md b/doc/concept/standard.md index a4f21412a9f..bd120d2b6eb 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -42,7 +42,7 @@ ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, [auth](/draft/moq-auth) to tell each peer what it may publish and subscribe to, [active-count](/draft/moq-active-count) to count the `NAMESPACE` messages -before a `SUBSCRIBE_NAMESPACE` is caught up, and +before a `SUBSCRIBE_NAMESPACE` is synced, and [probe](/draft/moq-probe) for bandwidth estimation. Both libraries offer solicit and auth (draft 17 and later) unless a side's `extensions` setting declines one, which connects it as a peer that does not diff --git a/drafts/draft-lcurley-moq-active-count.md b/drafts/draft-lcurley-moq-active-count.md index 3be4d75db7b..cc28ea0c6dd 100644 --- a/drafts/draft-lcurley-moq-active-count.md +++ b/drafts/draft-lcurley-moq-active-count.md @@ -24,7 +24,7 @@ informative: --- abstract This document defines an extension for MoQ Transport {{moqt}} that tells a subscriber how many NAMESPACE messages answering its SUBSCRIBE_NAMESPACE come before the first change. -The subscriber then knows when it has caught up, instead of guessing from a quiet stream. +The subscriber then knows when it has synced, instead of guessing from a quiet stream. --- note_Note_to_Readers @@ -37,7 +37,7 @@ Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github. {::boilerplate bcp14-tagged} A suffix is **active** on a SUBSCRIBE_NAMESPACE stream from a NAMESPACE message carrying it until a NAMESPACE_DONE message carrying it. -A subscriber is **caught up** on a SUBSCRIBE_NAMESPACE once it has read Count ({{count}}) NAMESPACE messages after REQUEST_OK, immediately on a Count of 0. +A subscriber is **synced** on a SUBSCRIBE_NAMESPACE once it has read Count ({{count}}) NAMESPACE messages after REQUEST_OK, immediately on a Count of 0. # Introduction @@ -87,7 +87,7 @@ An endpoint MUST close the session with a PROTOCOL_VIOLATION if a REQUEST_OK ans # Security Considerations The count reveals no more than the NAMESPACE messages that follow it. -A publisher that promises more than it sends can only delay the subscriber's notion of having caught up, which it could equally do by withholding NAMESPACE messages. +A publisher that promises more than it sends can only delay the subscriber's notion of having synced, which it could equally do by withholding NAMESPACE messages. # IANA Considerations diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index a687858fbe1..70569c6f12d 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -137,60 +137,60 @@ const start = (prefix: string): Announce.Event => ({ route: Route.default, }); -test("a stream catches up once the reader is past what was queued at the boundary", async () => { +test("a stream syncs once the reader is past what was queued at the boundary", async () => { const producer = new Announce.Producer(); const consumer = producer.consume(); producer.append(start("a")); producer.append(start("b")); - expect(consumer.caughtUp.peek()).toBe(false); + expect(consumer.synced.peek()).toBe(false); - producer.caughtUp(); + producer.synced(); // Appended after the boundary, so not owed. producer.append(start("c")); - expect(consumer.caughtUp.peek()).toBe(false); + expect(consumer.synced.peek()).toBe(false); await consumer.next(); await consumer.next(); // Still handling b. - expect(consumer.caughtUp.peek()).toBe(false); + expect(consumer.synced.peek()).toBe(false); expect(await consumer.next()).toMatchObject({ prefix: p("c") }); - expect(consumer.caughtUp.peek()).toBe(true); + expect(consumer.synced.peek()).toBe(true); }); -test("an event taken before the boundary is handled before catching up", async () => { +test("an event taken before the boundary is handled before syncing", async () => { const producer = new Announce.Producer(); const consumer = producer.consume(); producer.append(start("a")); await consumer.next(); - producer.caughtUp(); - expect(consumer.caughtUp.peek()).toBe(false); + producer.synced(); + expect(consumer.synced.peek()).toBe(false); void consumer.next(); - expect(consumer.caughtUp.peek()).toBe(true); + expect(consumer.synced.peek()).toBe(true); producer.close(); }); -test("behind takes caught up back, and closing hands it back for good", () => { +test("behind takes sync back, and closing hands it back for good", () => { const producer = new Announce.Producer(); const consumer = producer.consume(); - producer.caughtUp(); - expect(consumer.caughtUp.peek()).toBe(true); + producer.synced(); + expect(consumer.synced.peek()).toBe(true); producer.behind(); - expect(consumer.caughtUp.peek()).toBe(false); + expect(consumer.synced.peek()).toBe(false); producer.close(); - expect(consumer.caughtUp.peek()).toBe(true); + expect(consumer.synced.peek()).toBe(true); producer.behind(); - expect(consumer.caughtUp.peek()).toBe(true); + expect(consumer.synced.peek()).toBe(true); }); -test("an origin with no session delivering catches up on its own replay", async () => { +test("an origin with no session delivering syncs on its own replay", async () => { const origin = new OriginProducer(); const room = publish(origin, p("room")); const announced = origin.consume().announced(); expect(await announced.next()).toMatchObject({ prefix: p("room"), kind: "start" }); void announced.next(); await tick(); - expect(announced.caughtUp.peek()).toBe(true); + expect(announced.synced.peek()).toBe(true); room.close(); announced.close(); }); @@ -204,14 +204,14 @@ test("an origin stream waits on every overlapping initial set in flight", async const scoped = origin.consume().announced(Path.Pattern.subtree(p("room"))); const all = origin.consume().announced(); await tick(); - expect(scoped.caughtUp.peek()).toBe(false); - expect(all.caughtUp.peek()).toBe(false); + expect(scoped.synced.peek()).toBe(false); + expect(all.synced.peek()).toBe(false); other(); await tick(); // Still held by the room snapshot; the other one never held the scoped stream. - expect(scoped.caughtUp.peek()).toBe(false); - expect(all.caughtUp.peek()).toBe(false); + expect(scoped.synced.peek()).toBe(false); + expect(all.synced.peek()).toBe(false); // The initial set lands, and so does its snapshot. const route = wire.receive(p("room/cam")); @@ -219,15 +219,15 @@ test("an origin stream waits on every overlapping initial set in flight", async expect(await scoped.next()).toMatchObject({ prefix: p("room/cam"), kind: "start" }); void scoped.next(); await tick(); - expect(scoped.caughtUp.peek()).toBe(true); + expect(scoped.synced.peek()).toBe(true); // A new session, as a reconnect makes, takes it back until its own set lands. const second = wire.snapshot(p("")); await tick(); - expect(scoped.caughtUp.peek()).toBe(false); + expect(scoped.synced.peek()).toBe(false); second(); await tick(); - expect(scoped.caughtUp.peek()).toBe(true); + expect(scoped.synced.peek()).toBe(true); route.close(); scoped.close(); @@ -239,7 +239,7 @@ test("a snapshot elsewhere never holds a scoped stream", async () => { const other = wireOf(origin).snapshot(p("other")); const scoped = origin.consume().announced(Path.Pattern.subtree(p("room"))); await tick(); - expect(scoped.caughtUp.peek()).toBe(true); + expect(scoped.synced.peek()).toBe(true); other(); scoped.close(); }); @@ -250,8 +250,8 @@ test("a snapshot under a hidden segment holds only a reader that opts in", async const all = origin.consume().announced(); const hidden = origin.consume().announced(Path.Pattern.all(), { hidden: true }); await tick(); - expect(all.caughtUp.peek()).toBe(true); - expect(hidden.caughtUp.peek()).toBe(false); + expect(all.synced.peek()).toBe(true); + expect(hidden.synced.peek()).toBe(false); stats(); all.close(); hidden.close(); diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 2a68bd7a371..35ba251bf62 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -61,18 +61,18 @@ export interface Options { class AnnounceState { queue = new Signal([]); closed = new Once(); - caughtUp = new Signal(false); - // The queued events still to read before the stream counts as caught up, or undefined + synced = new Signal(false); + // The queued events still to read before the stream counts as synced, or undefined // while the initial set is still arriving. owed: number | undefined; - // A reader holds the last event it took before catching up until it asks for the next - // one, so whatever it does with that event lands before caught up flips. + // A reader holds the last event it took before syncing until it asks for the next + // one, so whatever it does with that event lands before synced flips. reading = false; } -// Caught up once nothing is owed and the reader has come back for more. +// Synced once nothing is owed and the reader has come back for more. function settle(state: AnnounceState) { - if (state.owed === 0 && !state.reading) state.caughtUp.set(true); + if (state.owed === 0 && !state.reading) state.synced.set(true); } // Once.set throws on a second settle, and both ends of a stream can close independently. @@ -85,7 +85,7 @@ function closeState(state: AnnounceState, abort?: Error) { // Nothing more is coming, so nothing waiting on the boundary should hang. state.owed = 0; state.reading = false; - state.caughtUp.set(true); + state.synced.set(true); } /** @@ -118,12 +118,12 @@ export class Producer { } /** - * Marks the initial set as delivered: the stream catches up once the reader has taken + * Marks the initial set as delivered: the stream syncs once the reader has taken * everything queued so far. A no-op until {@link behind} once marked. * * @internal */ - caughtUp() { + synced() { const state = this.#state; if (state.owed !== undefined) return; state.owed = state.queue.peek().length; @@ -131,7 +131,7 @@ export class Producer { } /** - * Takes caught up back until the next {@link caughtUp}, as when another initial set starts. + * Takes sync back until the next {@link synced}, as when another initial set starts. * * @internal */ @@ -139,7 +139,7 @@ export class Producer { const state = this.#state; if (state.closed.peek() !== undefined) return; state.owed = undefined; - state.caughtUp.set(false); + state.synced.set(false); } /** Closes the writer. Idempotent. */ @@ -185,8 +185,8 @@ export class Consumer { * on versions that count it, best-effort on the rest. Reading an origin, it is every * attached session's initial set, so a reconnect takes it back until the new one lands. */ - get caughtUp(): Getter { - return this.#state.caughtUp; + get synced(): Getter { + return this.#state.synced; } /** The events as they arrive, until the stream closes. */ @@ -207,7 +207,7 @@ export class Consumer { const announce = state.queue.peek().shift(); if (announce) { if (state.owed) state.owed -= 1; - if (!state.caughtUp.peek()) state.reading = true; + if (!state.synced.peek()) state.reading = true; return announce; } diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 10313563e99..ed3350c345a 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -42,8 +42,8 @@ class FakeSession { */ readonly consumed = new Map(); - /** Whether each request is caught up as it opens, as one with no initial set to wait for is. */ - caughtUpAtOnce = false; + /** Whether each request is synced as it opens, as one with no initial set to wait for is. */ + syncedAtOnce = false; closed: Promise; #die!: (reason: Error | null) => void; @@ -58,7 +58,7 @@ class FakeSession { announced(scope: Path.Pattern = Path.Pattern.all(), options?: Announce.Options): Announce.Consumer { const stream = new Announce.Producer(); this.requests.push({ scope: scope.text, hidden: options?.hidden, stream }); - if (this.caughtUpAtOnce) stream.caughtUp(); + if (this.syncedAtOnce) stream.synced(); return stream.consume(); } @@ -615,7 +615,7 @@ test("one failed request leaves the other routes live until the session ends", a origin.close(); }); -test("an origin watcher catches up with the session's initial set, not before", async () => { +test("an origin watcher syncs with the session's initial set, not before", async () => { const origin = new OriginProducer(); const session = new FakeSession(); forwardAnnounced(session.session, origin); @@ -623,18 +623,18 @@ test("an origin watcher catches up with the session's initial set, not before", const watch = origin.announced(Path.Pattern.subtree(Path.from("room"))); const events = record(watch); await settle(); - expect(watch.caughtUp.peek()).toBe(false); + expect(watch.synced.peek()).toBe(false); const stream = session.stream("room/**"); start(stream, "room/cam"); await settle(); // The route is in, but the peer has not said the set is complete. expect(events).toEqual(["start room/cam"]); - expect(watch.caughtUp.peek()).toBe(false); + expect(watch.synced.peek()).toBe(false); - stream.caughtUp(); + stream.synced(); await settle(); - expect(watch.caughtUp.peek()).toBe(true); + expect(watch.synced.peek()).toBe(true); watch.close(); session.die(); @@ -649,27 +649,27 @@ test("a session ending before its initial set lands stops holding the watcher", const watch = origin.announced(); record(watch); await settle(); - expect(watch.caughtUp.peek()).toBe(false); + expect(watch.synced.peek()).toBe(false); session.die(); await settle(); - expect(watch.caughtUp.peek()).toBe(true); + expect(watch.synced.peek()).toBe(true); watch.close(); origin.close(); }); -test("a request caught up as it opens releases the watcher", async () => { +test("a request synced as it opens releases the watcher", async () => { const origin = new OriginProducer(); const session = new FakeSession(); - session.caughtUpAtOnce = true; + session.syncedAtOnce = true; forwardAnnounced(session.session, origin); const watch = origin.announced(); record(watch); await settle(); expect(session.requests).toHaveLength(1); - expect(watch.caughtUp.peek()).toBe(true); + expect(watch.synced.peek()).toBe(true); watch.close(); session.die(); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index b9b20528419..932533666da 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -54,7 +54,7 @@ interface Reported { * already covers, and closes {@link LINGER} after nothing at or beneath its prefix is watched. * A narrower watch rides an open broader request, and a narrower request already open stays * open until the broader one covering it goes, so neither takes the other's routes over - * before it has caught up, which not every version can tell. A route both report is inserted once and + * before it has synced, which not every version can tell. A route both report is inserted once and * driven by its first reporter; if it changes hands anyway (a request failing), it restarts * unless one epoch proves both requests saw one instance. * @@ -165,14 +165,14 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi interests.set(prefix, interest); // The origin's readers under the prefix wait on this request's initial set. Released - // once the session stream has caught up, which it reports only after the last of those + // once the session stream has synced, which it reports only after the last of those // events is reported here. const snapshot = originWire.snapshot(prefix); - const caughtUp = interest.announced.caughtUp.subscribe((caughtUp) => { - if (caughtUp) snapshot(); + const synced = interest.announced.synced.subscribe((synced) => { + if (synced) snapshot(); }); - // A request with no initial set to wait for is caught up before the listener exists. - if (interest.announced.caughtUp.peek()) snapshot(); + // A request with no initial set to wait for is synced before the listener exists. + if (interest.announced.synced.peek()) snapshot(); void (async () => { let failure: unknown; @@ -186,7 +186,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi // The session died mid-stream, or the peer refused or reset the request. failure = err; } finally { - caughtUp(); + synced(); snapshot(); for (const reported of [...interest.reported]) unreport(interest, reported); @@ -211,7 +211,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi /** * Settle a request nothing beneath it watches any more. A broader open request that is * still needed holds it until that one goes, so the broader never takes its routes over - * before it has caught up; otherwise it lingers out. + * before it has synced; otherwise it lingers out. */ const release = (prefix: Path.Valid, interest: Interest) => { for (const above of coveringPrefixes(prefix)) { diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts index 3f419a6db10..3a8a4451c74 100644 --- a/js/net/src/connection/pool.test.ts +++ b/js/net/src/connection/pool.test.ts @@ -608,18 +608,18 @@ test("pooled probe readers share demand and carry it through reconnects", async } }); -test("a handle's announcements catch up with the shared origin", async () => { +test("a handle's announcements sync with the shared origin", async () => { stubTransports(); const handle = new Connection({ url }); const watch = handle.announced(); - expect(watch.caughtUp.peek()).toBe(false); + expect(watch.synced.peek()).toBe(false); await advance(); expect(handle.status.peek()).toBe("connected"); // The session's announce request answers after it establishes. await advance(); - expect(watch.caughtUp.peek()).toBe(true); + expect(watch.synced.peek()).toBe(true); watch.close(); handle.close(); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 5bfb72641fd..ac37be11839 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -290,7 +290,7 @@ export class Connection { if (!origin) { // Like a reconnecting loop's origin: behind while enabled with nothing to deliver. if (effect.get(this.enabled)) producer.behind(); - else producer.caughtUp(); + else producer.synced(); return; } // A URL switch retracts what the old origin announced. diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 09a8d029d52..2385d760dcb 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -604,11 +604,11 @@ test("announced accepts a non-prefix scope before any session exists", () => { }); /** - * A watcher on the consume origin catches up only once the session has delivered the + * A watcher on the consume origin syncs only once the session has delivered the * server's initial set, exactly on versions that count it (lite-05+, MoQ Active Count), and * a reconnect takes it back until the next session's set lands. */ -test("a watcher catches up with the server's initial set, and again after a reconnect", async () => { +test("a watcher syncs with the server's initial set, and again after a reconnect", async () => { // Through the consume origin, and through the handle's own stream without one. for (const [alpn, viaOrigin] of [ [Lite.ALPN_06, true], @@ -650,13 +650,13 @@ test("a watcher catches up with the server's initial set, and again after a reco try { // Nothing is connected yet, which is not the same as nothing being announced. - expect(watching.caughtUp.peek()).toBe(false); - await waitUntil(() => watching.caughtUp.peek()); + expect(watching.synced.peek()).toBe(false); + await waitUntil(() => watching.synced.peek()); expect([...seen].sort()).toEqual(["a", "b"]); sessions[0]?.abort(); - await waitUntil(() => !watching.caughtUp.peek()); - await waitUntil(() => sessions.length > 1 && watching.caughtUp.peek()); + await waitUntil(() => !watching.synced.peek()); + await waitUntil(() => sessions.length > 1 && watching.synced.peek()); expect([...seen].sort()).toEqual(["a", "b"]); } finally { watching.close(); @@ -670,7 +670,7 @@ test("a watcher catches up with the server's initial set, and again after a reco } }); -/** Replacing a caught-up upstream with one already caught up still owes the old routes' ends. */ +/** Replacing a synced upstream with one already synced still owes the old routes' ends. */ test("a pump owes the retractions of the upstream it replaces", async () => { const tick = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); @@ -680,24 +680,24 @@ test("a pump owes the retractions of the upstream it replaces", async () => { const first = new Announce.Producer(); first.append({ prefix: Path.from("a"), captures: undefined, kind: "start", route: Route.default }); - first.caughtUp(); + first.synced(); const upstream = new Signal(first); const pump = new Effect((effect) => pumpAnnounced(effect, effect.get(upstream).consume(), producer)); expect(await consumer.next()).toMatchObject({ prefix: Path.from("a"), kind: "start" }); const parked = consumer.next(); await tick(); - expect(consumer.caughtUp.peek()).toBe(true); + expect(consumer.synced.peek()).toBe(true); const second = new Announce.Producer(); - second.caughtUp(); + second.synced(); upstream.set(second); expect(await parked).toMatchObject({ prefix: Path.from("a"), kind: "end" }); await tick(); - expect(consumer.caughtUp.peek()).toBe(false); + expect(consumer.synced.peek()).toBe(false); void consumer.next(); await tick(); - expect(consumer.caughtUp.peek()).toBe(true); + expect(consumer.synced.peek()).toBe(true); pump.close(); producer.close(); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 69eb89ba94f..9570ddb5927 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -245,11 +245,11 @@ export class Reload { if (this.consume) { const consume = wireOf(this.consume); this.#signals.cleanup(consume.expect()); - // Likewise, its announcement streams are not caught up while no session is there to + // Likewise, its announcement streams are not synced while no session is there to // deliver an initial set, so an empty table before the first connect, or during a // reconnect, never reads as nothing being announced. The next session takes its own // hold before this one is released. Taken now rather than in the effect, which runs - // a microtask later, after a watcher created alongside would already have caught up. + // a microtask later, after a watcher created alongside would already have synced. let hold = this.enabled.peek() ? consume.snapshot(Path.empty()) : undefined; this.#signals.run((effect) => { if (effect.get(this.enabled) && !effect.get(this.established)) { @@ -601,7 +601,7 @@ export class Reload { if (!conn) { // The same hold as a consume origin's: behind while enabled with no session. if (effect.get(this.enabled)) producer.behind(); - else producer.caughtUp(); + else producer.synced(); return; } @@ -635,7 +635,7 @@ export class Reload { /** * Pump `upstream` into `producer` for the rest of `effect`'s run: its events, then an `end` * for each route still active once it closes or the run ends, so nothing clings to a source - * that went away. Caught up follows the upstream's, and owes those retractions too. + * that went away. Synced follows the upstream's, and owes those retractions too. * * @internal */ @@ -653,14 +653,14 @@ export function pumpAnnounced(effect: Effect, upstream: Announce.Consumer, produ }; // Torn down in reverse: the mirror stops, the retractions land, then the upstream closes, - // so its closing never reads as caught up. + // so its closing never reads as synced. effect.cleanup(() => upstream.close()); effect.cleanup(retract); // A new upstream owes its own initial set, and the retractions of the one before. producer.behind(); - effect.subscribe(upstream.caughtUp, (caughtUp) => { - if (!caughtUp) producer.behind(); - else if (upstream.closed.peek() === undefined) producer.caughtUp(); + effect.subscribe(upstream.synced, (synced) => { + if (!synced) producer.behind(); + else if (upstream.closed.peek() === undefined) producer.synced(); }); effect.spawn(async () => { @@ -680,7 +680,7 @@ export function pumpAnnounced(effect: Effect, upstream: Announce.Consumer, produ // retractions are read there is nothing left to wait for. retract(); producer.behind(); - producer.caughtUp(); + producer.synced(); } } }); diff --git a/js/net/src/ietf/active_count.ts b/js/net/src/ietf/active_count.ts index 7baf9799a5d..361d98c9777 100644 --- a/js/net/src/ietf/active_count.ts +++ b/js/net/src/ietf/active_count.ts @@ -8,7 +8,7 @@ import { type IetfVersion, Version } from "./version.ts"; * advertises, then reports changes on the same stream. Nothing in moq-transport says where * the first part ends, so when both endpoints declare the ACTIVE_COUNT option, the * publisher puts on its REQUEST_OK how many NAMESPACE messages come before the - * subscription is caught up. + * subscription is synced. * * @module * @internal diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index 0482731d172..13f9d5f4cca 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -554,7 +554,7 @@ export class Parameters { else this.vars.delete(MSG_PARAM_HIDDEN); } - /** How many NAMESPACE messages follow a REQUEST_OK before the subscription is caught up (MoQ Active Count). */ + /** How many NAMESPACE messages follow a REQUEST_OK before the subscription is synced (MoQ Active Count). */ get activeCount(): number | undefined { const v = this.vars.get(MSG_PARAM_ACTIVE_COUNT); if (v === undefined) return undefined; diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index b75eeadc119..c35795565c0 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -2121,8 +2121,8 @@ async function violationClosed(pair: ReturnType) const yieldTask = () => new Promise((resolve) => setTimeout(resolve, 0)); -/** MoQ Active Count: the consumer catches up once the counted NAMESPACEs are read. */ -test("counted NAMESPACEs catch up", async () => { +/** MoQ Active Count: the consumer syncs once the counted NAMESPACEs are read. */ +test("counted NAMESPACEs sync", async () => { const pair = createMockTransportPair(ALPN.DRAFT_19); const session = new NativeSession(pair.server, VERSION, true); const subscriber = new Subscriber({ session, activeCount: true }); @@ -2133,31 +2133,31 @@ test("counted NAMESPACEs catch up", async () => { expect(await announced.next()).toMatchObject({ prefix: Path.from("a"), kind: "start" }); const b = announced.next(); await yieldTask(); - expect(announced.caughtUp.peek()).toBe(false); + expect(announced.synced.peek()).toBe(false); await inlineNamespace(subscription, Path.from("b")); expect(await b).toMatchObject({ prefix: Path.from("b"), kind: "start" }); await yieldTask(); // Still handling the last of them. - expect(announced.caughtUp.peek()).toBe(false); + expect(announced.synced.peek()).toBe(false); void announced.next(); await yieldTask(); - expect(announced.caughtUp.peek()).toBe(true); + expect(announced.synced.peek()).toBe(true); announced.close(); }); /** Without MoQ Active Count, REQUEST_OK is the best boundary there is. */ -test("an uncounted answer catches up on the OK", async () => { +test("an uncounted answer syncs on the OK", async () => { const pair = createMockTransportPair(ALPN.DRAFT_19); const session = new NativeSession(pair.server, VERSION, true); const subscriber = new Subscriber({ session }); const announced = subscriber.announced(); await yieldTask(); - expect(announced.caughtUp.peek()).toBe(false); + expect(announced.synced.peek()).toBe(false); await acceptSubscribeNamespace(pair.client); await yieldTask(); - expect(announced.caughtUp.peek()).toBe(true); + expect(announced.synced.peek()).toBe(true); announced.close(); }); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index f89c6a91496..37745c1a048 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -426,7 +426,7 @@ export class Subscriber { const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15; if (legacy && prefix.length === 0 && this.#solicit === undefined) { // Nothing marks where what it tells unasked starts or ends, so there is nothing to wait for. - announced.caughtUp(); + announced.synced(); // No request stream ends this wait, so the session's end has to. const ends: PromiseLike[] = [announced.closed]; if (this.#quic) ends.push(this.#quic.closed.catch(() => undefined)); @@ -538,7 +538,7 @@ export class Subscriber { // Loop reading Namespace/NamespaceDone entries const readLoop = (async () => { for (;;) { - if (initial === 0) announced.caughtUp(); + if (initial === 0) announced.synced(); const done = await stream.reader.done(); if (done) { diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index c401c3642e7..9c70364afe9 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1833,7 +1833,7 @@ test("closing a broadcast while TRACK_INFO is pending leaves no unhandled reject const announceStart = (suffix: string) => (w: Writer) => encodeAnnounceBroadcast(w, { status: "active", suffix: Path.from(suffix), hops: [PUBLISHER_A] }, Version.DRAFT_06); -test("a counted initial set catches up once the reader is past its last announce", async () => { +test("a counted initial set syncs once the reader is past its last announce", async () => { const { subscriber, send, settle } = announceHarness(Version.DRAFT_06); const announced = subscriber.announced(); await settle(); @@ -1841,31 +1841,31 @@ test("a counted initial set catches up once the reader is past its last announce await send((w) => new AnnounceOk(PEER, 2).encode(w, Version.DRAFT_06)); await send(announceStart("a")); expect(await announced.next()).toMatchObject({ prefix: Path.from("a"), kind: "start" }); - expect(announced.caughtUp.peek()).toBe(false); + expect(announced.synced.peek()).toBe(false); await send(announceStart("b")); expect(await announced.next()).toMatchObject({ prefix: Path.from("b"), kind: "start" }); await settle(); // Still handling the last of them. - expect(announced.caughtUp.peek()).toBe(false); + expect(announced.synced.peek()).toBe(false); void announced.next(); await settle(); - expect(announced.caughtUp.peek()).toBe(true); + expect(announced.synced.peek()).toBe(true); announced.close(); subscriber.close(); }); -test("an empty initial set catches up on the answer", async () => { +test("an empty initial set syncs on the answer", async () => { const { subscriber, send, settle } = announceHarness(Version.DRAFT_06); const announced = subscriber.announced(); await settle(); - expect(announced.caughtUp.peek()).toBe(false); + expect(announced.synced.peek()).toBe(false); await send((w) => new AnnounceOk(PEER, 0).encode(w, Version.DRAFT_06)); await settle(); - expect(announced.caughtUp.peek()).toBe(true); + expect(announced.synced.peek()).toBe(true); announced.close(); subscriber.close(); @@ -1892,11 +1892,11 @@ test("a counted initial set cut short is a protocol violation", async () => { } }); -test("a version without a boundary catches up once the request is out", async () => { +test("a version without a boundary syncs once the request is out", async () => { const { subscriber, settle } = announceHarness(Version.DRAFT_03); const announced = subscriber.announced(); await settle(); - expect(announced.caughtUp.peek()).toBe(true); + expect(announced.synced.peek()).toBe(true); announced.close(); subscriber.close(); @@ -1911,7 +1911,7 @@ test("announce init is the initial set", async () => { expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "start" }); void announced.next(); await settle(); - expect(announced.caughtUp.peek()).toBe(true); + expect(announced.synced.peek()).toBe(true); announced.close(); subscriber.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 17b10e41cad..4d4f2a1d3b3 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -317,7 +317,7 @@ export class Subscriber { // keep the reflected-announce loop check seeing the full chain. let responderOrigin: Hop | undefined; // The ANNOUNCEs still owed of the peer's initial set (lite-05+). Older versions - // mark no boundary, so they catch up once the request is out, as a best effort. + // mark no boundary, so they sync once the request is out, as a best effort. let initial = 0; if (hasAnnounceOk(this.version)) { const ok = await race([AnnounceOk.decode(stream.reader, this.version), announced.closed]); @@ -394,7 +394,7 @@ export class Subscriber { // Receive announce updates (for Draft03+, this includes initial state) for (;;) { - if (initial === 0) announced.caughtUp(); + if (initial === 0) announced.synced(); const announce = await race([ decodeAnnounceBroadcastMaybe(stream.reader, this.version), diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 821a8a5aecf..3fbe24f4c73 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -610,7 +610,7 @@ class OriginState { sessions = new Signal({ total: 0, discovery: 0 }); // The prefixes whose initial announcements a session is still delivering, counted. An - // announcement stream overlapping one has not caught up yet. + // announcement stream overlapping one has not synced yet. snapshots = new Signal(new Map()); /** Hold an initial set in flight under the absolute `prefix` until the returned dispose. */ @@ -2053,7 +2053,7 @@ export class Consumer { } active = next; - // Caught up once no session is still delivering an initial set this stream could + // Synced once no session is still delivering an initial set this stream could // list: one under a hidden segment holds only a reader that opts in. let behind = false; for (const prefix of this.#state.snapshots.peek().keys()) { @@ -2063,7 +2063,7 @@ export class Consumer { } } if (behind) producer.behind(); - else producer.caughtUp(); + else producer.synced(); await Signal.race( this.#state.local, diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 3cd0c1c8c2f..a0470d30bf6 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -54,7 +54,7 @@ export interface OriginProducer { attach(discovery: boolean): Dispose; expect(): Dispose; /** - * Hold the origin's announcement streams overlapping `prefix` short of caught up until + * Hold the origin's announcement streams overlapping `prefix` unsynced until * the returned dispose: a session holds one while its peer's initial set is in flight. */ snapshot(prefix: Path.Valid): Dispose; diff --git a/quest/m1/lazy-discovery/rust.md b/quest/m1/lazy-discovery/rust.md index f5caece745c..87e248b5c22 100644 --- a/quest/m1/lazy-discovery/rust.md +++ b/quest/m1/lazy-discovery/rust.md @@ -33,7 +33,7 @@ covering-set changes a watch causes, so a change costs the prefixes it covers or uncovers and stays flat over unrelated held watches (`js/net/bench/forward.ts`); an announce request stays open while anything at or beneath its prefix is watched, so a narrower watch rides it and never takes -its routes over (nothing tells when a fresh request has caught up); a narrower +its routes over (nothing tells when a fresh request has synced); a narrower request already open when a broader watch arrives stays open until the broader request goes; a route reported by overlapping requests is inserted once, driven by the first reporter, and if it changes hands anyway (a request diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 52b56031557..817d74e89bd 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -97,7 +97,7 @@ required-features = ["fuzz"] # Reaches the crate-private session snapshot through the hidden `fuzz` module. [[bench]] -name = "caught_up" +name = "synced" harness = false required-features = ["fuzz"] diff --git a/rs/moq-net/benches/caught_up.rs b/rs/moq-net/benches/synced.rs similarity index 90% rename from rs/moq-net/benches/caught_up.rs rename to rs/moq-net/benches/synced.rs index 0613c0f9710..a648e25131e 100644 --- a/rs/moq-net/benches/caught_up.rs +++ b/rs/moq-net/benches/synced.rs @@ -1,4 +1,4 @@ -//! What it costs an origin to track whether its announce consumers have caught up. +//! What it costs an origin to track whether its announce consumers have synced. //! //! Every session requesting announcements holds a snapshot on the origin until its //! peer's initial set is in, and the announce consumers its prefix overlaps wait on @@ -11,7 +11,7 @@ //! //! The snapshot is crate-private, so this reaches it through the hidden `fuzz` module. //! -//! Run with `cargo bench -p moq-net --features fuzz --bench caught_up`. +//! Run with `cargo bench -p moq-net --features fuzz --bench synced`. use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use moq_net::{Hop, Pattern, Patterns, announce, fuzz, origin}; @@ -52,7 +52,7 @@ fn scoped(producer: &origin::Producer, prefix: &str) -> origin::Consumer { } fn bench_land(c: &mut Criterion) { - let mut group = c.benchmark_group("caught_up/land"); + let mut group = c.benchmark_group("synced/land"); for (streams, consumers) in SHAPES { let id = BenchmarkId::from_parameter(format!("{streams}st_{consumers}c")); group.bench_function(id, |b| { @@ -60,9 +60,9 @@ fn bench_land(c: &mut Criterion) { let watcher = scoped(&table.producer, "room/incoming").announced(); b.iter(|| { let snapshot = fuzz::snapshot(&table.producer, "room/incoming"); - assert!(!watcher.is_caught_up()); + assert!(!watcher.is_synced()); drop(snapshot); - assert!(watcher.is_caught_up()); + assert!(watcher.is_synced()); }); }); } @@ -70,7 +70,7 @@ fn bench_land(c: &mut Criterion) { } fn bench_register(c: &mut Criterion) { - let mut group = c.benchmark_group("caught_up/register"); + let mut group = c.benchmark_group("synced/register"); for (streams, consumers) in SHAPES { let id = BenchmarkId::from_parameter(format!("{streams}st_{consumers}c")); group.bench_function(id, |b| { @@ -79,7 +79,7 @@ fn bench_register(c: &mut Criterion) { let _held = fuzz::snapshot(&table.producer, "room/incoming"); b.iter(|| { let watcher = incoming.announced(); - assert!(!watcher.is_caught_up()); + assert!(!watcher.is_synced()); }); }); } diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index aa6d4c447c5..c7b1b8ed26c 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -268,7 +268,7 @@ pub fn excluding(origin: crate::origin::Consumer, peer: crate::Hop) -> crate::or origin.excluding(peer) } -/// A session's snapshot of its peer's initial announcements, for the caught-up bench. +/// A session's snapshot of its peer's initial announcements, for the synced bench. /// Drop it to land the snapshot. pub struct Snapshot(#[allow(dead_code)] crate::model::Snapshot); diff --git a/rs/moq-net/src/ietf/active_count.rs b/rs/moq-net/src/ietf/active_count.rs index 37138f4fb65..24b55d1a096 100644 --- a/rs/moq-net/src/ietf/active_count.rs +++ b/rs/moq-net/src/ietf/active_count.rs @@ -4,16 +4,16 @@ //! advertises, then reports changes on the same stream. Nothing in moq-transport says //! where the first part ends, so a subscriber can only guess from the stream going //! quiet. When both endpoints declare the ACTIVE_COUNT option, the publisher puts on its -//! REQUEST_OK how many NAMESPACE messages come before the subscription is caught up. +//! REQUEST_OK how many NAMESPACE messages come before the subscription is synced. use super::Version; /// ACTIVE_COUNT Setup Option: the sender counts the NAMESPACE messages before a -/// subscription is caught up, and reads the peer's count. Even, so the value is a bare varint. +/// subscription is synced, and reads the peer's count. Even, so the value is a bare varint. pub const ACTIVE_COUNT: u64 = 0x40B64; /// ACTIVE_COUNT REQUEST_OK parameter: how many NAMESPACE messages immediately follow -/// before the subscription is caught up. Only answers SUBSCRIBE_NAMESPACE. +/// before the subscription is synced. Only answers SUBSCRIBE_NAMESPACE. /// Even, so the value is a bare varint. pub const ACTIVE_COUNT_PARAM: u64 = 0x40B66; diff --git a/rs/moq-net/src/ietf/peer.rs b/rs/moq-net/src/ietf/peer.rs index fcf4014369c..3f0e920789c 100644 --- a/rs/moq-net/src/ietf/peer.rs +++ b/rs/moq-net/src/ietf/peer.rs @@ -24,7 +24,7 @@ pub(crate) struct Peer { pub auth: bool, /// MoQ Active Count: whether the REQUEST_OK answering SUBSCRIBE_NAMESPACE counts the - /// NAMESPACE messages before the subscription is caught up, in both directions. + /// NAMESPACE messages before the subscription is synced, in both directions. pub active_count: bool, } diff --git a/rs/moq-net/src/ietf/request.rs b/rs/moq-net/src/ietf/request.rs index ea970b7df62..b5d461869dd 100644 --- a/rs/moq-net/src/ietf/request.rs +++ b/rs/moq-net/src/ietf/request.rs @@ -87,7 +87,7 @@ impl Message for RequestsBlocked { pub struct RequestOk { pub request_id: Option, /// MoQ Active Count: how many NAMESPACE messages follow before the subscription is - /// caught up. Only on the answer to a SUBSCRIBE_NAMESPACE, when negotiated. + /// synced. Only on the answer to a SUBSCRIBE_NAMESPACE, when negotiated. pub active: Option, } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index fe8192ff399..633e149790b 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -156,7 +156,7 @@ where // 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. // Each holds a snapshot from now, so the origin's announce consumers never read as - // caught up before the request goes out. + // synced before the request goes out. let namespaces: Vec<_> = subscribe .as_ref() .map(|origin| { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 1f1babb9328..99bbd8c736d 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1016,7 +1016,7 @@ where /// stream of ours that simply died (survivable). /// /// `snapshot` holds the origin's announce consumers under the prefix short of - /// caught up. It drops once the NAMESPACE messages MoQ Active Count promised are + /// synced. It drops once the NAMESPACE messages MoQ Active Count promised are /// in, on REQUEST_OK without the extension, or when no request goes out at all. pub async fn run_subscribe_namespace( &mut self, @@ -5931,11 +5931,11 @@ mod tests { (subscriber, consumer, snapshot) } - /// With MoQ Active Count, the origin catches up once the counted NAMESPACEs are in. + /// With MoQ Active Count, the origin syncs once the counted NAMESPACEs are in. #[moq_clock::test] async fn counted_namespaces_catch_up() { const VERSION: Version = Version::Draft18; - for (entries, caught_up) in [(&["a"][..], false), (&["a", "b"][..], true)] { + for (entries, synced) in [(&["a"][..], false), (&["a", "b"][..], true)] { let session = crate::lite::test_transport::ScriptedSession::new(counted_response(VERSION, Some(2), entries).await); let (mut subscriber, consumer, snapshot) = counted_subscriber(VERSION, true, session.clone()); @@ -5950,7 +5950,7 @@ mod tests { for entry in entries { cursor.assert_next_active(*entry); } - assert_eq!(cursor.is_caught_up(), caught_up, "{entries:?}"); + assert_eq!(cursor.is_synced(), synced, "{entries:?}"); } } @@ -5961,7 +5961,7 @@ mod tests { let session = crate::lite::test_transport::ScriptedSession::new(counted_response(VERSION, None, &[]).await); let (mut subscriber, consumer, snapshot) = counted_subscriber(VERSION, false, session.clone()); let cursor = consumer.announced(); - assert!(!cursor.is_caught_up()); + assert!(!cursor.is_synced()); 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(), snapshot)); @@ -5969,7 +5969,7 @@ mod tests { assert!(futures::poll!(run.as_mut()).is_pending()); settle().await; } - assert!(cursor.is_caught_up()); + assert!(cursor.is_synced()); } /// A counted set cut short, by a NAMESPACE_DONE or the stream ending, is the peer's fault. diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 0bfb2caf42d..741db1edae5 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -2593,7 +2593,7 @@ mod announce_test { } /// A relay answers with what it holds now: an upstream still delivering its own - /// initial set never delays the ANNOUNCE_OK, so a peer catches up with this relay. + /// initial set never delays the ANNOUNCE_OK, so a peer syncs with this relay. #[moq_clock::test] async fn a_silent_upstream_never_delays_the_answer() { let origin = Hop::new(1).unwrap().produce(); @@ -2604,10 +2604,7 @@ mod announce_test { let log = Log::default(); let writes = log.writes.clone(); let consumer = origin.consume(); - assert!( - !consumer.announced().is_caught_up(), - "the relay itself is still catching up" - ); + assert!(!consumer.announced().is_synced(), "the relay itself is still syncing"); let mut stream = Stream:: { writer: Writer::new(SinkSend::new(log), VERSION), reader: Reader::new(PendingRecv, VERSION), diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index e2436312f11..7d5f26368cc 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1285,7 +1285,7 @@ struct AnnouncePrefix { subscriber: Subscriber, prefix: PathOwned, state: PrefixState, - /// Holds the origin's announce consumers under the prefix short of caught up + /// Holds the origin's announce consumers under the prefix unsynced /// until the peer's initial set is in. Dropped at the boundary the version /// marks, or with the stream. snapshot: Option, @@ -1334,7 +1334,7 @@ struct PrefixRun { impl AnnouncePrefix { fn new(subscriber: Subscriber, prefix: PathOwned) -> Self { // Taken now, while the session is being set up, so a consumer of the origin never - // reads as caught up before the request even goes out. + // reads as synced before the request even goes out. let snapshot = Some(subscriber.origin.snapshot(&prefix)); Self { subscriber, @@ -4276,7 +4276,7 @@ mod tests { (AnnouncePrefix::new(subscriber, Path::new("").to_owned()), consumer) } - /// The origin's consumers catch up once the counted starts are in and read, never before. + /// The origin's consumers sync once the counted starts are in and read, never before. #[moq_clock::test] async fn a_counted_initial_set_catches_up() { const VERSION: Version = Version::Lite06; @@ -4290,23 +4290,23 @@ mod tests { ); let (mut prefix, consumer) = root_prefix(VERSION, crate::lite::test_transport::ScriptedSession::new(script)); let mut cursor = consumer.announced(); - assert!(!cursor.is_caught_up(), "held from the request on"); + assert!(!cursor.is_synced(), "held from the request on"); 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"); - assert!(!cursor.is_caught_up(), "never before an earlier event is read"); + assert!(!cursor.is_synced(), "never before an earlier event is read"); cursor.assert_next_active("b"); - assert!(cursor.is_caught_up()); - // A consumer created after the boundary catches up on its replay. + assert!(cursor.is_synced()); + // A consumer created after the boundary syncs on its replay. let mut late = consumer.announced(); late.assert_next_active("a"); late.assert_next_active("b"); - assert!(late.is_caught_up()); + assert!(late.is_synced()); } - /// Part of a counted set is not caught up, however much of it was read. + /// Part of a counted set is not synced, however much of it was read. #[moq_clock::test] async fn a_partial_initial_set_holds() { const VERSION: Version = Version::Lite06; @@ -4317,10 +4317,10 @@ mod tests { assert!(futures::poll!(run.as_mut()).is_pending()); cursor.assert_next_active("a"); - assert!(!cursor.is_caught_up()); + assert!(!cursor.is_synced()); } - /// A count of zero is caught up on the ANNOUNCE_OK. + /// A count of zero is synced on the ANNOUNCE_OK. #[moq_clock::test] async fn an_empty_initial_set_catches_up_at_once() { const VERSION: Version = Version::Lite07; @@ -4329,7 +4329,7 @@ mod tests { let cursor = consumer.announced(); let mut run = std::pin::pin!(kio::wait(|waiter| prefix.poll(waiter))); assert!(futures::poll!(run.as_mut()).is_pending()); - assert!(cursor.is_caught_up()); + assert!(cursor.is_synced()); } /// Anything but a start inside the counted set cuts it short: a protocol violation. @@ -4359,17 +4359,17 @@ mod tests { assert!(matches!(err, Error::ProtocolViolation), "{err}"); } - /// Lite03/04 mark no initial set: caught up, best effort, once the request is sent. + /// Lite03/04 mark no initial set: synced, best effort, once the request is sent. #[moq_clock::test] async fn an_unmarked_version_catches_up_on_the_request() { const VERSION: Version = Version::Lite04; let (mut prefix, consumer) = root_prefix(VERSION, crate::lite::test_transport::ScriptedSession::new(Vec::new())); let cursor = consumer.announced(); - assert!(!cursor.is_caught_up()); + assert!(!cursor.is_synced()); let mut run = std::pin::pin!(kio::wait(|waiter| prefix.poll(waiter))); assert!(futures::poll!(run.as_mut()).is_pending()); - assert!(cursor.is_caught_up()); + assert!(cursor.is_synced()); } /// Lite01/02 mark the initial set with ANNOUNCE_INIT. @@ -4390,9 +4390,9 @@ mod tests { 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()); - assert!(!cursor.is_caught_up()); + assert!(!cursor.is_synced()); cursor.assert_next_active("a"); - assert!(cursor.is_caught_up()); + assert!(cursor.is_synced()); } /// An announce past the session's cap fails the session with TOO_MANY_REQUESTS, and diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index ca3ae7a9eec..0bab3c74b62 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -816,21 +816,21 @@ struct OriginConsumerState { /// the end instead of parking forever on a table that can never fire again. ended: bool, /// Snapshots in flight that can present on this consumer's cursors, counted - /// once per cursor they touch. See [`AnnounceConsumer::poll_caught_up`]. + /// once per cursor they touch. See [`AnnounceConsumer::poll_synced`]. /// /// Changed through a read guard, so a session joining wakes none of the cursors it - /// holds back (nothing waits on losing caught up), except the last landing, which + /// holds back (nothing waits on losing sync), except the last landing, which /// takes the write guard to owe the queue too. Only the table lock changes it. waiting: AtomicUsize, /// The pending prefixes the consumer still owes reading from before the last - /// snapshot landed. Caught up once empty and nothing is `waiting`. + /// snapshot landed. Synced once empty and nothing is `waiting`. owed: BTreeSet, } impl OriginConsumerState { /// Whether the consumer has read everything its sources announced before they - /// caught up. A torn-down origin has no sources left to wait on. - fn caught_up(&self) -> bool { + /// synced. A torn-down origin has no sources left to wait on. + fn synced(&self) -> bool { (self.waiting.load(Ordering::Relaxed) == 0 || self.ended) && self.owed.is_empty() } @@ -2007,8 +2007,8 @@ impl Producer { } /// Start a [`Snapshot`] under `prefix`: the announce consumers it overlaps are not - /// caught up until the returned guard drops. Taken before the request goes out, so - /// nothing reads as caught up in between. + /// synced until the returned guard drops. Taken before the request goes out, so + /// nothing reads as synced in between. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn snapshot(&self, prefix: impl AsPath) -> Snapshot { let prefix = self.root.join(prefix.as_path()).to_owned(); @@ -3747,8 +3747,8 @@ struct SnapshotEntry { /// A session still delivering its peer's initial announcements under a prefix. /// -/// Every announce consumer whose scope the prefix overlaps is short of caught up -/// (see [`AnnounceConsumer::poll_caught_up`]) until this drops: once the session +/// Every announce consumer whose scope the prefix overlaps is unsynced +/// (see [`AnnounceConsumer::poll_synced`]) until this drops: once the session /// has inserted the announcements its peer counted, or it gave up on them. pub(crate) struct Snapshot { shared: Option>>, @@ -4392,7 +4392,7 @@ impl OriginState { self.cursors.insert(id, cursor); } - /// Hold every cursor `snapshot` can present on short of caught up until it lands. + /// Hold every cursor `snapshot` can present on unsynced until it lands. fn add_snapshot(&mut self, prefix: &Path, snapshot: SnapshotEntry) { for id in self.routes.cursors_touching(prefix) { if let Some(cursor) = self.cursors.get(&id) @@ -4421,7 +4421,7 @@ impl OriginState { let Some(cursor) = self.cursors.get(&id).filter(|cursor| cursor.awaits(&snapshot)) else { continue; }; - // Only the last landing changes what a caught-up waiter sees, so only it takes the + // Only the last landing changes what a synced waiter sees, so only it takes the // write guard and wakes. It zeroes the count and owes the queue in one critical // section, so no poll sees nothing waiting before the owed set is in. The table // lock keeps the count from moving between the two guards. @@ -5988,40 +5988,40 @@ impl AnnounceConsumer { state.is_closed() || state.ended } - /// Whether this consumer has caught up; see [`Self::poll_caught_up`]. - pub fn is_caught_up(&self) -> bool { + /// Whether this consumer has synced; see [`Self::poll_synced`]. + pub fn is_synced(&self) -> bool { let state = self.state.read(); - state.is_closed() || state.caught_up() + state.is_closed() || state.synced() } - /// Wait until this consumer has caught up with what its sources announced. + /// Wait until this consumer has synced with what its sources announced. /// - /// Caught up means every session feeding this scope has delivered its peer's + /// Synced means every session feeding this scope has delivered its peer's /// announcements as of when it answered, and this consumer has read every event /// queued before then. A prefix not announced by then is not announced, rather /// than not heard yet. A session joining the scope later, such as a reconnect, - /// takes this back until it catches up too; routes published on this origin + /// takes this back until it syncs too; routes published on this origin /// itself never wait. /// - /// Caught up is relative to the session's peer, never global: a relay answers with + /// Synced is relative to the session's peer, never global: a relay answers with /// what it holds without waiting on its own upstreams. Versions that cannot mark /// the boundary (moq-lite-03/04, and moq-transport without MoQ Active Count) catch /// up once the peer accepts the request, or once it is sent when nothing answers. /// - /// Level-triggered: ready whenever caught up, and once the cursor is closed. - pub fn poll_caught_up(&self, waiter: &kio::Waiter) -> Poll<()> { + /// Level-triggered: ready whenever synced, and once the cursor is closed. + pub fn poll_synced(&self, waiter: &kio::Waiter) -> Poll<()> { self.state - .poll(waiter, |state| match state.caught_up() { + .poll(waiter, |state| match state.synced() { true => Poll::Ready(()), false => Poll::Pending, }) .map(|_| ()) } - /// Wait until this consumer has caught up; see [`Self::poll_caught_up`]. + /// Wait until this consumer has synced; see [`Self::poll_synced`]. #[cfg(feature = "async")] - pub async fn caught_up(&self) { - kio::wait(|waiter| self.poll_caught_up(waiter)).await + pub async fn synced(&self) { + kio::wait(|waiter| self.poll_synced(waiter)).await } /// Returns the root that is automatically stripped from emitted prefixes. @@ -6323,39 +6323,39 @@ mod tests { announced.assert_next_wait(); } - /// A consumer is caught up once a snapshot lands and it has read what came before. + /// A consumer is synced once a snapshot lands and it has read what came before. #[test] - fn caught_up_after_the_snapshot_and_its_events() { + fn synced_after_the_snapshot_and_its_events() { let producer = origin(1).produce(); let snapshot = producer.snapshot("room"); let mut announced = producer.consume().announced(); - assert!(!announced.is_caught_up(), "a snapshot in flight holds it"); + assert!(!announced.is_synced(), "a snapshot in flight holds it"); let _alice = producer.announce("room/alice", Route::default()).unwrap(); drop(snapshot); - assert!(!announced.is_caught_up(), "never before an earlier event is read"); + assert!(!announced.is_synced(), "never before an earlier event is read"); announced.assert_next_active("room/alice"); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); - // A later announcement is news, not part of what it caught up with. + // A later announcement is news, not part of what it synced with. let _bob = producer.announce("room/bob", Route::default()).unwrap(); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); } - /// Without any session feeding it, a consumer catches up on its own replay. + /// Without any session feeding it, a consumer syncs on its own replay. #[test] fn a_local_origin_catches_up_on_its_replay() { let producer = origin(1).produce(); let mut empty = producer.consume().announced(); - assert!(empty.is_caught_up(), "nothing to wait on"); + assert!(empty.is_synced(), "nothing to wait on"); empty.assert_next_wait(); let _alice = producer.announce("room/alice", Route::default()).unwrap(); let mut announced = producer.consume().announced(); - assert!(!announced.is_caught_up(), "the replay is owed"); + assert!(!announced.is_synced(), "the replay is owed"); announced.assert_next_active("room/alice"); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); } /// A start and end that cancel before being read are owed nothing. @@ -6367,9 +6367,9 @@ mod tests { let alice = producer.announce("room/alice", Route::default()).unwrap(); drop(snapshot); - assert!(!announced.is_caught_up()); + assert!(!announced.is_synced()); drop(alice); - assert!(announced.is_caught_up(), "the pair cancelled"); + assert!(announced.is_synced(), "the pair cancelled"); announced.assert_next_wait(); } @@ -6384,23 +6384,23 @@ mod tests { let snapshot = producer.snapshot(""); alice.update(Route::default().with_cost(5)).unwrap(); drop(snapshot); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); } - /// A snapshot taken after catching up (a reconnect) holds it again until it lands. + /// A snapshot taken after syncing (a reconnect) holds it again until it lands. #[test] - fn a_new_snapshot_takes_caught_up_back() { + fn a_new_snapshot_takes_sync_back() { let producer = origin(1).produce(); let announced = producer.consume().announced(); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); let snapshot = producer.snapshot("room/alice"); - assert!(!announced.is_caught_up(), "a snapshot beneath the scope holds it"); + assert!(!announced.is_synced(), "a snapshot beneath the scope holds it"); let second = producer.snapshot(""); drop(snapshot); - assert!(!announced.is_caught_up(), "every current snapshot must land"); + assert!(!announced.is_synced(), "every current snapshot must land"); drop(second); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); } /// Only snapshots that could present on a consumer hold it. @@ -6413,20 +6413,20 @@ mod tests { .unwrap(); let other = producer.snapshot("other"); - assert!(room.announced().is_caught_up(), "a disjoint prefix"); + assert!(room.announced().is_synced(), "a disjoint prefix"); let above = producer.snapshot(""); let announced = room.announced(); - assert!(!announced.is_caught_up(), "a snapshot above the root covers it"); + assert!(!announced.is_synced(), "a snapshot above the root covers it"); drop(above); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); drop(other); // A local view never waits on a peer's routes. let _peer = producer.clone().peer().snapshot("room"); - assert!(producer.consume().local().announced().is_caught_up()); - assert!(!producer.consume().announced().is_caught_up()); + assert!(producer.consume().local().announced().is_synced()); + assert!(!producer.consume().announced().is_synced()); } /// A source shadowed by a mount can present nothing on the handle, so it never holds it. @@ -6435,15 +6435,15 @@ mod tests { let producer = origin(1).produce(); let project = mounted(&producer, "p1", &["**"]).with_hidden(true); let announced = project.announced(); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); let shadowed = producer.snapshot("p1/.svc"); - assert!(announced.is_caught_up(), "a snapshot in flight under the hole"); - assert!(project.announced().is_caught_up(), "a reader registering past one"); + assert!(announced.is_synced(), "a snapshot in flight under the hole"); + assert!(project.announced().is_synced(), "a reader registering past one"); drop(shadowed); let _target = producer.snapshot(".svc/p1"); - assert!(!announced.is_caught_up(), "the mount's target does hold it"); + assert!(!announced.is_synced(), "the mount's target does hold it"); } /// A source under a hidden segment holds only the readers that opt into hidden routes. @@ -6452,15 +6452,15 @@ mod tests { let producer = origin(1).produce(); let announced = producer.consume().announced(); let _stats = producer.snapshot(".stats"); - assert!(announced.is_caught_up(), "a snapshot in flight under a hidden segment"); + assert!(announced.is_synced(), "a snapshot in flight under a hidden segment"); assert!( - producer.consume().announced().is_caught_up(), + producer.consume().announced().is_synced(), "a reader registering past one" ); - assert!(!producer.consume().with_hidden(true).announced().is_caught_up()); + assert!(!producer.consume().with_hidden(true).announced().is_synced()); } - /// A waiter parked on catching up is woken when it does. + /// A waiter parked on syncing is woken when it does. #[test] fn catching_up_wakes_the_waiter() { struct Woken(AtomicBool); @@ -6475,10 +6475,10 @@ mod tests { let announced = producer.consume().announced(); let woken = Arc::new(Woken(AtomicBool::new(false))); let waiter = kio::Waiter::new(std::task::Waker::from(woken.clone())); - assert!(announced.poll_caught_up(&waiter).is_pending()); + assert!(announced.poll_synced(&waiter).is_pending()); drop(snapshot); assert!(woken.0.load(Ordering::SeqCst), "not woken"); - assert!(announced.poll_caught_up(&waiter).is_ready()); + assert!(announced.poll_synced(&waiter).is_ready()); } /// A session joining or landing short of the last one wakes no parked cursor, so a @@ -6497,7 +6497,7 @@ mod tests { let woken = Arc::new(Woken(AtomicBool::new(false))); let waiter = kio::Waiter::new(std::task::Waker::from(woken.clone())); assert!(announced.poll_next(&waiter).is_pending()); - assert!(announced.poll_caught_up(&waiter).is_ready()); + assert!(announced.poll_synced(&waiter).is_ready()); let first = producer.snapshot(""); let second = producer.snapshot("room"); @@ -9702,9 +9702,9 @@ mod tests { let _alice = producer.announce("room/alice", Route::default()).unwrap(); drop(driver); - assert!(!announced.is_caught_up(), "the queue is still owed"); + assert!(!announced.is_synced(), "the queue is still owed"); announced.assert_next_active("room/alice"); - assert!(announced.is_caught_up()); + assert!(announced.is_synced()); drop(snapshot); } diff --git a/rs/moq-net/tests/integration/main.rs b/rs/moq-net/tests/integration/main.rs index 341acebfe44..0a814a8af27 100644 --- a/rs/moq-net/tests/integration/main.rs +++ b/rs/moq-net/tests/integration/main.rs @@ -11,7 +11,6 @@ mod announce_to_serve; mod auth; mod broadcast_close; mod catalog_acquisition; -mod caught_up; mod datagram; mod dial_split_horizon; mod drained_claim; @@ -46,6 +45,7 @@ mod settled_end_survives_abort; mod subscribe_tracks; mod subscription_end_integrity; mod support; +mod synced; mod track_abort_over_session; mod track_info_cancel; mod track_stream_demand; diff --git a/rs/moq-net/tests/integration/caught_up.rs b/rs/moq-net/tests/integration/synced.rs similarity index 78% rename from rs/moq-net/tests/integration/caught_up.rs rename to rs/moq-net/tests/integration/synced.rs index bb13864b85b..47a05873b41 100644 --- a/rs/moq-net/tests/integration/caught_up.rs +++ b/rs/moq-net/tests/integration/synced.rs @@ -1,6 +1,6 @@ #![cfg(feature = "async")] -//! A subscriber's announce consumers catch up once the session has delivered its +//! A subscriber's announce consumers sync once the session has delivered its //! peer's initial announcements: exactly on versions that mark the boundary, and //! on the answer, as a best effort, on the rest. @@ -21,13 +21,13 @@ fn produce_origin(hop: u64) -> origin::Producer { producer } -/// Read events until the consumer catches up, returning the prefixes then announced. -async fn read_until_caught_up(announced: &mut announce::Consumer) -> BTreeSet { +/// Read events until the consumer syncs, returning the prefixes then announced. +async fn read_until_synced(announced: &mut announce::Consumer) -> BTreeSet { let mut seen = BTreeSet::new(); - while !announced.is_caught_up() { + while !announced.is_synced() { let event = moq_clock::timeout(SETTLE, announced.next()) .await - .expect("caught up in time") + .expect("synced in time") .expect("the cursor stays open"); match event { announce::Event::Start(announce) | announce::Event::Restart(announce) => { @@ -43,8 +43,8 @@ async fn read_until_caught_up(announced: &mut announce::Consumer) -> BTreeSet BTreeSet { +/// origin's consumer had read when it synced. +async fn synced(version: &str) -> BTreeSet { let publisher = produce_origin(1); let a = publisher.create_broadcast("a").unwrap(); a.announce(Default::default()).unwrap(); @@ -58,14 +58,14 @@ async fn caught_up(version: &str) -> BTreeSet { let _pair = connect_mock(options).await; let mut announced = subscriber.consume().announced(); - read_until_caught_up(&mut announced).await + read_until_synced(&mut announced).await } fn both() -> BTreeSet { ["a", "b"].into_iter().map(String::from).collect() } -/// Every version that marks the boundary catches up with the whole initial set. +/// Every version that marks the boundary syncs with the whole initial set. #[moq_clock::test] async fn an_exact_boundary_catches_up_with_the_initial_set() { for version in [ @@ -80,19 +80,19 @@ async fn an_exact_boundary_catches_up_with_the_initial_set() { "moq-transport-21", "moq-transport-22", ] { - assert_eq!(caught_up(version).await, both(), "{version}"); + assert_eq!(synced(version).await, both(), "{version}"); } } -/// Versions without a boundary still catch up, so nothing waiting on it hangs. +/// Versions without a boundary still sync, so nothing waiting on it hangs. #[moq_clock::test] async fn a_best_effort_boundary_still_catches_up() { for version in ["moq-lite-03", "moq-lite-04", "moq-transport-14", "moq-transport-15"] { - caught_up(version).await; + synced(version).await; } } -/// A second session into the same origin, as a reconnect makes, catches up too: the +/// A second session into the same origin, as a reconnect makes, syncs too: the /// consumer waits on every current session. #[moq_clock::test] async fn a_second_session_catches_up() { @@ -110,13 +110,13 @@ async fn a_second_session_catches_up() { let first = connect().await; let mut announced = subscriber.consume().announced(); assert_eq!( - read_until_caught_up(&mut announced).await, + read_until_synced(&mut announced).await, ["a"].into_iter().map(String::from).collect() ); let second = connect().await; drop(first); moq_clock::sleep(SETTLE).await; - read_until_caught_up(&mut announced).await; + read_until_synced(&mut announced).await; drop(second); }