From 0ba9972a66eefc2396210f0890b9274646e6d6c6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 2 Jun 2026 20:49:52 -0700 Subject: [PATCH 1/7] moq-lite-05: move immutable track props to a Track Stream (TRACK_INFO) Replace the per-response publisher metadata in SUBSCRIBE_OK with a dedicated, on-demand Track Stream, per moq-dev/drafts#25. Scoped to the WIP Lite05 version; Lite01-04 keep SUBSCRIBE_OK unchanged. - New Track Stream (0x6): a TRACK request (broadcast path + track name) answered with a single TRACK_INFO carrying the immutable publisher properties (Priority, Ordered, Cache, Timescale, Compression), then a FIN (or reset on error / missing track). - Removed the static props (compression/timescale/cache) from SUBSCRIBE_OK; on Lite05 a subscription is accepted implicitly (rejection is a reset) and the publisher sends nothing on the subscribe stream. - Subscriber flights TRACK and SUBSCRIBE in parallel, so the first group still arrives in one round trip. A pending TrackEntry is inserted before SUBSCRIBE, so group streams that race ahead of TRACK_INFO park on a resolved channel (buffered by QUIC flow control) instead of being dropped. The resolved (producer, compression, timescale) is reused for every group's decode instead of being re-derived per response, and is fetched once for the upstream subscription's lifetime (linger). Publisher resolves TRACK_INFO by subscribing to read the track's .info and dropping the subscription; a parallel SUBSCRIBE coalesces onto the same upstream producer. Priority/Ordered are sent as 0/false for now since the model Track carries no publisher priority/order field yet. Not included (no functional gap in the Rust impl, which never resolves a group range or emits drops): SUBSCRIBE_START/SUBSCRIBE_END and the SUBSCRIBE_DROP renumber. Cross-package sync to js/net and doc/concept is also deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- rs/moq-net/src/lite/mod.rs | 3 + rs/moq-net/src/lite/publisher.rs | 113 +++++++++++++--- rs/moq-net/src/lite/stream.rs | 3 + rs/moq-net/src/lite/subscribe.rs | 146 +++------------------ rs/moq-net/src/lite/subscriber.rs | 211 +++++++++++++++++++----------- rs/moq-net/src/lite/track.rs | 183 ++++++++++++++++++++++++++ 6 files changed, 441 insertions(+), 218 deletions(-) create mode 100644 rs/moq-net/src/lite/track.rs diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index 4b1612594..4df1bb449 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -19,6 +19,7 @@ mod session; mod stream; mod subscribe; mod subscriber; +mod track; mod version; pub use announce::*; @@ -37,4 +38,6 @@ pub(super) use session::*; pub use stream::*; pub use subscribe::*; use subscriber::*; +#[allow(unused_imports)] +pub use track::*; pub use version::Version; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 29a71c43d..2348ef7f9 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -68,6 +68,7 @@ impl Publisher { if let Err(err) = match kind { lite::ControlType::Announce => self.recv_announce(stream).await, lite::ControlType::Subscribe => self.recv_subscribe(stream).await, + lite::ControlType::Track => self.recv_track(stream).await, lite::ControlType::Probe => { self.recv_probe(stream); Ok(()) @@ -468,8 +469,9 @@ impl Publisher { .await?; // Compress only when the producer marked the track worth it and the - // negotiated draft understands the SUBSCRIBE_OK codec field. Older drafts - // (lite-04 and below) get None and the frames stream verbatim. + // negotiated draft understands the codec field (carried in SUBSCRIBE_OK on + // lite-04 and below, in TRACK_INFO on lite-05+). Older drafts without it + // get None and the frames stream verbatim. let supports_compression = !matches!( version, Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 @@ -494,20 +496,23 @@ impl Publisher { // broadcast. Dropping this guard (subscription end) releases it. let _broadcast_sub = broadcasts.subscribe(&absolute); - let info = lite::SubscribeOk { - priority: subscribe.priority, - ordered: false, - max_latency: std::time::Duration::ZERO, - start_group: None, - end_group: None, - compression, - timescale, - // Announce the publisher's cache window so the subscriber (a relay) - // re-serves with the same eviction window. Pre-lite-05 peers ignore it. - cache: track.cache, - }; + // Lite05+ accepts a subscription implicitly (rejection is a stream reset) + // and serves the immutable properties over a TRACK_INFO stream instead. + // Older drafts confirm acceptance with SUBSCRIBE_OK here. + if matches!( + version, + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 + ) { + let info = lite::SubscribeOk { + priority: subscribe.priority, + ordered: false, + max_latency: std::time::Duration::ZERO, + start_group: None, + end_group: None, + }; - stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?; + stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?; + } // Track-level subscriber priority. SUBSCRIBE_UPDATE messages broadcast new values // to both run_track (so future groups inherit the new priority) and serve_group @@ -545,6 +550,84 @@ impl Publisher { stream.writer.finish()?; stream.writer.closed().await } + + /// Serve a Track Stream: reply with the track's immutable [`lite::TrackInfo`] + /// and FIN, or reset on error (e.g. the track does not exist). Lite05+ only. + pub async fn recv_track(&mut self, mut stream: Stream) -> Result<(), Error> { + let req = stream.reader.decode::().await?; + + let track = req.track.to_string(); + let absolute = self.origin.absolute(&req.broadcast).to_owned(); + + tracing::debug!(broadcast = %absolute, %track, "track info requested"); + + let broadcast = self.origin.get_broadcast(&req.broadcast); + let version = self.version; + + web_async::spawn(async move { + if let Err(err) = Self::run_track_info(&mut stream, &track, broadcast, version).await { + match &err { + Error::Cancel | Error::Transport(_) => { + tracing::debug!(broadcast = %absolute, %track, "track info cancelled") + } + err => tracing::warn!(broadcast = %absolute, %track, %err, "track info error"), + } + stream.writer.abort(&err); + } + }); + + Ok(()) + } + + async fn run_track_info( + stream: &mut Stream, + track_name: &str, + consumer: Option, + version: Version, + ) -> Result<(), Error> { + let broadcast = consumer.ok_or(Error::NotFound)?; + + // The immutable properties are delivered when the (possibly dynamic) + // producer is accepted, so resolving them means subscribing. We read them + // off the subscriber and drop it immediately; a SUBSCRIBE flighted in + // parallel coalesces onto the same upstream producer, which linger keeps + // alive across this brief gap. + let track = broadcast + .consume_track(track_name) + .subscribe(crate::Subscription::default()) + .await?; + + // Mirror the negotiation in `run_subscribe` so the subscriber decodes + // frames the same way it'll see them served. + let supports_compression = !matches!( + version, + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 + ); + let compression = if track.compress && supports_compression { + Compression::Deflate + } else { + Compression::None + }; + let timescale = if version.has_timestamps() { + track.timescale + } else { + None + }; + + let info = lite::TrackInfo { + // The model carries no publisher-chosen priority/order yet, so both + // default to the tie-break-neutral values. + priority: 0, + ordered: false, + cache: track.cache, + timescale, + compression, + }; + + stream.writer.encode(&info).await?; + stream.writer.finish()?; + stream.writer.closed().await + } } /// Shared per-subscription state for the publisher side. Cloned (cheaply — every diff --git a/rs/moq-net/src/lite/stream.rs b/rs/moq-net/src/lite/stream.rs index d52a77943..fda6bbefb 100644 --- a/rs/moq-net/src/lite/stream.rs +++ b/rs/moq-net/src/lite/stream.rs @@ -13,6 +13,9 @@ pub enum ControlType { Fetch = 3, Probe = 4, Goaway = 5, + /// Track Stream: a subscriber requests a track's immutable publisher + /// properties (TRACK_INFO) without subscribing or fetching. Lite05+ only. + Track = 6, } impl Decode for ControlType { diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index 8f17a003a..fb0901a38 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use crate::{ - Compression, Path, Timescale, + Path, coding::{Decode, DecodeError, Encode, EncodeError, Sizer}, }; @@ -72,6 +72,11 @@ impl Message for Subscribe<'_> { } } +/// Sent by the publisher to accept a subscription (Lite01-04 only). +/// +/// On Lite05+ a subscription is accepted implicitly (rejection is a stream +/// reset) and the immutable publisher properties moved to [`TrackInfo`], fetched +/// once over a [Track Stream](super::Track). This message is no longer sent. #[derive(Clone, Debug)] pub struct SubscribeOk { pub priority: u8, @@ -79,21 +84,6 @@ pub struct SubscribeOk { pub max_latency: std::time::Duration, pub start_group: Option, pub end_group: Option, - /// Codec the publisher will use for every frame on this track. Negotiated - /// here (not in SUBSCRIBE) so the subscriber blocks on this message before it - /// can decode any frame payload. Lite05+ only; older drafts always get - /// [`Compression::None`]. - pub compression: Compression, - /// Per-frame timestamp scale advertised by the publisher. `None` means the - /// publisher doesn't carry per-frame timestamps on the wire (so frame - /// headers omit them). Lite05+ only; older drafts always decode as `None`. - /// On the wire `None` is `0` and `Some(n)` is `n`. - pub timescale: Option, - /// How long the publisher keeps old groups available before evicting them. - /// A relay re-serves with the same window and clamps each subscriber's stale - /// preference to it. Lite05+ only; older drafts always get - /// [`crate::DEFAULT_CACHE`]. - pub cache: std::time::Duration, } impl Message for SubscribeOk { @@ -103,23 +93,14 @@ impl Message for SubscribeOk { self.priority.encode(w, version)?; } Version::Lite02 => {} - Version::Lite03 | Version::Lite04 => { - self.priority.encode(w, version)?; - (self.ordered as u8).encode(w, version)?; - self.max_latency.encode(w, version)?; - self.start_group.encode(w, version)?; - self.end_group.encode(w, version)?; - } + // Lite05+ no longer sends SUBSCRIBE_OK, but keep the Lite03/04 layout as + // the forward default so an accidental encode stays well-formed. _ => { self.priority.encode(w, version)?; (self.ordered as u8).encode(w, version)?; self.max_latency.encode(w, version)?; self.start_group.encode(w, version)?; self.end_group.encode(w, version)?; - // Order matches draft-lcurley-moq-lite-05 SUBSCRIBE_OK: Timescale, Cache, Compression. - self.timescale.map(u64::from).unwrap_or(0).encode(w, version)?; - self.cache.encode(w, version)?; - self.compression.to_code().encode(w, version)?; } } @@ -134,9 +115,6 @@ impl Message for SubscribeOk { max_latency: std::time::Duration::ZERO, start_group: None, end_group: None, - compression: Compression::None, - timescale: None, - cache: crate::DEFAULT_CACHE, }), Version::Lite02 => Ok(Self { priority: 0, @@ -144,51 +122,14 @@ impl Message for SubscribeOk { max_latency: std::time::Duration::ZERO, start_group: None, end_group: None, - compression: Compression::None, - timescale: None, - cache: crate::DEFAULT_CACHE, }), - Version::Lite03 | Version::Lite04 => { - let priority = u8::decode(r, version)?; - let ordered = u8::decode(r, version)? != 0; - let max_latency = std::time::Duration::decode(r, version)?; - let start_group = Option::::decode(r, version)?; - let end_group = Option::::decode(r, version)?; - - Ok(Self { - priority, - ordered, - max_latency, - start_group, - end_group, - compression: Compression::None, - timescale: None, - cache: crate::DEFAULT_CACHE, - }) - } - _ => { - let priority = u8::decode(r, version)?; - let ordered = u8::decode(r, version)? != 0; - let max_latency = std::time::Duration::decode(r, version)?; - let start_group = Option::::decode(r, version)?; - let end_group = Option::::decode(r, version)?; - // Order matches draft-lcurley-moq-lite-05 SUBSCRIBE_OK: Timescale, Cache, Compression. - let timescale = Timescale::new(u64::decode(r, version)?).ok(); - let cache = std::time::Duration::decode(r, version)?; - let compression = - Compression::from_code(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue)?; - - Ok(Self { - priority, - ordered, - max_latency, - start_group, - end_group, - compression, - timescale, - cache, - }) - } + _ => Ok(Self { + priority: u8::decode(r, version)?, + ordered: u8::decode(r, version)? != 0, + max_latency: std::time::Duration::decode(r, version)?, + start_group: Option::::decode(r, version)?, + end_group: Option::::decode(r, version)?, + }), } } } @@ -398,9 +339,6 @@ mod test { max_latency: Duration::from_millis(250), start_group: Some(3), end_group: None, - compression: Compression::Deflate, - timescale: Some(Timescale::MICRO), - cache: Duration::from_secs(10), } } @@ -412,60 +350,12 @@ mod test { } #[test] - fn compression_roundtrips_on_lite05() { - let got = roundtrip(Version::Lite05Wip, &sample()); - assert_eq!(got.compression, Compression::Deflate); + fn fields_roundtrip_on_lite04() { + let got = roundtrip(Version::Lite04, &sample()); assert_eq!(got.priority, 7); assert!(got.ordered); + assert_eq!(got.max_latency, Duration::from_millis(250)); assert_eq!(got.start_group, Some(3)); assert_eq!(got.end_group, None); } - - #[test] - fn compression_absent_before_lite05() { - let ok = sample(); - - // The compression varint only exists on lite-05+, so the older encoding is - // strictly shorter and always decodes back as None. - let mut buf04 = Vec::new(); - ok.encode_msg(&mut buf04, Version::Lite04).unwrap(); - let mut buf05 = Vec::new(); - ok.encode_msg(&mut buf05, Version::Lite05Wip).unwrap(); - assert!( - buf05.len() > buf04.len(), - "lite-05 carries extra compression + timescale varints" - ); - - assert_eq!(roundtrip(Version::Lite04, &ok).compression, Compression::None); - } - - #[test] - fn timescale_roundtrips_on_lite05() { - let got = roundtrip(Version::Lite05Wip, &sample()); - assert_eq!(got.timescale, Some(Timescale::MICRO)); - } - - #[test] - fn timescale_absent_before_lite05() { - // Lite04 doesn't carry the timescale varint, so it always decodes as None. - assert_eq!(roundtrip(Version::Lite04, &sample()).timescale, None); - } - - #[test] - fn timescale_zero_on_wire_decodes_as_none() { - let mut ok = sample(); - ok.timescale = None; - assert_eq!(roundtrip(Version::Lite05Wip, &ok).timescale, None); - } - - #[test] - fn cache_roundtrips_on_lite05() { - assert_eq!(roundtrip(Version::Lite05Wip, &sample()).cache, Duration::from_secs(10)); - } - - #[test] - fn cache_absent_before_lite05() { - // Lite04 doesn't carry the cache varint, so it always decodes as the default. - assert_eq!(roundtrip(Version::Lite04, &sample()).cache, crate::DEFAULT_CACHE); - } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 3e5ee4e6d..de8a5d1f6 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -61,12 +61,23 @@ pub(super) struct Subscriber { #[derive(Clone)] struct TrackEntry { - producer: TrackProducer, stats: Arc, - /// The SUBSCRIBE_OK for this subscription. `None` until it arrives; group - /// streams block on it before decoding any frame, since a group can race - /// ahead of SUBSCRIBE_OK on its own QUIC stream. - subscribe_ok: kio::Consumer>, + /// Resolves once the upstream subscription is accepted: after TRACK_INFO on + /// lite-05, after SUBSCRIBE_OK on older drafts. Group streams park on this so a + /// group that races ahead of acceptance buffers (in QUIC flow control) instead + /// of being dropped. `None` until resolved; a closed channel means the + /// subscription ended first, which group streams treat as cancelled. + resolved: kio::Consumer>, +} + +/// The decoded-once-per-track state a group stream needs: where to write groups +/// and how to parse their frames. Populated from TRACK_INFO (lite-05) so a single +/// lookup is reused across every group instead of re-derived per response. +#[derive(Clone)] +struct ResolvedTrack { + producer: TrackProducer, + compression: Compression, + timescale: Option, } /// Result of an upstream subscribe lifecycle. @@ -536,10 +547,14 @@ impl Subscriber { } } - /// Open the upstream subscribe stream, wait for SUBSCRIBE_OK, then accept the - /// pending request (unblocking the downstream subscriber) and run the linger - /// lifecycle. The producer is created only after SUBSCRIBE_OK, so a downstream - /// a downstream `subscribe` resolves exactly when the upstream confirms. + /// Open the upstream subscribe stream, resolve the track's immutable + /// properties, then accept the pending request (unblocking the downstream + /// subscriber) and run the linger lifecycle. + /// + /// On lite-05 the properties come from a TRACK_INFO stream flighted alongside + /// SUBSCRIBE, so the first group still arrives in one round trip; older drafts + /// read them (implicitly) from SUBSCRIBE_OK. The producer is created once those + /// properties are known, so a downstream `subscribe` resolves exactly then. async fn run_subscribe_session( &self, id: u64, @@ -555,11 +570,25 @@ impl Subscriber { let ordered = msg.ordered; let max_latency = msg.max_latency; let start_group = msg.start_group; + let broadcast_path = msg.broadcast.clone(); // SubscribeUpdate only exists on Lite03+; older versions take the // immediate-FIN path with no linger. let supports_linger = !matches!(self.version, Version::Lite01 | Version::Lite02); + // Insert a pending entry up front so a group stream that races ahead of + // acceptance parks on `resolved` instead of being dropped. Held here for + // the session's lifetime; dropping it closes the channel and wakes any + // parked group streams with a cancellation. + let resolved_tx: kio::Producer> = kio::Producer::new(None); + self.subscribes.lock().insert( + id, + TrackEntry { + stats: track_stats, + resolved: resolved_tx.consume(), + }, + ); + let mut stream = match Stream::open(&self.session, self.version).await { Ok(s) => s, Err(err) => { @@ -579,49 +608,65 @@ impl Subscriber { return SessionOutcome::Error(err); } - // The first response MUST be a SUBSCRIBE_OK. Bail if the broadcast dies first. - let resp = tokio::select! { - err = broadcast.closed() => { - request.deny(err.clone()); - return SessionOutcome::BroadcastClosed(err); + // Resolve the track's immutable properties (compression/timescale/cache). + // lite-05 fetches TRACK_INFO on its own stream (already racing the SUBSCRIBE + // above); older drafts take them from SUBSCRIBE_OK, which carries no such + // properties, so they fall back to the untimed/uncompressed defaults. + let (compression, timescale, cache) = if matches!(self.version, Version::Lite05Wip) { + tokio::select! { + err = broadcast.closed() => { + request.deny(err.clone()); + return SessionOutcome::BroadcastClosed(err); + } + res = self.fetch_track_info(&broadcast_path, name) => match res { + Ok(info) => (info.compression, info.timescale, info.cache), + Err(err) => { + stream.writer.abort(&err); + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + } } - resp = stream.reader.decode::() => match resp { - Ok(r) => r, - Err(err) => { - stream.writer.abort(&err); + } else { + let resp = tokio::select! { + err = broadcast.closed() => { request.deny(err.clone()); - return SessionOutcome::Error(err); + return SessionOutcome::BroadcastClosed(err); } + resp = stream.reader.decode::() => match resp { + Ok(r) => r, + Err(err) => { + stream.writer.abort(&err); + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + } + }; + if !matches!(resp, lite::SubscribeResponse::Ok(_)) { + let err = Error::ProtocolViolation; + stream.writer.abort(&err); + request.deny(err.clone()); + return SessionOutcome::Error(err); } - }; - let lite::SubscribeResponse::Ok(info) = resp else { - let err = Error::ProtocolViolation; - stream.writer.abort(&err); - request.deny(err.clone()); - return SessionOutcome::Error(err); + (Compression::None, None, crate::DEFAULT_CACHE) }; - // Upstream confirmed the subscription, so this session is now actively - // feeding the broadcast: take the per-(session, broadcast) sentinel. It - // drops when this fn returns (subscription end / cancel), releasing - // `broadcasts_closed`. Taken only after SUBSCRIBE_OK so a sub cancelled - // before confirmation isn't counted as a feeding session. + // Upstream confirmed the subscription (TRACK_INFO on lite-05, SUBSCRIBE_OK on + // older drafts), so this session is now actively feeding the broadcast: take + // the per-(session, broadcast) sentinel. It drops when this fn returns + // (subscription end / cancel), releasing `broadcasts_closed`. Taken only + // after confirmation so a sub cancelled before it isn't counted as a feeding + // session. let abs = self.origin.absolute(&msg.broadcast); let _broadcast_sub = self.broadcasts.subscribe(&abs); - // The publisher accepted: create the producer (unblocking the downstream - // subscriber) and start routing incoming groups to it. SUBSCRIBE_OK is known - // now, so the group streams never have to wait; they still read it through a - // kio channel (a group's QUIC stream can otherwise race ahead of SUBSCRIBE_OK). - // - // Stamp the negotiated timescale onto the local Track so groups inherit - // it and downstream consumers (including this subscriber's frame decode - // path) can validate per-frame timestamps at the model layer. + // Stamp the negotiated timescale and cache window onto the local Track so + // groups inherit the timescale (the frame decode path validates per-frame + // timestamps at the model layer) and the producer evicts (and clamps + // downstream stale windows) with the same bound when re-served. let mut local_info = Track::new(name); - local_info.timescale = info.timescale; - // Carry the publisher's cache window so the local producer evicts (and - // clamps downstream stale windows) with the same bound when re-served. - local_info.cache = info.cache; + local_info.timescale = timescale; + local_info.cache = cache; let mut track = match request.accept(local_info) { Ok(track) => track, Err(err) => { @@ -629,15 +674,16 @@ impl Subscriber { return SessionOutcome::Error(err); } }; - let subscribe_ok = kio::Producer::new(Some(info)).consume(); - self.subscribes.lock().insert( - id, - TrackEntry { + + // Resolve the pending entry: group streams parked on it can now create + // groups (with the right timescale) and decode frames. + if let Ok(mut resolved) = resolved_tx.write() { + *resolved = Some(ResolvedTrack { producer: track.clone(), - stats: track_stats, - subscribe_ok, - }, - ); + compression, + timescale, + }); + } // Lifecycle loop: serve → linger → resume → serve → ... → FIN. let outcome = 'lifecycle: loop { @@ -729,40 +775,47 @@ impl Subscriber { outcome } + /// Open a Track Stream, send TRACK, and read the single TRACK_INFO reply. + /// + /// The publisher FINs after TRACK_INFO (or resets on error, e.g. the track + /// does not exist); we drop the stream once the reply is in. Lite05+ only. + async fn fetch_track_info(&self, broadcast: &Path<'_>, name: &str) -> Result { + let mut stream = Stream::open(&self.session, self.version).await?; + stream.writer.encode(&lite::ControlType::Track).await?; + let req = lite::Track { + broadcast: broadcast.clone(), + track: name.into(), + }; + stream.writer.encode(&req).await?; + + let info = stream.reader.decode::().await?; + let _ = stream.writer.finish(); + Ok(info) + } + pub async fn recv_group(&mut self, stream: &mut Reader) -> Result<(), Error> { let hdr: lite::Group = stream.decode().await?; - let (mut group, track, track_stats, subscribe_ok) = { - let mut subs = self.subscribes.lock(); - let entry = subs.get_mut(&hdr.subscribe).ok_or(Error::Cancel)?; - - let group_info = Group { sequence: hdr.sequence }; - let group = entry.producer.create_group(group_info)?; - ( - group, - entry.producer.clone(), - entry.stats.clone(), - entry.subscribe_ok.clone(), - ) + let (resolved, track_stats) = { + let subs = self.subscribes.lock(); + let entry = subs.get(&hdr.subscribe).ok_or(Error::Cancel)?; + (entry.resolved.clone(), entry.stats.clone()) }; - // Bump groups counter for this incoming group on the subscriber side. - track_stats.group(); - - // Block until SUBSCRIBE_OK arrives. The group's QUIC stream can arrive - // before SUBSCRIBE_OK lands on the subscribe stream, so we can't decode - // frames until this resolves. A closed channel means the subscription - // ended before SUBSCRIBE_OK, so treat it as cancelled. + // Block until the upstream is accepted and TRACK_INFO is known. The group's + // QUIC stream can arrive before that resolves; its unread bytes stay + // buffered by QUIC flow control until we create the group below. A closed + // channel means the subscription ended first, so treat it as cancelled. // // Map the closed `Ref` to `None` inside the poll closure (rather than using // `Consumer::wait`) so the `!Send` guard never enters this spawned future. - let (compression, timescale) = kio::wait(|waiter| { - let poll = subscribe_ok.poll(waiter, |ok| match &**ok { - Some(ok) => Poll::Ready((ok.compression, ok.timescale)), + let resolved = kio::wait(|waiter| { + let poll = resolved.poll(waiter, |r| match &**r { + Some(r) => Poll::Ready(r.clone()), None => Poll::Pending, }); match poll { - Poll::Ready(Ok(pair)) => Poll::Ready(Some(pair)), + Poll::Ready(Ok(r)) => Poll::Ready(Some(r)), Poll::Ready(Err(_closed)) => Poll::Ready(None), Poll::Pending => Poll::Pending, } @@ -770,10 +823,18 @@ impl Subscriber { .await .ok_or(Error::Cancel)?; + // Create the group now that the timescale is known, so it inherits the right + // per-frame timestamp scale. + let mut producer = resolved.producer.clone(); + let mut group = producer.create_group(Group { sequence: hdr.sequence })?; + + // Bump groups counter for this incoming group on the subscriber side. + track_stats.group(); + let res = tokio::select! { - err = track.closed() => Err(err), + err = producer.closed() => Err(err), err = group.closed() => Err(err), - res = self.run_group(stream, group.clone(), track_stats.clone(), compression, timescale) => res, + res = self.run_group(stream, group.clone(), track_stats.clone(), resolved.compression, resolved.timescale) => res, }; match res { diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs new file mode 100644 index 000000000..26834459d --- /dev/null +++ b/rs/moq-net/src/lite/track.rs @@ -0,0 +1,183 @@ +use std::borrow::Cow; + +use crate::{ + Compression, Path, Timescale, + coding::{Decode, DecodeError, Encode, EncodeError}, +}; + +use super::{Message, Version}; + +/// Sent by the subscriber to open a Track Stream (0x6), requesting a track's +/// immutable publisher properties without subscribing or fetching. +/// +/// The publisher replies with a single [`TrackInfo`] and then FINs the stream, +/// or resets it on error (e.g. the track does not exist). Lite05+ only. +#[derive(Clone, Debug)] +pub struct Track<'a> { + pub broadcast: Path<'a>, + pub track: Cow<'a, str>, +} + +impl Message for Track<'_> { + fn decode_msg(r: &mut R, version: Version) -> Result { + match version { + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { + return Err(DecodeError::Version); + } + _ => {} + } + + let broadcast = Path::decode(r, version)?; + let track = Cow::::decode(r, version)?; + + Ok(Self { broadcast, track }) + } + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + match version { + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { + return Err(EncodeError::Version); + } + _ => {} + } + + self.broadcast.encode(w, version)?; + self.track.encode(w, version)?; + + Ok(()) + } +} + +/// Sent by the publisher in response to a [`Track`] request, carrying the track's +/// immutable publisher properties. It is the sole message on the Track Stream; the +/// publisher FINs immediately afterward, or resets the stream on error. +/// +/// Every field is fixed for the lifetime of the track. Fetched once and cached by +/// the subscriber, so the properties are no longer echoed on every SUBSCRIBE/FETCH +/// response. Lite05+ only. +#[derive(Clone, Debug)] +pub struct TrackInfo { + /// The publisher's priority for this track, used only to resolve ties between + /// subscriptions of equal subscriber priority. + pub priority: u8, + /// The publisher's group ordering preference, used only to resolve ties. + pub ordered: bool, + /// How long the publisher keeps old groups available before evicting them. A + /// relay re-serves with the same window and clamps each subscriber's stale + /// preference to it. + pub cache: std::time::Duration, + /// Per-frame timestamp scale. `None` (wire `0`) means the publisher doesn't + /// carry per-frame timestamps, so frame headers omit them. + pub timescale: Option, + /// Codec applied to every frame payload on this track. The subscriber needs + /// this (and `timescale`) before it can decode any frame. + pub compression: Compression, +} + +impl Message for TrackInfo { + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + match version { + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { + return Err(EncodeError::Version); + } + _ => {} + } + + // Order matches draft-lcurley-moq-lite-05 TRACK_INFO: Priority, Ordered, + // Cache, Timescale, Compression. + self.priority.encode(w, version)?; + (self.ordered as u8).encode(w, version)?; + self.cache.encode(w, version)?; + self.timescale.map(u64::from).unwrap_or(0).encode(w, version)?; + self.compression.to_code().encode(w, version)?; + + Ok(()) + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + match version { + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { + return Err(DecodeError::Version); + } + _ => {} + } + + let priority = u8::decode(r, version)?; + let ordered = u8::decode(r, version)? != 0; + let cache = std::time::Duration::decode(r, version)?; + let timescale = Timescale::new(u64::decode(r, version)?).ok(); + let compression = Compression::from_code(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue)?; + + Ok(Self { + priority, + ordered, + cache, + timescale, + compression, + }) + } +} + +#[cfg(test)] +mod test { + use std::time::Duration; + + use super::*; + + fn sample() -> TrackInfo { + TrackInfo { + priority: 7, + ordered: true, + cache: Duration::from_secs(10), + timescale: Some(Timescale::MICRO), + compression: Compression::Deflate, + } + } + + fn roundtrip(info: &TrackInfo) -> TrackInfo { + let mut buf = Vec::new(); + info.encode_msg(&mut buf, Version::Lite05Wip).unwrap(); + let mut slice = buf.as_slice(); + TrackInfo::decode_msg(&mut slice, Version::Lite05Wip).unwrap() + } + + #[test] + fn track_info_roundtrips() { + let got = roundtrip(&sample()); + assert_eq!(got.priority, 7); + assert!(got.ordered); + assert_eq!(got.cache, Duration::from_secs(10)); + assert_eq!(got.timescale, Some(Timescale::MICRO)); + assert_eq!(got.compression, Compression::Deflate); + } + + #[test] + fn timescale_zero_decodes_as_none() { + let mut info = sample(); + info.timescale = None; + assert_eq!(roundtrip(&info).timescale, None); + } + + #[test] + fn rejected_before_lite05() { + let mut buf = Vec::new(); + assert!(matches!( + sample().encode_msg(&mut buf, Version::Lite04), + Err(EncodeError::Version) + )); + } + + #[test] + fn track_request_roundtrips() { + let req = Track { + broadcast: Path::new("room/1"), + track: Cow::Borrowed("video"), + }; + let mut buf = Vec::new(); + req.encode_msg(&mut buf, Version::Lite05Wip).unwrap(); + let mut slice = buf.as_slice(); + let got = Track::decode_msg(&mut slice, Version::Lite05Wip).unwrap(); + assert_eq!(got.broadcast, Path::new("room/1")); + assert_eq!(got.track, "video"); + } +} From f37b862b416a464b0ee6f1689ae011c052536e6b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 3 Jun 2026 17:43:47 -0700 Subject: [PATCH 2/7] moq-net: resolve track info via model TrackConsumer::info(), not subscribe Addresses PR review: the publisher must not open a subscription just to learn a track's immutable properties. Add a first-class info path to the model and route every property lookup (publisher TRACK reply, subscriber SUBSCRIBE) and the cache through it. Model (broadcast.rs): - TrackConsumer::info() -> InfoPending: resolves a track's immutable Track (timescale/compression/cache) without subscribing. Warm (a live producer exists, or the value is cached) it resolves with no round trip; cold it queues a dynamic info request. - New dynamic info-request channel mirroring requested_track/TrackRequest: BroadcastDynamic::requested_info() -> InfoRequest::resolve(Track)/deny, plus a combined requested() -> DynamicRequest{Track,Info} so one loop serves both. - track_info cache keyed by name (a re-announce replaces the broadcast and State, invalidating it). TrackRequest::accept warms it, so a subscribe and a concurrent TRACK coalesce. Group-by-group fetches (which keep no track-level producer) reuse the one cached lookup. Publisher: recv_track now calls consume_track(name).info().await instead of subscribing-and-dropping. The spawn stays (a cold relay lookup is an upstream round trip; the accept loop must not block on it). Subscriber: the relay serves downstream info requests (run_info) by fetching TRACK_INFO upstream and caching it; its own lite-05 SUBSCRIBE path now resolves props through info() too, so a downstream's parallel TRACK + SUBSCRIBE collapse to a single upstream TRACK fetch. Tests cover warm/cold/coalesced/NotFound info() and accept-warms-cache. Co-Authored-By: Claude Opus 4.8 (1M context) --- rs/moq-net/src/lite/publisher.rs | 17 +- rs/moq-net/src/lite/subscriber.rs | 73 ++++-- rs/moq-net/src/model/broadcast.rs | 372 +++++++++++++++++++++++++++++- 3 files changed, 437 insertions(+), 25 deletions(-) diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 2348ef7f9..00b6d9a3a 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -564,6 +564,9 @@ impl Publisher { let broadcast = self.origin.get_broadcast(&req.broadcast); let version = self.version; + // Spawn rather than serve inline: resolving the info can be a cold upstream + // TRACK fetch (relay case), and the publisher's accept loop in `run` must + // stay free to handle other control streams meanwhile. web_async::spawn(async move { if let Err(err) = Self::run_track_info(&mut stream, &track, broadcast, version).await { match &err { @@ -587,15 +590,11 @@ impl Publisher { ) -> Result<(), Error> { let broadcast = consumer.ok_or(Error::NotFound)?; - // The immutable properties are delivered when the (possibly dynamic) - // producer is accepted, so resolving them means subscribing. We read them - // off the subscriber and drop it immediately; a SUBSCRIBE flighted in - // parallel coalesces onto the same upstream producer, which linger keeps - // alive across this brief gap. - let track = broadcast - .consume_track(track_name) - .subscribe(crate::Subscription::default()) - .await?; + // Resolve the immutable properties without subscribing. Warm (a producer + // exists or the info is cached) this returns with no round trip; cold (a + // relay with no prior subscription) it triggers a single upstream TRACK + // fetch via the model's info-request channel. + let track = broadcast.consume_track(track_name).info().await?; // Mirror the negotiation in `run_subscribe` so the subscriber decodes // frames the same way it'll see them served. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index de8a5d1f6..8fa5c9966 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -463,12 +463,12 @@ impl Subscriber { } async fn run_broadcast(self, path: PathOwned, mut broadcast: BroadcastDynamic) { - // Actually start serving subscriptions. + // Actually start serving subscriptions and info lookups. loop { // Keep serving requests until there are no more consumers. // This way we'll clean up the task when the broadcast is no longer needed. let request = tokio::select! { - request = broadcast.requested_track() => match request { + request = broadcast.requested() => match request { Ok(request) => request, Err(err) => { tracing::debug!(%err, "broadcast closed"); @@ -478,12 +478,45 @@ impl Subscriber { _ = self.session.closed() => break, }; - let mut this = self.clone(); let path = path.clone(); - let broadcast = broadcast.clone(); - web_async::spawn(async move { - this.run_subscribe(path, broadcast, request).await; - }); + match request { + crate::DynamicRequest::Track(request) => { + let mut this = self.clone(); + let broadcast = broadcast.clone(); + web_async::spawn(async move { + this.run_subscribe(path, broadcast, request).await; + }); + } + crate::DynamicRequest::Info(request) => { + let this = self.clone(); + web_async::spawn(async move { + this.run_info(path, request).await; + }); + } + } + } + } + + /// Serve a downstream info-only request by fetching the track's immutable + /// properties from upstream over a TRACK stream, then caching them in the + /// model. Subsequent info()/subscribe/fetch reuse the cache (no round trip). + async fn run_info(&self, path: PathOwned, request: crate::InfoRequest) { + let name = request.name().to_string(); + let upstream = path.as_path(); + match self.fetch_track_info(&upstream, &name).await { + Ok(info) => { + let mut track = Track::new(&name); + track.timescale = info.timescale; + track.cache = info.cache; + // The model carries compression as a bool; the codec set is + // {none, deflate}, so the flag round-trips losslessly. + track.compress = info.compression != Compression::None; + request.resolve(track); + } + Err(err) => { + tracing::debug!(broadcast = %self.log_path(&path), track = %name, %err, "track info fetch failed"); + request.deny(err); + } } } @@ -570,7 +603,6 @@ impl Subscriber { let ordered = msg.ordered; let max_latency = msg.max_latency; let start_group = msg.start_group; - let broadcast_path = msg.broadcast.clone(); // SubscribeUpdate only exists on Lite03+; older versions take the // immediate-FIN path with no linger. @@ -609,24 +641,35 @@ impl Subscriber { } // Resolve the track's immutable properties (compression/timescale/cache). - // lite-05 fetches TRACK_INFO on its own stream (already racing the SUBSCRIBE - // above); older drafts take them from SUBSCRIBE_OK, which carries no such - // properties, so they fall back to the untimed/uncompressed defaults. + // lite-05 has no SUBSCRIBE_OK: resolve them via the model's info(), which is + // warm (cached) when a downstream TRACK or a prior lookup already fetched + // them, and otherwise coalesces with those into one upstream TRACK fetch. + // The SUBSCRIBE above is already in flight, so groups still arrive in one + // round trip and buffer on `resolved` until this lands. Older drafts take + // the props from SUBSCRIBE_OK, which carries none, so they fall back to the + // untimed/uncompressed defaults. let (compression, timescale, cache) = if matches!(self.version, Version::Lite05Wip) { - tokio::select! { + let props = tokio::select! { err = broadcast.closed() => { request.deny(err.clone()); return SessionOutcome::BroadcastClosed(err); } - res = self.fetch_track_info(&broadcast_path, name) => match res { - Ok(info) => (info.compression, info.timescale, info.cache), + res = broadcast.consume().consume_track(name).info() => match res { + Ok(props) => props, Err(err) => { stream.writer.abort(&err); request.deny(err.clone()); return SessionOutcome::Error(err); } } - } + }; + // Codec set is {none, deflate}, so the model's bool maps back losslessly. + let compression = if props.compress { + Compression::Deflate + } else { + Compression::None + }; + (compression, props.timescale, props.cache) } else { let resp = tokio::select! { err = broadcast.closed() => { diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index ab6b53bc5..b40fbdf68 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -61,11 +61,35 @@ fn fail_resolvers(resolvers: Vec, err: &Error) { } } +/// The slot a pending info request resolves into: `None` until the dynamic +/// handler resolves it with the track's immutable [`Track`] (or denies). The +/// info-only analogue of [`PendingSlot`], it never creates a producer. +type InfoSlot = Option>; + +/// One waiting info request: the producer side of its resolver channel. +type InfoResolver = kio::Producer; + +/// Resolve every waiting info request with `err`. +fn fail_info_resolvers(resolvers: Vec, err: &Error) { + for slot in resolvers { + if let Ok(mut slot) = slot.write() { + *slot = Some(Err(err.clone())); + } + } +} + #[derive(Default)] struct State { // Weak references for deduplication. Doesn't prevent track auto-close. tracks: HashMap, + // Resolved immutable track properties, cached so repeated info() lookups (and + // group-by-group fetches, which keep no track-level producer) reuse one + // resolution instead of re-fetching upstream. Keyed by track name; a + // re-announce replaces the whole broadcast (and this State), so the cache is + // implicitly invalidated then. + track_info: HashMap, + // Pending requests keyed by track name, waiting for the dynamic handler to // accept or deny them. requests: HashMap, @@ -74,6 +98,12 @@ struct State { // stays in `requests` (but not here) once handed out as a `TrackRequest`. request_order: VecDeque, + // Pending info-only requests keyed by track name, plus FIFO order for the + // dynamic handler to drain. Mirrors `requests`/`request_order` but resolves a + // `Track` rather than a producer. + info_requests: HashMap>, + info_request_order: VecDeque, + // The current number of dynamic producers. // If this is 0, requests must be empty. dynamic: usize, @@ -99,12 +129,17 @@ impl State { Ok(()) } - /// Drop every pending request, notifying all waiting subscribers with `err`. + /// Drop every pending request, notifying all waiting subscribers and info + /// requests with `err`. fn abort_requests(&mut self, err: &Error) { self.request_order.clear(); for (_, pending) in self.requests.drain() { fail_resolvers(pending.resolvers, err); } + self.info_request_order.clear(); + for (_, resolvers) in self.info_requests.drain() { + fail_info_resolvers(resolvers, err); + } } /// Drop a single named pending request, notifying its subscribers with `err`. @@ -114,6 +149,29 @@ impl State { fail_resolvers(pending.resolvers, &err); } } + + /// Drop a single named pending info request, notifying its waiters with `err`. + fn deny_info_request(&mut self, name: &str, err: Error) { + self.info_request_order.retain(|n| n != name); + if let Some(resolvers) = self.info_requests.remove(name) { + fail_info_resolvers(resolvers, &err); + } + } + + /// Cache a track's resolved info and wake any pending info requests for it. + /// Called both when a subscription is accepted (warming the cache) and when an + /// info-only request resolves. + fn resolve_info(&mut self, name: &str, info: Track) { + self.track_info.insert(name.to_string(), info.clone()); + self.info_request_order.retain(|n| n != name); + if let Some(resolvers) = self.info_requests.remove(name) { + for slot in resolvers { + if let Ok(mut slot) = slot.write() { + *slot = Some(Ok(info.clone())); + } + } + } + } } /// Manages tracks within a broadcast. @@ -289,6 +347,10 @@ impl TrackRequest { let pending = state.requests.remove(&self.name).ok_or(Error::Cancel)?; state.request_order.retain(|n| n != &self.name); + // Warm the info cache and wake any info-only waiters: the props are now + // known, so a concurrent TRACK request needn't fetch them separately. + state.resolve_info(&self.name, track.clone()); + let producer = TrackProducer::new(track); // Insert a weak reference so future subscribers dedupe onto this producer. @@ -352,6 +414,127 @@ impl Drop for TrackRequest { } } +/// An info-only request waiting to be served, handed out by +/// [`BroadcastDynamic::requested_info`]. +/// +/// The publisher inspects [`Self::name`], then either [`Self::resolve`]s it with +/// the track's immutable [`Track`] (waking every waiting `info()` caller and +/// caching the result) or [`Self::deny`]s it. Dropping without doing either +/// denies with [`Error::Cancel`]. Unlike [`TrackRequest`], no producer or +/// subscription is created. +pub struct InfoRequest { + name: String, + state: kio::Weak, + /// Set once resolved or denied so [`Drop`] doesn't deny a second time. + completed: bool, +} + +impl InfoRequest { + /// The requested track name. + pub fn name(&self) -> &str { + &self.name + } + + /// Resolve the request with the track's immutable info, caching it and waking + /// every waiting `info()` caller. + pub fn resolve(mut self, info: Track) { + self.completed = true; + if let Ok(mut state) = self.state.write() { + state.resolve_info(&self.name, info); + } + } + + /// Reject the request, waking all waiting `info()` callers with `err`. + pub fn deny(mut self, err: Error) { + self.completed = true; + if let Ok(mut state) = self.state.write() { + state.deny_info_request(&self.name, err); + } + } +} + +impl Drop for InfoRequest { + fn drop(&mut self) { + if !self.completed + && let Ok(mut state) = self.state.write() + { + state.deny_info_request(&self.name, Error::Cancel); + } + } +} + +/// A consumer request handed out by [`BroadcastDynamic::requested`]: either a full +/// subscription or an info-only lookup. +pub enum DynamicRequest { + Track(TrackRequest), + Info(InfoRequest), +} + +/// A pending info lookup returned by [`TrackConsumer::info`]. +/// +/// Resolves once the track's immutable [`Track`] is known: synchronously if a +/// producer already exists or the info is cached, otherwise once the dynamic +/// handler serves it via [`BroadcastDynamic::requested_info`]. Implements +/// [`Future`]; poll-based callers can use [`Self::poll_info`]. +pub struct InfoPending { + inner: InfoPendingInner, + /// Kept alive between `Future::poll` calls so its resolver-channel + /// registration stays valid until the next poll replaces it. + waiter: Option, +} + +enum InfoPendingInner { + /// Resolved synchronously: cached, a live producer existed, or it failed. + Ready(Result), + /// Waiting for the dynamic handler to resolve or deny. + Waiting(kio::Consumer), +} + +impl InfoPending { + fn ready(result: Result) -> Self { + Self { + inner: InfoPendingInner::Ready(result), + waiter: None, + } + } + + fn waiting(consumer: kio::Consumer) -> Self { + Self { + inner: InfoPendingInner::Waiting(consumer), + waiter: None, + } + } + + /// Poll for the resolved [`Track`], without blocking. + pub fn poll_info(&self, waiter: &kio::Waiter) -> Poll> { + match &self.inner { + InfoPendingInner::Ready(result) => Poll::Ready(result.clone()), + InfoPendingInner::Waiting(consumer) => match consumer.poll(waiter, |slot| match &**slot { + Some(result) => Poll::Ready(result.clone()), + None => Poll::Pending, + }) { + Poll::Ready(Ok(result)) => Poll::Ready(result), + // Channel closed: the resolver may have left the final result behind. + Poll::Ready(Err(closed)) => Poll::Ready(match &*closed { + Some(result) => result.clone(), + None => Err(Error::Cancel), + }), + Poll::Pending => Poll::Pending, + }, + } + } +} + +impl std::future::Future for InfoPending { + type Output = Result; + + fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { + let this = self.get_mut(); + this.waiter = Some(kio::Waiter::new(cx.waker().clone())); + this.poll_info(this.waiter.as_ref().unwrap()) + } +} + /// A pending subscription returned by [`TrackConsumer::subscribe`]. /// /// The subscription isn't live until the publisher accepts it (for the wire, @@ -505,6 +688,46 @@ impl BroadcastDynamic { kio::wait(|waiter| self.poll_requested_track(waiter)).await } + /// Poll for the next consumer info-only request, without blocking. + pub fn poll_requested_info(&mut self, waiter: &kio::Waiter) -> Poll> { + let weak = self.state.weak(); + self.poll(waiter, |state| match state.info_request_order.pop_front() { + // The name stays in `info_requests` so concurrent info() callers coalesce + // onto it until this resolves or denies. + Some(name) => Poll::Ready(name), + None => Poll::Pending, + }) + .map(|res| { + res.map(|name| InfoRequest { + name, + state: weak, + completed: false, + }) + }) + } + + /// Block until a consumer requests track info, returning an [`InfoRequest`] to serve. + pub async fn requested_info(&mut self) -> Result { + kio::wait(|waiter| self.poll_requested_info(waiter)).await + } + + /// Poll for the next consumer request of either kind, without blocking. + /// + /// Info requests are preferred since they're a cheap one-shot lookup that + /// shouldn't queue behind a (potentially long-lived) subscription. + pub fn poll_requested(&mut self, waiter: &kio::Waiter) -> Poll> { + if let Poll::Ready(res) = self.poll_requested_info(waiter) { + return Poll::Ready(res.map(DynamicRequest::Info)); + } + self.poll_requested_track(waiter) + .map(|res| res.map(DynamicRequest::Track)) + } + + /// Block until a consumer requests a track or its info, returning the request to serve. + pub async fn requested(&mut self) -> Result { + kio::wait(|waiter| self.poll_requested(waiter)).await + } + /// Create a consumer that can subscribe to tracks in this broadcast. pub fn consume(&self) -> BroadcastConsumer { BroadcastConsumer { @@ -572,6 +795,13 @@ impl BroadcastDynamic { pub fn assert_no_request(&mut self) { assert!(self.requested_track().now_or_never().is_none(), "should have blocked"); } + + pub fn assert_info_request(&mut self) -> InfoRequest { + self.requested_info() + .now_or_never() + .expect("should not have blocked") + .expect("should not have errored") + } } /// Subscribe to arbitrary broadcast/tracks. @@ -652,6 +882,54 @@ impl BroadcastConsumer { TrackPending::waiting(consumer) } + /// Resolve a track's immutable info, returning an [`InfoPending`]. + /// + /// Returns immediately when the info is cached or a live producer already + /// carries it (no round trip). Otherwise queues a dynamic info request served + /// via [`BroadcastDynamic::requested_info`] and [`InfoRequest::resolve`] (for + /// the wire this is a TRACK Stream / TRACK_INFO). Resolves to [`Error::NotFound`] + /// if no dynamic producer exists to handle it. Unlike [`Self::request_subscribe`], + /// this never opens a subscription. + fn request_info(&self, name: &str) -> InfoPending { + let Some(producer) = self.state.produce() else { + let err = self.state.read().abort.clone().unwrap_or(Error::Dropped); + return InfoPending::ready(Err(err)); + }; + let mut state = match modify(&producer) { + Ok(state) => state, + Err(err) => return InfoPending::ready(Err(err)), + }; + + // Already resolved for this track. + if let Some(info) = state.track_info.get(name) { + return InfoPending::ready(Ok(info.clone())); + } + + // A live producer carries the info; cache and return it without a round trip. + if let Some(weak) = state.tracks.get(name) + && !weak.is_closed() + { + let info = weak.info.clone(); + state.track_info.insert(name.to_string(), info.clone()); + return InfoPending::ready(Ok(info)); + } + + let slot = kio::Producer::new(None); + let consumer = slot.consume(); + + if let Some(resolvers) = state.info_requests.get_mut(name) { + // Coalesce onto an in-flight info request for the same name. + resolvers.push(slot); + } else if state.dynamic == 0 { + return InfoPending::ready(Err(Error::NotFound)); + } else { + state.info_requests.insert(name.to_string(), vec![slot]); + state.info_request_order.push_back(name.to_string()); + } + + InfoPending::waiting(consumer) + } + /// Block until the broadcast is closed and return the cause. /// /// Returns [`Error::Dropped`] if every producer was dropped without an @@ -715,6 +993,16 @@ impl TrackConsumer { self.broadcast .request_subscribe(&self.name, subscription.into().unwrap_or_default()) } + + /// Resolve this track's immutable [`Track`] info without subscribing. + /// + /// Returns an [`InfoPending`] that resolves to the publisher's properties + /// (timescale, compression, cache). Warm (cached or a live producer exists) it + /// resolves with no round trip; cold it triggers a single TRACK lookup. Reused + /// across every subscribe and fetch of the track. + pub fn info(&self) -> InfoPending { + self.broadcast.request_info(&self.name) + } } #[cfg(test)] @@ -940,4 +1228,86 @@ mod test { // instead of failing with NotFound. let _fut = subscribe_pending!(consumer, "track1"); } + + #[tokio::test] + async fn info_warm_from_live_producer() { + let mut producer = Broadcast::new().produce(); + // Hold the track producer so the weak handle stays live. + let track = Track::new("video").with_timescale(crate::Timescale::MICRO).produce(); + producer.assert_insert_track(&track); + + let consumer = producer.consume(); + + // A live producer carries the info, so info() resolves with no round trip. + let got = consumer + .consume_track("video") + .info() + .now_or_never() + .expect("warm info should not block") + .unwrap(); + assert_eq!(got.timescale, Some(crate::Timescale::MICRO)); + } + + #[tokio::test] + async fn info_cold_resolves_via_request() { + let mut producer = Broadcast::new().produce().dynamic(); + let consumer = producer.consume(); + let consumer2 = consumer.clone(); + + // No producer yet: two info() callers for the same track coalesce into one + // pending request. + let info1 = consumer.consume_track("video").info(); + assert!(info1.poll_info(&kio::Waiter::noop()).is_pending()); + let info2 = consumer2.consume_track("video").info(); + + // Exactly one info request to serve. + let request = producer.assert_info_request(); + assert_eq!(request.name(), "video"); + + request.resolve(Track::new("video").with_timescale(crate::Timescale::MICRO)); + + assert_eq!(info1.await.unwrap().timescale, Some(crate::Timescale::MICRO)); + // The second caller and any later lookup read the cached value, no new request. + assert_eq!(info2.await.unwrap().timescale, Some(crate::Timescale::MICRO)); + producer.assert_no_request(); + let cached = consumer + .consume_track("video") + .info() + .now_or_never() + .expect("cached info should not block") + .unwrap(); + assert_eq!(cached.timescale, Some(crate::Timescale::MICRO)); + } + + #[tokio::test] + async fn info_cold_missing_without_dynamic() { + // No producer and no dynamic handler: info() resolves to NotFound rather + // than hanging. + let producer = Broadcast::new().produce(); + let consumer = producer.consume(); + let err = consumer.consume_track("video").info().await.unwrap_err(); + assert!(matches!(err, Error::NotFound)); + } + + #[tokio::test] + async fn accept_warms_info_cache() { + let mut producer = Broadcast::new().produce().dynamic(); + let consumer = producer.consume(); + + // A subscribe accept caches the info, so a later info() needs no request. + let _sub = subscribe_pending!(consumer, "video"); + let request = producer.assert_request(); + let _track = request + .accept(Track::new("video").with_timescale(crate::Timescale::MICRO)) + .unwrap(); + + let got = consumer + .consume_track("video") + .info() + .now_or_never() + .expect("info should be cached by accept") + .unwrap(); + assert_eq!(got.timescale, Some(crate::Timescale::MICRO)); + producer.assert_no_request(); + } } From d7c1112f6c4c884d0d6c47bd8c5e4c048c53409e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 3 Jun 2026 18:22:52 -0700 Subject: [PATCH 3/7] moq-native: e2e test for Lite05 TrackConsumer::info() over a session Resolves a track's immutable properties via a TRACK stream (info(), no subscription), then subscribes and reads a frame. Guards the on-demand info path end-to-end ahead of the model refactor. Co-Authored-By: Claude Opus 4.8 (1M context) --- rs/moq-native/tests/broadcast.rs | 98 ++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/rs/moq-native/tests/broadcast.rs b/rs/moq-native/tests/broadcast.rs index e4a001fd7..dcd47e96f 100644 --- a/rs/moq-native/tests/broadcast.rs +++ b/rs/moq-native/tests/broadcast.rs @@ -227,6 +227,104 @@ async fn broadcast_moq_lite_05_timestamps_webtransport() { lite05_timestamp_roundtrip("https").await; } +/// Lite05 Track Stream: the client resolves a track's immutable properties via +/// `info()` (a TRACK request answered with TRACK_INFO) without subscribing, then +/// subscribes and reads a frame. Exercises the on-demand info path end-to-end. +async fn lite05_info_roundtrip(scheme: &str) { + use moq_native::moq_net::Timescale; + + let pub_origin = Origin::random().produce(); + let mut broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + let mut track = broadcast + .create_track(Track::new("video").with_timescale(Timescale::MICRO)) + .expect("create track"); + + let mut group = track.append_group().expect("append group"); + let frame = moq_native::moq_net::Frame { + size: 5, + timestamp: Some(moq_native::moq_net::Timestamp::new(10_000, Timescale::MICRO).unwrap()), + }; + let mut writer = group.create_frame(frame).expect("create frame"); + writer.write(bytes::Bytes::from_static(b"hello")).expect("write frame"); + writer.finish().expect("finish frame"); + group.finish().expect("finish group"); + + let mut server_config = moq_native::ServerConfig::default(); + server_config.bind = Some("[::]:0".to_string()); + server_config.tls.generate = vec!["localhost".into()]; + server_config.version = vec!["moq-lite-05-wip".parse().unwrap()]; + let mut server = server_config.init().expect("init server"); + let addr = server.local_addr().expect("local addr"); + + let sub_origin = Origin::random().produce(); + let mut announcements = sub_origin.consume().announced(); + + let mut client_config = moq_native::ClientConfig::default(); + client_config.tls.disable_verify = Some(true); + client_config.version = vec!["moq-lite-05-wip".parse().unwrap()]; + let client = client_config.init().expect("init client"); + let url: url::Url = format!("{scheme}://localhost:{}", addr.port()).parse().unwrap(); + + let server_handle = tokio::spawn(async move { + let request = server.accept().await.expect("no incoming connection"); + let session = request.with_publisher(pub_origin.clone()).ok().await?; + let _broadcast = broadcast; + let _track = track; + let _ = session.closed().await; + Ok::<_, anyhow::Error>(()) + }); + + let client = client.with_consumer(sub_origin); + let session = tokio::time::timeout(TIMEOUT, client.connect(url)) + .await + .expect("client connect timed out") + .expect("client connect failed"); + + let (path, bc) = tokio::time::timeout(TIMEOUT, announcements.next()) + .await + .expect("announce timed out") + .expect("origin closed"); + assert_eq!(path.as_str(), "test"); + let bc = bc.broadcast().expect("expected announce, got unannounce"); + + // Resolve the track's immutable info without subscribing. + let info = tokio::time::timeout(TIMEOUT, bc.consume_track("video").info()) + .await + .expect("info timed out") + .expect("info failed"); + assert_eq!(info.timescale, Some(Timescale::MICRO)); + + // A subscribe still works (and reuses the now-cached info). + let mut track_sub = bc + .consume_track("video") + .subscribe(None) + .await + .expect("subscribe failed"); + let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.recv_group()) + .await + .expect("recv_group timed out") + .expect("recv_group failed") + .expect("track closed prematurely"); + let frame = tokio::time::timeout(TIMEOUT, group_sub.read_frame()) + .await + .expect("read_frame timed out") + .expect("read_frame failed") + .expect("group closed prematurely"); + assert_eq!(&*frame, b"hello"); + + drop(session); + server_handle + .await + .expect("server task panicked") + .expect("server task failed"); +} + +#[tracing_test::traced_test] +#[tokio::test] +async fn broadcast_moq_lite_05_info_webtransport() { + lite05_info_roundtrip("https").await; +} + /// On Lite05 a publisher that doesn't advertise a timescale still works: /// SUBSCRIBE_OK carries `timescale = 0` and neither side encodes a /// per-frame timestamp byte. Subscribers receive `frame.timestamp = None`. From a109347f007243e275926769604a95bc587b1057 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 3 Jun 2026 18:36:02 -0700 Subject: [PATCH 4/7] moq-net: unify info into one demand-driven track request, drop InfoRequest Per review: collapse the separate InfoRequest/DynamicRequest channel into a single demand-driven request. info() and subscribe() now coalesce onto one PendingRequest, so a downstream's parallel TRACK + SUBSCRIBE for a track triggers exactly one upstream fetch (not two). Model (broadcast.rs): - TrackConsumer::info() coalesces onto the same dynamic request as subscribe (a new info-waiter list on PendingRequest); a single TrackRequest::accept resolves both the subscribers and the info waiters and caches the Track. - TrackRequest::subscription() is now Option: None means a pure info() request with no group demand, so the handler resolves info without subscribing. - BroadcastDynamic::cached_info() lets a handler skip the upstream TRACK fetch when the props are already known. Removed InfoRequest, DynamicRequest, requested_info(), requested(), and the parallel info_requests state. Relay subscriber (subscriber.rs): - run_subscribe is demand-gated: it resolves props (cached or one upstream TRACK fetch) and opens the upstream SUBSCRIBE only when there's group demand, flighting it alongside the info fetch so a fresh subscribe stays one round trip. A pure info() request resolves + caches the props and opens no subscription; if a subscriber coalesces meanwhile, it subscribes then. The linger lifecycle is factored into serve_lifecycle and reused. Publisher recv_track is unchanged (still consume_track(name).info().await); it now drives the unified request. Tested end-to-end: rs/moq-native broadcast tests cover info() + subscribe over a real session (cold fetch, then cached reuse), plus the existing timestamp round-trips. Model unit tests cover warm/cold-coalesced/NotFound/accept-warms. Co-Authored-By: Claude Opus 4.8 (1M context) --- rs/moq-net/src/lite/subscriber.rs | 353 ++++++++++++++++-------------- rs/moq-net/src/model/broadcast.rs | 259 ++++++++-------------- 2 files changed, 278 insertions(+), 334 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 8fa5c9966..dee01b003 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -10,7 +10,7 @@ use futures::{StreamExt, stream::FuturesUnordered}; use crate::{ AsPath, BandwidthProducer, Broadcast, BroadcastDynamic, Compression, Error, Frame, FrameProducer, Group, GroupProducer, MAX_FRAME_SIZE, OriginProducer, Path, PathOwned, StatsHandle, SubscriberStats, SubscriberTrack, - Timescale, Timestamp, Track, TrackProducer, TrackRequest, + Subscription, Timescale, Timestamp, Track, TrackProducer, TrackRequest, coding::{Reader, Stream}, lite, model::BroadcastProducer, @@ -463,12 +463,11 @@ impl Subscriber { } async fn run_broadcast(self, path: PathOwned, mut broadcast: BroadcastDynamic) { - // Actually start serving subscriptions and info lookups. + // Serve track requests (subscribe and info-only, coalesced) until the + // broadcast is gone. A request with no subscriber is an info-only lookup. loop { - // Keep serving requests until there are no more consumers. - // This way we'll clean up the task when the broadcast is no longer needed. let request = tokio::select! { - request = broadcast.requested() => match request { + request = broadcast.requested_track() => match request { Ok(request) => request, Err(err) => { tracing::debug!(%err, "broadcast closed"); @@ -478,57 +477,60 @@ impl Subscriber { _ = self.session.closed() => break, }; + let mut this = self.clone(); let path = path.clone(); - match request { - crate::DynamicRequest::Track(request) => { - let mut this = self.clone(); - let broadcast = broadcast.clone(); - web_async::spawn(async move { - this.run_subscribe(path, broadcast, request).await; - }); - } - crate::DynamicRequest::Info(request) => { - let this = self.clone(); - web_async::spawn(async move { - this.run_info(path, request).await; - }); - } - } + let broadcast = broadcast.clone(); + web_async::spawn(async move { + this.run_subscribe(path, broadcast, request).await; + }); } } - /// Serve a downstream info-only request by fetching the track's immutable - /// properties from upstream over a TRACK stream, then caching them in the - /// model. Subsequent info()/subscribe/fetch reuse the cache (no round trip). - async fn run_info(&self, path: PathOwned, request: crate::InfoRequest) { - let name = request.name().to_string(); - let upstream = path.as_path(); - match self.fetch_track_info(&upstream, &name).await { - Ok(info) => { - let mut track = Track::new(&name); - track.timescale = info.timescale; - track.cache = info.cache; - // The model carries compression as a bool; the codec set is - // {none, deflate}, so the flag round-trips losslessly. - track.compress = info.compression != Compression::None; - request.resolve(track); - } - Err(err) => { - tracing::debug!(broadcast = %self.log_path(&path), track = %name, %err, "track info fetch failed"); - request.deny(err); - } + /// Forward the aggregate downstream preferences upstream as a SUBSCRIBE. + async fn open_subscribe( + &self, + id: u64, + name: &str, + path: &PathOwned, + sub: &Subscription, + ) -> Result, Error> { + let mut stream = Stream::open(&self.session, self.version).await?; + stream.writer.encode(&lite::ControlType::Subscribe).await?; + let msg = lite::Subscribe { + id, + broadcast: path.as_path(), + track: name.into(), + priority: sub.priority, + ordered: sub.ordered, + max_latency: sub.stale, + start_group: sub.group_start, + end_group: sub.group_end, + }; + stream.writer.encode(&msg).await?; + Ok(stream) + } + + /// Resolve a track's immutable props on lite-05: a cached value (no round + /// trip), else a single upstream TRACK fetch (which also caches via `accept`). + async fn resolve_props(&self, broadcast: &BroadcastDynamic, path: &PathOwned, name: &str) -> Result { + if let Some(props) = broadcast.cached_info(name) { + return Ok(props); } + let info = self.fetch_track_info(&path.as_path(), name).await?; + let mut props = Track::new(name); + props.timescale = info.timescale; + props.cache = info.cache; + // The model carries compression as a bool; the codec set is {none, deflate}, + // so the flag round-trips losslessly. + props.compress = info.compression != Compression::None; + Ok(props) } - /// Drive one upstream subscription end-to-end, including linger across consumer churn. + /// Serve one track request: resolve its immutable info, and — if there's group + /// demand — drive the upstream subscription with linger across consumer churn. /// - /// On linger entry (last consumer drops) we send `SubscribeUpdate(priority=0, - /// end_group=Some(latest))`. The publisher treats `end_group` as a serving cap, - /// not a terminator: it holds any groups beyond the cap and resumes when we - /// raise it. On resume (a new consumer arrives) we send `SubscribeUpdate(end_group=None)` - /// to uncap. The stream stays open across the whole lifecycle — only a timeout - /// or a publisher-side close ends it. This avoids the stream-churn / duplicate-fetch - /// race that an unsubscribe-and-reissue approach would have. + /// A request with no subscriber (`info()` only) just resolves and caches the + /// info; no upstream SUBSCRIBE is opened. When demand appears, we open it then. async fn run_subscribe(&mut self, path: PathOwned, broadcast: BroadcastDynamic, request: TrackRequest) { // Subscriber-side track stats; counters bump as frames/bytes/groups arrive. // Drop on subscription end records `subscriber.subscriptions_closed`. We use @@ -537,29 +539,13 @@ impl Subscriber { let name = request.name().to_string(); let abs = self.origin.absolute(&path); let track_stats = Arc::new(self.stats.broadcast(&abs).subscriber_track(&name)); - // The per-(session, broadcast) `broadcasts` sentinel is taken later, once - // the upstream confirms with SUBSCRIBE_OK (see `run_subscribe_session`), so a - // sub cancelled before then isn't counted as a feeding session. let id = self.next_id.fetch_add(1, atomic::Ordering::Relaxed); - // Forward the aggregate of every downstream subscriber's preferences upstream. - let subscription = request.subscription().clone(); - let msg = lite::Subscribe { - id, - broadcast: path.as_path(), - track: (&name).into(), - priority: subscription.priority, - ordered: subscription.ordered, - max_latency: subscription.stale, - start_group: subscription.group_start, - end_group: subscription.group_end, - }; - - tracing::info!(id, broadcast = %self.log_path(&path), track = %name, "subscribe started"); + tracing::info!(id, broadcast = %self.log_path(&path), track = %name, "track requested"); let result = self - .run_subscribe_session(id, &name, request, track_stats, &broadcast, msg) + .run_subscribe_session(id, &name, request, track_stats, &broadcast, &path) .await; self.subscribes.lock().remove(&id); @@ -580,14 +566,13 @@ impl Subscriber { } } - /// Open the upstream subscribe stream, resolve the track's immutable - /// properties, then accept the pending request (unblocking the downstream - /// subscriber) and run the linger lifecycle. + /// Resolve the track's immutable props, then — if there's group demand — accept + /// the producer and drive the upstream subscription with linger. /// - /// On lite-05 the properties come from a TRACK_INFO stream flighted alongside - /// SUBSCRIBE, so the first group still arrives in one round trip; older drafts - /// read them (implicitly) from SUBSCRIBE_OK. The producer is created once those - /// properties are known, so a downstream `subscribe` resolves exactly then. + /// On lite-05 the props come from the cache or a TRACK_INFO stream flighted + /// alongside SUBSCRIBE (so the first group still arrives in one round trip); + /// older drafts read them (absent) from SUBSCRIBE_OK. A pure `info()` request + /// (no subscriber) resolves the props and caches them without subscribing. async fn run_subscribe_session( &self, id: u64, @@ -595,23 +580,11 @@ impl Subscriber { request: TrackRequest, track_stats: Arc, broadcast: &BroadcastDynamic, - msg: lite::Subscribe<'_>, + path: &PathOwned, ) -> SessionOutcome { - // Stash the original parameters so SubscribeUpdate messages can echo them - // while only varying the linger-related fields (priority, end_group). - let original_priority = msg.priority; - let ordered = msg.ordered; - let max_latency = msg.max_latency; - let start_group = msg.start_group; - - // SubscribeUpdate only exists on Lite03+; older versions take the - // immediate-FIN path with no linger. - let supports_linger = !matches!(self.version, Version::Lite01 | Version::Lite02); - - // Insert a pending entry up front so a group stream that races ahead of - // acceptance parks on `resolved` instead of being dropped. Held here for - // the session's lifetime; dropping it closes the channel and wakes any - // parked group streams with a cancellation. + // Pending entry up front so a group stream that races ahead of acceptance + // parks on `resolved` instead of being dropped. Held for the session's + // lifetime; dropping it closes the channel and wakes parked group streams. let resolved_tx: kio::Producer> = kio::Producer::new(None); self.subscribes.lock().insert( id, @@ -621,49 +594,43 @@ impl Subscriber { }, ); - let mut stream = match Stream::open(&self.session, self.version).await { - Ok(s) => s, - Err(err) => { - request.deny(err.clone()); - return SessionOutcome::Error(err); - } - }; + // Group demand at hand-out? Always true before lite-05 (which has no info() + // callers); on lite-05 a pure TRACK request has none. + let initial_sub = request.subscription().cloned(); + let lite05 = matches!(self.version, Version::Lite05Wip); - if let Err(err) = stream.writer.encode(&lite::ControlType::Subscribe).await { - request.deny(err.clone()); - return SessionOutcome::Error(err); - } + // The upstream subscribe stream, opened only when there's group demand. + let mut stream: Option> = None; - if let Err(err) = stream.writer.encode(&msg).await { - stream.writer.abort(&err); - request.deny(err.clone()); - return SessionOutcome::Error(err); - } + // Resolve the immutable props; flight SUBSCRIBE in parallel when wanted. + let (compression, timescale, cache) = if lite05 { + // Flight SUBSCRIBE now if there's demand, so it races the info fetch (1 RTT). + if let Some(sub) = &initial_sub { + match self.open_subscribe(id, name, path, sub).await { + Ok(s) => stream = Some(s), + Err(err) => { + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + } + } - // Resolve the track's immutable properties (compression/timescale/cache). - // lite-05 has no SUBSCRIBE_OK: resolve them via the model's info(), which is - // warm (cached) when a downstream TRACK or a prior lookup already fetched - // them, and otherwise coalesces with those into one upstream TRACK fetch. - // The SUBSCRIBE above is already in flight, so groups still arrive in one - // round trip and buffer on `resolved` until this lands. Older drafts take - // the props from SUBSCRIBE_OK, which carries none, so they fall back to the - // untimed/uncompressed defaults. - let (compression, timescale, cache) = if matches!(self.version, Version::Lite05Wip) { let props = tokio::select! { err = broadcast.closed() => { request.deny(err.clone()); return SessionOutcome::BroadcastClosed(err); } - res = broadcast.consume().consume_track(name).info() => match res { + res = self.resolve_props(broadcast, path, name) => match res { Ok(props) => props, Err(err) => { - stream.writer.abort(&err); + if let Some(s) = &mut stream { + s.writer.abort(&err); + } request.deny(err.clone()); return SessionOutcome::Error(err); } } }; - // Codec set is {none, deflate}, so the model's bool maps back losslessly. let compression = if props.compress { Compression::Deflate } else { @@ -671,15 +638,24 @@ impl Subscriber { }; (compression, props.timescale, props.cache) } else { + // Older drafts: open the subscribe stream and read SUBSCRIBE_OK (no props). + let sub = initial_sub.clone().unwrap_or_default(); + let mut s = match self.open_subscribe(id, name, path, &sub).await { + Ok(s) => s, + Err(err) => { + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + }; let resp = tokio::select! { err = broadcast.closed() => { request.deny(err.clone()); return SessionOutcome::BroadcastClosed(err); } - resp = stream.reader.decode::() => match resp { + resp = s.reader.decode::() => match resp { Ok(r) => r, Err(err) => { - stream.writer.abort(&err); + s.writer.abort(&err); request.deny(err.clone()); return SessionOutcome::Error(err); } @@ -687,39 +663,36 @@ impl Subscriber { }; if !matches!(resp, lite::SubscribeResponse::Ok(_)) { let err = Error::ProtocolViolation; - stream.writer.abort(&err); + s.writer.abort(&err); request.deny(err.clone()); return SessionOutcome::Error(err); } + stream = Some(s); (Compression::None, None, crate::DEFAULT_CACHE) }; - // Upstream confirmed the subscription (TRACK_INFO on lite-05, SUBSCRIBE_OK on - // older drafts), so this session is now actively feeding the broadcast: take - // the per-(session, broadcast) sentinel. It drops when this fn returns - // (subscription end / cancel), releasing `broadcasts_closed`. Taken only - // after confirmation so a sub cancelled before it isn't counted as a feeding - // session. - let abs = self.origin.absolute(&msg.broadcast); + // Accept: create the producer, resolving info + subscriber waiters. Stamp + // the negotiated timescale and cache window onto the local Track so groups + // inherit the timescale (validated at the model layer) and the producer + // evicts (and clamps downstream stale windows) with the same bound. + let abs = self.origin.absolute(path); let _broadcast_sub = self.broadcasts.subscribe(&abs); - // Stamp the negotiated timescale and cache window onto the local Track so - // groups inherit the timescale (the frame decode path validates per-frame - // timestamps at the model layer) and the producer evicts (and clamps - // downstream stale windows) with the same bound when re-served. let mut local_info = Track::new(name); local_info.timescale = timescale; local_info.cache = cache; let mut track = match request.accept(local_info) { Ok(track) => track, Err(err) => { - stream.writer.abort(&err); + if let Some(s) = &mut stream { + s.writer.abort(&err); + } return SessionOutcome::Error(err); } }; - // Resolve the pending entry: group streams parked on it can now create - // groups (with the right timescale) and decode frames. + // Resolve the pending entry: parked group streams can now create groups + // (with the right timescale) and decode frames. if let Ok(mut resolved) = resolved_tx.write() { *resolved = Some(ResolvedTrack { producer: track.clone(), @@ -728,8 +701,80 @@ impl Subscriber { }); } - // Lifecycle loop: serve → linger → resume → serve → ... → FIN. - let outcome = 'lifecycle: loop { + // If we didn't open the stream eagerly (info-only at hand-out), a subscriber + // may have coalesced during the info fetch: open it now (props are cached, so + // just SUBSCRIBE). Otherwise wait briefly for one, else drop (info stays cached). + if stream.is_none() { + loop { + if let Some(sub) = track.subscription() { + match self.open_subscribe(id, name, path, &sub).await { + Ok(s) => { + stream = Some(s); + break; + } + Err(err) => { + let _ = track.abort(err.clone()); + return SessionOutcome::Error(err); + } + } + } else { + tokio::select! { + _ = track.used() => continue, + err = broadcast.closed() => { + let _ = track.abort(err.clone()); + return SessionOutcome::BroadcastClosed(err); + } + _ = tokio::time::sleep(LINGER_TIMEOUT) => { + let _ = track.finish(); + return SessionOutcome::Cancelled; + } + } + } + } + } + + let mut stream = stream.expect("subscribe stream is open once there's group demand"); + let sub = track.subscription().or(initial_sub).unwrap_or_default(); + let outcome = self.serve_lifecycle(&mut stream, &mut track, broadcast, &sub).await; + + // Apply the outcome to the producer that downstream consumers read from. + match &outcome { + SessionOutcome::Complete => { + let _ = track.finish(); + } + SessionOutcome::Cancelled => { + let _ = track.abort(Error::Cancel); + } + SessionOutcome::BroadcastClosed(err) | SessionOutcome::Error(err) => { + let _ = track.abort(err.clone()); + } + } + + outcome + } + + /// The linger lifecycle on an open subscribe stream: serve → linger → resume → + /// ... → FIN. + /// + /// On linger entry (last consumer drops) we send `SubscribeUpdate(priority=0, + /// end_group=Some(latest))`. The publisher treats `end_group` as a serving cap, + /// not a terminator: it holds any groups beyond the cap and resumes when we + /// raise it. On resume (a new consumer arrives) we uncap with `end_group=None`. + /// The stream stays open across the whole lifecycle — only a timeout or a + /// publisher-side close ends it, avoiding the stream-churn an unsubscribe-and- + /// reissue approach would have. + async fn serve_lifecycle( + &self, + stream: &mut Stream, + track: &mut TrackProducer, + broadcast: &BroadcastDynamic, + sub: &Subscription, + ) -> SessionOutcome { + // SubscribeUpdate only exists on Lite03+; older versions take the + // immediate-FIN path with no linger. + let supports_linger = !matches!(self.version, Version::Lite01 | Version::Lite02); + + 'lifecycle: loop { // Phase 1 — serving. Wait for the last consumer to drop (enter linger), // the broadcast to die, or the upstream to close the stream. tokio::select! { @@ -744,22 +789,21 @@ impl Subscriber { }, } - // No linger on Lite01/02: FIN and report cancellation. if !supports_linger { let _ = stream.writer.finish(); break 'lifecycle SessionOutcome::Cancelled; } // Phase 2 — linger. Cap the publisher's serving cursor at the latest - // group we've cached and drop priority to 0; the publisher holds any - // group beyond the cap until we resume or FIN. `unwrap_or(0)` handles - // the corner case where we subscribed but haven't received a group yet. + // cached group and drop priority to 0; the publisher holds any group + // beyond the cap until we resume or FIN. `unwrap_or(0)` handles the case + // where we subscribed but haven't received a group yet. let cap = track.latest().unwrap_or(0); let pause = lite::SubscribeUpdate { priority: 0, - ordered, - max_latency, - start_group, + ordered: sub.ordered, + max_latency: sub.stale, + start_group: sub.group_start, end_group: Some(cap), }; if let Err(err) = stream.writer.encode(&pause).await { @@ -789,10 +833,10 @@ impl Subscriber { tracing::info!(track = %track.name, "subscribe resumed"); let uncap = lite::SubscribeUpdate { - priority: original_priority, - ordered, - max_latency, - start_group, + priority: sub.priority, + ordered: sub.ordered, + max_latency: sub.stale, + start_group: sub.group_start, end_group: None, }; if let Err(err) = stream.writer.encode(&uncap).await { @@ -800,22 +844,7 @@ impl Subscriber { break 'lifecycle SessionOutcome::Error(err); } // Loop back to Phase 1. - }; - - // Apply the outcome to the producer that downstream consumers read from. - match &outcome { - SessionOutcome::Complete => { - let _ = track.finish(); - } - SessionOutcome::Cancelled => { - let _ = track.abort(Error::Cancel); - } - SessionOutcome::BroadcastClosed(err) | SessionOutcome::Error(err) => { - let _ = track.abort(err.clone()); - } } - - outcome } /// Open a Track Stream, send TRACK, and read the single TRACK_INFO reply. diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index b40fbdf68..98e04d71a 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -42,14 +42,26 @@ type PendingSlot = Option>; /// One waiting subscriber: its preferences and the producer side of its resolver channel. type Resolver = (Subscription, kio::Producer); -/// A track that has been subscribed to but not yet served by the dynamic handler. +/// A track that has been requested but not yet served by the dynamic handler. /// -/// Multiple subscribers to the same name before it is accepted coalesce into one -/// pending request, each adding a resolver channel so they all receive a consumer -/// for the same producer once it is accepted. +/// Subscribers and info-only callers for the same name before it is served +/// coalesce into one pending request: each subscriber adds a resolver channel (so +/// they all receive a consumer for the same producer) and each `info()` caller +/// adds an info resolver (so they all receive the resolved [`Track`]). A single +/// `accept` resolves both kinds, so a parallel TRACK + SUBSCRIBE for a track +/// triggers just one upstream fetch. #[derive(Default)] struct PendingRequest { resolvers: Vec, + info_resolvers: Vec, +} + +impl PendingRequest { + /// Fail every waiting subscriber and info caller with `err`. + fn fail(self, err: &Error) { + fail_resolvers(self.resolvers, err); + fail_info_resolvers(self.info_resolvers, err); + } } /// Resolve every waiting subscriber with `err`. @@ -98,12 +110,6 @@ struct State { // stays in `requests` (but not here) once handed out as a `TrackRequest`. request_order: VecDeque, - // Pending info-only requests keyed by track name, plus FIFO order for the - // dynamic handler to drain. Mirrors `requests`/`request_order` but resolves a - // `Track` rather than a producer. - info_requests: HashMap>, - info_request_order: VecDeque, - // The current number of dynamic producers. // If this is 0, requests must be empty. dynamic: usize, @@ -130,47 +136,27 @@ impl State { } /// Drop every pending request, notifying all waiting subscribers and info - /// requests with `err`. + /// callers with `err`. fn abort_requests(&mut self, err: &Error) { self.request_order.clear(); for (_, pending) in self.requests.drain() { - fail_resolvers(pending.resolvers, err); - } - self.info_request_order.clear(); - for (_, resolvers) in self.info_requests.drain() { - fail_info_resolvers(resolvers, err); + pending.fail(err); } } - /// Drop a single named pending request, notifying its subscribers with `err`. + /// Drop a single named pending request, notifying its subscribers and info + /// callers with `err`. fn deny_request(&mut self, name: &str, err: Error) { self.request_order.retain(|n| n != name); if let Some(pending) = self.requests.remove(name) { - fail_resolvers(pending.resolvers, &err); + pending.fail(&err); } } - /// Drop a single named pending info request, notifying its waiters with `err`. - fn deny_info_request(&mut self, name: &str, err: Error) { - self.info_request_order.retain(|n| n != name); - if let Some(resolvers) = self.info_requests.remove(name) { - fail_info_resolvers(resolvers, &err); - } - } - - /// Cache a track's resolved info and wake any pending info requests for it. - /// Called both when a subscription is accepted (warming the cache) and when an - /// info-only request resolves. - fn resolve_info(&mut self, name: &str, info: Track) { - self.track_info.insert(name.to_string(), info.clone()); - self.info_request_order.retain(|n| n != name); - if let Some(resolvers) = self.info_requests.remove(name) { - for slot in resolvers { - if let Ok(mut slot) = slot.write() { - *slot = Some(Ok(info.clone())); - } - } - } + /// Cache a track's resolved immutable info, so later `info()` / subscribe / + /// fetch reuse it instead of re-fetching upstream. + fn cache_info(&mut self, name: &str, info: Track) { + self.track_info.insert(name.to_string(), info); } } @@ -302,15 +288,19 @@ impl BroadcastProducer { } } -/// A subscription waiting to be served, handed out by [`BroadcastDynamic::requested_track`]. +/// A track request waiting to be served, handed out by [`BroadcastDynamic::requested_track`]. /// -/// The publisher inspects [`Self::name`] (and optionally [`Self::subscription`]), -/// then either [`Self::accept`]s it with a concrete [`Track`], which resolves all -/// waiting subscribers, or [`Self::deny`]s it. Dropping without doing either -/// denies with [`Error::Cancel`]. +/// The publisher inspects [`Self::name`] and [`Self::subscription`], then +/// [`Self::accept`]s it with a concrete [`Track`], which resolves every waiting +/// subscriber and info caller, or [`Self::deny`]s it. Dropping without doing +/// either denies with [`Error::Cancel`]. +/// +/// A request may have no subscriber at all (only `info()` callers): then +/// [`Self::subscription`] is `None` and the handler should resolve the info +/// without opening an upstream subscription. pub struct TrackRequest { name: String, - subscription: Subscription, + subscription: Option, state: kio::Weak, /// Set once accepted or denied so [`Drop`] doesn't deny a second time. completed: bool, @@ -322,11 +312,12 @@ impl TrackRequest { &self.name } - /// The first waiting subscriber's preferences, as a hint for constructing the - /// [`Track`]. The full aggregate is available on the [`TrackProducer`] returned - /// by [`Self::accept`] via [`TrackProducer::subscription`]. - pub fn subscription(&self) -> &Subscription { - &self.subscription + /// The first waiting subscriber's preferences, or `None` when the request has + /// only `info()` callers (no group demand). A handler uses this to decide + /// whether to open an upstream subscription. The full aggregate is available on + /// the [`TrackProducer`] returned by [`Self::accept`]. + pub fn subscription(&self) -> Option<&Subscription> { + self.subscription.as_ref() } /// Serve the request with the given track, resolving every waiting subscriber. @@ -347,15 +338,23 @@ impl TrackRequest { let pending = state.requests.remove(&self.name).ok_or(Error::Cancel)?; state.request_order.retain(|n| n != &self.name); - // Warm the info cache and wake any info-only waiters: the props are now - // known, so a concurrent TRACK request needn't fetch them separately. - state.resolve_info(&self.name, track.clone()); + // Warm the info cache: the props are now known, so a concurrent TRACK + // request (or a later subscribe/fetch) reuses them instead of re-fetching. + let info = track.clone(); + state.cache_info(&self.name, info.clone()); let producer = TrackProducer::new(track); // Insert a weak reference so future subscribers dedupe onto this producer. state.tracks.insert(self.name.clone(), producer.weak()); + // Wake any info-only waiters with the resolved props. + for slot in pending.info_resolvers { + if let Ok(mut slot) = slot.write() { + *slot = Some(Ok(info.clone())); + } + } + // Hand each waiting subscriber a consumer carrying its own preferences. // Building it here (not when the subscriber polls) means it counts toward // the producer immediately, so a publisher checking `unused()` right after @@ -414,67 +413,11 @@ impl Drop for TrackRequest { } } -/// An info-only request waiting to be served, handed out by -/// [`BroadcastDynamic::requested_info`]. -/// -/// The publisher inspects [`Self::name`], then either [`Self::resolve`]s it with -/// the track's immutable [`Track`] (waking every waiting `info()` caller and -/// caching the result) or [`Self::deny`]s it. Dropping without doing either -/// denies with [`Error::Cancel`]. Unlike [`TrackRequest`], no producer or -/// subscription is created. -pub struct InfoRequest { - name: String, - state: kio::Weak, - /// Set once resolved or denied so [`Drop`] doesn't deny a second time. - completed: bool, -} - -impl InfoRequest { - /// The requested track name. - pub fn name(&self) -> &str { - &self.name - } - - /// Resolve the request with the track's immutable info, caching it and waking - /// every waiting `info()` caller. - pub fn resolve(mut self, info: Track) { - self.completed = true; - if let Ok(mut state) = self.state.write() { - state.resolve_info(&self.name, info); - } - } - - /// Reject the request, waking all waiting `info()` callers with `err`. - pub fn deny(mut self, err: Error) { - self.completed = true; - if let Ok(mut state) = self.state.write() { - state.deny_info_request(&self.name, err); - } - } -} - -impl Drop for InfoRequest { - fn drop(&mut self) { - if !self.completed - && let Ok(mut state) = self.state.write() - { - state.deny_info_request(&self.name, Error::Cancel); - } - } -} - -/// A consumer request handed out by [`BroadcastDynamic::requested`]: either a full -/// subscription or an info-only lookup. -pub enum DynamicRequest { - Track(TrackRequest), - Info(InfoRequest), -} - /// A pending info lookup returned by [`TrackConsumer::info`]. /// /// Resolves once the track's immutable [`Track`] is known: synchronously if a /// producer already exists or the info is cached, otherwise once the dynamic -/// handler serves it via [`BroadcastDynamic::requested_info`]. Implements +/// handler accepts the coalesced request via [`TrackRequest::accept`]. Implements /// [`Future`]; poll-based callers can use [`Self::poll_info`]. pub struct InfoPending { inner: InfoPendingInner, @@ -667,10 +610,11 @@ impl BroadcastDynamic { let Some(name) = state.request_order.pop_front() else { return Poll::Pending; }; - // The name stays in `requests` so concurrent subscribers can still - // coalesce onto it until the publisher accepts or denies. + // The name stays in `requests` so concurrent subscribers and info callers + // can still coalesce onto it until the publisher accepts or denies. let pending = state.requests.get(&name).expect("request_order out of sync"); - let subscription = pending.resolvers.first().map(|(s, _)| s.clone()).unwrap_or_default(); + // `None` when only `info()` callers are waiting (no group demand yet). + let subscription = pending.resolvers.first().map(|(s, _)| s.clone()); Poll::Ready((name, subscription)) }) .map(|res| { @@ -688,44 +632,10 @@ impl BroadcastDynamic { kio::wait(|waiter| self.poll_requested_track(waiter)).await } - /// Poll for the next consumer info-only request, without blocking. - pub fn poll_requested_info(&mut self, waiter: &kio::Waiter) -> Poll> { - let weak = self.state.weak(); - self.poll(waiter, |state| match state.info_request_order.pop_front() { - // The name stays in `info_requests` so concurrent info() callers coalesce - // onto it until this resolves or denies. - Some(name) => Poll::Ready(name), - None => Poll::Pending, - }) - .map(|res| { - res.map(|name| InfoRequest { - name, - state: weak, - completed: false, - }) - }) - } - - /// Block until a consumer requests track info, returning an [`InfoRequest`] to serve. - pub async fn requested_info(&mut self) -> Result { - kio::wait(|waiter| self.poll_requested_info(waiter)).await - } - - /// Poll for the next consumer request of either kind, without blocking. - /// - /// Info requests are preferred since they're a cheap one-shot lookup that - /// shouldn't queue behind a (potentially long-lived) subscription. - pub fn poll_requested(&mut self, waiter: &kio::Waiter) -> Poll> { - if let Poll::Ready(res) = self.poll_requested_info(waiter) { - return Poll::Ready(res.map(DynamicRequest::Info)); - } - self.poll_requested_track(waiter) - .map(|res| res.map(DynamicRequest::Track)) - } - - /// Block until a consumer requests a track or its info, returning the request to serve. - pub async fn requested(&mut self) -> Result { - kio::wait(|waiter| self.poll_requested(waiter)).await + /// The track's cached immutable info, if a prior subscribe/info already + /// resolved it. A sync peek used by a handler to skip an upstream TRACK fetch. + pub fn cached_info(&self, name: &str) -> Option { + self.state.read().track_info.get(name).cloned() } /// Create a consumer that can subscribe to tracks in this broadcast. @@ -795,13 +705,6 @@ impl BroadcastDynamic { pub fn assert_no_request(&mut self) { assert!(self.requested_track().now_or_never().is_none(), "should have blocked"); } - - pub fn assert_info_request(&mut self) -> InfoRequest { - self.requested_info() - .now_or_never() - .expect("should not have blocked") - .expect("should not have errored") - } } /// Subscribe to arbitrary broadcast/tracks. @@ -874,6 +777,7 @@ impl BroadcastConsumer { name.to_string(), PendingRequest { resolvers: vec![(subscription, slot)], + ..Default::default() }, ); state.request_order.push_back(name.to_string()); @@ -885,11 +789,12 @@ impl BroadcastConsumer { /// Resolve a track's immutable info, returning an [`InfoPending`]. /// /// Returns immediately when the info is cached or a live producer already - /// carries it (no round trip). Otherwise queues a dynamic info request served - /// via [`BroadcastDynamic::requested_info`] and [`InfoRequest::resolve`] (for - /// the wire this is a TRACK Stream / TRACK_INFO). Resolves to [`Error::NotFound`] - /// if no dynamic producer exists to handle it. Unlike [`Self::request_subscribe`], - /// this never opens a subscription. + /// carries it (no round trip). Otherwise coalesces onto the track's dynamic + /// request (alongside any subscribers) and resolves when the handler calls + /// [`TrackRequest::accept`] (for the wire this is a TRACK Stream / TRACK_INFO). + /// Resolves to [`Error::NotFound`] if no dynamic producer exists to handle it. + /// Unlike [`Self::request_subscribe`], an info caller adds no subscription, so a + /// pure-info request leaves [`TrackRequest::subscription`] empty. fn request_info(&self, name: &str) -> InfoPending { let Some(producer) = self.state.produce() else { let err = self.state.read().abort.clone().unwrap_or(Error::Dropped); @@ -917,14 +822,20 @@ impl BroadcastConsumer { let slot = kio::Producer::new(None); let consumer = slot.consume(); - if let Some(resolvers) = state.info_requests.get_mut(name) { - // Coalesce onto an in-flight info request for the same name. - resolvers.push(slot); + if let Some(pending) = state.requests.get_mut(name) { + // Coalesce onto an in-flight request (subscribe or info) for the same name. + pending.info_resolvers.push(slot); } else if state.dynamic == 0 { return InfoPending::ready(Err(Error::NotFound)); } else { - state.info_requests.insert(name.to_string(), vec![slot]); - state.info_request_order.push_back(name.to_string()); + state.requests.insert( + name.to_string(), + PendingRequest { + info_resolvers: vec![slot], + ..Default::default() + }, + ); + state.request_order.push_back(name.to_string()); } InfoPending::waiting(consumer) @@ -1255,16 +1166,20 @@ mod test { let consumer2 = consumer.clone(); // No producer yet: two info() callers for the same track coalesce into one - // pending request. + // pending request, which is handed out as a TrackRequest with no subscriber. let info1 = consumer.consume_track("video").info(); assert!(info1.poll_info(&kio::Waiter::noop()).is_pending()); let info2 = consumer2.consume_track("video").info(); - // Exactly one info request to serve. - let request = producer.assert_info_request(); + // Exactly one request to serve, and it has no group demand. + let request = producer.assert_request(); assert_eq!(request.name(), "video"); + assert!(request.subscription().is_none(), "info-only request has no subscriber"); - request.resolve(Track::new("video").with_timescale(crate::Timescale::MICRO)); + // accept resolves the info waiters (and caches the value). + request + .accept(Track::new("video").with_timescale(crate::Timescale::MICRO)) + .unwrap(); assert_eq!(info1.await.unwrap().timescale, Some(crate::Timescale::MICRO)); // The second caller and any later lookup read the cached value, no new request. From 3a8c8cdcc8a1fc8faab4ecd12682770fb1414438 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 3 Jun 2026 20:29:55 -0700 Subject: [PATCH 5/7] moq-net: collapse the consume_track handle to one TrackConsumer Per review, drop the pending-vs-resolved split: consume_track() returns a single TrackConsumer (Clone) that Derefs to a name-only Track (so `.name` works) and exposes async info() / subscribe(). Every other track property is unknown until info() resolves it (for a relay it comes from upstream), so there's no separate "resolved" handle and no ok()/poll_ok ceremony. The previous subscribe-future TrackPending is renamed SubscribePending (one moq-mux reference). Existing callers (consume_track(x).subscribe(None).await / .info().await) are unchanged. This is the consumer-side of the model redesign; the demand-side (TrackState shared by TrackRequest/TrackProducer, async subscription(), accept upgrading the request into the producer) is the follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- rs/moq-mux/src/container/source.rs | 2 +- rs/moq-net/src/model/broadcast.rs | 84 +++++++++++++++++------------- rs/moq-net/src/model/track.rs | 2 +- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index f5dbca6a4..14728e2b7 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -49,7 +49,7 @@ impl VideoTransform { /// A subscription that resolves on first poll, then the live consumer. enum SourceState { /// Waiting for the subscription to resolve (blocks on the publisher's SUBSCRIBE_OK). - Subscribing(moq_net::TrackPending), + Subscribing(moq_net::SubscribePending), /// The resolved consumer, reading frames. Active(Consumer), } diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index 98e04d71a..b7dabe14a 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -484,31 +484,31 @@ impl std::future::Future for InfoPending { /// SUBSCRIBE_OK). It implements [`Future`], so `.await` it to get the /// [`TrackSubscriber`] (or an error). Poll-based callers can instead drive it /// with [`Self::poll_ok`] inside a `kio` poll loop. -pub struct TrackPending { - inner: TrackPendingInner, +pub struct SubscribePending { + inner: SubscribePendingInner, /// Kept alive between `Future::poll` calls so its registration in the /// resolver channel stays valid until the next poll replaces it. waiter: Option, } -enum TrackPendingInner { +enum SubscribePendingInner { /// Resolved synchronously: the track already existed, or it failed immediately. Ready(Result), /// Waiting for the publisher to accept or deny via the dynamic handler. Waiting(kio::Consumer), } -impl TrackPending { +impl SubscribePending { fn ready(result: Result) -> Self { Self { - inner: TrackPendingInner::Ready(result), + inner: SubscribePendingInner::Ready(result), waiter: None, } } fn waiting(consumer: kio::Consumer) -> Self { Self { - inner: TrackPendingInner::Waiting(consumer), + inner: SubscribePendingInner::Waiting(consumer), waiter: None, } } @@ -516,8 +516,8 @@ impl TrackPending { /// Poll for the resolved [`TrackSubscriber`], without blocking. pub fn poll_ok(&self, waiter: &kio::Waiter) -> Poll> { match &self.inner { - TrackPendingInner::Ready(result) => Poll::Ready(result.clone()), - TrackPendingInner::Waiting(consumer) => match consumer.poll(waiter, |slot| match &**slot { + SubscribePendingInner::Ready(result) => Poll::Ready(result.clone()), + SubscribePendingInner::Waiting(consumer) => match consumer.poll(waiter, |slot| match &**slot { Some(result) => Poll::Ready(result.clone()), None => Poll::Pending, }) { @@ -533,7 +533,7 @@ impl TrackPending { } } -impl std::future::Future for TrackPending { +impl std::future::Future for SubscribePending { type Output = Result; fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { @@ -727,16 +727,16 @@ impl BroadcastConsumer { /// /// This is a cheap, synchronous lookup that returns a [`TrackConsumer`] bound /// to `name`. Nothing is sent to the publisher yet: call - /// [`TrackConsumer::subscribe`] to open a live subscription (blocking on - /// SUBSCRIBE_OK), or hold the handle and subscribe later. + /// [`TrackConsumer::subscribe`] to open a live subscription, [`TrackConsumer::info`] + /// to resolve its properties, or hold the handle and act later. pub fn consume_track(&self, name: &str) -> TrackConsumer { TrackConsumer { broadcast: self.clone(), - name: name.to_string(), + info: Track::new(name), } } - /// Register a subscription for `name` and return a [`TrackPending`] that + /// Register a subscription for `name` and return a [`SubscribePending`] that /// resolves once the publisher accepts it. /// /// Reuses a live producer if one is already publishing the track (the pending @@ -744,21 +744,21 @@ impl BroadcastConsumer { /// [`BroadcastDynamic::requested_track`] and [`TrackRequest::accept`] (for the /// wire this is SUBSCRIBE_OK). Resolves to [`Error::NotFound`] if no dynamic /// producer exists to handle the request. - fn request_subscribe(&self, name: &str, subscription: Subscription) -> TrackPending { + fn request_subscribe(&self, name: &str, subscription: Subscription) -> SubscribePending { // Upgrade to a temporary producer so we can modify the state. let Some(producer) = self.state.produce() else { let err = self.state.read().abort.clone().unwrap_or(Error::Dropped); - return TrackPending::ready(Err(err)); + return SubscribePending::ready(Err(err)); }; let mut state = match modify(&producer) { Ok(state) => state, - Err(err) => return TrackPending::ready(Err(err)), + Err(err) => return SubscribePending::ready(Err(err)), }; // Reuse a live producer if one is already publishing the track. if let Some(weak) = state.tracks.get(name) { if !weak.is_closed() { - return TrackPending::ready(Ok(weak.subscribe(subscription))); + return SubscribePending::ready(Ok(weak.subscribe(subscription))); } // Drop the stale entry and fall through to a fresh request. state.tracks.remove(name); @@ -771,7 +771,7 @@ impl BroadcastConsumer { // Coalesce onto an in-flight request for the same name. pending.resolvers.push((subscription, slot)); } else if state.dynamic == 0 { - return TrackPending::ready(Err(Error::NotFound)); + return SubscribePending::ready(Err(Error::NotFound)); } else { state.requests.insert( name.to_string(), @@ -783,7 +783,7 @@ impl BroadcastConsumer { state.request_order.push_back(name.to_string()); } - TrackPending::waiting(consumer) + SubscribePending::waiting(consumer) } /// Resolve a track's immutable info, returning an [`InfoPending`]. @@ -872,47 +872,57 @@ impl BroadcastConsumer { /// A handle to a single track within a broadcast. /// -/// Obtained from [`BroadcastConsumer::consume_track`]. Holding it sends nothing -/// to the publisher; it just names a track you can [`subscribe`](Self::subscribe) -/// to (a live, ongoing stream of groups) later. The same handle can be subscribed -/// to multiple times, and clones are cheap. +/// Obtained from [`BroadcastConsumer::consume_track`]. Holding it sends nothing to +/// the publisher; it just names a track you can [`subscribe`](Self::subscribe) to +/// (a live, ongoing stream of groups) or resolve [`info`](Self::info) for later. +/// Clones are cheap and concurrent operations on the same name coalesce onto one +/// request. +/// +/// Only the track's `name` is known up front (available via `Deref`); every other +/// property is resolved on demand via [`info`](Self::info), since for a relay it +/// comes from upstream. // TODO: add `fetch` for one-shot retrieval of a past group range. #[derive(Clone)] pub struct TrackConsumer { broadcast: BroadcastConsumer, - name: String, + /// Name-only [`Track`]: `Deref` exposes `name`; the other fields are + /// placeholders until [`Self::info`] resolves the real values. + info: Track, } -impl TrackConsumer { - /// The track name this handle is bound to. - pub fn name(&self) -> &str { - &self.name +impl Deref for TrackConsumer { + type Target = Track; + + fn deref(&self) -> &Self::Target { + &self.info } +} +impl TrackConsumer { /// Open a live subscription. /// - /// Returns a [`TrackPending`] that resolves once the publisher accepts the - /// subscription (SUBSCRIBE_OK on the wire). `.await` it for the - /// [`TrackSubscriber`], which carries the publisher's [`Track`] and reads its - /// groups; or drive it with [`TrackPending::poll_ok`] from a poll loop. + /// Returns a [`SubscribePending`] that resolves once the publisher accepts the + /// subscription. `.await` it for the [`TrackSubscriber`], which carries the + /// publisher's [`Track`] and reads its groups; or drive it with + /// [`SubscribePending::poll_ok`] from a poll loop. /// /// `subscription` is this subscriber's preferences and feeds the producer's /// [`TrackProducer::subscription`] aggregate; pass `None` for /// [`Subscription::default`]. Concurrent subscribers to the same name coalesce /// onto one request. - pub fn subscribe(&self, subscription: impl Into>) -> TrackPending { + pub fn subscribe(&self, subscription: impl Into>) -> SubscribePending { self.broadcast - .request_subscribe(&self.name, subscription.into().unwrap_or_default()) + .request_subscribe(&self.info.name, subscription.into().unwrap_or_default()) } - /// Resolve this track's immutable [`Track`] info without subscribing. + /// Resolve this track's immutable [`Track`] info. /// /// Returns an [`InfoPending`] that resolves to the publisher's properties /// (timescale, compression, cache). Warm (cached or a live producer exists) it /// resolves with no round trip; cold it triggers a single TRACK lookup. Reused /// across every subscribe and fetch of the track. pub fn info(&self) -> InfoPending { - self.broadcast.request_info(&self.name) + self.broadcast.request_info(&self.info.name) } } @@ -932,7 +942,7 @@ mod test { use super::*; /// Subscribe and assert the result hasn't resolved yet (it stays pending until - /// a publisher accepts). Returns the [`TrackPending`] to resolve after accepting. + /// a publisher accepts). Returns the [`SubscribePending`] to resolve after accepting. macro_rules! subscribe_pending { ($consumer:expr, $name:expr) => {{ let pending = $consumer.consume_track($name).subscribe(None); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 4c2f6588c..2e160528f 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -754,7 +754,7 @@ impl TrackWeak { /// A live subscription to a track, used to read its groups. /// -/// Created via [`TrackConsumer::subscribe`](crate::TrackConsumer::subscribe), or +/// Created via [`TrackPending::subscribe`](crate::TrackPending::subscribe), or /// directly from a [`TrackProducer`] for an in-process track. Carries this /// subscriber's [`Subscription`] preferences, which feed the producer's aggregate. #[derive(Clone)] From 9f61ed220c7b63ec0c0c44ab4ec999026220194a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 3 Jun 2026 21:44:21 -0700 Subject: [PATCH 6/7] moq-net model: split TrackProducer into TrackRequest + producer; add TrackConsumer::info() Restart of the model per review. Reverts the lite-layer wire/relay work back to dev (Track stream, SUBSCRIBE_OK changes, demand loop) to get the model right first; only the model refactor remains. track.rs: - Split TrackProducer into a headless `TrackRequest` (the shared state + info-free methods: subscription/used/unused/closed/finish/abort/...) and `TrackProducer`, a wrapper of `TrackRequest` + `info: Track` that Derefs to the Track. `TrackRequest::accept(info)` upgrades a request into a producer. The public `TrackProducer` API is unchanged, so all callers compile as-is. broadcast.rs: - `TrackConsumer::info() -> InfoPending` (+ `poll_info()` / `Future`): resolves a track's immutable Track without subscribing. Warm via the live producer in `tracks`; cold it coalesces onto the same dynamic request as subscribers and resolves on `accept`. No separate info cache or channel. - Renamed the broadcast-level dynamic request `TrackRequest` -> `PendingTrack` (to free the name for the track-level headless producer); updated its two consumers in lite/ietf subscriber.rs (type name only). Held off on rewiring publisher/subscriber to the new model, as requested. Co-Authored-By: Claude Opus 4.8 (1M context) --- rs/moq-mux/src/container/source.rs | 2 +- rs/moq-native/tests/broadcast.rs | 98 ------ rs/moq-net/src/ietf/subscriber.rs | 4 +- rs/moq-net/src/lite/mod.rs | 3 - rs/moq-net/src/lite/publisher.rs | 109 +------ rs/moq-net/src/lite/stream.rs | 3 - rs/moq-net/src/lite/subscribe.rs | 146 +++++++-- rs/moq-net/src/lite/subscriber.rs | 459 ++++++++++------------------- rs/moq-net/src/lite/track.rs | 183 ------------ rs/moq-net/src/model/broadcast.rs | 382 +++++++++--------------- rs/moq-net/src/model/track.rs | 299 ++++++++++++------- 11 files changed, 645 insertions(+), 1043 deletions(-) delete mode 100644 rs/moq-net/src/lite/track.rs diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 14728e2b7..f5dbca6a4 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -49,7 +49,7 @@ impl VideoTransform { /// A subscription that resolves on first poll, then the live consumer. enum SourceState { /// Waiting for the subscription to resolve (blocks on the publisher's SUBSCRIBE_OK). - Subscribing(moq_net::SubscribePending), + Subscribing(moq_net::TrackPending), /// The resolved consumer, reading frames. Active(Consumer), } diff --git a/rs/moq-native/tests/broadcast.rs b/rs/moq-native/tests/broadcast.rs index dcd47e96f..e4a001fd7 100644 --- a/rs/moq-native/tests/broadcast.rs +++ b/rs/moq-native/tests/broadcast.rs @@ -227,104 +227,6 @@ async fn broadcast_moq_lite_05_timestamps_webtransport() { lite05_timestamp_roundtrip("https").await; } -/// Lite05 Track Stream: the client resolves a track's immutable properties via -/// `info()` (a TRACK request answered with TRACK_INFO) without subscribing, then -/// subscribes and reads a frame. Exercises the on-demand info path end-to-end. -async fn lite05_info_roundtrip(scheme: &str) { - use moq_native::moq_net::Timescale; - - let pub_origin = Origin::random().produce(); - let mut broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); - let mut track = broadcast - .create_track(Track::new("video").with_timescale(Timescale::MICRO)) - .expect("create track"); - - let mut group = track.append_group().expect("append group"); - let frame = moq_native::moq_net::Frame { - size: 5, - timestamp: Some(moq_native::moq_net::Timestamp::new(10_000, Timescale::MICRO).unwrap()), - }; - let mut writer = group.create_frame(frame).expect("create frame"); - writer.write(bytes::Bytes::from_static(b"hello")).expect("write frame"); - writer.finish().expect("finish frame"); - group.finish().expect("finish group"); - - let mut server_config = moq_native::ServerConfig::default(); - server_config.bind = Some("[::]:0".to_string()); - server_config.tls.generate = vec!["localhost".into()]; - server_config.version = vec!["moq-lite-05-wip".parse().unwrap()]; - let mut server = server_config.init().expect("init server"); - let addr = server.local_addr().expect("local addr"); - - let sub_origin = Origin::random().produce(); - let mut announcements = sub_origin.consume().announced(); - - let mut client_config = moq_native::ClientConfig::default(); - client_config.tls.disable_verify = Some(true); - client_config.version = vec!["moq-lite-05-wip".parse().unwrap()]; - let client = client_config.init().expect("init client"); - let url: url::Url = format!("{scheme}://localhost:{}", addr.port()).parse().unwrap(); - - let server_handle = tokio::spawn(async move { - let request = server.accept().await.expect("no incoming connection"); - let session = request.with_publisher(pub_origin.clone()).ok().await?; - let _broadcast = broadcast; - let _track = track; - let _ = session.closed().await; - Ok::<_, anyhow::Error>(()) - }); - - let client = client.with_consumer(sub_origin); - let session = tokio::time::timeout(TIMEOUT, client.connect(url)) - .await - .expect("client connect timed out") - .expect("client connect failed"); - - let (path, bc) = tokio::time::timeout(TIMEOUT, announcements.next()) - .await - .expect("announce timed out") - .expect("origin closed"); - assert_eq!(path.as_str(), "test"); - let bc = bc.broadcast().expect("expected announce, got unannounce"); - - // Resolve the track's immutable info without subscribing. - let info = tokio::time::timeout(TIMEOUT, bc.consume_track("video").info()) - .await - .expect("info timed out") - .expect("info failed"); - assert_eq!(info.timescale, Some(Timescale::MICRO)); - - // A subscribe still works (and reuses the now-cached info). - let mut track_sub = bc - .consume_track("video") - .subscribe(None) - .await - .expect("subscribe failed"); - let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.recv_group()) - .await - .expect("recv_group timed out") - .expect("recv_group failed") - .expect("track closed prematurely"); - let frame = tokio::time::timeout(TIMEOUT, group_sub.read_frame()) - .await - .expect("read_frame timed out") - .expect("read_frame failed") - .expect("group closed prematurely"); - assert_eq!(&*frame, b"hello"); - - drop(session); - server_handle - .await - .expect("server task panicked") - .expect("server task failed"); -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_moq_lite_05_info_webtransport() { - lite05_info_roundtrip("https").await; -} - /// On Lite05 a publisher that doesn't advertise a timescale still works: /// SUBSCRIBE_OK carries `timescale = 0` and neither side encodes a /// per-frame timestamp byte. Subscribers receive `frame.timestamp = None`. diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d167c8ae2..d8fe277be 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use crate::{ Broadcast, BroadcastDynamic, Error, Frame, FrameProducer, Group, GroupProducer, MAX_FRAME_SIZE, OriginProducer, - Path, PathOwned, StatsHandle, SubscriberStats, SubscriberTrack, Track, TrackProducer, TrackRequest, + Path, PathOwned, PendingTrack, StatsHandle, SubscriberStats, SubscriberTrack, Track, TrackProducer, coding::{Reader, Stream}, ietf::{self, Control, FilterType, GroupOrder, RequestId}, model::BroadcastProducer, @@ -536,7 +536,7 @@ impl Subscriber { Ok(()) } - async fn run_subscribe(&mut self, broadcast_path: Path<'_>, broadcast: BroadcastDynamic, request: TrackRequest) { + async fn run_subscribe(&mut self, broadcast_path: Path<'_>, broadcast: BroadcastDynamic, request: PendingTrack) { // Accept right away: IETF group data can arrive before SubscribeOk, so we // need the producer in place to route it. This also unblocks the // downstream subscriber's `consume_track`. diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index 4df1bb449..4b1612594 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -19,7 +19,6 @@ mod session; mod stream; mod subscribe; mod subscriber; -mod track; mod version; pub use announce::*; @@ -38,6 +37,4 @@ pub(super) use session::*; pub use stream::*; pub use subscribe::*; use subscriber::*; -#[allow(unused_imports)] -pub use track::*; pub use version::Version; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 4a1c4a59f..9a020fa75 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -80,7 +80,6 @@ impl Publisher { match kind { lite::ControlType::Announce => self.recv_announce(stream).await, lite::ControlType::Subscribe => self.recv_subscribe(stream).await, - lite::ControlType::Track => self.recv_track(stream).await, lite::ControlType::Probe => { self.recv_probe(stream).await; Ok(()) @@ -462,9 +461,8 @@ impl Publisher { .await?; // Compress only when the producer marked the track worth it and the - // negotiated draft understands the codec field (carried in SUBSCRIBE_OK on - // lite-04 and below, in TRACK_INFO on lite-05+). Older drafts without it - // get None and the frames stream verbatim. + // negotiated draft understands the SUBSCRIBE_OK codec field. Older drafts + // (lite-04 and below) get None and the frames stream verbatim. let supports_compression = !matches!( version, Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 @@ -489,23 +487,20 @@ impl Publisher { // broadcast. Dropping this guard (subscription end) releases it. let _broadcast_sub = broadcasts.subscribe(&absolute); - // Lite05+ accepts a subscription implicitly (rejection is a stream reset) - // and serves the immutable properties over a TRACK_INFO stream instead. - // Older drafts confirm acceptance with SUBSCRIBE_OK here. - if matches!( - version, - Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 - ) { - let info = lite::SubscribeOk { - priority: subscribe.priority, - ordered: false, - max_latency: std::time::Duration::ZERO, - start_group: None, - end_group: None, - }; + let info = lite::SubscribeOk { + priority: subscribe.priority, + ordered: false, + max_latency: std::time::Duration::ZERO, + start_group: None, + end_group: None, + compression, + timescale, + // Announce the publisher's cache window so the subscriber (a relay) + // re-serves with the same eviction window. Pre-lite-05 peers ignore it. + cache: track.cache, + }; - stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?; - } + stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?; // Track-level subscriber priority. SUBSCRIBE_UPDATE messages broadcast new values // to both run_track (so future groups inherit the new priority) and serve_group @@ -543,80 +538,6 @@ impl Publisher { stream.writer.finish()?; stream.writer.closed().await } - - /// Serve a Track Stream: reply with the track's immutable [`lite::TrackInfo`] - /// and FIN, or reset on error (e.g. the track does not exist). Lite05+ only. - /// - /// Runs inline in its own control-stream task (see [`Self::handle`]); resolving - /// the info can be a cold upstream TRACK fetch, but that only blocks this task. - pub async fn recv_track(&self, mut stream: Stream) -> Result<(), Error> { - let req = stream.reader.decode::().await?; - - let track = req.track.to_string(); - let absolute = self.origin.absolute(&req.broadcast).to_owned(); - - tracing::debug!(broadcast = %absolute, %track, "track info requested"); - - let broadcast = self.origin.get_broadcast(&req.broadcast); - - if let Err(err) = Self::run_track_info(&mut stream, &track, broadcast, self.version).await { - match &err { - Error::Cancel | Error::Transport(_) => { - tracing::debug!(broadcast = %absolute, %track, "track info cancelled") - } - err => tracing::warn!(broadcast = %absolute, %track, %err, "track info error"), - } - stream.writer.abort(&err); - } - - Ok(()) - } - - async fn run_track_info( - stream: &mut Stream, - track_name: &str, - consumer: Option, - version: Version, - ) -> Result<(), Error> { - let broadcast = consumer.ok_or(Error::NotFound)?; - - // Resolve the immutable properties without subscribing. Warm (a producer - // exists or the info is cached) this returns with no round trip; cold (a - // relay with no prior subscription) it triggers a single upstream TRACK - // fetch via the model's info-request channel. - let track = broadcast.consume_track(track_name).info().await?; - - // Mirror the negotiation in `run_subscribe` so the subscriber decodes - // frames the same way it'll see them served. - let supports_compression = !matches!( - version, - Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 - ); - let compression = if track.compress && supports_compression { - Compression::Deflate - } else { - Compression::None - }; - let timescale = if version.has_timestamps() { - track.timescale - } else { - None - }; - - let info = lite::TrackInfo { - // The model carries no publisher-chosen priority/order yet, so both - // default to the tie-break-neutral values. - priority: 0, - ordered: false, - cache: track.cache, - timescale, - compression, - }; - - stream.writer.encode(&info).await?; - stream.writer.finish()?; - stream.writer.closed().await - } } /// Shared per-subscription state for the publisher side. Cloned (cheaply — every diff --git a/rs/moq-net/src/lite/stream.rs b/rs/moq-net/src/lite/stream.rs index fda6bbefb..d52a77943 100644 --- a/rs/moq-net/src/lite/stream.rs +++ b/rs/moq-net/src/lite/stream.rs @@ -13,9 +13,6 @@ pub enum ControlType { Fetch = 3, Probe = 4, Goaway = 5, - /// Track Stream: a subscriber requests a track's immutable publisher - /// properties (TRACK_INFO) without subscribing or fetching. Lite05+ only. - Track = 6, } impl Decode for ControlType { diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index fb0901a38..8f17a003a 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use crate::{ - Path, + Compression, Path, Timescale, coding::{Decode, DecodeError, Encode, EncodeError, Sizer}, }; @@ -72,11 +72,6 @@ impl Message for Subscribe<'_> { } } -/// Sent by the publisher to accept a subscription (Lite01-04 only). -/// -/// On Lite05+ a subscription is accepted implicitly (rejection is a stream -/// reset) and the immutable publisher properties moved to [`TrackInfo`], fetched -/// once over a [Track Stream](super::Track). This message is no longer sent. #[derive(Clone, Debug)] pub struct SubscribeOk { pub priority: u8, @@ -84,6 +79,21 @@ pub struct SubscribeOk { pub max_latency: std::time::Duration, pub start_group: Option, pub end_group: Option, + /// Codec the publisher will use for every frame on this track. Negotiated + /// here (not in SUBSCRIBE) so the subscriber blocks on this message before it + /// can decode any frame payload. Lite05+ only; older drafts always get + /// [`Compression::None`]. + pub compression: Compression, + /// Per-frame timestamp scale advertised by the publisher. `None` means the + /// publisher doesn't carry per-frame timestamps on the wire (so frame + /// headers omit them). Lite05+ only; older drafts always decode as `None`. + /// On the wire `None` is `0` and `Some(n)` is `n`. + pub timescale: Option, + /// How long the publisher keeps old groups available before evicting them. + /// A relay re-serves with the same window and clamps each subscriber's stale + /// preference to it. Lite05+ only; older drafts always get + /// [`crate::DEFAULT_CACHE`]. + pub cache: std::time::Duration, } impl Message for SubscribeOk { @@ -93,14 +103,23 @@ impl Message for SubscribeOk { self.priority.encode(w, version)?; } Version::Lite02 => {} - // Lite05+ no longer sends SUBSCRIBE_OK, but keep the Lite03/04 layout as - // the forward default so an accidental encode stays well-formed. + Version::Lite03 | Version::Lite04 => { + self.priority.encode(w, version)?; + (self.ordered as u8).encode(w, version)?; + self.max_latency.encode(w, version)?; + self.start_group.encode(w, version)?; + self.end_group.encode(w, version)?; + } _ => { self.priority.encode(w, version)?; (self.ordered as u8).encode(w, version)?; self.max_latency.encode(w, version)?; self.start_group.encode(w, version)?; self.end_group.encode(w, version)?; + // Order matches draft-lcurley-moq-lite-05 SUBSCRIBE_OK: Timescale, Cache, Compression. + self.timescale.map(u64::from).unwrap_or(0).encode(w, version)?; + self.cache.encode(w, version)?; + self.compression.to_code().encode(w, version)?; } } @@ -115,6 +134,9 @@ impl Message for SubscribeOk { max_latency: std::time::Duration::ZERO, start_group: None, end_group: None, + compression: Compression::None, + timescale: None, + cache: crate::DEFAULT_CACHE, }), Version::Lite02 => Ok(Self { priority: 0, @@ -122,14 +144,51 @@ impl Message for SubscribeOk { max_latency: std::time::Duration::ZERO, start_group: None, end_group: None, + compression: Compression::None, + timescale: None, + cache: crate::DEFAULT_CACHE, }), - _ => Ok(Self { - priority: u8::decode(r, version)?, - ordered: u8::decode(r, version)? != 0, - max_latency: std::time::Duration::decode(r, version)?, - start_group: Option::::decode(r, version)?, - end_group: Option::::decode(r, version)?, - }), + Version::Lite03 | Version::Lite04 => { + let priority = u8::decode(r, version)?; + let ordered = u8::decode(r, version)? != 0; + let max_latency = std::time::Duration::decode(r, version)?; + let start_group = Option::::decode(r, version)?; + let end_group = Option::::decode(r, version)?; + + Ok(Self { + priority, + ordered, + max_latency, + start_group, + end_group, + compression: Compression::None, + timescale: None, + cache: crate::DEFAULT_CACHE, + }) + } + _ => { + let priority = u8::decode(r, version)?; + let ordered = u8::decode(r, version)? != 0; + let max_latency = std::time::Duration::decode(r, version)?; + let start_group = Option::::decode(r, version)?; + let end_group = Option::::decode(r, version)?; + // Order matches draft-lcurley-moq-lite-05 SUBSCRIBE_OK: Timescale, Cache, Compression. + let timescale = Timescale::new(u64::decode(r, version)?).ok(); + let cache = std::time::Duration::decode(r, version)?; + let compression = + Compression::from_code(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue)?; + + Ok(Self { + priority, + ordered, + max_latency, + start_group, + end_group, + compression, + timescale, + cache, + }) + } } } } @@ -339,6 +398,9 @@ mod test { max_latency: Duration::from_millis(250), start_group: Some(3), end_group: None, + compression: Compression::Deflate, + timescale: Some(Timescale::MICRO), + cache: Duration::from_secs(10), } } @@ -350,12 +412,60 @@ mod test { } #[test] - fn fields_roundtrip_on_lite04() { - let got = roundtrip(Version::Lite04, &sample()); + fn compression_roundtrips_on_lite05() { + let got = roundtrip(Version::Lite05Wip, &sample()); + assert_eq!(got.compression, Compression::Deflate); assert_eq!(got.priority, 7); assert!(got.ordered); - assert_eq!(got.max_latency, Duration::from_millis(250)); assert_eq!(got.start_group, Some(3)); assert_eq!(got.end_group, None); } + + #[test] + fn compression_absent_before_lite05() { + let ok = sample(); + + // The compression varint only exists on lite-05+, so the older encoding is + // strictly shorter and always decodes back as None. + let mut buf04 = Vec::new(); + ok.encode_msg(&mut buf04, Version::Lite04).unwrap(); + let mut buf05 = Vec::new(); + ok.encode_msg(&mut buf05, Version::Lite05Wip).unwrap(); + assert!( + buf05.len() > buf04.len(), + "lite-05 carries extra compression + timescale varints" + ); + + assert_eq!(roundtrip(Version::Lite04, &ok).compression, Compression::None); + } + + #[test] + fn timescale_roundtrips_on_lite05() { + let got = roundtrip(Version::Lite05Wip, &sample()); + assert_eq!(got.timescale, Some(Timescale::MICRO)); + } + + #[test] + fn timescale_absent_before_lite05() { + // Lite04 doesn't carry the timescale varint, so it always decodes as None. + assert_eq!(roundtrip(Version::Lite04, &sample()).timescale, None); + } + + #[test] + fn timescale_zero_on_wire_decodes_as_none() { + let mut ok = sample(); + ok.timescale = None; + assert_eq!(roundtrip(Version::Lite05Wip, &ok).timescale, None); + } + + #[test] + fn cache_roundtrips_on_lite05() { + assert_eq!(roundtrip(Version::Lite05Wip, &sample()).cache, Duration::from_secs(10)); + } + + #[test] + fn cache_absent_before_lite05() { + // Lite04 doesn't carry the cache varint, so it always decodes as the default. + assert_eq!(roundtrip(Version::Lite04, &sample()).cache, crate::DEFAULT_CACHE); + } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index dee01b003..c306cc29a 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -9,8 +9,8 @@ use futures::{StreamExt, stream::FuturesUnordered}; use crate::{ AsPath, BandwidthProducer, Broadcast, BroadcastDynamic, Compression, Error, Frame, FrameProducer, Group, - GroupProducer, MAX_FRAME_SIZE, OriginProducer, Path, PathOwned, StatsHandle, SubscriberStats, SubscriberTrack, - Subscription, Timescale, Timestamp, Track, TrackProducer, TrackRequest, + GroupProducer, MAX_FRAME_SIZE, OriginProducer, Path, PathOwned, PendingTrack, StatsHandle, SubscriberStats, + SubscriberTrack, Timescale, Timestamp, Track, TrackProducer, coding::{Reader, Stream}, lite, model::BroadcastProducer, @@ -61,23 +61,12 @@ pub(super) struct Subscriber { #[derive(Clone)] struct TrackEntry { - stats: Arc, - /// Resolves once the upstream subscription is accepted: after TRACK_INFO on - /// lite-05, after SUBSCRIBE_OK on older drafts. Group streams park on this so a - /// group that races ahead of acceptance buffers (in QUIC flow control) instead - /// of being dropped. `None` until resolved; a closed channel means the - /// subscription ended first, which group streams treat as cancelled. - resolved: kio::Consumer>, -} - -/// The decoded-once-per-track state a group stream needs: where to write groups -/// and how to parse their frames. Populated from TRACK_INFO (lite-05) so a single -/// lookup is reused across every group instead of re-derived per response. -#[derive(Clone)] -struct ResolvedTrack { producer: TrackProducer, - compression: Compression, - timescale: Option, + stats: Arc, + /// The SUBSCRIBE_OK for this subscription. `None` until it arrives; group + /// streams block on it before decoding any frame, since a group can race + /// ahead of SUBSCRIBE_OK on its own QUIC stream. + subscribe_ok: kio::Consumer>, } /// Result of an upstream subscribe lifecycle. @@ -463,9 +452,10 @@ impl Subscriber { } async fn run_broadcast(self, path: PathOwned, mut broadcast: BroadcastDynamic) { - // Serve track requests (subscribe and info-only, coalesced) until the - // broadcast is gone. A request with no subscriber is an info-only lookup. + // Actually start serving subscriptions. loop { + // Keep serving requests until there are no more consumers. + // This way we'll clean up the task when the broadcast is no longer needed. let request = tokio::select! { request = broadcast.requested_track() => match request { Ok(request) => request, @@ -486,52 +476,16 @@ impl Subscriber { } } - /// Forward the aggregate downstream preferences upstream as a SUBSCRIBE. - async fn open_subscribe( - &self, - id: u64, - name: &str, - path: &PathOwned, - sub: &Subscription, - ) -> Result, Error> { - let mut stream = Stream::open(&self.session, self.version).await?; - stream.writer.encode(&lite::ControlType::Subscribe).await?; - let msg = lite::Subscribe { - id, - broadcast: path.as_path(), - track: name.into(), - priority: sub.priority, - ordered: sub.ordered, - max_latency: sub.stale, - start_group: sub.group_start, - end_group: sub.group_end, - }; - stream.writer.encode(&msg).await?; - Ok(stream) - } - - /// Resolve a track's immutable props on lite-05: a cached value (no round - /// trip), else a single upstream TRACK fetch (which also caches via `accept`). - async fn resolve_props(&self, broadcast: &BroadcastDynamic, path: &PathOwned, name: &str) -> Result { - if let Some(props) = broadcast.cached_info(name) { - return Ok(props); - } - let info = self.fetch_track_info(&path.as_path(), name).await?; - let mut props = Track::new(name); - props.timescale = info.timescale; - props.cache = info.cache; - // The model carries compression as a bool; the codec set is {none, deflate}, - // so the flag round-trips losslessly. - props.compress = info.compression != Compression::None; - Ok(props) - } - - /// Serve one track request: resolve its immutable info, and — if there's group - /// demand — drive the upstream subscription with linger across consumer churn. + /// Drive one upstream subscription end-to-end, including linger across consumer churn. /// - /// A request with no subscriber (`info()` only) just resolves and caches the - /// info; no upstream SUBSCRIBE is opened. When demand appears, we open it then. - async fn run_subscribe(&mut self, path: PathOwned, broadcast: BroadcastDynamic, request: TrackRequest) { + /// On linger entry (last consumer drops) we send `SubscribeUpdate(priority=0, + /// end_group=Some(latest))`. The publisher treats `end_group` as a serving cap, + /// not a terminator: it holds any groups beyond the cap and resumes when we + /// raise it. On resume (a new consumer arrives) we send `SubscribeUpdate(end_group=None)` + /// to uncap. The stream stays open across the whole lifecycle — only a timeout + /// or a publisher-side close ends it. This avoids the stream-churn / duplicate-fetch + /// race that an unsubscribe-and-reissue approach would have. + async fn run_subscribe(&mut self, path: PathOwned, broadcast: BroadcastDynamic, request: PendingTrack) { // Subscriber-side track stats; counters bump as frames/bytes/groups arrive. // Drop on subscription end records `subscriber.subscriptions_closed`. We use // subscriber_track to avoid double-counting broadcasts: the broadcast lifetime @@ -539,13 +493,29 @@ impl Subscriber { let name = request.name().to_string(); let abs = self.origin.absolute(&path); let track_stats = Arc::new(self.stats.broadcast(&abs).subscriber_track(&name)); + // The per-(session, broadcast) `broadcasts` sentinel is taken later, once + // the upstream confirms with SUBSCRIBE_OK (see `run_subscribe_session`), so a + // sub cancelled before then isn't counted as a feeding session. let id = self.next_id.fetch_add(1, atomic::Ordering::Relaxed); - tracing::info!(id, broadcast = %self.log_path(&path), track = %name, "track requested"); + // Forward the aggregate of every downstream subscriber's preferences upstream. + let subscription = request.subscription().clone(); + let msg = lite::Subscribe { + id, + broadcast: path.as_path(), + track: (&name).into(), + priority: subscription.priority, + ordered: subscription.ordered, + max_latency: subscription.stale, + start_group: subscription.group_start, + end_group: subscription.group_end, + }; + + tracing::info!(id, broadcast = %self.log_path(&path), track = %name, "subscribe started"); let result = self - .run_subscribe_session(id, &name, request, track_stats, &broadcast, &path) + .run_subscribe_session(id, &name, request, track_stats, &broadcast, msg) .await; self.subscribes.lock().remove(&id); @@ -566,215 +536,111 @@ impl Subscriber { } } - /// Resolve the track's immutable props, then — if there's group demand — accept - /// the producer and drive the upstream subscription with linger. - /// - /// On lite-05 the props come from the cache or a TRACK_INFO stream flighted - /// alongside SUBSCRIBE (so the first group still arrives in one round trip); - /// older drafts read them (absent) from SUBSCRIBE_OK. A pure `info()` request - /// (no subscriber) resolves the props and caches them without subscribing. + /// Open the upstream subscribe stream, wait for SUBSCRIBE_OK, then accept the + /// pending request (unblocking the downstream subscriber) and run the linger + /// lifecycle. The producer is created only after SUBSCRIBE_OK, so a downstream + /// a downstream `subscribe` resolves exactly when the upstream confirms. async fn run_subscribe_session( &self, id: u64, name: &str, - request: TrackRequest, + request: PendingTrack, track_stats: Arc, broadcast: &BroadcastDynamic, - path: &PathOwned, + msg: lite::Subscribe<'_>, ) -> SessionOutcome { - // Pending entry up front so a group stream that races ahead of acceptance - // parks on `resolved` instead of being dropped. Held for the session's - // lifetime; dropping it closes the channel and wakes parked group streams. - let resolved_tx: kio::Producer> = kio::Producer::new(None); - self.subscribes.lock().insert( - id, - TrackEntry { - stats: track_stats, - resolved: resolved_tx.consume(), - }, - ); - - // Group demand at hand-out? Always true before lite-05 (which has no info() - // callers); on lite-05 a pure TRACK request has none. - let initial_sub = request.subscription().cloned(); - let lite05 = matches!(self.version, Version::Lite05Wip); + // Stash the original parameters so SubscribeUpdate messages can echo them + // while only varying the linger-related fields (priority, end_group). + let original_priority = msg.priority; + let ordered = msg.ordered; + let max_latency = msg.max_latency; + let start_group = msg.start_group; - // The upstream subscribe stream, opened only when there's group demand. - let mut stream: Option> = None; + // SubscribeUpdate only exists on Lite03+; older versions take the + // immediate-FIN path with no linger. + let supports_linger = !matches!(self.version, Version::Lite01 | Version::Lite02); - // Resolve the immutable props; flight SUBSCRIBE in parallel when wanted. - let (compression, timescale, cache) = if lite05 { - // Flight SUBSCRIBE now if there's demand, so it races the info fetch (1 RTT). - if let Some(sub) = &initial_sub { - match self.open_subscribe(id, name, path, sub).await { - Ok(s) => stream = Some(s), - Err(err) => { - request.deny(err.clone()); - return SessionOutcome::Error(err); - } - } + let mut stream = match Stream::open(&self.session, self.version).await { + Ok(s) => s, + Err(err) => { + request.deny(err.clone()); + return SessionOutcome::Error(err); } + }; - let props = tokio::select! { - err = broadcast.closed() => { - request.deny(err.clone()); - return SessionOutcome::BroadcastClosed(err); - } - res = self.resolve_props(broadcast, path, name) => match res { - Ok(props) => props, - Err(err) => { - if let Some(s) = &mut stream { - s.writer.abort(&err); - } - request.deny(err.clone()); - return SessionOutcome::Error(err); - } - } - }; - let compression = if props.compress { - Compression::Deflate - } else { - Compression::None - }; - (compression, props.timescale, props.cache) - } else { - // Older drafts: open the subscribe stream and read SUBSCRIBE_OK (no props). - let sub = initial_sub.clone().unwrap_or_default(); - let mut s = match self.open_subscribe(id, name, path, &sub).await { - Ok(s) => s, + if let Err(err) = stream.writer.encode(&lite::ControlType::Subscribe).await { + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + + if let Err(err) = stream.writer.encode(&msg).await { + stream.writer.abort(&err); + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + + // The first response MUST be a SUBSCRIBE_OK. Bail if the broadcast dies first. + let resp = tokio::select! { + err = broadcast.closed() => { + request.deny(err.clone()); + return SessionOutcome::BroadcastClosed(err); + } + resp = stream.reader.decode::() => match resp { + Ok(r) => r, Err(err) => { + stream.writer.abort(&err); request.deny(err.clone()); return SessionOutcome::Error(err); } - }; - let resp = tokio::select! { - err = broadcast.closed() => { - request.deny(err.clone()); - return SessionOutcome::BroadcastClosed(err); - } - resp = s.reader.decode::() => match resp { - Ok(r) => r, - Err(err) => { - s.writer.abort(&err); - request.deny(err.clone()); - return SessionOutcome::Error(err); - } - } - }; - if !matches!(resp, lite::SubscribeResponse::Ok(_)) { - let err = Error::ProtocolViolation; - s.writer.abort(&err); - request.deny(err.clone()); - return SessionOutcome::Error(err); } - stream = Some(s); - (Compression::None, None, crate::DEFAULT_CACHE) + }; + let lite::SubscribeResponse::Ok(info) = resp else { + let err = Error::ProtocolViolation; + stream.writer.abort(&err); + request.deny(err.clone()); + return SessionOutcome::Error(err); }; - // Accept: create the producer, resolving info + subscriber waiters. Stamp - // the negotiated timescale and cache window onto the local Track so groups - // inherit the timescale (validated at the model layer) and the producer - // evicts (and clamps downstream stale windows) with the same bound. - let abs = self.origin.absolute(path); + // Upstream confirmed the subscription, so this session is now actively + // feeding the broadcast: take the per-(session, broadcast) sentinel. It + // drops when this fn returns (subscription end / cancel), releasing + // `broadcasts_closed`. Taken only after SUBSCRIBE_OK so a sub cancelled + // before confirmation isn't counted as a feeding session. + let abs = self.origin.absolute(&msg.broadcast); let _broadcast_sub = self.broadcasts.subscribe(&abs); + // The publisher accepted: create the producer (unblocking the downstream + // subscriber) and start routing incoming groups to it. SUBSCRIBE_OK is known + // now, so the group streams never have to wait; they still read it through a + // kio channel (a group's QUIC stream can otherwise race ahead of SUBSCRIBE_OK). + // + // Stamp the negotiated timescale onto the local Track so groups inherit + // it and downstream consumers (including this subscriber's frame decode + // path) can validate per-frame timestamps at the model layer. let mut local_info = Track::new(name); - local_info.timescale = timescale; - local_info.cache = cache; + local_info.timescale = info.timescale; + // Carry the publisher's cache window so the local producer evicts (and + // clamps downstream stale windows) with the same bound when re-served. + local_info.cache = info.cache; let mut track = match request.accept(local_info) { Ok(track) => track, Err(err) => { - if let Some(s) = &mut stream { - s.writer.abort(&err); - } + stream.writer.abort(&err); return SessionOutcome::Error(err); } }; - - // Resolve the pending entry: parked group streams can now create groups - // (with the right timescale) and decode frames. - if let Ok(mut resolved) = resolved_tx.write() { - *resolved = Some(ResolvedTrack { + let subscribe_ok = kio::Producer::new(Some(info)).consume(); + self.subscribes.lock().insert( + id, + TrackEntry { producer: track.clone(), - compression, - timescale, - }); - } - - // If we didn't open the stream eagerly (info-only at hand-out), a subscriber - // may have coalesced during the info fetch: open it now (props are cached, so - // just SUBSCRIBE). Otherwise wait briefly for one, else drop (info stays cached). - if stream.is_none() { - loop { - if let Some(sub) = track.subscription() { - match self.open_subscribe(id, name, path, &sub).await { - Ok(s) => { - stream = Some(s); - break; - } - Err(err) => { - let _ = track.abort(err.clone()); - return SessionOutcome::Error(err); - } - } - } else { - tokio::select! { - _ = track.used() => continue, - err = broadcast.closed() => { - let _ = track.abort(err.clone()); - return SessionOutcome::BroadcastClosed(err); - } - _ = tokio::time::sleep(LINGER_TIMEOUT) => { - let _ = track.finish(); - return SessionOutcome::Cancelled; - } - } - } - } - } - - let mut stream = stream.expect("subscribe stream is open once there's group demand"); - let sub = track.subscription().or(initial_sub).unwrap_or_default(); - let outcome = self.serve_lifecycle(&mut stream, &mut track, broadcast, &sub).await; - - // Apply the outcome to the producer that downstream consumers read from. - match &outcome { - SessionOutcome::Complete => { - let _ = track.finish(); - } - SessionOutcome::Cancelled => { - let _ = track.abort(Error::Cancel); - } - SessionOutcome::BroadcastClosed(err) | SessionOutcome::Error(err) => { - let _ = track.abort(err.clone()); - } - } - - outcome - } - - /// The linger lifecycle on an open subscribe stream: serve → linger → resume → - /// ... → FIN. - /// - /// On linger entry (last consumer drops) we send `SubscribeUpdate(priority=0, - /// end_group=Some(latest))`. The publisher treats `end_group` as a serving cap, - /// not a terminator: it holds any groups beyond the cap and resumes when we - /// raise it. On resume (a new consumer arrives) we uncap with `end_group=None`. - /// The stream stays open across the whole lifecycle — only a timeout or a - /// publisher-side close ends it, avoiding the stream-churn an unsubscribe-and- - /// reissue approach would have. - async fn serve_lifecycle( - &self, - stream: &mut Stream, - track: &mut TrackProducer, - broadcast: &BroadcastDynamic, - sub: &Subscription, - ) -> SessionOutcome { - // SubscribeUpdate only exists on Lite03+; older versions take the - // immediate-FIN path with no linger. - let supports_linger = !matches!(self.version, Version::Lite01 | Version::Lite02); + stats: track_stats, + subscribe_ok, + }, + ); - 'lifecycle: loop { + // Lifecycle loop: serve → linger → resume → serve → ... → FIN. + let outcome = 'lifecycle: loop { // Phase 1 — serving. Wait for the last consumer to drop (enter linger), // the broadcast to die, or the upstream to close the stream. tokio::select! { @@ -789,21 +655,22 @@ impl Subscriber { }, } + // No linger on Lite01/02: FIN and report cancellation. if !supports_linger { let _ = stream.writer.finish(); break 'lifecycle SessionOutcome::Cancelled; } // Phase 2 — linger. Cap the publisher's serving cursor at the latest - // cached group and drop priority to 0; the publisher holds any group - // beyond the cap until we resume or FIN. `unwrap_or(0)` handles the case - // where we subscribed but haven't received a group yet. + // group we've cached and drop priority to 0; the publisher holds any + // group beyond the cap until we resume or FIN. `unwrap_or(0)` handles + // the corner case where we subscribed but haven't received a group yet. let cap = track.latest().unwrap_or(0); let pause = lite::SubscribeUpdate { priority: 0, - ordered: sub.ordered, - max_latency: sub.stale, - start_group: sub.group_start, + ordered, + max_latency, + start_group, end_group: Some(cap), }; if let Err(err) = stream.writer.encode(&pause).await { @@ -833,10 +700,10 @@ impl Subscriber { tracing::info!(track = %track.name, "subscribe resumed"); let uncap = lite::SubscribeUpdate { - priority: sub.priority, - ordered: sub.ordered, - max_latency: sub.stale, - start_group: sub.group_start, + priority: original_priority, + ordered, + max_latency, + start_group, end_group: None, }; if let Err(err) = stream.writer.encode(&uncap).await { @@ -844,50 +711,58 @@ impl Subscriber { break 'lifecycle SessionOutcome::Error(err); } // Loop back to Phase 1. - } - } - - /// Open a Track Stream, send TRACK, and read the single TRACK_INFO reply. - /// - /// The publisher FINs after TRACK_INFO (or resets on error, e.g. the track - /// does not exist); we drop the stream once the reply is in. Lite05+ only. - async fn fetch_track_info(&self, broadcast: &Path<'_>, name: &str) -> Result { - let mut stream = Stream::open(&self.session, self.version).await?; - stream.writer.encode(&lite::ControlType::Track).await?; - let req = lite::Track { - broadcast: broadcast.clone(), - track: name.into(), }; - stream.writer.encode(&req).await?; - let info = stream.reader.decode::().await?; - let _ = stream.writer.finish(); - Ok(info) + // Apply the outcome to the producer that downstream consumers read from. + match &outcome { + SessionOutcome::Complete => { + let _ = track.finish(); + } + SessionOutcome::Cancelled => { + let _ = track.abort(Error::Cancel); + } + SessionOutcome::BroadcastClosed(err) | SessionOutcome::Error(err) => { + let _ = track.abort(err.clone()); + } + } + + outcome } pub async fn recv_group(&mut self, stream: &mut Reader) -> Result<(), Error> { let hdr: lite::Group = stream.decode().await?; - let (resolved, track_stats) = { - let subs = self.subscribes.lock(); - let entry = subs.get(&hdr.subscribe).ok_or(Error::Cancel)?; - (entry.resolved.clone(), entry.stats.clone()) + let (mut group, track, track_stats, subscribe_ok) = { + let mut subs = self.subscribes.lock(); + let entry = subs.get_mut(&hdr.subscribe).ok_or(Error::Cancel)?; + + let group_info = Group { sequence: hdr.sequence }; + let group = entry.producer.create_group(group_info)?; + ( + group, + entry.producer.clone(), + entry.stats.clone(), + entry.subscribe_ok.clone(), + ) }; - // Block until the upstream is accepted and TRACK_INFO is known. The group's - // QUIC stream can arrive before that resolves; its unread bytes stay - // buffered by QUIC flow control until we create the group below. A closed - // channel means the subscription ended first, so treat it as cancelled. + // Bump groups counter for this incoming group on the subscriber side. + track_stats.group(); + + // Block until SUBSCRIBE_OK arrives. The group's QUIC stream can arrive + // before SUBSCRIBE_OK lands on the subscribe stream, so we can't decode + // frames until this resolves. A closed channel means the subscription + // ended before SUBSCRIBE_OK, so treat it as cancelled. // // Map the closed `Ref` to `None` inside the poll closure (rather than using // `Consumer::wait`) so the `!Send` guard never enters this spawned future. - let resolved = kio::wait(|waiter| { - let poll = resolved.poll(waiter, |r| match &**r { - Some(r) => Poll::Ready(r.clone()), + let (compression, timescale) = kio::wait(|waiter| { + let poll = subscribe_ok.poll(waiter, |ok| match &**ok { + Some(ok) => Poll::Ready((ok.compression, ok.timescale)), None => Poll::Pending, }); match poll { - Poll::Ready(Ok(r)) => Poll::Ready(Some(r)), + Poll::Ready(Ok(pair)) => Poll::Ready(Some(pair)), Poll::Ready(Err(_closed)) => Poll::Ready(None), Poll::Pending => Poll::Pending, } @@ -895,18 +770,10 @@ impl Subscriber { .await .ok_or(Error::Cancel)?; - // Create the group now that the timescale is known, so it inherits the right - // per-frame timestamp scale. - let mut producer = resolved.producer.clone(); - let mut group = producer.create_group(Group { sequence: hdr.sequence })?; - - // Bump groups counter for this incoming group on the subscriber side. - track_stats.group(); - let res = tokio::select! { - err = producer.closed() => Err(err), + err = track.closed() => Err(err), err = group.closed() => Err(err), - res = self.run_group(stream, group.clone(), track_stats.clone(), resolved.compression, resolved.timescale) => res, + res = self.run_group(stream, group.clone(), track_stats.clone(), compression, timescale) => res, }; match res { diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs deleted file mode 100644 index 26834459d..000000000 --- a/rs/moq-net/src/lite/track.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::borrow::Cow; - -use crate::{ - Compression, Path, Timescale, - coding::{Decode, DecodeError, Encode, EncodeError}, -}; - -use super::{Message, Version}; - -/// Sent by the subscriber to open a Track Stream (0x6), requesting a track's -/// immutable publisher properties without subscribing or fetching. -/// -/// The publisher replies with a single [`TrackInfo`] and then FINs the stream, -/// or resets it on error (e.g. the track does not exist). Lite05+ only. -#[derive(Clone, Debug)] -pub struct Track<'a> { - pub broadcast: Path<'a>, - pub track: Cow<'a, str>, -} - -impl Message for Track<'_> { - fn decode_msg(r: &mut R, version: Version) -> Result { - match version { - Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { - return Err(DecodeError::Version); - } - _ => {} - } - - let broadcast = Path::decode(r, version)?; - let track = Cow::::decode(r, version)?; - - Ok(Self { broadcast, track }) - } - - fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { - match version { - Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { - return Err(EncodeError::Version); - } - _ => {} - } - - self.broadcast.encode(w, version)?; - self.track.encode(w, version)?; - - Ok(()) - } -} - -/// Sent by the publisher in response to a [`Track`] request, carrying the track's -/// immutable publisher properties. It is the sole message on the Track Stream; the -/// publisher FINs immediately afterward, or resets the stream on error. -/// -/// Every field is fixed for the lifetime of the track. Fetched once and cached by -/// the subscriber, so the properties are no longer echoed on every SUBSCRIBE/FETCH -/// response. Lite05+ only. -#[derive(Clone, Debug)] -pub struct TrackInfo { - /// The publisher's priority for this track, used only to resolve ties between - /// subscriptions of equal subscriber priority. - pub priority: u8, - /// The publisher's group ordering preference, used only to resolve ties. - pub ordered: bool, - /// How long the publisher keeps old groups available before evicting them. A - /// relay re-serves with the same window and clamps each subscriber's stale - /// preference to it. - pub cache: std::time::Duration, - /// Per-frame timestamp scale. `None` (wire `0`) means the publisher doesn't - /// carry per-frame timestamps, so frame headers omit them. - pub timescale: Option, - /// Codec applied to every frame payload on this track. The subscriber needs - /// this (and `timescale`) before it can decode any frame. - pub compression: Compression, -} - -impl Message for TrackInfo { - fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { - match version { - Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { - return Err(EncodeError::Version); - } - _ => {} - } - - // Order matches draft-lcurley-moq-lite-05 TRACK_INFO: Priority, Ordered, - // Cache, Timescale, Compression. - self.priority.encode(w, version)?; - (self.ordered as u8).encode(w, version)?; - self.cache.encode(w, version)?; - self.timescale.map(u64::from).unwrap_or(0).encode(w, version)?; - self.compression.to_code().encode(w, version)?; - - Ok(()) - } - - fn decode_msg(r: &mut R, version: Version) -> Result { - match version { - Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { - return Err(DecodeError::Version); - } - _ => {} - } - - let priority = u8::decode(r, version)?; - let ordered = u8::decode(r, version)? != 0; - let cache = std::time::Duration::decode(r, version)?; - let timescale = Timescale::new(u64::decode(r, version)?).ok(); - let compression = Compression::from_code(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue)?; - - Ok(Self { - priority, - ordered, - cache, - timescale, - compression, - }) - } -} - -#[cfg(test)] -mod test { - use std::time::Duration; - - use super::*; - - fn sample() -> TrackInfo { - TrackInfo { - priority: 7, - ordered: true, - cache: Duration::from_secs(10), - timescale: Some(Timescale::MICRO), - compression: Compression::Deflate, - } - } - - fn roundtrip(info: &TrackInfo) -> TrackInfo { - let mut buf = Vec::new(); - info.encode_msg(&mut buf, Version::Lite05Wip).unwrap(); - let mut slice = buf.as_slice(); - TrackInfo::decode_msg(&mut slice, Version::Lite05Wip).unwrap() - } - - #[test] - fn track_info_roundtrips() { - let got = roundtrip(&sample()); - assert_eq!(got.priority, 7); - assert!(got.ordered); - assert_eq!(got.cache, Duration::from_secs(10)); - assert_eq!(got.timescale, Some(Timescale::MICRO)); - assert_eq!(got.compression, Compression::Deflate); - } - - #[test] - fn timescale_zero_decodes_as_none() { - let mut info = sample(); - info.timescale = None; - assert_eq!(roundtrip(&info).timescale, None); - } - - #[test] - fn rejected_before_lite05() { - let mut buf = Vec::new(); - assert!(matches!( - sample().encode_msg(&mut buf, Version::Lite04), - Err(EncodeError::Version) - )); - } - - #[test] - fn track_request_roundtrips() { - let req = Track { - broadcast: Path::new("room/1"), - track: Cow::Borrowed("video"), - }; - let mut buf = Vec::new(); - req.encode_msg(&mut buf, Version::Lite05Wip).unwrap(); - let mut slice = buf.as_slice(); - let got = Track::decode_msg(&mut slice, Version::Lite05Wip).unwrap(); - assert_eq!(got.broadcast, Path::new("room/1")); - assert_eq!(got.track, "video"); - } -} diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index b7dabe14a..ccbf84df0 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -42,14 +42,21 @@ type PendingSlot = Option>; /// One waiting subscriber: its preferences and the producer side of its resolver channel. type Resolver = (Subscription, kio::Producer); +/// The slot a pending `info()` resolves into: `None` until accept delivers the +/// track's immutable [`Track`] (or a denial). The info-only analogue of +/// [`PendingSlot`]; it never creates a subscription. +type InfoSlot = Option>; + +/// One waiting `info()` caller: the producer side of its resolver channel. +type InfoResolver = kio::Producer; + /// A track that has been requested but not yet served by the dynamic handler. /// -/// Subscribers and info-only callers for the same name before it is served +/// Subscribers and `info()` callers for the same name before it is accepted /// coalesce into one pending request: each subscriber adds a resolver channel (so /// they all receive a consumer for the same producer) and each `info()` caller /// adds an info resolver (so they all receive the resolved [`Track`]). A single -/// `accept` resolves both kinds, so a parallel TRACK + SUBSCRIBE for a track -/// triggers just one upstream fetch. +/// `accept` resolves both. #[derive(Default)] struct PendingRequest { resolvers: Vec, @@ -57,10 +64,14 @@ struct PendingRequest { } impl PendingRequest { - /// Fail every waiting subscriber and info caller with `err`. + /// Fail every waiting subscriber and `info()` caller with `err`. fn fail(self, err: &Error) { fail_resolvers(self.resolvers, err); - fail_info_resolvers(self.info_resolvers, err); + for slot in self.info_resolvers { + if let Ok(mut slot) = slot.write() { + *slot = Some(Err(err.clone())); + } + } } } @@ -73,41 +84,17 @@ fn fail_resolvers(resolvers: Vec, err: &Error) { } } -/// The slot a pending info request resolves into: `None` until the dynamic -/// handler resolves it with the track's immutable [`Track`] (or denies). The -/// info-only analogue of [`PendingSlot`], it never creates a producer. -type InfoSlot = Option>; - -/// One waiting info request: the producer side of its resolver channel. -type InfoResolver = kio::Producer; - -/// Resolve every waiting info request with `err`. -fn fail_info_resolvers(resolvers: Vec, err: &Error) { - for slot in resolvers { - if let Ok(mut slot) = slot.write() { - *slot = Some(Err(err.clone())); - } - } -} - #[derive(Default)] struct State { // Weak references for deduplication. Doesn't prevent track auto-close. tracks: HashMap, - // Resolved immutable track properties, cached so repeated info() lookups (and - // group-by-group fetches, which keep no track-level producer) reuse one - // resolution instead of re-fetching upstream. Keyed by track name; a - // re-announce replaces the whole broadcast (and this State), so the cache is - // implicitly invalidated then. - track_info: HashMap, - // Pending requests keyed by track name, waiting for the dynamic handler to // accept or deny them. requests: HashMap, // Requested names in FIFO order for the dynamic handler to drain. A name - // stays in `requests` (but not here) once handed out as a `TrackRequest`. + // stays in `requests` (but not here) once handed out as a `PendingTrack`. request_order: VecDeque, // The current number of dynamic producers. @@ -135,7 +122,7 @@ impl State { Ok(()) } - /// Drop every pending request, notifying all waiting subscribers and info + /// Drop every pending request, notifying all waiting subscribers and `info()` /// callers with `err`. fn abort_requests(&mut self, err: &Error) { self.request_order.clear(); @@ -144,7 +131,7 @@ impl State { } } - /// Drop a single named pending request, notifying its subscribers and info + /// Drop a single named pending request, notifying its subscribers and `info()` /// callers with `err`. fn deny_request(&mut self, name: &str, err: Error) { self.request_order.retain(|n| n != name); @@ -152,12 +139,6 @@ impl State { pending.fail(&err); } } - - /// Cache a track's resolved immutable info, so later `info()` / subscribe / - /// fetch reuse it instead of re-fetching upstream. - fn cache_info(&mut self, name: &str, info: Track) { - self.track_info.insert(name.to_string(), info); - } } /// Manages tracks within a broadcast. @@ -288,36 +269,31 @@ impl BroadcastProducer { } } -/// A track request waiting to be served, handed out by [`BroadcastDynamic::requested_track`]. -/// -/// The publisher inspects [`Self::name`] and [`Self::subscription`], then -/// [`Self::accept`]s it with a concrete [`Track`], which resolves every waiting -/// subscriber and info caller, or [`Self::deny`]s it. Dropping without doing -/// either denies with [`Error::Cancel`]. +/// A subscription waiting to be served, handed out by [`BroadcastDynamic::requested_track`]. /// -/// A request may have no subscriber at all (only `info()` callers): then -/// [`Self::subscription`] is `None` and the handler should resolve the info -/// without opening an upstream subscription. -pub struct TrackRequest { +/// The publisher inspects [`Self::name`] (and optionally [`Self::subscription`]), +/// then either [`Self::accept`]s it with a concrete [`Track`], which resolves all +/// waiting subscribers, or [`Self::deny`]s it. Dropping without doing either +/// denies with [`Error::Cancel`]. +pub struct PendingTrack { name: String, - subscription: Option, + subscription: Subscription, state: kio::Weak, /// Set once accepted or denied so [`Drop`] doesn't deny a second time. completed: bool, } -impl TrackRequest { +impl PendingTrack { /// The requested track name. pub fn name(&self) -> &str { &self.name } - /// The first waiting subscriber's preferences, or `None` when the request has - /// only `info()` callers (no group demand). A handler uses this to decide - /// whether to open an upstream subscription. The full aggregate is available on - /// the [`TrackProducer`] returned by [`Self::accept`]. - pub fn subscription(&self) -> Option<&Subscription> { - self.subscription.as_ref() + /// The first waiting subscriber's preferences, as a hint for constructing the + /// [`Track`]. The full aggregate is available on the [`TrackProducer`] returned + /// by [`Self::accept`] via [`TrackProducer::subscription`]. + pub fn subscription(&self) -> &Subscription { + &self.subscription } /// Serve the request with the given track, resolving every waiting subscriber. @@ -338,17 +314,13 @@ impl TrackRequest { let pending = state.requests.remove(&self.name).ok_or(Error::Cancel)?; state.request_order.retain(|n| n != &self.name); - // Warm the info cache: the props are now known, so a concurrent TRACK - // request (or a later subscribe/fetch) reuses them instead of re-fetching. let info = track.clone(); - state.cache_info(&self.name, info.clone()); - let producer = TrackProducer::new(track); // Insert a weak reference so future subscribers dedupe onto this producer. state.tracks.insert(self.name.clone(), producer.weak()); - // Wake any info-only waiters with the resolved props. + // Wake any `info()` callers with the resolved properties. for slot in pending.info_resolvers { if let Ok(mut slot) = slot.write() { *slot = Some(Ok(info.clone())); @@ -403,7 +375,7 @@ impl TrackRequest { } } -impl Drop for TrackRequest { +impl Drop for PendingTrack { fn drop(&mut self) { if !self.completed && let Ok(mut state) = self.state.write() @@ -413,46 +385,46 @@ impl Drop for TrackRequest { } } -/// A pending info lookup returned by [`TrackConsumer::info`]. +/// A pending subscription returned by [`TrackConsumer::subscribe`]. /// -/// Resolves once the track's immutable [`Track`] is known: synchronously if a -/// producer already exists or the info is cached, otherwise once the dynamic -/// handler accepts the coalesced request via [`TrackRequest::accept`]. Implements -/// [`Future`]; poll-based callers can use [`Self::poll_info`]. -pub struct InfoPending { - inner: InfoPendingInner, - /// Kept alive between `Future::poll` calls so its resolver-channel - /// registration stays valid until the next poll replaces it. +/// The subscription isn't live until the publisher accepts it (for the wire, +/// SUBSCRIBE_OK). It implements [`Future`], so `.await` it to get the +/// [`TrackSubscriber`] (or an error). Poll-based callers can instead drive it +/// with [`Self::poll_ok`] inside a `kio` poll loop. +pub struct TrackPending { + inner: TrackPendingInner, + /// Kept alive between `Future::poll` calls so its registration in the + /// resolver channel stays valid until the next poll replaces it. waiter: Option, } -enum InfoPendingInner { - /// Resolved synchronously: cached, a live producer existed, or it failed. - Ready(Result), - /// Waiting for the dynamic handler to resolve or deny. - Waiting(kio::Consumer), +enum TrackPendingInner { + /// Resolved synchronously: the track already existed, or it failed immediately. + Ready(Result), + /// Waiting for the publisher to accept or deny via the dynamic handler. + Waiting(kio::Consumer), } -impl InfoPending { - fn ready(result: Result) -> Self { +impl TrackPending { + fn ready(result: Result) -> Self { Self { - inner: InfoPendingInner::Ready(result), + inner: TrackPendingInner::Ready(result), waiter: None, } } - fn waiting(consumer: kio::Consumer) -> Self { + fn waiting(consumer: kio::Consumer) -> Self { Self { - inner: InfoPendingInner::Waiting(consumer), + inner: TrackPendingInner::Waiting(consumer), waiter: None, } } - /// Poll for the resolved [`Track`], without blocking. - pub fn poll_info(&self, waiter: &kio::Waiter) -> Poll> { + /// Poll for the resolved [`TrackSubscriber`], without blocking. + pub fn poll_ok(&self, waiter: &kio::Waiter) -> Poll> { match &self.inner { - InfoPendingInner::Ready(result) => Poll::Ready(result.clone()), - InfoPendingInner::Waiting(consumer) => match consumer.poll(waiter, |slot| match &**slot { + TrackPendingInner::Ready(result) => Poll::Ready(result.clone()), + TrackPendingInner::Waiting(consumer) => match consumer.poll(waiter, |slot| match &**slot { Some(result) => Poll::Ready(result.clone()), None => Poll::Pending, }) { @@ -468,61 +440,62 @@ impl InfoPending { } } -impl std::future::Future for InfoPending { - type Output = Result; +impl std::future::Future for TrackPending { + type Output = Result; fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { let this = self.get_mut(); + // Replacing drops the previous waiter, freeing its slot so poll_ok's + // register call can recycle it (see `kio::wait`). this.waiter = Some(kio::Waiter::new(cx.waker().clone())); - this.poll_info(this.waiter.as_ref().unwrap()) + this.poll_ok(this.waiter.as_ref().unwrap()) } } -/// A pending subscription returned by [`TrackConsumer::subscribe`]. +/// A pending info lookup returned by [`TrackConsumer::info`]. /// -/// The subscription isn't live until the publisher accepts it (for the wire, -/// SUBSCRIBE_OK). It implements [`Future`], so `.await` it to get the -/// [`TrackSubscriber`] (or an error). Poll-based callers can instead drive it -/// with [`Self::poll_ok`] inside a `kio` poll loop. -pub struct SubscribePending { - inner: SubscribePendingInner, - /// Kept alive between `Future::poll` calls so its registration in the - /// resolver channel stays valid until the next poll replaces it. +/// Resolves to the track's immutable [`Track`] once it's known: synchronously if a +/// producer already exists, otherwise once the dynamic handler accepts the +/// coalesced request. Implements [`Future`], so `.await` it; poll-based callers can +/// drive it with [`Self::poll_info`]. +pub struct InfoPending { + inner: InfoPendingInner, + /// Kept alive between `Future::poll` calls so its resolver-channel registration + /// stays valid until the next poll replaces it. waiter: Option, } -enum SubscribePendingInner { - /// Resolved synchronously: the track already existed, or it failed immediately. - Ready(Result), - /// Waiting for the publisher to accept or deny via the dynamic handler. - Waiting(kio::Consumer), +enum InfoPendingInner { + /// Resolved synchronously: a producer existed, or it failed immediately. + Ready(Result), + /// Waiting for the dynamic handler to accept or deny. + Waiting(kio::Consumer), } -impl SubscribePending { - fn ready(result: Result) -> Self { +impl InfoPending { + fn ready(result: Result) -> Self { Self { - inner: SubscribePendingInner::Ready(result), + inner: InfoPendingInner::Ready(result), waiter: None, } } - fn waiting(consumer: kio::Consumer) -> Self { + fn waiting(consumer: kio::Consumer) -> Self { Self { - inner: SubscribePendingInner::Waiting(consumer), + inner: InfoPendingInner::Waiting(consumer), waiter: None, } } - /// Poll for the resolved [`TrackSubscriber`], without blocking. - pub fn poll_ok(&self, waiter: &kio::Waiter) -> Poll> { + /// Poll for the resolved [`Track`], without blocking. + pub fn poll_info(&self, waiter: &kio::Waiter) -> Poll> { match &self.inner { - SubscribePendingInner::Ready(result) => Poll::Ready(result.clone()), - SubscribePendingInner::Waiting(consumer) => match consumer.poll(waiter, |slot| match &**slot { + InfoPendingInner::Ready(result) => Poll::Ready(result.clone()), + InfoPendingInner::Waiting(consumer) => match consumer.poll(waiter, |slot| match &**slot { Some(result) => Poll::Ready(result.clone()), None => Poll::Pending, }) { Poll::Ready(Ok(result)) => Poll::Ready(result), - // Channel closed: the resolver may have left the final result behind. Poll::Ready(Err(closed)) => Poll::Ready(match &*closed { Some(result) => result.clone(), None => Err(Error::Cancel), @@ -533,15 +506,13 @@ impl SubscribePending { } } -impl std::future::Future for SubscribePending { - type Output = Result; +impl std::future::Future for InfoPending { + type Output = Result; fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { let this = self.get_mut(); - // Replacing drops the previous waiter, freeing its slot so poll_ok's - // register call can recycle it (see `kio::wait`). this.waiter = Some(kio::Waiter::new(cx.waker().clone())); - this.poll_ok(this.waiter.as_ref().unwrap()) + this.poll_info(this.waiter.as_ref().unwrap()) } } @@ -549,8 +520,8 @@ impl std::future::Future for SubscribePending { /// /// When a consumer requests a track that doesn't exist, the dynamic producer /// picks up the request via [`Self::requested_track`] and either -/// [`TrackRequest::accept`]s it with a concrete [`Track`] or -/// [`TrackRequest::deny`]s it. Dropped when no longer needed; pending requests +/// [`PendingTrack::accept`]s it with a concrete [`Track`] or +/// [`PendingTrack::deny`]s it. Dropped when no longer needed; pending requests /// are automatically aborted. pub struct BroadcastDynamic { info: Broadcast, @@ -604,21 +575,20 @@ impl BroadcastDynamic { } /// Poll for the next consumer-requested track, without blocking. - pub fn poll_requested_track(&mut self, waiter: &kio::Waiter) -> Poll> { + pub fn poll_requested_track(&mut self, waiter: &kio::Waiter) -> Poll> { let weak = self.state.weak(); self.poll(waiter, |state| { let Some(name) = state.request_order.pop_front() else { return Poll::Pending; }; - // The name stays in `requests` so concurrent subscribers and info callers - // can still coalesce onto it until the publisher accepts or denies. + // The name stays in `requests` so concurrent subscribers can still + // coalesce onto it until the publisher accepts or denies. let pending = state.requests.get(&name).expect("request_order out of sync"); - // `None` when only `info()` callers are waiting (no group demand yet). - let subscription = pending.resolvers.first().map(|(s, _)| s.clone()); + let subscription = pending.resolvers.first().map(|(s, _)| s.clone()).unwrap_or_default(); Poll::Ready((name, subscription)) }) .map(|res| { - res.map(|(name, subscription)| TrackRequest { + res.map(|(name, subscription)| PendingTrack { name, subscription, state: weak, @@ -627,17 +597,11 @@ impl BroadcastDynamic { }) } - /// Block until a consumer requests a track, returning a [`TrackRequest`] to serve. - pub async fn requested_track(&mut self) -> Result { + /// Block until a consumer requests a track, returning a [`PendingTrack`] to serve. + pub async fn requested_track(&mut self) -> Result { kio::wait(|waiter| self.poll_requested_track(waiter)).await } - /// The track's cached immutable info, if a prior subscribe/info already - /// resolved it. A sync peek used by a handler to skip an upstream TRACK fetch. - pub fn cached_info(&self, name: &str) -> Option { - self.state.read().track_info.get(name).cloned() - } - /// Create a consumer that can subscribe to tracks in this broadcast. pub fn consume(&self) -> BroadcastConsumer { BroadcastConsumer { @@ -695,7 +659,7 @@ use futures::FutureExt; #[cfg(test)] impl BroadcastDynamic { - pub fn assert_request(&mut self) -> TrackRequest { + pub fn assert_request(&mut self) -> PendingTrack { self.requested_track() .now_or_never() .expect("should not have blocked") @@ -727,38 +691,38 @@ impl BroadcastConsumer { /// /// This is a cheap, synchronous lookup that returns a [`TrackConsumer`] bound /// to `name`. Nothing is sent to the publisher yet: call - /// [`TrackConsumer::subscribe`] to open a live subscription, [`TrackConsumer::info`] - /// to resolve its properties, or hold the handle and act later. + /// [`TrackConsumer::subscribe`] to open a live subscription (blocking on + /// SUBSCRIBE_OK), or hold the handle and subscribe later. pub fn consume_track(&self, name: &str) -> TrackConsumer { TrackConsumer { broadcast: self.clone(), - info: Track::new(name), + name: name.to_string(), } } - /// Register a subscription for `name` and return a [`SubscribePending`] that + /// Register a subscription for `name` and return a [`TrackPending`] that /// resolves once the publisher accepts it. /// /// Reuses a live producer if one is already publishing the track (the pending /// resolves right away), otherwise queues a dynamic request served via - /// [`BroadcastDynamic::requested_track`] and [`TrackRequest::accept`] (for the + /// [`BroadcastDynamic::requested_track`] and [`PendingTrack::accept`] (for the /// wire this is SUBSCRIBE_OK). Resolves to [`Error::NotFound`] if no dynamic /// producer exists to handle the request. - fn request_subscribe(&self, name: &str, subscription: Subscription) -> SubscribePending { + fn request_subscribe(&self, name: &str, subscription: Subscription) -> TrackPending { // Upgrade to a temporary producer so we can modify the state. let Some(producer) = self.state.produce() else { let err = self.state.read().abort.clone().unwrap_or(Error::Dropped); - return SubscribePending::ready(Err(err)); + return TrackPending::ready(Err(err)); }; let mut state = match modify(&producer) { Ok(state) => state, - Err(err) => return SubscribePending::ready(Err(err)), + Err(err) => return TrackPending::ready(Err(err)), }; // Reuse a live producer if one is already publishing the track. if let Some(weak) = state.tracks.get(name) { if !weak.is_closed() { - return SubscribePending::ready(Ok(weak.subscribe(subscription))); + return TrackPending::ready(Ok(weak.subscribe(subscription))); } // Drop the stale entry and fall through to a fresh request. state.tracks.remove(name); @@ -771,7 +735,7 @@ impl BroadcastConsumer { // Coalesce onto an in-flight request for the same name. pending.resolvers.push((subscription, slot)); } else if state.dynamic == 0 { - return SubscribePending::ready(Err(Error::NotFound)); + return TrackPending::ready(Err(Error::NotFound)); } else { state.requests.insert( name.to_string(), @@ -783,18 +747,16 @@ impl BroadcastConsumer { state.request_order.push_back(name.to_string()); } - SubscribePending::waiting(consumer) + TrackPending::waiting(consumer) } - /// Resolve a track's immutable info, returning an [`InfoPending`]. + /// Resolve a track's immutable [`Track`] info, returning an [`InfoPending`]. /// - /// Returns immediately when the info is cached or a live producer already - /// carries it (no round trip). Otherwise coalesces onto the track's dynamic - /// request (alongside any subscribers) and resolves when the handler calls - /// [`TrackRequest::accept`] (for the wire this is a TRACK Stream / TRACK_INFO). + /// Returns immediately when a live producer already carries it (warm via + /// `tracks`); otherwise coalesces onto the track's dynamic request (alongside + /// any subscribers) and resolves when the handler [`PendingTrack::accept`]s it. /// Resolves to [`Error::NotFound`] if no dynamic producer exists to handle it. - /// Unlike [`Self::request_subscribe`], an info caller adds no subscription, so a - /// pure-info request leaves [`TrackRequest::subscription`] empty. + /// An `info()` caller adds no subscription. fn request_info(&self, name: &str) -> InfoPending { let Some(producer) = self.state.produce() else { let err = self.state.read().abort.clone().unwrap_or(Error::Dropped); @@ -805,18 +767,12 @@ impl BroadcastConsumer { Err(err) => return InfoPending::ready(Err(err)), }; - // Already resolved for this track. - if let Some(info) = state.track_info.get(name) { - return InfoPending::ready(Ok(info.clone())); - } - - // A live producer carries the info; cache and return it without a round trip. - if let Some(weak) = state.tracks.get(name) - && !weak.is_closed() - { - let info = weak.info.clone(); - state.track_info.insert(name.to_string(), info.clone()); - return InfoPending::ready(Ok(info)); + // A live producer carries the info; return it without a round trip. + if let Some(weak) = state.tracks.get(name) { + if !weak.is_closed() { + return InfoPending::ready(Ok(weak.info.clone())); + } + state.tracks.remove(name); } let slot = kio::Producer::new(None); @@ -872,57 +828,47 @@ impl BroadcastConsumer { /// A handle to a single track within a broadcast. /// -/// Obtained from [`BroadcastConsumer::consume_track`]. Holding it sends nothing to -/// the publisher; it just names a track you can [`subscribe`](Self::subscribe) to -/// (a live, ongoing stream of groups) or resolve [`info`](Self::info) for later. -/// Clones are cheap and concurrent operations on the same name coalesce onto one -/// request. -/// -/// Only the track's `name` is known up front (available via `Deref`); every other -/// property is resolved on demand via [`info`](Self::info), since for a relay it -/// comes from upstream. +/// Obtained from [`BroadcastConsumer::consume_track`]. Holding it sends nothing +/// to the publisher; it just names a track you can [`subscribe`](Self::subscribe) +/// to (a live, ongoing stream of groups) later. The same handle can be subscribed +/// to multiple times, and clones are cheap. // TODO: add `fetch` for one-shot retrieval of a past group range. #[derive(Clone)] pub struct TrackConsumer { broadcast: BroadcastConsumer, - /// Name-only [`Track`]: `Deref` exposes `name`; the other fields are - /// placeholders until [`Self::info`] resolves the real values. - info: Track, + name: String, } -impl Deref for TrackConsumer { - type Target = Track; - - fn deref(&self) -> &Self::Target { - &self.info +impl TrackConsumer { + /// The track name this handle is bound to. + pub fn name(&self) -> &str { + &self.name } -} -impl TrackConsumer { /// Open a live subscription. /// - /// Returns a [`SubscribePending`] that resolves once the publisher accepts the - /// subscription. `.await` it for the [`TrackSubscriber`], which carries the - /// publisher's [`Track`] and reads its groups; or drive it with - /// [`SubscribePending::poll_ok`] from a poll loop. + /// Returns a [`TrackPending`] that resolves once the publisher accepts the + /// subscription (SUBSCRIBE_OK on the wire). `.await` it for the + /// [`TrackSubscriber`], which carries the publisher's [`Track`] and reads its + /// groups; or drive it with [`TrackPending::poll_ok`] from a poll loop. /// /// `subscription` is this subscriber's preferences and feeds the producer's /// [`TrackProducer::subscription`] aggregate; pass `None` for /// [`Subscription::default`]. Concurrent subscribers to the same name coalesce /// onto one request. - pub fn subscribe(&self, subscription: impl Into>) -> SubscribePending { + pub fn subscribe(&self, subscription: impl Into>) -> TrackPending { self.broadcast - .request_subscribe(&self.info.name, subscription.into().unwrap_or_default()) + .request_subscribe(&self.name, subscription.into().unwrap_or_default()) } - /// Resolve this track's immutable [`Track`] info. + /// Resolve this track's immutable [`Track`] properties without subscribing. /// /// Returns an [`InfoPending`] that resolves to the publisher's properties - /// (timescale, compression, cache). Warm (cached or a live producer exists) it - /// resolves with no round trip; cold it triggers a single TRACK lookup. Reused - /// across every subscribe and fetch of the track. + /// (timescale, compression, cache, ...). `.await` it, or drive it with + /// [`InfoPending::poll_info`] from a poll loop. Warm (a live producer exists) it + /// resolves with no round trip. pub fn info(&self) -> InfoPending { - self.broadcast.request_info(&self.info.name) + self.broadcast.request_info(&self.name) } } @@ -942,7 +888,7 @@ mod test { use super::*; /// Subscribe and assert the result hasn't resolved yet (it stays pending until - /// a publisher accepts). Returns the [`SubscribePending`] to resolve after accepting. + /// a publisher accepts). Returns the [`TrackPending`] to resolve after accepting. macro_rules! subscribe_pending { ($consumer:expr, $name:expr) => {{ let pending = $consumer.consume_track($name).subscribe(None); @@ -1153,10 +1099,8 @@ mod test { #[tokio::test] async fn info_warm_from_live_producer() { let mut producer = Broadcast::new().produce(); - // Hold the track producer so the weak handle stays live. let track = Track::new("video").with_timescale(crate::Timescale::MICRO).produce(); producer.assert_insert_track(&track); - let consumer = producer.consume(); // A live producer carries the info, so info() resolves with no round trip. @@ -1170,69 +1114,33 @@ mod test { } #[tokio::test] - async fn info_cold_resolves_via_request() { + async fn info_cold_resolves_via_accept() { let mut producer = Broadcast::new().produce().dynamic(); let consumer = producer.consume(); let consumer2 = consumer.clone(); - // No producer yet: two info() callers for the same track coalesce into one - // pending request, which is handed out as a TrackRequest with no subscriber. + // No producer yet: two info() callers coalesce onto one request (no subscriber). let info1 = consumer.consume_track("video").info(); assert!(info1.poll_info(&kio::Waiter::noop()).is_pending()); let info2 = consumer2.consume_track("video").info(); - // Exactly one request to serve, and it has no group demand. let request = producer.assert_request(); assert_eq!(request.name(), "video"); - assert!(request.subscription().is_none(), "info-only request has no subscriber"); - - // accept resolves the info waiters (and caches the value). request .accept(Track::new("video").with_timescale(crate::Timescale::MICRO)) .unwrap(); assert_eq!(info1.await.unwrap().timescale, Some(crate::Timescale::MICRO)); - // The second caller and any later lookup read the cached value, no new request. + // Second caller and any later lookup read the live producer, no new request. assert_eq!(info2.await.unwrap().timescale, Some(crate::Timescale::MICRO)); producer.assert_no_request(); - let cached = consumer - .consume_track("video") - .info() - .now_or_never() - .expect("cached info should not block") - .unwrap(); - assert_eq!(cached.timescale, Some(crate::Timescale::MICRO)); } #[tokio::test] - async fn info_cold_missing_without_dynamic() { - // No producer and no dynamic handler: info() resolves to NotFound rather - // than hanging. + async fn info_missing_without_dynamic() { let producer = Broadcast::new().produce(); let consumer = producer.consume(); let err = consumer.consume_track("video").info().await.unwrap_err(); assert!(matches!(err, Error::NotFound)); } - - #[tokio::test] - async fn accept_warms_info_cache() { - let mut producer = Broadcast::new().produce().dynamic(); - let consumer = producer.consume(); - - // A subscribe accept caches the info, so a later info() needs no request. - let _sub = subscribe_pending!(consumer, "video"); - let request = producer.assert_request(); - let _track = request - .accept(Track::new("video").with_timescale(crate::Timescale::MICRO)) - .unwrap(); - - let got = consumer - .consume_track("video") - .info() - .now_or_never() - .expect("info should be cached by accept") - .unwrap(); - assert_eq!(got.timescale, Some(crate::Timescale::MICRO)); - producer.assert_no_request(); - } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 2e160528f..0dd41bea3 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -487,10 +487,154 @@ fn max_option(a: Option, b: Option) -> Option { } } +/// A track producer whose immutable [`Track`] properties aren't known yet. +/// +/// Holds the shared state (subscriptions, demand, groups) but no `info`. This is +/// the headless half of a [`TrackProducer`]: a relay can accept subscribers and +/// observe demand ([`Self::subscription`]) on it before the properties are +/// resolved, then [`Self::accept`] the resolved [`Track`] to upgrade it into a +/// full producer that can create groups. +#[derive(Default, Clone)] +pub struct TrackRequest { + state: kio::Producer, +} + +impl TrackRequest { + /// Build an empty request with no consumers yet. + pub fn new() -> Self { + Self::default() + } + + /// Resolve the track's immutable properties, upgrading into a [`TrackProducer`]. + pub fn accept(self, info: Track) -> TrackProducer { + TrackProducer { request: self, info } + } + + /// Mark the track as finished after the last appended group. + /// + /// Sets the final sequence to one past the current max_sequence. + /// No new groups at or above this sequence can be appended. + /// NOTE: Old groups with lower sequence numbers can still arrive. + pub fn finish(&mut self) -> Result<()> { + let mut state = self.modify()?; + if state.final_sequence.is_some() { + return Err(Error::Closed); + } + state.final_sequence = Some(match state.max_sequence { + Some(max) => max.checked_add(1).ok_or(coding::BoundsExceeded)?, + None => 0, + }); + Ok(()) + } + + /// Mark the track as finished at an exact final sequence. + /// + /// The caller must pass the current max_sequence exactly. + /// Freezes the final boundary at one past the current max_sequence. + /// No new groups at or above that sequence can be created. + /// NOTE: Old groups with lower sequence numbers can still arrive. + pub fn finish_at(&mut self, sequence: u64) -> Result<()> { + let mut state = self.modify()?; + let max = state.max_sequence.ok_or(Error::Closed)?; + if state.final_sequence.is_some() || sequence != max { + return Err(Error::Closed); + } + state.final_sequence = Some(max.checked_add(1).ok_or(coding::BoundsExceeded)?); + Ok(()) + } + + /// Abort the track with the given error. + /// + /// Child groups are independent and must be aborted separately if desired; + /// existing group consumers can still finish reading any groups that were + /// already created. + pub fn abort(&mut self, err: Error) -> Result<()> { + let mut guard = self.modify()?; + guard.abort = Some(err); + guard.close(); + Ok(()) + } + + /// The aggregate of every live subscriber's [`Subscription`] (most demanding + /// request across all consumers), or `None` when there are no live subscribers. + pub fn subscription(&self) -> Option { + self.state + .write() + .ok() + .and_then(|mut state| state.aggregate_subscription()) + } + + /// Block until there are no active consumers. + pub async fn unused(&self) -> Result<()> { + self.state + .unused() + .await + .map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) + } + + /// Block until there is at least one active consumer. + pub async fn used(&self) -> Result<()> { + self.state + .used() + .await + .map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) + } + + /// Block until the track is closed or aborted, returning the cause. + pub async fn closed(&self) -> Error { + self.state.closed().await; + self.state.read().abort.clone().unwrap_or(Error::Dropped) + } + + /// Return true if the track has been closed. + pub fn is_closed(&self) -> bool { + self.state.read().is_closed() + } + + /// Return the latest sequence number successfully appended to the track. + pub fn latest(&self) -> Option { + self.state.read().max_sequence + } + + /// Return true if this is the same track. + pub fn is_clone(&self, other: &Self) -> bool { + self.state.same_channel(&other.state) + } + + /// Register a subscription `Arc` and build the consumer once `info` is known. + fn subscribe_with(&self, info: &Track, subscription: impl Into>) -> TrackSubscriber { + let mut subscription = subscription.into().unwrap_or_default(); + subscription.stale = info.clamp_stale(subscription.stale); + let subscription = Arc::new(Mutex::new(subscription)); + if let Ok(mut state) = self.state.write() { + state.subscriptions.push(Arc::downgrade(&subscription)); + } + TrackSubscriber { + info: info.clone(), + state: self.state.consume(), + subscription, + index: 0, + min_sequence: 0, + next_sequence: 0, + end_sequence: None, + } + } + + fn modify(&self) -> Result> { + self.state + .write() + .map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) + } +} + /// A producer for a track, used to create new groups. +/// +/// A [`TrackRequest`] plus the resolved immutable [`Track`] properties. Derefs to +/// the [`Track`], so `producer.name` / `producer.timescale` etc. read the resolved +/// values. pub struct TrackProducer { + request: TrackRequest, info: Track, - state: kio::Producer, } impl std::ops::Deref for TrackProducer { @@ -505,8 +649,8 @@ impl TrackProducer { /// Build a producer for the given track metadata. Prefer [`Track::produce`]. pub fn new(info: Track) -> Self { Self { + request: TrackRequest::new(), info, - state: kio::Producer::default(), } } @@ -514,7 +658,7 @@ impl TrackProducer { pub fn create_group(&mut self, info: Group) -> Result { let group = GroupProducer::new_with_timescale(info, self.info.timescale); - let mut state = self.modify()?; + let mut state = self.request.modify()?; if let Some(fin) = state.final_sequence && group.sequence >= fin { @@ -535,7 +679,7 @@ impl TrackProducer { /// Create a new group with the next sequence number. pub fn append_group(&mut self) -> Result { - let mut state = self.modify()?; + let mut state = self.request.modify()?; let sequence = match state.max_sequence { Some(s) => s.checked_add(1).ok_or(coding::BoundsExceeded)?, None => 0, @@ -565,140 +709,79 @@ impl TrackProducer { Ok(()) } - /// Mark the track as finished after the last appended group. - /// - /// Sets the final sequence to one past the current max_sequence. - /// No new groups at or above this sequence can be appended. - /// NOTE: Old groups with lower sequence numbers can still arrive. - pub fn finish(&mut self) -> Result<()> { - let mut state = self.modify()?; - if state.final_sequence.is_some() { - return Err(Error::Closed); - } - state.final_sequence = Some(match state.max_sequence { - Some(max) => max.checked_add(1).ok_or(coding::BoundsExceeded)?, - None => 0, - }); - Ok(()) - } - - /// Mark the track as finished at an exact final sequence. - /// - /// The caller must pass the current max_sequence exactly. - /// Freezes the final boundary at one past the current max_sequence. - /// No new groups at or above that sequence can be created. - /// NOTE: Old groups with lower sequence numbers can still arrive. - pub fn finish_at(&mut self, sequence: u64) -> Result<()> { - let mut state = self.modify()?; - let max = state.max_sequence.ok_or(Error::Closed)?; - if state.final_sequence.is_some() || sequence != max { - return Err(Error::Closed); - } - state.final_sequence = Some(max.checked_add(1).ok_or(coding::BoundsExceeded)?); - Ok(()) - } - - /// Abort the track with the given error. - /// - /// Child groups are independent and must be aborted separately if desired; - /// existing group consumers can still finish reading any groups that were - /// already created. - pub fn abort(&mut self, err: Error) -> Result<()> { - let mut guard = self.modify()?; - guard.abort = Some(err); - guard.close(); - Ok(()) - } - /// Subscribe to the track in-process with the given subscriber preferences. /// /// Pass `None` for [`Subscription::default`]. The preferences feed the /// producer's [`Self::subscription`] aggregate and can be changed later via /// [`TrackSubscriber::update`]. pub fn subscribe(&self, subscription: impl Into>) -> TrackSubscriber { - let mut subscription = subscription.into().unwrap_or_default(); - subscription.stale = self.info.clamp_stale(subscription.stale); - let subscription = Arc::new(Mutex::new(subscription)); - if let Ok(mut state) = self.state.write() { - state.subscriptions.push(Arc::downgrade(&subscription)); - } - TrackSubscriber { + self.request.subscribe_with(&self.info, subscription) + } + + /// Create a weak reference that doesn't prevent auto-close. + pub(crate) fn weak(&self) -> TrackWeak { + TrackWeak { info: self.info.clone(), - state: self.state.consume(), - subscription, - index: 0, - min_sequence: 0, - next_sequence: 0, - end_sequence: None, + state: self.request.state.weak(), } } - /// The aggregate of every live subscriber's [`Subscription`] (most demanding - /// request across all consumers), or `None` when there are no live subscribers. + /// Mark the track as finished after the last appended group. See [`TrackRequest::finish`]. + pub fn finish(&mut self) -> Result<()> { + self.request.finish() + } + + /// Mark the track as finished at an exact final sequence. See [`TrackRequest::finish_at`]. + pub fn finish_at(&mut self, sequence: u64) -> Result<()> { + self.request.finish_at(sequence) + } + + /// Abort the track with the given error. See [`TrackRequest::abort`]. + pub fn abort(&mut self, err: Error) -> Result<()> { + self.request.abort(err) + } + + /// The aggregate of every live subscriber's [`Subscription`]. See [`TrackRequest::subscription`]. pub fn subscription(&self) -> Option { - self.state - .write() - .ok() - .and_then(|mut state| state.aggregate_subscription()) + self.request.subscription() } /// Block until there are no active consumers. pub async fn unused(&self) -> Result<()> { - self.state - .unused() - .await - .map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) + self.request.unused().await } /// Block until there is at least one active consumer. pub async fn used(&self) -> Result<()> { - self.state - .used() - .await - .map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) + self.request.used().await } /// Block until the track is closed or aborted, returning the cause. pub async fn closed(&self) -> Error { - self.state.closed().await; - self.state.read().abort.clone().unwrap_or(Error::Dropped) + self.request.closed().await } /// Return true if the track has been closed. pub fn is_closed(&self) -> bool { - self.state.read().is_closed() + self.request.is_closed() } /// Return the latest sequence number successfully appended to the track. pub fn latest(&self) -> Option { - self.state.read().max_sequence + self.request.latest() } /// Return true if this is the same track. pub fn is_clone(&self, other: &Self) -> bool { - self.state.same_channel(&other.state) - } - - /// Create a weak reference that doesn't prevent auto-close. - pub(crate) fn weak(&self) -> TrackWeak { - TrackWeak { - info: self.info.clone(), - state: self.state.weak(), - } - } - - fn modify(&self) -> Result> { - self.state - .write() - .map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) + self.request.is_clone(&other.request) } } impl Clone for TrackProducer { fn clone(&self) -> Self { Self { + request: self.request.clone(), info: self.info.clone(), - state: self.state.clone(), } } } @@ -754,7 +837,7 @@ impl TrackWeak { /// A live subscription to a track, used to read its groups. /// -/// Created via [`TrackPending::subscribe`](crate::TrackPending::subscribe), or +/// Created via [`TrackConsumer::subscribe`](crate::TrackConsumer::subscribe), or /// directly from a [`TrackProducer`] for an in-process track. Carries this /// subscriber's [`Subscription`] preferences, which feed the producer's aggregate. #[derive(Clone)] @@ -1034,7 +1117,7 @@ mod test { producer.append_group().unwrap(); // seq 2 { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(live_groups(&state), 3); assert_eq!(state.offset, 0); } @@ -1048,7 +1131,7 @@ mod test { // Groups 0, 1, 2 are expired but seq 3 (max_sequence) is kept. // Leading tombstones are trimmed, so only seq 3 remains. { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(live_groups(&state), 1); assert_eq!(first_live_sequence(&state), 3); assert_eq!(state.offset, 3); @@ -1073,7 +1156,7 @@ mod test { producer.append_group().unwrap(); // seq 1 { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(live_groups(&state), 1); assert_eq!(first_live_sequence(&state), 1); assert_eq!(state.offset, 1); @@ -1090,7 +1173,7 @@ mod test { producer.append_group().unwrap(); // seq 2 { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(live_groups(&state), 3); assert_eq!(state.offset, 0); } @@ -1126,7 +1209,7 @@ mod test { producer.append_group().unwrap(); // seq 1 // Seq 0 is gone because the publisher only keeps groups for 1s. - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(live_groups(&state), 1); assert_eq!(first_live_sequence(&state), 1); } @@ -1161,7 +1244,7 @@ mod test { // max_sequence = 5, which is at the front of the VecDeque. { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(state.max_sequence, Some(5)); } @@ -1174,7 +1257,7 @@ mod test { // Seq 3, 4, 5 are all expired. Seq 5 was the old max_sequence but now 6 is. // All old groups are evicted. { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(live_groups(&state), 1); assert_eq!(first_live_sequence(&state), 6); assert!(!state.duplicates.contains(&3)); @@ -1201,7 +1284,7 @@ mod test { // Seq 5 is max_sequence (protected). Seq 3 is not expired (just created). // Nothing should be evicted. { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(live_groups(&state), 2); assert_eq!(state.offset, 0); } @@ -1217,7 +1300,7 @@ mod test { // Seq 2 is fresh → kept. // VecDeque: [Some(5), None, Some(2)]. Leading entry is Some, so offset stays. { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(live_groups(&state), 2); assert_eq!(state.offset, 0); assert!(state.duplicates.contains(&5)); @@ -1262,7 +1345,7 @@ mod test { assert!(producer.finish_at(5).is_ok()); { - let state = producer.state.read(); + let state = producer.request.state.read(); assert_eq!(state.final_sequence, Some(6)); } @@ -1722,7 +1805,7 @@ mod test { fn append_group_returns_bounds_exceeded_on_sequence_overflow() { let mut producer = Track::new("test").produce(); { - let mut state = producer.state.write().ok().unwrap(); + let mut state = producer.request.state.write().ok().unwrap(); state.max_sequence = Some(u64::MAX); } From 9bf0d7a72f83c7ea592fe4486646e078bb757f81 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 3 Jun 2026 22:10:21 -0700 Subject: [PATCH 7/7] moq-lite-05: re-apply the Track Stream wire work on the new model Re-adds the wire/relay implementation reverted in the model restart, now built on the split TrackRequest/TrackProducer model and the clean TrackConsumer::info(). - lite/track.rs, stream.rs (ControlType::Track), mod.rs, subscribe.rs: TRACK / TRACK_INFO messages and the SUBSCRIBE_OK slimming for lite-05, restored. - lite/publisher.rs: recv_track serves TRACK_INFO via consume_track(name).info(); skips SUBSCRIBE_OK on lite-05. - lite/subscriber.rs: lite-05 fetches TRACK_INFO upstream (flighted with SUBSCRIBE), demand-gated on PendingTrack::subscription() (info-only requests don't subscribe); resolve_props drops the removed cached_info and fetches once. - model/broadcast.rs: PendingTrack::subscription() now returns Option (None = info-only, no group demand) so the relay can gate the upstream subscribe. Tested: 359 model + 57 moq-native e2e (incl. lite05 info() + timestamp round-trips), clippy + fmt clean, relay/hang/mux build. Co-Authored-By: Claude Opus 4.8 (1M context) --- rs/moq-native/tests/broadcast.rs | 98 +++++++ rs/moq-net/src/lite/mod.rs | 3 + rs/moq-net/src/lite/publisher.rs | 109 ++++++- rs/moq-net/src/lite/stream.rs | 3 + rs/moq-net/src/lite/subscribe.rs | 146 ++-------- rs/moq-net/src/lite/subscriber.rs | 453 +++++++++++++++++++----------- rs/moq-net/src/lite/track.rs | 183 ++++++++++++ rs/moq-net/src/model/broadcast.rs | 20 +- 8 files changed, 703 insertions(+), 312 deletions(-) create mode 100644 rs/moq-net/src/lite/track.rs diff --git a/rs/moq-native/tests/broadcast.rs b/rs/moq-native/tests/broadcast.rs index e4a001fd7..dcd47e96f 100644 --- a/rs/moq-native/tests/broadcast.rs +++ b/rs/moq-native/tests/broadcast.rs @@ -227,6 +227,104 @@ async fn broadcast_moq_lite_05_timestamps_webtransport() { lite05_timestamp_roundtrip("https").await; } +/// Lite05 Track Stream: the client resolves a track's immutable properties via +/// `info()` (a TRACK request answered with TRACK_INFO) without subscribing, then +/// subscribes and reads a frame. Exercises the on-demand info path end-to-end. +async fn lite05_info_roundtrip(scheme: &str) { + use moq_native::moq_net::Timescale; + + let pub_origin = Origin::random().produce(); + let mut broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + let mut track = broadcast + .create_track(Track::new("video").with_timescale(Timescale::MICRO)) + .expect("create track"); + + let mut group = track.append_group().expect("append group"); + let frame = moq_native::moq_net::Frame { + size: 5, + timestamp: Some(moq_native::moq_net::Timestamp::new(10_000, Timescale::MICRO).unwrap()), + }; + let mut writer = group.create_frame(frame).expect("create frame"); + writer.write(bytes::Bytes::from_static(b"hello")).expect("write frame"); + writer.finish().expect("finish frame"); + group.finish().expect("finish group"); + + let mut server_config = moq_native::ServerConfig::default(); + server_config.bind = Some("[::]:0".to_string()); + server_config.tls.generate = vec!["localhost".into()]; + server_config.version = vec!["moq-lite-05-wip".parse().unwrap()]; + let mut server = server_config.init().expect("init server"); + let addr = server.local_addr().expect("local addr"); + + let sub_origin = Origin::random().produce(); + let mut announcements = sub_origin.consume().announced(); + + let mut client_config = moq_native::ClientConfig::default(); + client_config.tls.disable_verify = Some(true); + client_config.version = vec!["moq-lite-05-wip".parse().unwrap()]; + let client = client_config.init().expect("init client"); + let url: url::Url = format!("{scheme}://localhost:{}", addr.port()).parse().unwrap(); + + let server_handle = tokio::spawn(async move { + let request = server.accept().await.expect("no incoming connection"); + let session = request.with_publisher(pub_origin.clone()).ok().await?; + let _broadcast = broadcast; + let _track = track; + let _ = session.closed().await; + Ok::<_, anyhow::Error>(()) + }); + + let client = client.with_consumer(sub_origin); + let session = tokio::time::timeout(TIMEOUT, client.connect(url)) + .await + .expect("client connect timed out") + .expect("client connect failed"); + + let (path, bc) = tokio::time::timeout(TIMEOUT, announcements.next()) + .await + .expect("announce timed out") + .expect("origin closed"); + assert_eq!(path.as_str(), "test"); + let bc = bc.broadcast().expect("expected announce, got unannounce"); + + // Resolve the track's immutable info without subscribing. + let info = tokio::time::timeout(TIMEOUT, bc.consume_track("video").info()) + .await + .expect("info timed out") + .expect("info failed"); + assert_eq!(info.timescale, Some(Timescale::MICRO)); + + // A subscribe still works (and reuses the now-cached info). + let mut track_sub = bc + .consume_track("video") + .subscribe(None) + .await + .expect("subscribe failed"); + let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.recv_group()) + .await + .expect("recv_group timed out") + .expect("recv_group failed") + .expect("track closed prematurely"); + let frame = tokio::time::timeout(TIMEOUT, group_sub.read_frame()) + .await + .expect("read_frame timed out") + .expect("read_frame failed") + .expect("group closed prematurely"); + assert_eq!(&*frame, b"hello"); + + drop(session); + server_handle + .await + .expect("server task panicked") + .expect("server task failed"); +} + +#[tracing_test::traced_test] +#[tokio::test] +async fn broadcast_moq_lite_05_info_webtransport() { + lite05_info_roundtrip("https").await; +} + /// On Lite05 a publisher that doesn't advertise a timescale still works: /// SUBSCRIBE_OK carries `timescale = 0` and neither side encodes a /// per-frame timestamp byte. Subscribers receive `frame.timestamp = None`. diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index 4b1612594..4df1bb449 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -19,6 +19,7 @@ mod session; mod stream; mod subscribe; mod subscriber; +mod track; mod version; pub use announce::*; @@ -37,4 +38,6 @@ pub(super) use session::*; pub use stream::*; pub use subscribe::*; use subscriber::*; +#[allow(unused_imports)] +pub use track::*; pub use version::Version; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 9a020fa75..4a1c4a59f 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -80,6 +80,7 @@ impl Publisher { match kind { lite::ControlType::Announce => self.recv_announce(stream).await, lite::ControlType::Subscribe => self.recv_subscribe(stream).await, + lite::ControlType::Track => self.recv_track(stream).await, lite::ControlType::Probe => { self.recv_probe(stream).await; Ok(()) @@ -461,8 +462,9 @@ impl Publisher { .await?; // Compress only when the producer marked the track worth it and the - // negotiated draft understands the SUBSCRIBE_OK codec field. Older drafts - // (lite-04 and below) get None and the frames stream verbatim. + // negotiated draft understands the codec field (carried in SUBSCRIBE_OK on + // lite-04 and below, in TRACK_INFO on lite-05+). Older drafts without it + // get None and the frames stream verbatim. let supports_compression = !matches!( version, Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 @@ -487,20 +489,23 @@ impl Publisher { // broadcast. Dropping this guard (subscription end) releases it. let _broadcast_sub = broadcasts.subscribe(&absolute); - let info = lite::SubscribeOk { - priority: subscribe.priority, - ordered: false, - max_latency: std::time::Duration::ZERO, - start_group: None, - end_group: None, - compression, - timescale, - // Announce the publisher's cache window so the subscriber (a relay) - // re-serves with the same eviction window. Pre-lite-05 peers ignore it. - cache: track.cache, - }; + // Lite05+ accepts a subscription implicitly (rejection is a stream reset) + // and serves the immutable properties over a TRACK_INFO stream instead. + // Older drafts confirm acceptance with SUBSCRIBE_OK here. + if matches!( + version, + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 + ) { + let info = lite::SubscribeOk { + priority: subscribe.priority, + ordered: false, + max_latency: std::time::Duration::ZERO, + start_group: None, + end_group: None, + }; - stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?; + stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?; + } // Track-level subscriber priority. SUBSCRIBE_UPDATE messages broadcast new values // to both run_track (so future groups inherit the new priority) and serve_group @@ -538,6 +543,80 @@ impl Publisher { stream.writer.finish()?; stream.writer.closed().await } + + /// Serve a Track Stream: reply with the track's immutable [`lite::TrackInfo`] + /// and FIN, or reset on error (e.g. the track does not exist). Lite05+ only. + /// + /// Runs inline in its own control-stream task (see [`Self::handle`]); resolving + /// the info can be a cold upstream TRACK fetch, but that only blocks this task. + pub async fn recv_track(&self, mut stream: Stream) -> Result<(), Error> { + let req = stream.reader.decode::().await?; + + let track = req.track.to_string(); + let absolute = self.origin.absolute(&req.broadcast).to_owned(); + + tracing::debug!(broadcast = %absolute, %track, "track info requested"); + + let broadcast = self.origin.get_broadcast(&req.broadcast); + + if let Err(err) = Self::run_track_info(&mut stream, &track, broadcast, self.version).await { + match &err { + Error::Cancel | Error::Transport(_) => { + tracing::debug!(broadcast = %absolute, %track, "track info cancelled") + } + err => tracing::warn!(broadcast = %absolute, %track, %err, "track info error"), + } + stream.writer.abort(&err); + } + + Ok(()) + } + + async fn run_track_info( + stream: &mut Stream, + track_name: &str, + consumer: Option, + version: Version, + ) -> Result<(), Error> { + let broadcast = consumer.ok_or(Error::NotFound)?; + + // Resolve the immutable properties without subscribing. Warm (a producer + // exists or the info is cached) this returns with no round trip; cold (a + // relay with no prior subscription) it triggers a single upstream TRACK + // fetch via the model's info-request channel. + let track = broadcast.consume_track(track_name).info().await?; + + // Mirror the negotiation in `run_subscribe` so the subscriber decodes + // frames the same way it'll see them served. + let supports_compression = !matches!( + version, + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 + ); + let compression = if track.compress && supports_compression { + Compression::Deflate + } else { + Compression::None + }; + let timescale = if version.has_timestamps() { + track.timescale + } else { + None + }; + + let info = lite::TrackInfo { + // The model carries no publisher-chosen priority/order yet, so both + // default to the tie-break-neutral values. + priority: 0, + ordered: false, + cache: track.cache, + timescale, + compression, + }; + + stream.writer.encode(&info).await?; + stream.writer.finish()?; + stream.writer.closed().await + } } /// Shared per-subscription state for the publisher side. Cloned (cheaply — every diff --git a/rs/moq-net/src/lite/stream.rs b/rs/moq-net/src/lite/stream.rs index d52a77943..fda6bbefb 100644 --- a/rs/moq-net/src/lite/stream.rs +++ b/rs/moq-net/src/lite/stream.rs @@ -13,6 +13,9 @@ pub enum ControlType { Fetch = 3, Probe = 4, Goaway = 5, + /// Track Stream: a subscriber requests a track's immutable publisher + /// properties (TRACK_INFO) without subscribing or fetching. Lite05+ only. + Track = 6, } impl Decode for ControlType { diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index 8f17a003a..fb0901a38 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use crate::{ - Compression, Path, Timescale, + Path, coding::{Decode, DecodeError, Encode, EncodeError, Sizer}, }; @@ -72,6 +72,11 @@ impl Message for Subscribe<'_> { } } +/// Sent by the publisher to accept a subscription (Lite01-04 only). +/// +/// On Lite05+ a subscription is accepted implicitly (rejection is a stream +/// reset) and the immutable publisher properties moved to [`TrackInfo`], fetched +/// once over a [Track Stream](super::Track). This message is no longer sent. #[derive(Clone, Debug)] pub struct SubscribeOk { pub priority: u8, @@ -79,21 +84,6 @@ pub struct SubscribeOk { pub max_latency: std::time::Duration, pub start_group: Option, pub end_group: Option, - /// Codec the publisher will use for every frame on this track. Negotiated - /// here (not in SUBSCRIBE) so the subscriber blocks on this message before it - /// can decode any frame payload. Lite05+ only; older drafts always get - /// [`Compression::None`]. - pub compression: Compression, - /// Per-frame timestamp scale advertised by the publisher. `None` means the - /// publisher doesn't carry per-frame timestamps on the wire (so frame - /// headers omit them). Lite05+ only; older drafts always decode as `None`. - /// On the wire `None` is `0` and `Some(n)` is `n`. - pub timescale: Option, - /// How long the publisher keeps old groups available before evicting them. - /// A relay re-serves with the same window and clamps each subscriber's stale - /// preference to it. Lite05+ only; older drafts always get - /// [`crate::DEFAULT_CACHE`]. - pub cache: std::time::Duration, } impl Message for SubscribeOk { @@ -103,23 +93,14 @@ impl Message for SubscribeOk { self.priority.encode(w, version)?; } Version::Lite02 => {} - Version::Lite03 | Version::Lite04 => { - self.priority.encode(w, version)?; - (self.ordered as u8).encode(w, version)?; - self.max_latency.encode(w, version)?; - self.start_group.encode(w, version)?; - self.end_group.encode(w, version)?; - } + // Lite05+ no longer sends SUBSCRIBE_OK, but keep the Lite03/04 layout as + // the forward default so an accidental encode stays well-formed. _ => { self.priority.encode(w, version)?; (self.ordered as u8).encode(w, version)?; self.max_latency.encode(w, version)?; self.start_group.encode(w, version)?; self.end_group.encode(w, version)?; - // Order matches draft-lcurley-moq-lite-05 SUBSCRIBE_OK: Timescale, Cache, Compression. - self.timescale.map(u64::from).unwrap_or(0).encode(w, version)?; - self.cache.encode(w, version)?; - self.compression.to_code().encode(w, version)?; } } @@ -134,9 +115,6 @@ impl Message for SubscribeOk { max_latency: std::time::Duration::ZERO, start_group: None, end_group: None, - compression: Compression::None, - timescale: None, - cache: crate::DEFAULT_CACHE, }), Version::Lite02 => Ok(Self { priority: 0, @@ -144,51 +122,14 @@ impl Message for SubscribeOk { max_latency: std::time::Duration::ZERO, start_group: None, end_group: None, - compression: Compression::None, - timescale: None, - cache: crate::DEFAULT_CACHE, }), - Version::Lite03 | Version::Lite04 => { - let priority = u8::decode(r, version)?; - let ordered = u8::decode(r, version)? != 0; - let max_latency = std::time::Duration::decode(r, version)?; - let start_group = Option::::decode(r, version)?; - let end_group = Option::::decode(r, version)?; - - Ok(Self { - priority, - ordered, - max_latency, - start_group, - end_group, - compression: Compression::None, - timescale: None, - cache: crate::DEFAULT_CACHE, - }) - } - _ => { - let priority = u8::decode(r, version)?; - let ordered = u8::decode(r, version)? != 0; - let max_latency = std::time::Duration::decode(r, version)?; - let start_group = Option::::decode(r, version)?; - let end_group = Option::::decode(r, version)?; - // Order matches draft-lcurley-moq-lite-05 SUBSCRIBE_OK: Timescale, Cache, Compression. - let timescale = Timescale::new(u64::decode(r, version)?).ok(); - let cache = std::time::Duration::decode(r, version)?; - let compression = - Compression::from_code(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue)?; - - Ok(Self { - priority, - ordered, - max_latency, - start_group, - end_group, - compression, - timescale, - cache, - }) - } + _ => Ok(Self { + priority: u8::decode(r, version)?, + ordered: u8::decode(r, version)? != 0, + max_latency: std::time::Duration::decode(r, version)?, + start_group: Option::::decode(r, version)?, + end_group: Option::::decode(r, version)?, + }), } } } @@ -398,9 +339,6 @@ mod test { max_latency: Duration::from_millis(250), start_group: Some(3), end_group: None, - compression: Compression::Deflate, - timescale: Some(Timescale::MICRO), - cache: Duration::from_secs(10), } } @@ -412,60 +350,12 @@ mod test { } #[test] - fn compression_roundtrips_on_lite05() { - let got = roundtrip(Version::Lite05Wip, &sample()); - assert_eq!(got.compression, Compression::Deflate); + fn fields_roundtrip_on_lite04() { + let got = roundtrip(Version::Lite04, &sample()); assert_eq!(got.priority, 7); assert!(got.ordered); + assert_eq!(got.max_latency, Duration::from_millis(250)); assert_eq!(got.start_group, Some(3)); assert_eq!(got.end_group, None); } - - #[test] - fn compression_absent_before_lite05() { - let ok = sample(); - - // The compression varint only exists on lite-05+, so the older encoding is - // strictly shorter and always decodes back as None. - let mut buf04 = Vec::new(); - ok.encode_msg(&mut buf04, Version::Lite04).unwrap(); - let mut buf05 = Vec::new(); - ok.encode_msg(&mut buf05, Version::Lite05Wip).unwrap(); - assert!( - buf05.len() > buf04.len(), - "lite-05 carries extra compression + timescale varints" - ); - - assert_eq!(roundtrip(Version::Lite04, &ok).compression, Compression::None); - } - - #[test] - fn timescale_roundtrips_on_lite05() { - let got = roundtrip(Version::Lite05Wip, &sample()); - assert_eq!(got.timescale, Some(Timescale::MICRO)); - } - - #[test] - fn timescale_absent_before_lite05() { - // Lite04 doesn't carry the timescale varint, so it always decodes as None. - assert_eq!(roundtrip(Version::Lite04, &sample()).timescale, None); - } - - #[test] - fn timescale_zero_on_wire_decodes_as_none() { - let mut ok = sample(); - ok.timescale = None; - assert_eq!(roundtrip(Version::Lite05Wip, &ok).timescale, None); - } - - #[test] - fn cache_roundtrips_on_lite05() { - assert_eq!(roundtrip(Version::Lite05Wip, &sample()).cache, Duration::from_secs(10)); - } - - #[test] - fn cache_absent_before_lite05() { - // Lite04 doesn't carry the cache varint, so it always decodes as the default. - assert_eq!(roundtrip(Version::Lite04, &sample()).cache, crate::DEFAULT_CACHE); - } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c306cc29a..d3f4d1e8b 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -10,7 +10,7 @@ use futures::{StreamExt, stream::FuturesUnordered}; use crate::{ AsPath, BandwidthProducer, Broadcast, BroadcastDynamic, Compression, Error, Frame, FrameProducer, Group, GroupProducer, MAX_FRAME_SIZE, OriginProducer, Path, PathOwned, PendingTrack, StatsHandle, SubscriberStats, - SubscriberTrack, Timescale, Timestamp, Track, TrackProducer, + SubscriberTrack, Subscription, Timescale, Timestamp, Track, TrackProducer, coding::{Reader, Stream}, lite, model::BroadcastProducer, @@ -61,12 +61,23 @@ pub(super) struct Subscriber { #[derive(Clone)] struct TrackEntry { - producer: TrackProducer, stats: Arc, - /// The SUBSCRIBE_OK for this subscription. `None` until it arrives; group - /// streams block on it before decoding any frame, since a group can race - /// ahead of SUBSCRIBE_OK on its own QUIC stream. - subscribe_ok: kio::Consumer>, + /// Resolves once the upstream subscription is accepted: after TRACK_INFO on + /// lite-05, after SUBSCRIBE_OK on older drafts. Group streams park on this so a + /// group that races ahead of acceptance buffers (in QUIC flow control) instead + /// of being dropped. `None` until resolved; a closed channel means the + /// subscription ended first, which group streams treat as cancelled. + resolved: kio::Consumer>, +} + +/// The decoded-once-per-track state a group stream needs: where to write groups +/// and how to parse their frames. Populated from TRACK_INFO (lite-05) so a single +/// lookup is reused across every group instead of re-derived per response. +#[derive(Clone)] +struct ResolvedTrack { + producer: TrackProducer, + compression: Compression, + timescale: Option, } /// Result of an upstream subscribe lifecycle. @@ -452,10 +463,9 @@ impl Subscriber { } async fn run_broadcast(self, path: PathOwned, mut broadcast: BroadcastDynamic) { - // Actually start serving subscriptions. + // Serve track requests (subscribe and info-only, coalesced) until the + // broadcast is gone. A request with no subscriber is an info-only lookup. loop { - // Keep serving requests until there are no more consumers. - // This way we'll clean up the task when the broadcast is no longer needed. let request = tokio::select! { request = broadcast.requested_track() => match request { Ok(request) => request, @@ -476,15 +486,51 @@ impl Subscriber { } } - /// Drive one upstream subscription end-to-end, including linger across consumer churn. + /// Forward the aggregate downstream preferences upstream as a SUBSCRIBE. + async fn open_subscribe( + &self, + id: u64, + name: &str, + path: &PathOwned, + sub: &Subscription, + ) -> Result, Error> { + let mut stream = Stream::open(&self.session, self.version).await?; + stream.writer.encode(&lite::ControlType::Subscribe).await?; + let msg = lite::Subscribe { + id, + broadcast: path.as_path(), + track: name.into(), + priority: sub.priority, + ordered: sub.ordered, + max_latency: sub.stale, + start_group: sub.group_start, + end_group: sub.group_end, + }; + stream.writer.encode(&msg).await?; + Ok(stream) + } + + /// Resolve a track's immutable props on lite-05 via a single upstream TRACK fetch. /// - /// On linger entry (last consumer drops) we send `SubscribeUpdate(priority=0, - /// end_group=Some(latest))`. The publisher treats `end_group` as a serving cap, - /// not a terminator: it holds any groups beyond the cap and resumes when we - /// raise it. On resume (a new consumer arrives) we send `SubscribeUpdate(end_group=None)` - /// to uncap. The stream stays open across the whole lifecycle — only a timeout - /// or a publisher-side close ends it. This avoids the stream-churn / duplicate-fetch - /// race that an unsubscribe-and-reissue approach would have. + /// A repeat subscribe reuses the live producer in the model (so it never reaches + /// here), and the resolved props land back in the model's `tracks` once `accept` + /// runs, so a later `info()` is warm. + async fn resolve_props(&self, path: &PathOwned, name: &str) -> Result { + let info = self.fetch_track_info(&path.as_path(), name).await?; + let mut props = Track::new(name); + props.timescale = info.timescale; + props.cache = info.cache; + // The model carries compression as a bool; the codec set is {none, deflate}, + // so the flag round-trips losslessly. + props.compress = info.compression != Compression::None; + Ok(props) + } + + /// Serve one track request: resolve its immutable info, and — if there's group + /// demand — drive the upstream subscription with linger across consumer churn. + /// + /// A request with no subscriber (`info()` only) just resolves and caches the + /// info; no upstream SUBSCRIBE is opened. When demand appears, we open it then. async fn run_subscribe(&mut self, path: PathOwned, broadcast: BroadcastDynamic, request: PendingTrack) { // Subscriber-side track stats; counters bump as frames/bytes/groups arrive. // Drop on subscription end records `subscriber.subscriptions_closed`. We use @@ -493,29 +539,13 @@ impl Subscriber { let name = request.name().to_string(); let abs = self.origin.absolute(&path); let track_stats = Arc::new(self.stats.broadcast(&abs).subscriber_track(&name)); - // The per-(session, broadcast) `broadcasts` sentinel is taken later, once - // the upstream confirms with SUBSCRIBE_OK (see `run_subscribe_session`), so a - // sub cancelled before then isn't counted as a feeding session. let id = self.next_id.fetch_add(1, atomic::Ordering::Relaxed); - // Forward the aggregate of every downstream subscriber's preferences upstream. - let subscription = request.subscription().clone(); - let msg = lite::Subscribe { - id, - broadcast: path.as_path(), - track: (&name).into(), - priority: subscription.priority, - ordered: subscription.ordered, - max_latency: subscription.stale, - start_group: subscription.group_start, - end_group: subscription.group_end, - }; - - tracing::info!(id, broadcast = %self.log_path(&path), track = %name, "subscribe started"); + tracing::info!(id, broadcast = %self.log_path(&path), track = %name, "track requested"); let result = self - .run_subscribe_session(id, &name, request, track_stats, &broadcast, msg) + .run_subscribe_session(id, &name, request, track_stats, &broadcast, &path) .await; self.subscribes.lock().remove(&id); @@ -536,10 +566,13 @@ impl Subscriber { } } - /// Open the upstream subscribe stream, wait for SUBSCRIBE_OK, then accept the - /// pending request (unblocking the downstream subscriber) and run the linger - /// lifecycle. The producer is created only after SUBSCRIBE_OK, so a downstream - /// a downstream `subscribe` resolves exactly when the upstream confirms. + /// Resolve the track's immutable props, then — if there's group demand — accept + /// the producer and drive the upstream subscription with linger. + /// + /// On lite-05 the props come from the cache or a TRACK_INFO stream flighted + /// alongside SUBSCRIBE (so the first group still arrives in one round trip); + /// older drafts read them (absent) from SUBSCRIBE_OK. A pure `info()` request + /// (no subscriber) resolves the props and caches them without subscribing. async fn run_subscribe_session( &self, id: u64, @@ -547,100 +580,201 @@ impl Subscriber { request: PendingTrack, track_stats: Arc, broadcast: &BroadcastDynamic, - msg: lite::Subscribe<'_>, + path: &PathOwned, ) -> SessionOutcome { - // Stash the original parameters so SubscribeUpdate messages can echo them - // while only varying the linger-related fields (priority, end_group). - let original_priority = msg.priority; - let ordered = msg.ordered; - let max_latency = msg.max_latency; - let start_group = msg.start_group; - - // SubscribeUpdate only exists on Lite03+; older versions take the - // immediate-FIN path with no linger. - let supports_linger = !matches!(self.version, Version::Lite01 | Version::Lite02); - - let mut stream = match Stream::open(&self.session, self.version).await { - Ok(s) => s, - Err(err) => { - request.deny(err.clone()); - return SessionOutcome::Error(err); - } - }; + // Pending entry up front so a group stream that races ahead of acceptance + // parks on `resolved` instead of being dropped. Held for the session's + // lifetime; dropping it closes the channel and wakes parked group streams. + let resolved_tx: kio::Producer> = kio::Producer::new(None); + self.subscribes.lock().insert( + id, + TrackEntry { + stats: track_stats, + resolved: resolved_tx.consume(), + }, + ); - if let Err(err) = stream.writer.encode(&lite::ControlType::Subscribe).await { - request.deny(err.clone()); - return SessionOutcome::Error(err); - } + // Group demand at hand-out? Always true before lite-05 (which has no info() + // callers); on lite-05 a pure TRACK request has none. + let initial_sub = request.subscription(); + let lite05 = matches!(self.version, Version::Lite05Wip); - if let Err(err) = stream.writer.encode(&msg).await { - stream.writer.abort(&err); - request.deny(err.clone()); - return SessionOutcome::Error(err); - } + // The upstream subscribe stream, opened only when there's group demand. + let mut stream: Option> = None; - // The first response MUST be a SUBSCRIBE_OK. Bail if the broadcast dies first. - let resp = tokio::select! { - err = broadcast.closed() => { - request.deny(err.clone()); - return SessionOutcome::BroadcastClosed(err); + // Resolve the immutable props; flight SUBSCRIBE in parallel when wanted. + let (compression, timescale, cache) = if lite05 { + // Flight SUBSCRIBE now if there's demand, so it races the info fetch (1 RTT). + if let Some(sub) = &initial_sub { + match self.open_subscribe(id, name, path, sub).await { + Ok(s) => stream = Some(s), + Err(err) => { + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + } } - resp = stream.reader.decode::() => match resp { - Ok(r) => r, + + let props = tokio::select! { + err = broadcast.closed() => { + request.deny(err.clone()); + return SessionOutcome::BroadcastClosed(err); + } + res = self.resolve_props(path, name) => match res { + Ok(props) => props, + Err(err) => { + if let Some(s) = &mut stream { + s.writer.abort(&err); + } + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + } + }; + let compression = if props.compress { + Compression::Deflate + } else { + Compression::None + }; + (compression, props.timescale, props.cache) + } else { + // Older drafts: open the subscribe stream and read SUBSCRIBE_OK (no props). + let sub = initial_sub.clone().unwrap_or_default(); + let mut s = match self.open_subscribe(id, name, path, &sub).await { + Ok(s) => s, Err(err) => { - stream.writer.abort(&err); request.deny(err.clone()); return SessionOutcome::Error(err); } + }; + let resp = tokio::select! { + err = broadcast.closed() => { + request.deny(err.clone()); + return SessionOutcome::BroadcastClosed(err); + } + resp = s.reader.decode::() => match resp { + Ok(r) => r, + Err(err) => { + s.writer.abort(&err); + request.deny(err.clone()); + return SessionOutcome::Error(err); + } + } + }; + if !matches!(resp, lite::SubscribeResponse::Ok(_)) { + let err = Error::ProtocolViolation; + s.writer.abort(&err); + request.deny(err.clone()); + return SessionOutcome::Error(err); } - }; - let lite::SubscribeResponse::Ok(info) = resp else { - let err = Error::ProtocolViolation; - stream.writer.abort(&err); - request.deny(err.clone()); - return SessionOutcome::Error(err); + stream = Some(s); + (Compression::None, None, crate::DEFAULT_CACHE) }; - // Upstream confirmed the subscription, so this session is now actively - // feeding the broadcast: take the per-(session, broadcast) sentinel. It - // drops when this fn returns (subscription end / cancel), releasing - // `broadcasts_closed`. Taken only after SUBSCRIBE_OK so a sub cancelled - // before confirmation isn't counted as a feeding session. - let abs = self.origin.absolute(&msg.broadcast); + // Accept: create the producer, resolving info + subscriber waiters. Stamp + // the negotiated timescale and cache window onto the local Track so groups + // inherit the timescale (validated at the model layer) and the producer + // evicts (and clamps downstream stale windows) with the same bound. + let abs = self.origin.absolute(path); let _broadcast_sub = self.broadcasts.subscribe(&abs); - // The publisher accepted: create the producer (unblocking the downstream - // subscriber) and start routing incoming groups to it. SUBSCRIBE_OK is known - // now, so the group streams never have to wait; they still read it through a - // kio channel (a group's QUIC stream can otherwise race ahead of SUBSCRIBE_OK). - // - // Stamp the negotiated timescale onto the local Track so groups inherit - // it and downstream consumers (including this subscriber's frame decode - // path) can validate per-frame timestamps at the model layer. let mut local_info = Track::new(name); - local_info.timescale = info.timescale; - // Carry the publisher's cache window so the local producer evicts (and - // clamps downstream stale windows) with the same bound when re-served. - local_info.cache = info.cache; + local_info.timescale = timescale; + local_info.cache = cache; let mut track = match request.accept(local_info) { Ok(track) => track, Err(err) => { - stream.writer.abort(&err); + if let Some(s) = &mut stream { + s.writer.abort(&err); + } return SessionOutcome::Error(err); } }; - let subscribe_ok = kio::Producer::new(Some(info)).consume(); - self.subscribes.lock().insert( - id, - TrackEntry { + + // Resolve the pending entry: parked group streams can now create groups + // (with the right timescale) and decode frames. + if let Ok(mut resolved) = resolved_tx.write() { + *resolved = Some(ResolvedTrack { producer: track.clone(), - stats: track_stats, - subscribe_ok, - }, - ); + compression, + timescale, + }); + } + + // If we didn't open the stream eagerly (info-only at hand-out), a subscriber + // may have coalesced during the info fetch: open it now (props are cached, so + // just SUBSCRIBE). Otherwise wait briefly for one, else drop (info stays cached). + if stream.is_none() { + loop { + if let Some(sub) = track.subscription() { + match self.open_subscribe(id, name, path, &sub).await { + Ok(s) => { + stream = Some(s); + break; + } + Err(err) => { + let _ = track.abort(err.clone()); + return SessionOutcome::Error(err); + } + } + } else { + tokio::select! { + _ = track.used() => continue, + err = broadcast.closed() => { + let _ = track.abort(err.clone()); + return SessionOutcome::BroadcastClosed(err); + } + _ = tokio::time::sleep(LINGER_TIMEOUT) => { + let _ = track.finish(); + return SessionOutcome::Cancelled; + } + } + } + } + } - // Lifecycle loop: serve → linger → resume → serve → ... → FIN. - let outcome = 'lifecycle: loop { + let mut stream = stream.expect("subscribe stream is open once there's group demand"); + let sub = track.subscription().or(initial_sub).unwrap_or_default(); + let outcome = self.serve_lifecycle(&mut stream, &mut track, broadcast, &sub).await; + + // Apply the outcome to the producer that downstream consumers read from. + match &outcome { + SessionOutcome::Complete => { + let _ = track.finish(); + } + SessionOutcome::Cancelled => { + let _ = track.abort(Error::Cancel); + } + SessionOutcome::BroadcastClosed(err) | SessionOutcome::Error(err) => { + let _ = track.abort(err.clone()); + } + } + + outcome + } + + /// The linger lifecycle on an open subscribe stream: serve → linger → resume → + /// ... → FIN. + /// + /// On linger entry (last consumer drops) we send `SubscribeUpdate(priority=0, + /// end_group=Some(latest))`. The publisher treats `end_group` as a serving cap, + /// not a terminator: it holds any groups beyond the cap and resumes when we + /// raise it. On resume (a new consumer arrives) we uncap with `end_group=None`. + /// The stream stays open across the whole lifecycle — only a timeout or a + /// publisher-side close ends it, avoiding the stream-churn an unsubscribe-and- + /// reissue approach would have. + async fn serve_lifecycle( + &self, + stream: &mut Stream, + track: &mut TrackProducer, + broadcast: &BroadcastDynamic, + sub: &Subscription, + ) -> SessionOutcome { + // SubscribeUpdate only exists on Lite03+; older versions take the + // immediate-FIN path with no linger. + let supports_linger = !matches!(self.version, Version::Lite01 | Version::Lite02); + + 'lifecycle: loop { // Phase 1 — serving. Wait for the last consumer to drop (enter linger), // the broadcast to die, or the upstream to close the stream. tokio::select! { @@ -655,22 +789,21 @@ impl Subscriber { }, } - // No linger on Lite01/02: FIN and report cancellation. if !supports_linger { let _ = stream.writer.finish(); break 'lifecycle SessionOutcome::Cancelled; } // Phase 2 — linger. Cap the publisher's serving cursor at the latest - // group we've cached and drop priority to 0; the publisher holds any - // group beyond the cap until we resume or FIN. `unwrap_or(0)` handles - // the corner case where we subscribed but haven't received a group yet. + // cached group and drop priority to 0; the publisher holds any group + // beyond the cap until we resume or FIN. `unwrap_or(0)` handles the case + // where we subscribed but haven't received a group yet. let cap = track.latest().unwrap_or(0); let pause = lite::SubscribeUpdate { priority: 0, - ordered, - max_latency, - start_group, + ordered: sub.ordered, + max_latency: sub.stale, + start_group: sub.group_start, end_group: Some(cap), }; if let Err(err) = stream.writer.encode(&pause).await { @@ -700,10 +833,10 @@ impl Subscriber { tracing::info!(track = %track.name, "subscribe resumed"); let uncap = lite::SubscribeUpdate { - priority: original_priority, - ordered, - max_latency, - start_group, + priority: sub.priority, + ordered: sub.ordered, + max_latency: sub.stale, + start_group: sub.group_start, end_group: None, }; if let Err(err) = stream.writer.encode(&uncap).await { @@ -711,58 +844,50 @@ impl Subscriber { break 'lifecycle SessionOutcome::Error(err); } // Loop back to Phase 1. - }; - - // Apply the outcome to the producer that downstream consumers read from. - match &outcome { - SessionOutcome::Complete => { - let _ = track.finish(); - } - SessionOutcome::Cancelled => { - let _ = track.abort(Error::Cancel); - } - SessionOutcome::BroadcastClosed(err) | SessionOutcome::Error(err) => { - let _ = track.abort(err.clone()); - } } + } - outcome + /// Open a Track Stream, send TRACK, and read the single TRACK_INFO reply. + /// + /// The publisher FINs after TRACK_INFO (or resets on error, e.g. the track + /// does not exist); we drop the stream once the reply is in. Lite05+ only. + async fn fetch_track_info(&self, broadcast: &Path<'_>, name: &str) -> Result { + let mut stream = Stream::open(&self.session, self.version).await?; + stream.writer.encode(&lite::ControlType::Track).await?; + let req = lite::Track { + broadcast: broadcast.clone(), + track: name.into(), + }; + stream.writer.encode(&req).await?; + + let info = stream.reader.decode::().await?; + let _ = stream.writer.finish(); + Ok(info) } pub async fn recv_group(&mut self, stream: &mut Reader) -> Result<(), Error> { let hdr: lite::Group = stream.decode().await?; - let (mut group, track, track_stats, subscribe_ok) = { - let mut subs = self.subscribes.lock(); - let entry = subs.get_mut(&hdr.subscribe).ok_or(Error::Cancel)?; - - let group_info = Group { sequence: hdr.sequence }; - let group = entry.producer.create_group(group_info)?; - ( - group, - entry.producer.clone(), - entry.stats.clone(), - entry.subscribe_ok.clone(), - ) + let (resolved, track_stats) = { + let subs = self.subscribes.lock(); + let entry = subs.get(&hdr.subscribe).ok_or(Error::Cancel)?; + (entry.resolved.clone(), entry.stats.clone()) }; - // Bump groups counter for this incoming group on the subscriber side. - track_stats.group(); - - // Block until SUBSCRIBE_OK arrives. The group's QUIC stream can arrive - // before SUBSCRIBE_OK lands on the subscribe stream, so we can't decode - // frames until this resolves. A closed channel means the subscription - // ended before SUBSCRIBE_OK, so treat it as cancelled. + // Block until the upstream is accepted and TRACK_INFO is known. The group's + // QUIC stream can arrive before that resolves; its unread bytes stay + // buffered by QUIC flow control until we create the group below. A closed + // channel means the subscription ended first, so treat it as cancelled. // // Map the closed `Ref` to `None` inside the poll closure (rather than using // `Consumer::wait`) so the `!Send` guard never enters this spawned future. - let (compression, timescale) = kio::wait(|waiter| { - let poll = subscribe_ok.poll(waiter, |ok| match &**ok { - Some(ok) => Poll::Ready((ok.compression, ok.timescale)), + let resolved = kio::wait(|waiter| { + let poll = resolved.poll(waiter, |r| match &**r { + Some(r) => Poll::Ready(r.clone()), None => Poll::Pending, }); match poll { - Poll::Ready(Ok(pair)) => Poll::Ready(Some(pair)), + Poll::Ready(Ok(r)) => Poll::Ready(Some(r)), Poll::Ready(Err(_closed)) => Poll::Ready(None), Poll::Pending => Poll::Pending, } @@ -770,10 +895,18 @@ impl Subscriber { .await .ok_or(Error::Cancel)?; + // Create the group now that the timescale is known, so it inherits the right + // per-frame timestamp scale. + let mut producer = resolved.producer.clone(); + let mut group = producer.create_group(Group { sequence: hdr.sequence })?; + + // Bump groups counter for this incoming group on the subscriber side. + track_stats.group(); + let res = tokio::select! { - err = track.closed() => Err(err), + err = producer.closed() => Err(err), err = group.closed() => Err(err), - res = self.run_group(stream, group.clone(), track_stats.clone(), compression, timescale) => res, + res = self.run_group(stream, group.clone(), track_stats.clone(), resolved.compression, resolved.timescale) => res, }; match res { diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs new file mode 100644 index 000000000..26834459d --- /dev/null +++ b/rs/moq-net/src/lite/track.rs @@ -0,0 +1,183 @@ +use std::borrow::Cow; + +use crate::{ + Compression, Path, Timescale, + coding::{Decode, DecodeError, Encode, EncodeError}, +}; + +use super::{Message, Version}; + +/// Sent by the subscriber to open a Track Stream (0x6), requesting a track's +/// immutable publisher properties without subscribing or fetching. +/// +/// The publisher replies with a single [`TrackInfo`] and then FINs the stream, +/// or resets it on error (e.g. the track does not exist). Lite05+ only. +#[derive(Clone, Debug)] +pub struct Track<'a> { + pub broadcast: Path<'a>, + pub track: Cow<'a, str>, +} + +impl Message for Track<'_> { + fn decode_msg(r: &mut R, version: Version) -> Result { + match version { + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { + return Err(DecodeError::Version); + } + _ => {} + } + + let broadcast = Path::decode(r, version)?; + let track = Cow::::decode(r, version)?; + + Ok(Self { broadcast, track }) + } + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + match version { + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { + return Err(EncodeError::Version); + } + _ => {} + } + + self.broadcast.encode(w, version)?; + self.track.encode(w, version)?; + + Ok(()) + } +} + +/// Sent by the publisher in response to a [`Track`] request, carrying the track's +/// immutable publisher properties. It is the sole message on the Track Stream; the +/// publisher FINs immediately afterward, or resets the stream on error. +/// +/// Every field is fixed for the lifetime of the track. Fetched once and cached by +/// the subscriber, so the properties are no longer echoed on every SUBSCRIBE/FETCH +/// response. Lite05+ only. +#[derive(Clone, Debug)] +pub struct TrackInfo { + /// The publisher's priority for this track, used only to resolve ties between + /// subscriptions of equal subscriber priority. + pub priority: u8, + /// The publisher's group ordering preference, used only to resolve ties. + pub ordered: bool, + /// How long the publisher keeps old groups available before evicting them. A + /// relay re-serves with the same window and clamps each subscriber's stale + /// preference to it. + pub cache: std::time::Duration, + /// Per-frame timestamp scale. `None` (wire `0`) means the publisher doesn't + /// carry per-frame timestamps, so frame headers omit them. + pub timescale: Option, + /// Codec applied to every frame payload on this track. The subscriber needs + /// this (and `timescale`) before it can decode any frame. + pub compression: Compression, +} + +impl Message for TrackInfo { + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + match version { + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { + return Err(EncodeError::Version); + } + _ => {} + } + + // Order matches draft-lcurley-moq-lite-05 TRACK_INFO: Priority, Ordered, + // Cache, Timescale, Compression. + self.priority.encode(w, version)?; + (self.ordered as u8).encode(w, version)?; + self.cache.encode(w, version)?; + self.timescale.map(u64::from).unwrap_or(0).encode(w, version)?; + self.compression.to_code().encode(w, version)?; + + Ok(()) + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + match version { + Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04 => { + return Err(DecodeError::Version); + } + _ => {} + } + + let priority = u8::decode(r, version)?; + let ordered = u8::decode(r, version)? != 0; + let cache = std::time::Duration::decode(r, version)?; + let timescale = Timescale::new(u64::decode(r, version)?).ok(); + let compression = Compression::from_code(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue)?; + + Ok(Self { + priority, + ordered, + cache, + timescale, + compression, + }) + } +} + +#[cfg(test)] +mod test { + use std::time::Duration; + + use super::*; + + fn sample() -> TrackInfo { + TrackInfo { + priority: 7, + ordered: true, + cache: Duration::from_secs(10), + timescale: Some(Timescale::MICRO), + compression: Compression::Deflate, + } + } + + fn roundtrip(info: &TrackInfo) -> TrackInfo { + let mut buf = Vec::new(); + info.encode_msg(&mut buf, Version::Lite05Wip).unwrap(); + let mut slice = buf.as_slice(); + TrackInfo::decode_msg(&mut slice, Version::Lite05Wip).unwrap() + } + + #[test] + fn track_info_roundtrips() { + let got = roundtrip(&sample()); + assert_eq!(got.priority, 7); + assert!(got.ordered); + assert_eq!(got.cache, Duration::from_secs(10)); + assert_eq!(got.timescale, Some(Timescale::MICRO)); + assert_eq!(got.compression, Compression::Deflate); + } + + #[test] + fn timescale_zero_decodes_as_none() { + let mut info = sample(); + info.timescale = None; + assert_eq!(roundtrip(&info).timescale, None); + } + + #[test] + fn rejected_before_lite05() { + let mut buf = Vec::new(); + assert!(matches!( + sample().encode_msg(&mut buf, Version::Lite04), + Err(EncodeError::Version) + )); + } + + #[test] + fn track_request_roundtrips() { + let req = Track { + broadcast: Path::new("room/1"), + track: Cow::Borrowed("video"), + }; + let mut buf = Vec::new(); + req.encode_msg(&mut buf, Version::Lite05Wip).unwrap(); + let mut slice = buf.as_slice(); + let got = Track::decode_msg(&mut slice, Version::Lite05Wip).unwrap(); + assert_eq!(got.broadcast, Path::new("room/1")); + assert_eq!(got.track, "video"); + } +} diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index ccbf84df0..eefe200be 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -277,7 +277,7 @@ impl BroadcastProducer { /// denies with [`Error::Cancel`]. pub struct PendingTrack { name: String, - subscription: Subscription, + subscription: Option, state: kio::Weak, /// Set once accepted or denied so [`Drop`] doesn't deny a second time. completed: bool, @@ -289,11 +289,12 @@ impl PendingTrack { &self.name } - /// The first waiting subscriber's preferences, as a hint for constructing the - /// [`Track`]. The full aggregate is available on the [`TrackProducer`] returned - /// by [`Self::accept`] via [`TrackProducer::subscription`]. - pub fn subscription(&self) -> &Subscription { - &self.subscription + /// The first waiting subscriber's preferences, or `None` when only `info()` + /// callers are waiting (no group demand). A handler uses this to decide whether + /// to open an upstream subscription. The full aggregate is available on the + /// [`TrackProducer`] returned by [`Self::accept`]. + pub fn subscription(&self) -> Option { + self.subscription.clone() } /// Serve the request with the given track, resolving every waiting subscriber. @@ -581,10 +582,11 @@ impl BroadcastDynamic { let Some(name) = state.request_order.pop_front() else { return Poll::Pending; }; - // The name stays in `requests` so concurrent subscribers can still - // coalesce onto it until the publisher accepts or denies. + // The name stays in `requests` so concurrent subscribers (and info() + // callers) can still coalesce onto it until the publisher accepts or denies. let pending = state.requests.get(&name).expect("request_order out of sync"); - let subscription = pending.resolvers.first().map(|(s, _)| s.clone()).unwrap_or_default(); + // `None` when only `info()` callers are waiting (no group demand yet). + let subscription = pending.resolvers.first().map(|(s, _)| s.clone()); Poll::Ready((name, subscription)) }) .map(|res| {