diff --git a/Cargo.lock b/Cargo.lock index 26bcc68b7..8a34ce146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3768,6 +3768,7 @@ dependencies = [ "bytes", "conducer", "derive_more", + "futures", "h264-parser", "hang", "m3u8-rs", diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index c979602ad..25b243051 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -26,7 +26,8 @@ async def main(): catalog = await announcement.broadcast.catalog() for name in catalog.audio: - async for frame in announcement.broadcast.subscribe_media(name): + media = await announcement.broadcast.subscribe_media(name) + async for frame in media: print(f"Got frame: {len(frame.payload)} bytes, ts={frame.timestamp_us}") asyncio.run(main()) diff --git a/py/moq-rs/examples/clock.py b/py/moq-rs/examples/clock.py index 5a3ad2afa..f8503089c 100644 --- a/py/moq-rs/examples/clock.py +++ b/py/moq-rs/examples/clock.py @@ -43,7 +43,7 @@ async def subscribe(url: str, broadcast_name: str, track_name: str, tls_verify: broadcast = await client.announced_broadcast(broadcast_name) print(f"subscribed to {broadcast_name!r} track={track_name!r}") - track = broadcast.subscribe_track(track_name) + track = await broadcast.subscribe_track(track_name) async for group in track: prefix: bytes | None = None diff --git a/py/moq-rs/moq/subscribe.py b/py/moq-rs/moq/subscribe.py index bb6313a75..b0ec36ca8 100644 --- a/py/moq-rs/moq/subscribe.py +++ b/py/moq-rs/moq/subscribe.py @@ -135,17 +135,17 @@ class BroadcastConsumer: def __init__(self, inner: MoqBroadcastConsumer) -> None: self._inner = inner - def subscribe_catalog(self) -> CatalogConsumer: - return CatalogConsumer(self._inner.subscribe_catalog()) + async def subscribe_catalog(self) -> CatalogConsumer: + return CatalogConsumer(await self._inner.subscribe_catalog()) - def subscribe_track(self, name: str) -> TrackConsumer: - """Subscribe to a track — receive arbitrary byte payloads.""" - return TrackConsumer(self._inner.subscribe_track(name)) + async def subscribe_track(self, name: str) -> TrackConsumer: + """Subscribe to a track. Receive arbitrary byte payloads.""" + return TrackConsumer(await self._inner.subscribe_track(name)) - def subscribe_media(self, name: str, container: Container, max_latency_ms: int) -> MediaConsumer: - return MediaConsumer(self._inner.subscribe_media(name, container, max_latency_ms)) + async def subscribe_media(self, name: str, container: Container, max_latency_ms: int) -> MediaConsumer: + return MediaConsumer(await self._inner.subscribe_media(name, container, max_latency_ms)) async def catalog(self) -> Catalog: """Convenience: subscribe and return the first catalog.""" - consumer = self.subscribe_catalog() + consumer = await self.subscribe_catalog() return await anext(consumer) diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 7098eed07..f6ec9b607 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -110,7 +110,7 @@ async def test_local_publish_consume_audio(): assert audio.sample_rate == 48000 assert audio.channel_count == 2 - media_consumer = announcement.broadcast.subscribe_media(track_name, audio.container, 10_000) + media_consumer = await announcement.broadcast.subscribe_media(track_name, audio.container, 10_000) payload = b"opus audio payload data" media.write_frame(payload, 1_000_000) @@ -144,7 +144,7 @@ async def test_video_publish_consume(): assert video.coded.width == 1280 assert video.coded.height == 720 - media_consumer = announcement.broadcast.subscribe_media(track_name, video.container, 10_000) + media_consumer = await announcement.broadcast.subscribe_media(track_name, video.container, 10_000) keyframe = bytes([0x00, 0x00, 0x00, 0x01, 0x65, 0xAA, 0xBB, 0xCC]) media.write_frame(keyframe, 0) @@ -169,7 +169,7 @@ async def test_multiple_frames_ordering(): catalog = await announcement.broadcast.catalog() track_name = list(catalog.audio.keys())[0] audio = catalog.audio[track_name] - media_consumer = announcement.broadcast.subscribe_media(track_name, audio.container, 10_000) + media_consumer = await announcement.broadcast.subscribe_media(track_name, audio.container, 10_000) timestamps = [0, 20_000, 40_000, 60_000, 80_000] for i, ts in enumerate(timestamps): @@ -193,7 +193,7 @@ async def test_catalog_update_on_new_track(): consumer = origin.consume() async for announcement in consumer.announced(): - cat_consumer = announcement.broadcast.subscribe_catalog() + cat_consumer = await announcement.broadcast.subscribe_catalog() # First catalog: 1 audio track. catalog1 = await anext(cat_consumer) @@ -226,7 +226,7 @@ async def test_announced_broadcast(): async for announcement in consumer.announced(): assert announcement.path == "test/broadcast" - _catalog = announcement.broadcast.subscribe_catalog() + _catalog = await announcement.broadcast.subscribe_catalog() break @@ -334,7 +334,7 @@ async def test_raw_publish_consume(): async for announcement in consumer.announced(): assert announcement.path == "robot/arm" - raw_consumer = announcement.broadcast.subscribe_track("events") + raw_consumer = await announcement.broadcast.subscribe_track("events") payload = b'{"cmd": "button_changed", "arm": "left", "button": "THUMB", "state": "PRESSED"}' raw.write_frame(payload) @@ -357,7 +357,7 @@ async def test_raw_multiple_frames(): consumer = origin.consume() async for announcement in consumer.announced(): - raw_consumer = announcement.broadcast.subscribe_track("commands") + raw_consumer = await announcement.broadcast.subscribe_track("commands") messages = [ b'{"cmd": "led", "arm": "left", "led": "THUMB", "state": 1}', @@ -419,7 +419,7 @@ async def test_broadcast_producer_consume_direct(): raw = broadcast.publish_track("events") consumer = broadcast.consume() - raw_consumer = consumer.subscribe_track("events") + raw_consumer = await consumer.subscribe_track("events") raw.write_frame(b"event-0") async for group in raw_consumer: @@ -439,7 +439,7 @@ async def test_raw_group_sequence(): consumer = origin.consume() async for announcement in consumer.announced(): - raw_consumer = announcement.broadcast.subscribe_track("seq") + raw_consumer = await announcement.broadcast.subscribe_track("seq") sent_sequences = [] for i in range(3): @@ -470,7 +470,7 @@ async def test_raw_multi_frame_group(): consumer = origin.consume() async for announcement in consumer.announced(): - raw_consumer = announcement.broadcast.subscribe_track("chunks") + raw_consumer = await announcement.broadcast.subscribe_track("chunks") group_producer = raw.append_group() chunks = [b"chunk-0", b"chunk-1", b"chunk-2"] diff --git a/rs/conducer/src/waiter.rs b/rs/conducer/src/waiter.rs index 744e9be40..110aa1537 100644 --- a/rs/conducer/src/waiter.rs +++ b/rs/conducer/src/waiter.rs @@ -42,6 +42,12 @@ impl Waiter { pub fn register(&self, list: &mut WaiterList) { list.register(self); } + + /// The underlying [`Waker`]. Useful for bridging into other async machinery + /// (e.g. `futures::task::AtomicWaker`) that needs a `Waker` directly. + pub fn waker(&self) -> &Waker { + &self.waker + } } /// A list of weak wakers waiting for notification. diff --git a/rs/hang/examples/subscribe.rs b/rs/hang/examples/subscribe.rs index 3a33a4193..c3c74b6f1 100644 --- a/rs/hang/examples/subscribe.rs +++ b/rs/hang/examples/subscribe.rs @@ -50,7 +50,9 @@ async fn run_subscribe(mut consumer: moq_net::OriginConsumer) -> anyhow::Result< tracing::info!(%path, "broadcast announced"); // Read the catalog to discover available tracks. - let catalog_track = broadcast.subscribe_track(&hang::Catalog::default_track())?; + let catalog_track = broadcast + .subscribe_track(hang::Catalog::DEFAULT_NAME, moq_net::Subscription::default()) + .await?; let mut catalog = moq_mux::catalog::Consumer::new(catalog_track); let info = catalog.next().await?.ok_or_else(|| anyhow::anyhow!("no catalog"))?; @@ -71,13 +73,18 @@ async fn run_subscribe(mut consumer: moq_net::OriginConsumer) -> anyhow::Result< "subscribing to video track" ); - // Subscribe to the video track. - let track = moq_net::Track { - name: name.clone(), - priority: 1, - }; - - let track_consumer = broadcast.subscribe_track(&track)?; + // Subscribe to the video track. Priority is a subscriber preference; the + // publisher's authoritative Track properties (including timescale) arrive + // on the returned TrackConsumer. + let track_consumer = broadcast + .subscribe_track( + name, + moq_net::Subscription { + priority: 1, + timeout: Duration::ZERO, + }, + ) + .await?; let mut ordered = moq_mux::container::Consumer::new(track_consumer, moq_mux::container::Hang::Legacy) .with_latency(Duration::from_millis(500)); diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index b080cb7fd..b266b956a 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -44,6 +44,7 @@ fn create_track(broadcast: &mut moq_net::BroadcastProducer) -> anyhow::Result, + pub jitter: Option, } diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 888bce77c..8ff66ebb7 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -80,6 +80,7 @@ impl Catalog { moq_net::Track { name: Catalog::DEFAULT_NAME.to_string(), priority: 100, + timescale: moq_net::Timescale::UNKNOWN, } } } diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index f3f221066..97ba63920 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -139,5 +139,5 @@ pub struct VideoConfig { /// - If there can be up to 3 b-frames in a row, this would be 3 * 1000/fps. /// - If frames are buffered into 2s segments, this would be 2s. #[serde(default)] - pub jitter: Option, + pub jitter: Option, } diff --git a/rs/hang/src/container/frame.rs b/rs/hang/src/container/frame.rs index 6cbd2cd93..e8e93c046 100644 --- a/rs/hang/src/container/frame.rs +++ b/rs/hang/src/container/frame.rs @@ -1,9 +1,17 @@ use bytes::{Buf, Bytes, BytesMut}; use derive_more::Debug; +use moq_net::coding::VarInt; use crate::Error; -pub type Timestamp = moq_net::Timescale<1_000_000>; +pub use moq_net::Timestamp; + +/// Canonical timescale for hang frame timestamps: microseconds. +pub const TIMESCALE: moq_net::Timescale = moq_net::Timescale::MICRO; + +/// Re-export so callers don't need a direct `moq_net` import to refer to the +/// hang container timescale by type. +pub type Timescale = moq_net::Timescale; /// A media frame with a timestamp and codec-specific payload. /// @@ -29,14 +37,24 @@ pub struct Frame { impl Frame { /// Encode the frame to the given group as a single moq-lite frame: - /// VarInt timestamp prefix followed by the raw codec payload. + /// VarInt timestamp prefix followed by the raw codec payload. Also stamps + /// the moq-net [`moq_net::Frame::timestamp`] so the wire layer can + /// delta-encode it independently on Lite05+ (the container-level prefix + /// stays as a duplicate for now). pub fn encode(&self, group: &mut moq_net::GroupProducer) -> Result<(), Error> { + // Normalize to the hang container timescale on the wire so peers using a + // different source scale (e.g. nanoseconds from MKV) can decode without + // knowing the producer's internal scale. + let timestamp = self.timestamp.convert(TIMESCALE)?; + let mut header = BytesMut::new(); - self.timestamp.encode(&mut header).map_err(moq_net::Error::from)?; + let value = VarInt::try_from(timestamp.value()).map_err(moq_net::Error::from)?; + value.encode_quic(&mut header).map_err(moq_net::Error::from)?; - let size = header.len() + self.payload.len(); + let size = (header.len() + self.payload.len()) as u64; - let mut chunked = group.create_frame(size.into())?; + let net_frame = moq_net::Frame { size, timestamp }; + let mut chunked = group.create_frame(net_frame)?; chunked.write(header.freeze())?; chunked.write(self.payload.clone())?; chunked.finish()?; @@ -46,7 +64,8 @@ impl Frame { /// Decode a frame from raw bytes (VarInt timestamp prefix + payload). pub fn decode(mut buf: impl Buf) -> Result { - let timestamp = Timestamp::decode(&mut buf)?; + let value: u64 = VarInt::decode_quic(&mut buf).map_err(moq_net::Error::from)?.into(); + let timestamp = Timestamp::from_micros(value)?; let payload = buf.copy_to_bytes(buf.remaining()); Ok(Self { timestamp, payload }) diff --git a/rs/libmoq/src/consume.rs b/rs/libmoq/src/consume.rs index ff27679d7..c7e72ec1a 100644 --- a/rs/libmoq/src/consume.rs +++ b/rs/libmoq/src/consume.rs @@ -47,7 +47,6 @@ impl Consume { pub fn catalog(&mut self, broadcast: Id, on_catalog: OnStatus) -> Result { let broadcast = self.broadcast.get(broadcast).ok_or(Error::BroadcastNotFound)?.clone(); - let catalog = broadcast.subscribe_track(&hang::catalog::Catalog::default_track())?; let channel = oneshot::channel(); let entry = TaskEntry { @@ -58,7 +57,7 @@ impl Consume { tokio::spawn(async move { let res = tokio::select! { - res = Self::run_catalog(id, broadcast, catalog.into()) => res, + res = Self::run_catalog_task(id, broadcast) => res, _ = channel.1 => Ok(()), }; @@ -71,6 +70,13 @@ impl Consume { Ok(id) } + async fn run_catalog_task(task_id: Id, broadcast: moq_net::BroadcastConsumer) -> Result<(), Error> { + let catalog = broadcast + .subscribe_track(hang::catalog::Catalog::DEFAULT_NAME, moq_net::Subscription::default()) + .await?; + Self::run_catalog(task_id, broadcast, catalog.into()).await + } + async fn run_catalog( task_id: Id, broadcast: moq_net::BroadcastConsumer, @@ -216,11 +222,8 @@ impl Consume { .nth(index) .ok_or(Error::NoIndex)?; - let track = consume.broadcast.subscribe_track(&moq_net::Track { - name: rendition.clone(), - priority: 1, // TODO: Remove priority - })?; - let track = moq_mux::container::Consumer::new(track, moq_mux::container::Hang::Legacy).with_latency(latency); + let broadcast = consume.broadcast.clone(); + let name = rendition.clone(); let channel = oneshot::channel(); let entry = TaskEntry { @@ -230,6 +233,26 @@ impl Consume { let id = self.track_task.insert(Some(entry))?; tokio::spawn(async move { + let track = match broadcast + .subscribe_track( + &name, + moq_net::Subscription { + priority: 1, + timeout: std::time::Duration::ZERO, + }, + ) + .await + { + Ok(track) => track, + Err(err) => { + if let Some(entry) = State::lock().consume.track_task.remove(id).flatten() { + entry.callback.call(Result::::Err(err.into())); + } + return; + } + }; + let track = + moq_mux::container::Consumer::new(track, moq_mux::container::Hang::Legacy).with_latency(latency); let res = tokio::select! { res = Self::run_track(id, track) => res, _ = channel.1 => Ok(()), @@ -260,11 +283,8 @@ impl Consume { .nth(index) .ok_or(Error::NoIndex)?; - let track = consume.broadcast.subscribe_track(&moq_net::Track { - name: rendition.clone(), - priority: 2, // TODO: Remove priority - })?; - let track = moq_mux::container::Consumer::new(track, moq_mux::container::Hang::Legacy).with_latency(latency); + let broadcast = consume.broadcast.clone(); + let name = rendition.clone(); let channel = oneshot::channel(); let entry = TaskEntry { @@ -274,6 +294,26 @@ impl Consume { let id = self.track_task.insert(Some(entry))?; tokio::spawn(async move { + let track = match broadcast + .subscribe_track( + &name, + moq_net::Subscription { + priority: 2, + timeout: std::time::Duration::ZERO, + }, + ) + .await + { + Ok(track) => track, + Err(err) => { + if let Some(entry) = State::lock().consume.track_task.remove(id).flatten() { + entry.callback.call(Result::::Err(err.into())); + } + return; + } + }; + let track = + moq_mux::container::Consumer::new(track, moq_mux::container::Hang::Legacy).with_latency(latency); let res = tokio::select! { res = Self::run_track(id, track) => res, _ = channel.1 => Ok(()), @@ -327,7 +367,7 @@ impl Consume { pub fn frame(&self, frame: Id, dst: &mut moq_frame) -> Result<(), Error> { let f = self.frame.get(frame).ok_or(Error::FrameNotFound)?; - let timestamp_us = f.timestamp.as_micros().try_into().map_err(|_| moq_net::TimeOverflow)?; + let timestamp_us: u64 = f.timestamp.as_micros()?.try_into().map_err(|_| moq_net::TimeOverflow)?; *dst = moq_frame { payload: f.payload.as_ptr(), diff --git a/rs/moq-boy/src/input.rs b/rs/moq-boy/src/input.rs index ddaa0561f..2e0fcf4ee 100644 --- a/rs/moq-boy/src/input.rs +++ b/rs/moq-boy/src/input.rs @@ -99,12 +99,9 @@ async fn handle_viewer_commands( broadcast: moq_net::BroadcastConsumer, cmd_tx: &tokio::sync::mpsc::Sender, ) -> anyhow::Result<()> { - let command_track = moq_net::Track { - name: "command".to_string(), - priority: 0, - }; - - let mut track = broadcast.subscribe_track(&command_track)?; + let mut track = broadcast + .subscribe_track("command", moq_net::Subscription::default()) + .await?; while let Some(mut group) = track.recv_group().await? { while let Some(frame) = group.read_frame().await? { diff --git a/rs/moq-boy/src/status.rs b/rs/moq-boy/src/status.rs index f9f66c079..15259281c 100644 --- a/rs/moq-boy/src/status.rs +++ b/rs/moq-boy/src/status.rs @@ -43,6 +43,7 @@ impl StatusPublisher { let track = moq_net::Track { name: "status".to_string(), priority: 10, + timescale: moq_net::Timescale::UNKNOWN, }; let producer = broadcast.create_track(track)?; diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index 3c6799c7f..bfdcca181 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -93,7 +93,8 @@ impl Subscribe { // Fmp4 subscribes to the catalog internally, builds the merged init segment // from the first catalog snapshot, then yields moof+mdat fragments in // timestamp order across tracks. - let mut fmp4 = moq_mux::export::Fmp4::new(self.broadcast, self.catalog)? + let mut fmp4 = moq_mux::export::Fmp4::new(self.broadcast, self.catalog) + .await? .with_latency(self.args.max_latency) .with_fragment_duration(self.args.fragment_duration); @@ -111,7 +112,8 @@ impl Subscribe { // Mkv writes EBML + an unknown-size Segment header, then per-fragment // Cluster elements. Avc3/Hev1 sources are transcoded to avc1/hvc1 // shape internally (synthesizing avcC/hvcC from inline parameter sets). - let mut mkv = moq_mux::export::Mkv::new(self.broadcast, self.catalog)? + let mut mkv = moq_mux::export::Mkv::new(self.broadcast, self.catalog) + .await? .with_latency(self.args.max_latency) .with_fragment_duration(self.args.fragment_duration); diff --git a/rs/moq-clock/src/main.rs b/rs/moq-clock/src/main.rs index 74768f1fa..3dbf12f4a 100644 --- a/rs/moq-clock/src/main.rs +++ b/rs/moq-clock/src/main.rs @@ -57,6 +57,7 @@ async fn main() -> anyhow::Result<()> { let track = Track { name: config.track, priority: 0, + timescale: moq_net::Timescale::UNKNOWN, }; let origin = moq_net::Origin::random().produce(); @@ -99,7 +100,9 @@ async fn main() -> anyhow::Result<()> { Some(announce) = origin.announced() => match announce { (path, Some(broadcast)) => { tracing::info!(broadcast = %path, "broadcast is online, subscribing to track"); - let track = broadcast.subscribe_track(&track)?; + let track = broadcast + .subscribe_track(&track.name, moq_net::Subscription::default()) + .await?; clock = Some(clock::Subscriber::new(track)); } (path, None) => { diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 82612ddfd..7dfede50e 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -56,6 +56,7 @@ impl Media { let timestamp_us: u64 = frame .timestamp .as_micros() + .map_err(|_| MoqError::Codec("timestamp overflow".into()))? .try_into() .map_err(|_| MoqError::Codec("timestamp overflow".into()))?; @@ -75,9 +76,16 @@ impl Media { #[uniffi::export] impl MoqBroadcastConsumer { /// Subscribe to the catalog for this broadcast. - pub fn subscribe_catalog(&self) -> Result, MoqError> { - let _guard = crate::ffi::RUNTIME.enter(); - let track = self.inner.subscribe_track(&hang::catalog::Catalog::default_track())?; + pub async fn subscribe_catalog(&self) -> Result, MoqError> { + let broadcast = self.inner.clone(); + let track = crate::ffi::RUNTIME + .spawn(async move { + broadcast + .subscribe_track(hang::catalog::Catalog::DEFAULT_NAME, moq_net::Subscription::default()) + .await + }) + .await + .map_err(|err| MoqError::Codec(format!("subscribe task panicked: {err}")))??; let consumer = moq_mux::catalog::Consumer::from(track); Ok(Arc::new(MoqCatalogConsumer { task: Task::new(Catalog { inner: consumer }), @@ -87,9 +95,12 @@ impl MoqBroadcastConsumer { /// Subscribe to a track by name — same pattern as moq-boy's command/status tracks. /// /// Frames are returned as plain byte payloads with no codec or container parsing. - pub fn subscribe_track(&self, name: String) -> Result, MoqError> { - let _guard = crate::ffi::RUNTIME.enter(); - let track = self.inner.subscribe_track(&moq_net::Track { name, priority: 0 })?; + pub async fn subscribe_track(&self, name: String) -> Result, MoqError> { + let broadcast = self.inner.clone(); + let track = crate::ffi::RUNTIME + .spawn(async move { broadcast.subscribe_track(&name, moq_net::Subscription::default()).await }) + .await + .map_err(|err| MoqError::Codec(format!("subscribe task panicked: {err}")))??; Ok(Arc::new(MoqTrackConsumer::new(track))) } @@ -97,20 +108,23 @@ impl MoqBroadcastConsumer { /// /// `container` is the track container from the catalog. /// `max_latency_ms` controls the maximum buffering before skipping a GoP. - pub fn subscribe_media( + pub async fn subscribe_media( &self, name: String, container: Container, max_latency_ms: u64, ) -> Result, MoqError> { - let _guard = crate::ffi::RUNTIME.enter(); // Parse the container before subscribing so we don't leave a dangling // subscription if init parsing fails. let container: hang::catalog::Container = container.into(); let media: moq_mux::container::Hang = (&container) .try_into() .map_err(|e| MoqError::Codec(format!("invalid container: {e}")))?; - let track = self.inner.subscribe_track(&moq_net::Track { name, priority: 0 })?; + let broadcast = self.inner.clone(); + let track = crate::ffi::RUNTIME + .spawn(async move { broadcast.subscribe_track(&name, moq_net::Subscription::default()).await }) + .await + .map_err(|err| MoqError::Codec(format!("subscribe task panicked: {err}")))??; let latency = std::time::Duration::from_millis(max_latency_ms); let consumer = moq_mux::container::Consumer::new(track, media).with_latency(latency); Ok(Arc::new(MoqMediaConsumer { diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index ca7c24cc3..76a720ee7 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -93,7 +93,11 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::RUNTIME.enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or_else(|| MoqError::Closed)?; - let track = moq_net::Track { name, priority: 0 }; + let track = moq_net::Track { + name, + priority: 0, + timescale: moq_net::Timescale::UNKNOWN, + }; // Clone the broadcast handle (shared Arc internally) to get &mut access. let mut broadcast = state.broadcast.clone(); let producer = broadcast.create_track(track)?; diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index dea8bfb34..ad79d06de 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -78,7 +78,7 @@ async fn media_track_activity_and_name() { assert_eq!(track_name, "0.opus"); let broadcast_consumer = broadcast.consume().unwrap(); - let catalog_consumer = broadcast_consumer.subscribe_catalog().unwrap(); + let catalog_consumer = broadcast_consumer.subscribe_catalog().await.unwrap(); let catalog = tokio::time::timeout(TIMEOUT, catalog_consumer.next()) .await .expect("timed out waiting for catalog") @@ -86,7 +86,7 @@ async fn media_track_activity_and_name() { .expect("expected a catalog"); assert!(catalog.audio.contains_key(&track_name)); - let track_consumer = broadcast_consumer.subscribe_track(track_name).unwrap(); + let track_consumer = broadcast_consumer.subscribe_track(track_name).await.unwrap(); tokio::time::timeout(TIMEOUT, media.used()) .await .expect("timed out waiting for media track to become used") @@ -132,7 +132,7 @@ async fn local_publish_consume_audio() { assert_eq!(announcement.path(), "live"); let broadcast_consumer = announcement.broadcast(); - let catalog_consumer = broadcast_consumer.subscribe_catalog().unwrap(); + let catalog_consumer = broadcast_consumer.subscribe_catalog().await.unwrap(); let catalog = tokio::time::timeout(TIMEOUT, catalog_consumer.next()) .await @@ -149,6 +149,7 @@ async fn local_publish_consume_audio() { let media_consumer = broadcast_consumer .subscribe_media(track_name.clone(), audio.container.clone(), 10_000) + .await .unwrap(); let payload = b"opus audio payload data".to_vec(); @@ -182,7 +183,7 @@ async fn video_publish_consume() { .expect("expected announcement"); let broadcast_consumer = announcement.broadcast(); - let catalog_consumer = broadcast_consumer.subscribe_catalog().unwrap(); + let catalog_consumer = broadcast_consumer.subscribe_catalog().await.unwrap(); let catalog = tokio::time::timeout(TIMEOUT, catalog_consumer.next()) .await @@ -204,6 +205,7 @@ async fn video_publish_consume() { let media_consumer = broadcast_consumer .subscribe_media(track_name.clone(), video.container.clone(), 10_000) + .await .unwrap(); let keyframe = vec![0x00, 0x00, 0x00, 0x01, 0x65, 0xAA, 0xBB, 0xCC]; @@ -236,7 +238,7 @@ async fn multiple_frames_ordering() { .unwrap(); let broadcast_consumer = announcement.broadcast(); - let catalog_consumer = broadcast_consumer.subscribe_catalog().unwrap(); + let catalog_consumer = broadcast_consumer.subscribe_catalog().await.unwrap(); let catalog = tokio::time::timeout(TIMEOUT, catalog_consumer.next()) .await .unwrap() @@ -246,6 +248,7 @@ async fn multiple_frames_ordering() { let (track_name, audio) = catalog.audio.iter().next().unwrap(); let media_consumer = broadcast_consumer .subscribe_media(track_name.clone(), audio.container.clone(), 10_000) + .await .unwrap(); let timestamps: [u64; 5] = [0, 20_000, 40_000, 60_000, 80_000]; @@ -284,7 +287,7 @@ async fn catalog_update_on_new_track() { .unwrap(); let broadcast_consumer = announcement.broadcast(); - let catalog_consumer = broadcast_consumer.subscribe_catalog().unwrap(); + let catalog_consumer = broadcast_consumer.subscribe_catalog().await.unwrap(); let catalog1 = tokio::time::timeout(TIMEOUT, catalog_consumer.next()) .await @@ -333,7 +336,7 @@ async fn announced_broadcast() { .expect("expected announcement"); assert_eq!(announcement.path(), "test/broadcast"); - let _catalog = announcement.broadcast().subscribe_catalog().unwrap(); + let _catalog = announcement.broadcast().subscribe_catalog().await.unwrap(); } #[test] diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 9da9f6d9d..49389fc60 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -432,7 +432,9 @@ async fn run_session( _ = shutdown.changed() => return Ok(()), }; - let catalog_track = broadcast.subscribe_track(&hang::catalog::Catalog::default_track())?; + let catalog_track = broadcast + .subscribe_track(hang::catalog::Catalog::DEFAULT_NAME, moq_net::Subscription::default()) + .await?; let mut catalog = moq_mux::catalog::Consumer::new(catalog_track); let catalog = catalog.next().await?.context("catalog missing")?.clone(); @@ -445,8 +447,9 @@ async fn run_session( }; let caps = video_caps(&config)?; let endpoint = request_pad(&control_tx, descriptor.clone(), caps).await?; - let track_ref = moq_net::Track::new(&track_name); - let track_consumer = broadcast.subscribe_track(&track_ref)?; + let track_consumer = broadcast + .subscribe_track(&track_name, moq_net::Subscription::default()) + .await?; let track = moq_mux::container::Consumer::new(track_consumer, moq_mux::container::Hang::Legacy) .with_latency(Duration::from_secs(1)); tasks.push(spawn_track_pump(track, descriptor, endpoint, shutdown.clone())); @@ -459,8 +462,9 @@ async fn run_session( }; let caps = audio_caps(&config)?; let endpoint = request_pad(&control_tx, descriptor.clone(), caps).await?; - let track_ref = moq_net::Track::new(&track_name); - let track_consumer = broadcast.subscribe_track(&track_ref)?; + let track_consumer = broadcast + .subscribe_track(&track_name, moq_net::Subscription::default()) + .await?; let track = moq_mux::container::Consumer::new(track_consumer, moq_mux::container::Hang::Legacy) .with_latency(Duration::from_secs(1)); tasks.push(spawn_track_pump(track, descriptor, endpoint, shutdown.clone())); @@ -525,10 +529,14 @@ async fn run_track_pump( let buffer_mut = buffer.get_mut().unwrap(); let pts = match reference_ts { - Some(reference) => { - let delta: Duration = (timestamp - reference).into(); - gst::ClockTime::from_nseconds(delta.as_nanos() as u64) - } + Some(reference) => match timestamp.checked_sub(reference).and_then(|d| d.as_nanos()) { + Ok(nanos) => gst::ClockTime::from_nseconds(nanos as u64), + Err(_) => { + gst::warning!(CAT, "track {} timestamp delta overflow", descriptor.name); + pad_endpoint.send(PadMessage::Drop); + break; + } + }, None => { reference_ts = Some(timestamp); gst::ClockTime::ZERO diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 040b8bd5c..cf50fd207 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -21,6 +21,7 @@ anyhow = "1" base64 = "0.22" bytes = "1" conducer = { workspace = true } +futures = "0.3" h264-parser = { version = "0.4.0" } hang = { workspace = true } m3u8-rs = { version = "6" } diff --git a/rs/moq-mux/src/catalog/msf_consumer.rs b/rs/moq-mux/src/catalog/msf_consumer.rs index 6efd2fc78..3d67db792 100644 --- a/rs/moq-mux/src/catalog/msf_consumer.rs +++ b/rs/moq-mux/src/catalog/msf_consumer.rs @@ -223,7 +223,7 @@ fn video_config_from_msf(track: &moq_msf::Track) -> anyhow::Result= 0.0) - .and_then(|v| moq_net::Time::from_millis(v as u64).ok()), + .and_then(|v| moq_net::Timestamp::from_millis(v as u64).ok()), })) } @@ -270,7 +270,7 @@ fn audio_config_from_msf(track: &moq_msf::Track) -> anyhow::Result= 0.0) - .and_then(|v| moq_net::Time::from_millis(v as u64).ok()), + .and_then(|v| moq_net::Timestamp::from_millis(v as u64).ok()), })) } diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 9be2695ce..395d6146c 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -176,7 +176,7 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { alt_group: if has_multiple_video { Some(1) } else { None }, max_grp_sap_starting_type: sap_type, max_obj_sap_starting_type: sap_type, - jitter: config.jitter.map(|t| t.as_millis() as f64), + jitter: config.jitter.and_then(|t| t.as_millis().ok()).map(|ms| ms as f64), }); } @@ -212,7 +212,7 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { alt_group: if has_multiple_audio { Some(1) } else { None }, max_grp_sap_starting_type: Some(1), max_obj_sap_starting_type: Some(1), - jitter: config.jitter.map(|t| t.as_millis() as f64), + jitter: config.jitter.and_then(|t| t.as_millis().ok()).map(|ms| ms as f64), }); } @@ -434,7 +434,7 @@ mod test { framerate: Some(30.0), optimize_for_latency: None, container: Container::Legacy, - jitter: Some(moq_net::Time::from_millis_unchecked(100)), + jitter: Some(moq_net::Timestamp::from_millis_unchecked(100)), }, ); @@ -448,7 +448,7 @@ mod test { bitrate: None, description: None, container: Container::Legacy, - jitter: Some(moq_net::Time::from_millis_unchecked(40)), + jitter: Some(moq_net::Timestamp::from_millis_unchecked(40)), }, ); diff --git a/rs/moq-mux/src/container/cmaf.rs b/rs/moq-mux/src/container/cmaf.rs index 64cba0a17..6df5d0aeb 100644 --- a/rs/moq-mux/src/container/cmaf.rs +++ b/rs/moq-mux/src/container/cmaf.rs @@ -144,7 +144,8 @@ pub(crate) fn decode(data: Bytes, timescale: u64) -> Result, Error> { let cts = entry.cts.unwrap_or_default() as i64; let pts = dts.checked_add_signed(cts).ok_or(Error::PtsOverflow)?; - let timestamp = Timestamp::from_scale(pts, timescale)?; + let timestamp = + Timestamp::new(pts, moq_net::Timescale::new(timescale))?.convert(crate::container::TIMESCALE)?; let payload = Bytes::copy_from_slice(&mdat_data[offset..end]); let flags = entry.flags.unwrap_or(0); // depends_on_no_other (bits 24-25 == 0x2) means keyframe @@ -176,7 +177,7 @@ pub(crate) fn encode( return Ok(()); } - let dts = (frames[0].timestamp.as_micros() * timescale as u128 / 1_000_000) as u64; + let dts = (frames[0].timestamp.as_micros()? * timescale as u128 / 1_000_000) as u64; let sequence_number = group.frame_count() as u32; let entries: Vec<_> = frames @@ -223,7 +224,14 @@ pub(crate) fn encode( let mdat = mp4_atom::Mdat { data: mdat_data }; mdat.encode(&mut buf)?; - let mut writer = group.create_frame(buf.len().into())?; + // Stamp the wire-level timestamp on the moq-net frame so Lite05+ can + // delta-encode it. The CMAF fragment may pack multiple media samples; use + // the first sample's PTS as the representative timestamp. + let net_frame = moq_net::Frame { + size: buf.len() as u64, + timestamp: frames[0].timestamp, + }; + let mut writer = group.create_frame(net_frame)?; writer.write(Bytes::from(buf))?; writer.finish()?; diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 7ea9cc279..dc3be4e34 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -125,7 +125,7 @@ impl Consumer { && current.sequence <= self.current { match current.poll_min_timestamp(waiter, &self.format) { - Poll::Ready(Ok(ts)) => Some::(ts.into()), + Poll::Ready(Ok(ts)) => ts.try_into().ok(), _ => None, } } else { @@ -153,7 +153,9 @@ impl Consumer { } if let Poll::Ready(Ok(ts)) = group.poll_max_timestamp(waiter, &self.format) { - max_timestamp = max_timestamp.max(ts.into()); + if let Ok(ts) = std::time::Duration::try_from(ts) { + max_timestamp = max_timestamp.max(ts); + } break; // We know older groups won't be newer than this. } } @@ -887,7 +889,7 @@ mod tests { let frames = read_all(&mut consumer).await.unwrap(); assert_eq!(frames.len(), 1); assert_eq!(frames[0].timestamp, ts(one_hour)); - assert_eq!(frames[0].timestamp.as_micros(), one_hour as u128); + assert_eq!(frames[0].timestamp.as_micros().unwrap(), one_hour as u128); } #[tokio::test] diff --git a/rs/moq-mux/src/container/loc.rs b/rs/moq-mux/src/container/loc.rs index d5671d521..9e377b8b9 100644 --- a/rs/moq-mux/src/container/loc.rs +++ b/rs/moq-mux/src/container/loc.rs @@ -25,9 +25,10 @@ impl Container for Loc { fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error> { for frame in frames { - let data = moq_loc::encode(frame.timestamp.as_micros() as u64, &frame.payload)?; + let micros = frame.timestamp.as_micros().map_err(hang::Error::from)? as u64; + let data = moq_loc::encode(micros, &frame.payload)?; - let mut chunked = group.create_frame(data.len().into())?; + let mut chunked = group.create_frame(data.len())?; chunked.write(data)?; chunked.finish()?; } @@ -46,8 +47,10 @@ impl Container for Loc { }; let loc = moq_loc::decode(data)?; - let timescale = loc.timescale.unwrap_or(DEFAULT_TIMESCALE); - let timestamp = Timestamp::from_scale(loc.timestamp, timescale).map_err(hang::Error::from)?; + let timescale = moq_net::Timescale::new(loc.timescale.unwrap_or(DEFAULT_TIMESCALE)); + let timestamp = Timestamp::new(loc.timestamp, timescale) + .and_then(|ts| ts.convert(crate::container::TIMESCALE)) + .map_err(hang::Error::from)?; Poll::Ready(Ok(Some(vec![Frame { timestamp, diff --git a/rs/moq-mux/src/container/mod.rs b/rs/moq-mux/src/container/mod.rs index f810c16f8..e60bce2a2 100644 --- a/rs/moq-mux/src/container/mod.rs +++ b/rs/moq-mux/src/container/mod.rs @@ -31,8 +31,10 @@ pub use hang::Hang; pub use loc::Loc; pub use producer::Producer; +pub use moq_net::{Timescale, Timestamp}; + /// Microsecond presentation timestamp, the canonical timebase for media frames in moq-mux. -pub type Timestamp = moq_net::Timescale<1_000_000>; +pub const TIMESCALE: Timescale = Timescale::MICRO; /// A decoded media frame: timestamp, payload bytes, keyframe flag. /// diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index 2571c216e..140918755 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -85,10 +85,10 @@ impl Producer { // Check if buffered duration exceeds latency. if self.buffer.len() >= 2 { - let first_ts: std::time::Duration = self.buffer.first().unwrap().timestamp.into(); - let last_ts: std::time::Duration = self.buffer.last().unwrap().timestamp.into(); + let first_us = self.buffer.first().unwrap().timestamp.as_micros().unwrap_or(0); + let last_us = self.buffer.last().unwrap().timestamp.as_micros().unwrap_or(0); - if last_ts.saturating_sub(first_ts) >= self.latency { + if last_us.saturating_sub(first_us) >= self.latency.as_micros() { self.flush()?; } } diff --git a/rs/moq-mux/src/export/fmp4.rs b/rs/moq-mux/src/export/fmp4.rs index 24f29cb0d..a41f0372a 100644 --- a/rs/moq-mux/src/export/fmp4.rs +++ b/rs/moq-mux/src/export/fmp4.rs @@ -12,6 +12,10 @@ use crate::container::{Consumer, Frame, Hang}; use super::CatalogSource; +type Fmp4SubscribeFuture = std::pin::Pin< + Box)> + Send>, +>; + /// Subscribe to a moq broadcast and produce a single fMP4 / CMAF byte stream. /// /// Built from a [`moq_net::BroadcastConsumer`], `Fmp4` subscribes to the hang catalog, @@ -34,6 +38,17 @@ pub struct Fmp4 { tracks: HashMap, + /// Per-name metadata captured from the catalog snapshot at subscribe time, used + /// once the subscription resolves to construct the [`Fmp4Track`]. While entries + /// are present, a tokio task is awaiting SUBSCRIBE_OK and will push into + /// [`Self::subscribe_queue`]. + pending_meta: HashMap, + + /// In-flight `subscribe_track` futures, polled by [`Self::poll_next`] using + /// the waiter's underlying [`std::task::Waker`]. Resolved entries land in + /// [`Self::tracks`]. + pending_subscribes: futures::stream::FuturesUnordered, + /// Queued init segment, emitted on the first [`next`](Self::next) call after the /// initial catalog snapshot has been processed. init_pending: Option, @@ -43,6 +58,13 @@ pub struct Fmp4 { init_emitted: bool, } +struct PendingMeta { + media: Hang, + track_id: u32, + timescale: u64, + is_video: bool, +} + struct Fmp4Track { consumer: Consumer, @@ -71,8 +93,11 @@ impl Fmp4 { /// for track discovery. Both formats end up driving the same internal /// `hang::Catalog`-based pipeline (MSF snapshots are converted on receipt), /// so the only observable difference is which wire catalog is consumed. - pub fn new(broadcast: moq_net::BroadcastConsumer, catalog_format: CatalogFormat) -> Result { - let catalog = CatalogSource::new(&broadcast, catalog_format)?; + pub async fn new( + broadcast: moq_net::BroadcastConsumer, + catalog_format: CatalogFormat, + ) -> Result { + let catalog = CatalogSource::new(&broadcast, catalog_format).await?; Ok(Self { broadcast, @@ -80,6 +105,8 @@ impl Fmp4 { latency: Duration::ZERO, fragment_duration: None, tracks: HashMap::new(), + pending_meta: HashMap::new(), + pending_subscribes: futures::stream::FuturesUnordered::new(), init_pending: None, init_emitted: false, }) @@ -134,7 +161,43 @@ impl Fmp4 { } } - // 2. Emit the init segment once it's been built. + // 2. Drive in-flight subscribe_track futures forward using the waiter's + // Waker, then collect any that have resolved. + let mut resolved: Vec<(String, Result)> = Vec::new(); + { + use futures::stream::StreamExt; + let mut cx = std::task::Context::from_waker(waiter.waker()); + while let Poll::Ready(Some(item)) = self.pending_subscribes.poll_next_unpin(&mut cx) { + resolved.push(item); + } + } + for (name, result) in resolved { + let meta = match self.pending_meta.remove(&name) { + Some(meta) => meta, + None => continue, + }; + match result { + Ok(track) => { + let consumer = Consumer::new(track, meta.media).with_latency(self.latency); + self.tracks.insert( + name, + Fmp4Track { + consumer, + pending: None, + buffer: Vec::new(), + is_video: meta.is_video, + finished: false, + track_id: meta.track_id, + timescale: meta.timescale, + sequence_number: 1, + }, + ); + } + Err(err) => return Poll::Ready(Err(err.into())), + } + } + + // 3. Emit the init segment once it's been built. if !self.init_emitted && let Some(init) = self.init_pending.take() { @@ -142,7 +205,7 @@ impl Fmp4 { return Poll::Ready(Ok(Some(init))); } - // 3. Fill any empty pending slots by polling each consumer. + // 4. Fill any empty pending slots by polling each consumer. for track in self.tracks.values_mut() { if track.pending.is_some() || track.finished { continue; @@ -155,7 +218,7 @@ impl Fmp4 { } } - // 4. Pick the track whose pending frame has the smallest timestamp and + // 5. Pick the track whose pending frame has the smallest timestamp and // decide whether to flush its buffer before appending the new frame. let chosen = self .tracks @@ -181,7 +244,7 @@ impl Fmp4 { return self.poll_next(waiter); } - // 5. No pending frames. Flush any finished tracks' remaining buffers, + // 6. No pending frames. Flush any finished tracks' remaining buffers, // in ascending first-frame-timestamp order. let flushable = self .tracks @@ -203,12 +266,15 @@ impl Fmp4 { return Poll::Ready(Ok(Some(emit))); } - // 6. If catalog is closed and every track is finished and drained, we're done. - if self.catalog.is_none() && self.tracks.values().all(|t| t.finished && t.buffer.is_empty()) { + // 7. If catalog is closed and every track is finished and drained, we're done. + if self.catalog.is_none() + && self.pending_meta.is_empty() + && self.tracks.values().all(|t| t.finished && t.buffer.is_empty()) + { return Poll::Ready(Ok(None)); } - // 7. Drop finished tracks with empty buffers so the next catalog update can re-add a track of the same name. + // 8. Drop finished tracks with empty buffers so the next catalog update can re-add a track of the same name. self.tracks .retain(|_, t| !(t.finished && t.pending.is_none() && t.buffer.is_empty())); @@ -233,38 +299,46 @@ impl Fmp4 { // Add any new tracks. We use the rendition's catalog index as the track_id so // fragment moof traf.tfhd.track_id matches the moov trak ids in the init segment. - let mut next_track_id = self.tracks.values().map(|t| t.track_id).max().unwrap_or(0) + 1; + let mut next_track_id = self + .tracks + .values() + .map(|t| t.track_id) + .chain(self.pending_meta.values().map(|m| m.track_id)) + .max() + .unwrap_or(0) + + 1; for (name, container) in &active { - if self.tracks.contains_key(name) { + if self.tracks.contains_key(name) || self.pending_meta.contains_key(name) { continue; } let media: Hang = (*container).try_into()?; - let track = self.broadcast.subscribe_track(&moq_net::Track::new(name.clone()))?; - let consumer = Consumer::new(track, media).with_latency(self.latency); - let timescale = catalog_timescale(catalog, name).context("track not in catalog")?; let is_video = catalog.video.renditions.contains_key(name); - self.tracks.insert( + self.pending_meta.insert( name.clone(), - Fmp4Track { - consumer, - pending: None, - buffer: Vec::new(), - is_video, - finished: false, + PendingMeta { + media, track_id: next_track_id, timescale, - sequence_number: 1, + is_video, }, ); next_track_id += 1; + + let broadcast = self.broadcast.clone(); + let name = name.clone(); + self.pending_subscribes.push(Box::pin(async move { + let res = broadcast.subscribe_track(&name, moq_net::Subscription::default()).await; + (name, res) + })); } // Remove tracks no longer in the catalog. self.tracks.retain(|name, _| active.contains_key(name)); + self.pending_meta.retain(|name, _| active.contains_key(name)); Ok(()) } @@ -346,7 +420,7 @@ fn build_init(catalog: &Catalog) -> anyhow::Result { /// - Optional duration cap exceeded /// - Per-frame mode (`Some(ZERO)`) /// - Audio in an audio-only broadcast under default `None` mode (otherwise -/// the buffer would never flush — no keyframe boundary and no time cap) +/// the buffer would never flush, no keyframe boundary and no time cap) fn should_flush(track: &Fmp4Track, frame: &Frame, fragment_duration: Option, has_video_track: bool) -> bool { if track.buffer.is_empty() { return false; @@ -357,8 +431,9 @@ fn should_flush(track: &Fmp4Track, frame: &Frame, fragment_duration: Option true, Some(d) => { - let first = track.buffer.first().unwrap(); - let delta_us = frame.timestamp.as_micros().saturating_sub(first.timestamp.as_micros()); + let frame_us = frame.timestamp.as_micros().unwrap_or(0); + let first_us = track.buffer.first().unwrap().timestamp.as_micros().unwrap_or(0); + let delta_us = frame_us.saturating_sub(first_us); delta_us >= d.as_micros() } // No video keyframe will ever arrive to roll the fragment, so for @@ -371,7 +446,7 @@ fn should_flush(track: &Fmp4Track, frame: &Frame, fragment_duration: Option) -> anyhow::Result { anyhow::ensure!(!frames.is_empty(), "encode_fragment called with no frames"); - let base_dts = frames[0].timestamp.as_micros() as u64 * track.timescale / 1_000_000; + let base_dts = frames[0].timestamp.as_micros()? as u64 * track.timescale / 1_000_000; let entries: Vec = frames .iter() diff --git a/rs/moq-mux/src/export/mkv.rs b/rs/moq-mux/src/export/mkv.rs index 14a1c8572..5998565b6 100644 --- a/rs/moq-mux/src/export/mkv.rs +++ b/rs/moq-mux/src/export/mkv.rs @@ -52,6 +52,15 @@ pub struct Mkv { fragment_duration: Option, tracks: HashMap, + + /// Per-name metadata captured at subscribe time; resolved into [`MkvTrack`] + /// entries when the matching future in [`Self::pending_subscribes`] resolves. + pending_meta: HashMap, + + /// In-flight `subscribe_track` futures, polled by [`Self::poll_next`] using + /// the waiter's underlying [`std::task::Waker`]. + pending_subscribes: futures::stream::FuturesUnordered, + /// Catalog snapshot used to build the header. Retained until header emission; /// subsequent catalog updates only (un)subscribe tracks. catalog_snapshot: Option, @@ -63,6 +72,17 @@ pub struct Mkv { cluster: Option, } +struct MkvPendingMeta { + media: Hang, + track_number: u64, + kind: TrackKind, + video_transform: Option, +} + +type MkvSubscribeFuture = std::pin::Pin< + Box)> + Send>, +>; + struct MkvTrack { consumer: Consumer, pending: Option, @@ -185,8 +205,11 @@ impl Mkv { /// for track discovery. Both formats end up driving the same internal /// `hang::Catalog`-based pipeline (MSF snapshots are converted on receipt), /// so the only observable difference is which wire catalog is consumed. - pub fn new(broadcast: moq_net::BroadcastConsumer, catalog_format: CatalogFormat) -> Result { - let catalog = CatalogSource::new(&broadcast, catalog_format)?; + pub async fn new( + broadcast: moq_net::BroadcastConsumer, + catalog_format: CatalogFormat, + ) -> Result { + let catalog = CatalogSource::new(&broadcast, catalog_format).await?; Ok(Self { broadcast, @@ -194,6 +217,8 @@ impl Mkv { latency: Duration::ZERO, fragment_duration: None, tracks: HashMap::new(), + pending_meta: HashMap::new(), + pending_subscribes: futures::stream::FuturesUnordered::new(), catalog_snapshot: None, header_emitted: false, cluster: None, @@ -239,6 +264,42 @@ impl Mkv { } } + // 1b. Drive in-flight subscribe_track futures forward using the waiter's + // Waker, then collect any that have resolved. Driving them inline (vs + // `tokio::spawn` + a queue) means a single-threaded runtime with paused + // time still polls them to completion before the next time advance. + let mut resolved: Vec<(String, Result)> = Vec::new(); + { + use futures::stream::StreamExt; + let mut cx = std::task::Context::from_waker(waiter.waker()); + while let Poll::Ready(Some(item)) = self.pending_subscribes.poll_next_unpin(&mut cx) { + resolved.push(item); + } + } + for (name, result) in resolved { + let meta = match self.pending_meta.remove(&name) { + Some(meta) => meta, + None => continue, + }; + match result { + Ok(track) => { + let consumer = Consumer::new(track, meta.media).with_latency(self.latency); + self.tracks.insert( + name, + MkvTrack { + consumer, + pending: None, + finished: false, + track_number: meta.track_number, + kind: meta.kind, + video_transform: meta.video_transform, + }, + ); + } + Err(err) => return Poll::Ready(Err(err.into())), + } + } + // 2. Pull frames from each track into `pending`, transforming codec // shape (Annex-B → length-prefixed) at pull time so downstream code // never sees a raw Avc3/Hev1 payload. @@ -367,57 +428,77 @@ impl Mkv { return Ok(()); } - let mut next_track_number: u64 = self.tracks.values().map(|t| t.track_number).max().unwrap_or(0) + 1; + let mut next_track_number: u64 = self + .tracks + .values() + .map(|t| t.track_number) + .chain(self.pending_meta.values().map(|m| m.track_number)) + .max() + .unwrap_or(0) + + 1; for (name, config) in catalog.video.renditions.iter() { - if self.tracks.contains_key(name) { + if self.tracks.contains_key(name) || self.pending_meta.contains_key(name) { continue; } ensure_legacy(&config.container, "video", name)?; - let consumer = subscribe(&self.broadcast, name, &config.container, self.latency)?; + let media: Hang = (&config.container).try_into()?; let transform = build_video_transform(config)?; - self.tracks.insert( + self.pending_meta.insert( name.clone(), - MkvTrack { - consumer, - pending: None, - finished: false, + MkvPendingMeta { + media, track_number: next_track_number, kind: TrackKind::Video, video_transform: transform, }, ); next_track_number += 1; + self.pending_subscribes.push(self.enqueue_subscribe(name.clone())); } for (name, config) in catalog.audio.renditions.iter() { - if self.tracks.contains_key(name) { + if self.tracks.contains_key(name) || self.pending_meta.contains_key(name) { continue; } ensure_legacy(&config.container, "audio", name)?; - let consumer = subscribe(&self.broadcast, name, &config.container, self.latency)?; - self.tracks.insert( + let media: Hang = (&config.container).try_into()?; + self.pending_meta.insert( name.clone(), - MkvTrack { - consumer, - pending: None, - finished: false, + MkvPendingMeta { + media, track_number: next_track_number, kind: TrackKind::Audio, video_transform: None, }, ); next_track_number += 1; + self.pending_subscribes.push(self.enqueue_subscribe(name.clone())); } self.tracks.retain(|name, _| active.contains_key(name)); + self.pending_meta.retain(|name, _| active.contains_key(name)); self.catalog_snapshot = Some(catalog); Ok(()) } - /// Header is ready when every video track has its codec config — either - /// supplied in the catalog or built by the transform. + fn enqueue_subscribe(&self, name: String) -> MkvSubscribeFuture { + let broadcast = self.broadcast.clone(); + Box::pin(async move { + let res = broadcast.subscribe_track(&name, moq_net::Subscription::default()).await; + (name, res) + }) + } + + /// Header is ready when every catalog track has been subscribed *and* every + /// video track has its codec config (either supplied in the catalog or + /// built by the transform). fn header_ready(&self) -> bool { + // Any track that the catalog declared but hasn't been subscribed yet + // (its `subscribe_track` future hasn't resolved) blocks the header. + if !self.pending_meta.is_empty() { + return false; + } for track in self.tracks.values() { if track.kind != TrackKind::Video { continue; @@ -502,9 +583,8 @@ impl Mkv { let kind = track.kind; let payload = &frame.payload; - let frame_ticks: u64 = (frame.timestamp.as_micros() / 1_000) - .try_into() - .context("timestamp doesn't fit in u64 ms")?; + let micros = frame.timestamp.as_micros().context("timestamp scale unknown")?; + let frame_ticks: u64 = (micros / 1_000).try_into().context("timestamp doesn't fit in u64 ms")?; let is_video = kind == TrackKind::Video; let keyframe = frame.keyframe; @@ -514,7 +594,7 @@ impl Mkv { Some(cluster) => { let overflow = !cluster.fits(frame_ticks); // Roll on a video keyframe only once the cluster already has video - // frames in it — otherwise audio that arrived before the first + // frames in it, otherwise audio that arrived before the first // keyframe would split into its own (un-renderable) cluster. let gop_boundary = is_video && keyframe && cluster.has_video; // Optional time-based cap. Some(ZERO) means per-frame. @@ -556,17 +636,6 @@ fn ensure_legacy(container: &Container, kind: &str, name: &str) -> anyhow::Resul } } -fn subscribe( - broadcast: &moq_net::BroadcastConsumer, - name: &str, - container: &Container, - latency: Duration, -) -> Result, crate::Error> { - let media: Hang = container.try_into()?; - let track = broadcast.subscribe_track(&moq_net::Track::new(name.to_string()))?; - Ok(Consumer::new(track, media).with_latency(latency)) -} - /// Build a video transform for the given catalog config, or None if no /// transform is needed (e.g. VP8/VP9/AV1, or H.264/H.265 with an existing avcC/hvcC). fn build_video_transform(config: &VideoConfig) -> anyhow::Result> { diff --git a/rs/moq-mux/src/export/mod.rs b/rs/moq-mux/src/export/mod.rs index d3bc7955e..6b05754d4 100644 --- a/rs/moq-mux/src/export/mod.rs +++ b/rs/moq-mux/src/export/mod.rs @@ -35,14 +35,21 @@ pub(super) enum CatalogSource { } impl CatalogSource { - pub(super) fn new(broadcast: &moq_net::BroadcastConsumer, format: CatalogFormat) -> Result { + pub(super) async fn new( + broadcast: &moq_net::BroadcastConsumer, + format: CatalogFormat, + ) -> Result { Ok(match format { CatalogFormat::Hang => { - let track = broadcast.subscribe_track(&hang::Catalog::default_track())?; + let track = broadcast + .subscribe_track(hang::Catalog::DEFAULT_NAME, moq_net::Subscription::default()) + .await?; CatalogSource::Hang(crate::catalog::Consumer::new(track)) } CatalogFormat::Msf => { - let track = broadcast.subscribe_track(&moq_net::Track::new(moq_msf::DEFAULT_NAME))?; + let track = broadcast + .subscribe_track(moq_msf::DEFAULT_NAME, moq_net::Subscription::default()) + .await?; CatalogSource::Msf(crate::catalog::MsfConsumer::new(track)) } }) diff --git a/rs/moq-mux/src/export/test/mkv.rs b/rs/moq-mux/src/export/test/mkv.rs index ad6fd63d2..569333151 100644 --- a/rs/moq-mux/src/export/test/mkv.rs +++ b/rs/moq-mux/src/export/test/mkv.rs @@ -28,7 +28,9 @@ async fn export_header_roundtrip_vp9_opus() { importer.finish().unwrap(); // Now subscribe via the exporter and pull bytes. - let mut exporter = crate::export::Mkv::new(consumer, crate::catalog::CatalogFormat::Hang).unwrap(); + let mut exporter = crate::export::Mkv::new(consumer, crate::catalog::CatalogFormat::Hang) + .await + .unwrap(); // First `next()` should give us the header (EBML + Segment-start + Info + Tracks). let header = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) @@ -154,6 +156,7 @@ async fn export_emits_blocks_for_each_frame() { importer.finish().unwrap(); let mut exporter = crate::export::Mkv::new(consumer, crate::catalog::CatalogFormat::Hang) + .await .unwrap() // Use per-frame clustering so each frame is observable as its own // Cluster chunk; batching is exercised in a dedicated test below. @@ -223,7 +226,13 @@ async fn export_rejects_cmaf_track() { let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let track = producer.unique_track(".avc1").unwrap(); + let track = producer + .create_track(moq_net::Track { + name: producer.unique_name(".avc1"), + priority: 0, + timescale: hang::container::TIMESCALE, + }) + .unwrap(); catalog.lock().video.renditions.insert( track.name.clone(), VideoConfig { @@ -251,7 +260,9 @@ async fn export_rejects_cmaf_track() { }, ); - let mut exporter = crate::export::Mkv::new(consumer, crate::catalog::CatalogFormat::Hang).unwrap(); + let mut exporter = crate::export::Mkv::new(consumer, crate::catalog::CatalogFormat::Hang) + .await + .unwrap(); let result = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) .await .expect("exporter timed out"); @@ -274,7 +285,13 @@ async fn export_avc3_source_synthesizes_avcc_and_length_prefixes() { let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let track = producer.unique_track(".avc3").unwrap(); + let track = producer + .create_track(moq_net::Track { + name: producer.unique_name(".avc3"), + priority: 0, + timescale: hang::container::TIMESCALE, + }) + .unwrap(); catalog.lock().video.renditions.insert( track.name.clone(), VideoConfig { @@ -338,6 +355,7 @@ async fn export_avc3_source_synthesizes_avcc_and_length_prefixes() { catalog.finish().unwrap(); let mut exporter = crate::export::Mkv::new(consumer, crate::catalog::CatalogFormat::Hang) + .await .unwrap() .with_fragment_duration(std::time::Duration::ZERO); let mut exported: Vec = Vec::new(); @@ -473,6 +491,7 @@ async fn export_fragment_duration_batches_blocks() { catalog.finish().unwrap(); let mut exporter = crate::export::Mkv::new(consumer, crate::catalog::CatalogFormat::Hang) + .await .unwrap() .with_fragment_duration(std::time::Duration::from_secs(2)); let mut exported: Vec = Vec::new(); diff --git a/rs/moq-mux/src/import/aac.rs b/rs/moq-mux/src/import/aac.rs index da7e72faa..5cb4a0ae5 100644 --- a/rs/moq-mux/src/import/aac.rs +++ b/rs/moq-mux/src/import/aac.rs @@ -112,7 +112,12 @@ impl Aac { mut catalog: crate::catalog::Producer, config: AacConfig, ) -> anyhow::Result { - let track = broadcast.unique_track(".aac")?; + let name = broadcast.unique_name(".aac"); + let track = broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; let audio_config = hang::catalog::AudioConfig { codec: hang::catalog::AAC { diff --git a/rs/moq-mux/src/import/av01.rs b/rs/moq-mux/src/import/av01.rs index 82c9b23ba..816bcd9c5 100644 --- a/rs/moq-mux/src/import/av01.rs +++ b/rs/moq-mux/src/import/av01.rs @@ -102,7 +102,12 @@ impl Av01 { self.catalog.lock().video.renditions.remove(&track.name); } - let track = self.broadcast.unique_track(".av01")?; + let name = self.broadcast.unique_name(".av01"); + let track = self.broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; tracing::debug!(name = ?track.name, ?config, "starting track"); self.catalog .lock() @@ -146,7 +151,12 @@ impl Av01 { jitter: None, }; - let track = self.broadcast.unique_track(".av01")?; + let name = self.broadcast.unique_name(".av01"); + let track = self.broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; tracing::debug!(name = ?track.name, "starting track with minimal config"); self.catalog .lock() @@ -236,7 +246,12 @@ impl Av01 { self.catalog.lock().video.renditions.remove(&track.name); } - let track = self.broadcast.unique_track(".av01")?; + let name = self.broadcast.unique_name(".av01"); + let track = self.broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; self.catalog .lock() .video diff --git a/rs/moq-mux/src/import/avc1.rs b/rs/moq-mux/src/import/avc1.rs index 7014d2413..8a44063cc 100644 --- a/rs/moq-mux/src/import/avc1.rs +++ b/rs/moq-mux/src/import/avc1.rs @@ -103,7 +103,12 @@ impl Avc1 { catalog.video.renditions.remove(&track.name); } - let track = self.broadcast.unique_track(".avc1")?; + let name = self.broadcast.unique_name(".avc1"); + let track = self.broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; tracing::debug!(name = ?track.name, ?config, "starting avc1 track"); catalog.video.renditions.insert(track.name.clone(), config.clone()); diff --git a/rs/moq-mux/src/import/avc3.rs b/rs/moq-mux/src/import/avc3.rs index 6b123fe7e..07255b58b 100644 --- a/rs/moq-mux/src/import/avc3.rs +++ b/rs/moq-mux/src/import/avc3.rs @@ -39,7 +39,14 @@ impl Avc3 { pub fn new(mut broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { // Create the track eagerly so callers can monitor used/unused before any frames arrive. // The catalog entry is added later in init() once the codec config is known. - let track = broadcast.unique_track(".avc3").expect("failed to create avc3 track"); + let name = broadcast.unique_name(".avc3"); + let track = broadcast + .create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + }) + .expect("failed to create avc3 track"); Self { catalog, diff --git a/rs/moq-mux/src/import/fmp4.rs b/rs/moq-mux/src/import/fmp4.rs index e8bd6656c..5079fcdea 100644 --- a/rs/moq-mux/src/import/fmp4.rs +++ b/rs/moq-mux/src/import/fmp4.rs @@ -142,7 +142,13 @@ impl Fmp4 { let handler = &trak.mdia.hdlr.handler; let suffix = ".m4s"; - let track = self.broadcast.unique_track(suffix)?; + let name = self.broadcast.unique_name(suffix); + // moq-mux frames are always emitted at microsecond timescale. + let track = self.broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; let kind = match handler.as_ref() { b"vide" => { @@ -517,7 +523,8 @@ impl Fmp4 { .unwrap_or(tfhd.default_sample_size.unwrap_or(default_sample_size)) as usize; let pts = (dts as i64 + entry.cts.unwrap_or_default() as i64) as u64; - let timestamp = hang::container::Timestamp::from_scale(pts, timescale)?; + let timestamp = hang::container::Timestamp::new(pts, moq_net::Timescale::new(timescale))? + .convert(hang::container::TIMESCALE)?; if offset + size > mdat.data.len() { anyhow::bail!("invalid data offset"); @@ -658,7 +665,7 @@ impl Fmp4 { .renditions .get_mut(&track.track.name) .context("missing video config")?; - config.jitter = Some(jitter.convert()?); + config.jitter = Some(jitter.convert(moq_net::Timescale::MILLI)?); } TrackKind::Audio => { let config = catalog @@ -666,7 +673,7 @@ impl Fmp4 { .renditions .get_mut(&track.track.name) .context("missing audio config")?; - config.jitter = Some(jitter.convert()?); + config.jitter = Some(jitter.convert(moq_net::Timescale::MILLI)?); } } } diff --git a/rs/moq-mux/src/import/hev1.rs b/rs/moq-mux/src/import/hev1.rs index 49aecc309..482dc73a6 100644 --- a/rs/moq-mux/src/import/hev1.rs +++ b/rs/moq-mux/src/import/hev1.rs @@ -92,7 +92,12 @@ impl Hev1 { catalog.video.renditions.remove(&track.name); } - let track = self.broadcast.unique_track(".hev1")?; + let name = self.broadcast.unique_name(".hev1"); + let track = self.broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; tracing::debug!(name = ?track.name, ?config, "starting track"); catalog.video.renditions.insert(track.name.clone(), config.clone()); diff --git a/rs/moq-mux/src/import/jitter.rs b/rs/moq-mux/src/import/jitter.rs index 81ab64c41..f32ebe3b8 100644 --- a/rs/moq-mux/src/import/jitter.rs +++ b/rs/moq-mux/src/import/jitter.rs @@ -18,11 +18,11 @@ impl MinFrameDuration { /// Record a new frame timestamp. /// - /// Returns the new minimum-frame-duration as a `moq_net::Time` if it - /// changed, so the caller can persist it on the catalog rendition. Returns - /// `None` when this is the first observation, the timestamps are - /// non-monotonic, or the new gap is no smaller than the recorded minimum. - pub fn observe(&mut self, ts: Timestamp) -> Option { + /// Returns the new minimum-frame-duration as a millisecond-scale [`Timestamp`] + /// if it changed, so the caller can persist it on the catalog rendition. Returns + /// `None` when this is the first observation, the timestamps are non-monotonic, + /// or the new gap is no smaller than the recorded minimum. + pub fn observe(&mut self, ts: Timestamp) -> Option { let last = self.last_timestamp.replace(ts)?; let duration = ts.checked_sub(last).ok()?; @@ -31,6 +31,6 @@ impl MinFrameDuration { } self.min_duration = Some(duration); - duration.convert().ok() + duration.convert(moq_net::Timescale::MILLI).ok() } } diff --git a/rs/moq-mux/src/import/mkv.rs b/rs/moq-mux/src/import/mkv.rs index 37afa59fb..e8fd9673e 100644 --- a/rs/moq-mux/src/import/mkv.rs +++ b/rs/moq-mux/src/import/mkv.rs @@ -283,7 +283,12 @@ impl Mkv { } }; - let net_track = self.broadcast.unique_track(suffix)?; + let name = self.broadcast.unique_name(suffix); + let net_track = self.broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; let mut catalog = self.catalog.clone(); let mut catalog = catalog.lock(); diff --git a/rs/moq-mux/src/import/opus.rs b/rs/moq-mux/src/import/opus.rs index 4bce37985..b604ce102 100644 --- a/rs/moq-mux/src/import/opus.rs +++ b/rs/moq-mux/src/import/opus.rs @@ -55,7 +55,12 @@ impl Opus { mut catalog: crate::catalog::Producer, config: OpusConfig, ) -> anyhow::Result { - let track = broadcast.unique_track(".opus")?; + let name = broadcast.unique_name(".opus"); + let track = broadcast.create_track(moq_net::Track { + name, + priority: 0, + timescale: hang::container::TIMESCALE, + })?; let audio_config = hang::catalog::AudioConfig { codec: hang::catalog::AudioCodec::Opus, diff --git a/rs/moq-mux/src/import/test/mod.rs b/rs/moq-mux/src/import/test/mod.rs index a2bc36b18..9ab068146 100644 --- a/rs/moq-mux/src/import/test/mod.rs +++ b/rs/moq-mux/src/import/test/mod.rs @@ -152,7 +152,8 @@ async fn test_msf_catalog_roundtrip() { let _ = fmp4.decode(&mut buf); let track = consumer - .subscribe_track(&moq_net::Track::new(moq_msf::DEFAULT_NAME)) + .subscribe_track(moq_msf::DEFAULT_NAME, moq_net::Subscription::default()) + .await .expect("MSF catalog track should exist"); let mut msf = crate::catalog::MsfConsumer::new(track); diff --git a/rs/moq-native/examples/chat.rs b/rs/moq-native/examples/chat.rs index fc1c1d2cf..0e34b3ded 100644 --- a/rs/moq-native/examples/chat.rs +++ b/rs/moq-native/examples/chat.rs @@ -44,6 +44,7 @@ async fn run_broadcast(origin: moq_net::OriginProducer) -> anyhow::Result<()> { let mut track = broadcast.create_track(moq_net::Track { name: "chat".to_string(), priority: 0, + timescale: moq_net::Timescale::UNKNOWN, })?; // NOTE: The path is empty because we're using the URL to scope the broadcast. diff --git a/rs/moq-native/tests/backend.rs b/rs/moq-native/tests/backend.rs index 271f592e2..ac091f008 100644 --- a/rs/moq-native/tests/backend.rs +++ b/rs/moq-native/tests/backend.rs @@ -70,7 +70,8 @@ async fn backend_test(scheme: &str, backend: moq_native::QuicBackend) { let bc = bc.expect("expected announce, got unannounce"); let mut track_sub = bc - .subscribe_track(&Track::new("video")) + .subscribe_track("video", moq_net::Subscription::default()) + .await .expect("subscribe_track failed"); let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.recv_group()) @@ -222,7 +223,8 @@ async fn iroh_connect() { let bc = bc.expect("expected announce, got unannounce"); let mut track_sub = bc - .subscribe_track(&Track::new("video")) + .subscribe_track("video", moq_net::Subscription::default()) + .await .expect("subscribe_track failed"); let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.recv_group()) diff --git a/rs/moq-native/tests/broadcast.rs b/rs/moq-native/tests/broadcast.rs index a0f20da5c..d9d7278b4 100644 --- a/rs/moq-native/tests/broadcast.rs +++ b/rs/moq-native/tests/broadcast.rs @@ -88,7 +88,8 @@ async fn broadcast_test(scheme: &str, client_version: Option<&str>, server_versi // Subscribe to the track. let mut track_sub = bc - .subscribe_track(&Track::new("video")) + .subscribe_track("video", moq_net::Subscription::default()) + .await .expect("subscribe_track failed"); // Read one group. @@ -500,7 +501,8 @@ async fn broadcast_websocket() { // Subscribe to the track. let mut track_sub = bc - .subscribe_track(&Track::new("video")) + .subscribe_track("video", moq_net::Subscription::default()) + .await .expect("subscribe_track failed"); // Read one group. @@ -607,7 +609,8 @@ async fn broadcast_websocket_fallback() { // Subscribe to the track. let mut track_sub = bc - .subscribe_track(&Track::new("video")) + .subscribe_track("video", moq_net::Subscription::default()) + .await .expect("subscribe_track failed"); let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.recv_group()) diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 3f8340a0e..660a141ff 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -1,6 +1,6 @@ use crate::{ - ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_LITE, ALPN_LITE_03, ALPN_LITE_04, Error, NEGOTIATED, - OriginConsumer, OriginProducer, Session, StatsHandle, Version, Versions, + ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_LITE, ALPN_LITE_03, ALPN_LITE_04, ALPN_LITE_05, Error, + NEGOTIATED, OriginConsumer, OriginProducer, Session, StatsHandle, Version, Versions, coding::{self, Decode, Encode, Stream}, ietf, lite, setup, }; @@ -120,6 +120,22 @@ impl Client { .ok_or(Error::Version)?; (v, v.into()) } + Some(ALPN_LITE_05) => { + self.versions + .select(Version::Lite(lite::Version::Lite05)) + .ok_or(Error::Version)?; + + let recv_bw = lite::start( + session.clone(), + None, + self.publish.clone(), + self.consume.clone(), + self.stats.clone(), + lite::Version::Lite05, + )?; + + return Ok(Session::new(session, lite::Version::Lite05.into(), recv_bw)); + } Some(ALPN_LITE_04) => { self.versions .select(Version::Lite(lite::Version::Lite04)) diff --git a/rs/moq-net/src/coding/varint.rs b/rs/moq-net/src/coding/varint.rs index 81e6b8ca8..7640f1918 100644 --- a/rs/moq-net/src/coding/varint.rs +++ b/rs/moq-net/src/coding/varint.rs @@ -52,6 +52,25 @@ impl VarInt { pub const fn into_inner(self) -> u64 { self.0 } + + /// Encode a signed `i64` as a zigzag-then-unsigned varint: `(n << 1) ^ (n >> 63)`. + /// + /// Small negative numbers map to small unsigneds (-1 -> 1, 1 -> 2, -2 -> 3, ...). + /// Returns [`BoundsExceeded`] if `signed` is outside `[-2^61, 2^61 - 1]`, since the + /// zigzag-encoded result must fit in a 62-bit varint. + pub const fn from_zigzag(signed: i64) -> Result { + const RANGE: i64 = 1 << 61; + if signed < -RANGE || signed >= RANGE { + return Err(BoundsExceeded); + } + Ok(Self(((signed << 1) ^ (signed >> 63)) as u64)) + } + + /// Decode this varint as a signed `i64` via the inverse zigzag transform. + pub const fn to_zigzag(self) -> i64 { + let v = self.0; + ((v >> 1) as i64) ^ -((v & 1) as i64) + } } impl From for u64 { @@ -169,7 +188,7 @@ impl fmt::Display for VarInt { impl VarInt { /// Decode a QUIC-style varint (2-bit length tag in top bits). - fn decode_quic(r: &mut R) -> Result { + pub fn decode_quic(r: &mut R) -> Result { if !r.has_remaining() { return Err(DecodeError::Short); } @@ -210,7 +229,7 @@ impl VarInt { } /// Encode a QUIC-style varint (2-bit length tag in top bits). - fn encode_quic(&self, w: &mut W) -> Result<(), EncodeError> { + pub fn encode_quic(&self, w: &mut W) -> Result<(), EncodeError> { let remaining = w.remaining_mut(); if self.0 < (1u64 << 6) { if remaining < 1 { @@ -542,8 +561,8 @@ where #[cfg(test)] mod tests { - use super::{DecodeError, VarInt}; - use crate::ietf; + use super::*; + use crate::{ietf, lite}; use bytes::Bytes; /// Test vectors from the draft-17 spec (Table 2: Example Integer Encodings), @@ -645,6 +664,60 @@ mod tests { assert!(matches!(err, DecodeError::InvalidValue)); } + #[test] + fn zigzag_roundtrip_small() { + for n in [-3i64, -2, -1, 0, 1, 2, 3, 100, -100] { + let v = VarInt::from_zigzag(n).unwrap(); + assert_eq!(v.to_zigzag(), n, "roundtrip failed for {}", n); + } + } + + #[test] + fn zigzag_small_values_compact() { + // First few values should fit in 1 byte (varint range 0..=63 = top-2-bits tag 00). + assert_eq!(VarInt::from_zigzag(0).unwrap().into_inner(), 0); + assert_eq!(VarInt::from_zigzag(-1).unwrap().into_inner(), 1); + assert_eq!(VarInt::from_zigzag(1).unwrap().into_inner(), 2); + assert_eq!(VarInt::from_zigzag(-2).unwrap().into_inner(), 3); + assert_eq!(VarInt::from_zigzag(2).unwrap().into_inner(), 4); + } + + #[test] + fn zigzag_roundtrip_boundary() { + // Boundary values in the valid input range [-2^61, 2^61 - 1]. + let max = (1i64 << 61) - 1; + let min = -(1i64 << 61); + let mid = (1i64 << 30) + 17; + + for n in [max, min, mid, -mid] { + let v = VarInt::from_zigzag(n).unwrap(); + assert_eq!(v.to_zigzag(), n); + } + } + + #[test] + fn zigzag_out_of_range_rejected() { + // Values past the i61 boundary are out of varint range. + assert!(VarInt::from_zigzag(1i64 << 61).is_err()); + assert!(VarInt::from_zigzag(-(1i64 << 61) - 1).is_err()); + assert!(VarInt::from_zigzag(i64::MAX).is_err()); + assert!(VarInt::from_zigzag(i64::MIN).is_err()); + } + + #[test] + fn zigzag_quic_varint_roundtrip() { + // Encode a zigzag value through the QUIC varint wire format. + for n in [-5000i64, 0, 100, -1, 1_000_000, -1_000_000] { + let v = VarInt::from_zigzag(n).unwrap(); + + let mut buf = bytes::BytesMut::new(); + v.encode(&mut buf, lite::Version::Lite01).unwrap(); + let mut bytes = buf.freeze(); + let decoded = VarInt::decode(&mut bytes, lite::Version::Lite01).unwrap(); + assert_eq!(decoded.to_zigzag(), n); + } + } + #[test] fn draft18_accepts_7_byte_varint() { // Value 0x1234_5678_9ABC encoded as 7-byte leading-ones (1111110x | hi, +6 bytes). diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 45772bde5..d63c8c5c0 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -5,7 +5,7 @@ use web_async::FuturesExt; use web_transport_trait::SendStream; use crate::{ - AsPath, Error, Origin, OriginConsumer, Track, TrackConsumer, + AsPath, Error, Origin, OriginConsumer, TrackConsumer, coding::{Stream, Writer}, ietf::{self, Control, FetchHeader, FetchType, FilterType, GroupOrder, Location, RequestId}, model::GroupConsumer, @@ -113,12 +113,16 @@ impl Publisher { return Ok(()); }; - let track = Track { - name: msg.track_name.to_string(), - priority: msg.subscriber_priority, - }; - - let track = match broadcast.subscribe_track(&track) { + let track = match broadcast + .subscribe_track( + &msg.track_name, + crate::Subscription { + priority: msg.subscriber_priority, + timeout: std::time::Duration::ZERO, + }, + ) + .await + { Ok(track) => track, Err(err) => { self.write_subscribe_error(&mut stream.writer, request_id, 404, &err.to_string()) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index f371d7ff6..66db2dfc5 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -465,6 +465,7 @@ impl Subscriber { let track = Track { name: msg.track_name.to_string(), priority: 0, + timescale: crate::Timescale::UNKNOWN, } .produce(); @@ -499,9 +500,9 @@ impl Subscriber { async fn run_broadcast(&self, path: Path<'_>, mut broadcast: BroadcastDynamic) -> Result<(), Error> { loop { - let track = tokio::select! { - producer = broadcast.requested_track() => match producer { - Ok(producer) => producer, + let request = tokio::select! { + request = broadcast.requested_track() => match request { + Ok(request) => request, Err(err) => { tracing::debug!(%err, "broadcast closed"); break; @@ -515,18 +516,26 @@ impl Subscriber { let path = path.to_owned(); let broadcast = broadcast.clone(); web_async::spawn(async move { - this.run_subscribe(path, broadcast, track).await; + this.run_subscribe(path, broadcast, request).await; }); } Ok(()) } - async fn run_subscribe(&mut self, broadcast_path: Path<'_>, broadcast: BroadcastDynamic, mut track: TrackProducer) { + async fn run_subscribe( + &mut self, + broadcast_path: Path<'_>, + broadcast: BroadcastDynamic, + request: crate::TrackRequest, + ) { + let track_name = request.name().to_string(); + let priority = request.subscription().priority; + let request_id = match self.control.next_request_id().await { Ok(id) => id, Err(err) => { - let _ = track.abort(err); + request.deny(err); return; } }; @@ -535,71 +544,92 @@ impl Subscriber { Ok(s) => s, Err(err) => { tracing::debug!(%err, "failed to open subscribe stream"); - let _ = track.abort(err); + request.deny(err); return; } }; - // Pre-register the track so group data arriving before SubscribeOk can be routed. - // The publisher uses request_id.0 as track_alias, and recv_group falls back to - // RequestId(track_alias) when no alias mapping exists, so this works. - { - let mut state = self.state.lock(); - state.subscribes.insert( - request_id, - TrackState { - producer: track.clone(), - alias: None, - }, - ); - } - // Write Subscribe message if let Err(err) = self - .write_subscribe(&mut stream, request_id, &broadcast_path, &track) + .write_subscribe(&mut stream, request_id, &broadcast_path, &track_name, priority) .await { tracing::debug!(%err, "failed to write subscribe"); - self.state.lock().subscribes.remove(&request_id); - let _ = track.abort(err); + request.deny(err); return; } - tracing::info!(broadcast = %self.origin.as_ref().expect("origin set by start_announce").absolute(&broadcast_path), track = %track.name, "subscribe started"); + tracing::info!( + broadcast = %self.origin.as_ref().expect("origin set by start_announce").absolute(&broadcast_path), + track = %track_name, + "subscribe started" + ); // Read the response and register the alias mapping let track_alias = match self.read_subscribe_response(&mut stream).await { - Ok(alias) => { - if let Some(alias) = alias { - let mut state = self.state.lock(); - state.aliases.insert(alias, request_id); - if let Some(track_state) = state.subscribes.get_mut(&request_id) { - track_state.alias = Some(alias); - } - } - alias - } + Ok(alias) => alias, Err(err) => { tracing::debug!(%err, "subscribe response error"); - self.state.lock().subscribes.remove(&request_id); - let _ = track.abort(err); + request.deny(err); return; } }; + // LOC-style track properties are not parsed yet; default the timescale + // to UNKNOWN. TODO: read timescale from track properties on Draft17+. + let track_info = crate::Track { + name: track_name.clone(), + priority, + timescale: crate::Timescale::UNKNOWN, + }; + + let mut track = match request.accept(track_info) { + Ok(track) => track, + Err(err) => { + tracing::debug!(%err, "request accept failed"); + return; + } + }; + + { + let mut state = self.state.lock(); + if let Some(alias) = track_alias { + state.aliases.insert(alias, request_id); + } + state.subscribes.insert( + request_id, + TrackState { + producer: track.clone(), + alias: track_alias, + }, + ); + } + tokio::select! { _ = track.unused() => { - tracing::info!(broadcast = %self.origin.as_ref().expect("origin set by start_announce").absolute(&broadcast_path), track = %track.name, "subscribe cancelled"); + tracing::info!( + broadcast = %self.origin.as_ref().expect("origin set by start_announce").absolute(&broadcast_path), + track = %track_name, + "subscribe cancelled" + ); let _ = track.abort(Error::Cancel); } err = broadcast.closed() => { - tracing::info!(broadcast = %self.origin.as_ref().expect("origin set by start_announce").absolute(&broadcast_path), track = %track.name, "broadcast closed"); + tracing::info!( + broadcast = %self.origin.as_ref().expect("origin set by start_announce").absolute(&broadcast_path), + track = %track_name, + "broadcast closed" + ); let _ = track.abort(err); } res = stream.reader.closed() => { match res { Ok(()) => { - tracing::info!(broadcast = %self.origin.as_ref().expect("origin set by start_announce").absolute(&broadcast_path), track = %track.name, "subscribe complete"); + tracing::info!( + broadcast = %self.origin.as_ref().expect("origin set by start_announce").absolute(&broadcast_path), + track = %track_name, + "subscribe complete" + ); let _ = track.finish(); } Err(err) => { @@ -624,7 +654,8 @@ impl Subscriber { stream: &mut Stream, request_id: RequestId, broadcast: &Path<'_>, - track: &TrackProducer, + track_name: &str, + priority: u8, ) -> Result<(), Error> { stream.writer.encode(&ietf::Subscribe::ID).await?; stream @@ -632,8 +663,8 @@ impl Subscriber { .encode(&ietf::Subscribe { request_id, track_namespace: broadcast.to_owned(), - track_name: (&track.name).into(), - subscriber_priority: track.priority, + track_name: track_name.into(), + subscriber_priority: priority, group_order: GroupOrder::Descending, filter_type: FilterType::LargestObject, }) @@ -736,7 +767,7 @@ impl Subscriber { if size == 0 { let status: u64 = stream.decode().await?; if status == 0 { - let mut frame = producer.create_frame(Frame { size: 0 })?; + let mut frame = producer.create_frame(Frame::from(0u64))?; frame.finish()?; } else if status == 3 && !group.flags.has_end { break; @@ -744,7 +775,7 @@ impl Subscriber { return Err(Error::Unsupported); } } else { - let mut frame = producer.create_frame(Frame { size })?; + let mut frame = producer.create_frame(Frame::from(size))?; if let Err(err) = self.run_frame(stream, frame.clone()).await { let _ = frame.abort(err.clone()); diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index cff083c27..5c43c5ebb 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -49,7 +49,7 @@ //! standard [`std::task::Waker`] API and any [`std::task::Waker`] is a valid driver. mod client; -mod coding; +pub mod coding; mod error; mod ietf; mod lite; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 8f0e386f1..73c53ff2c 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -5,8 +5,8 @@ use web_async::FuturesExt; use web_transport_trait::Stats; use crate::{ - AsPath, BroadcastConsumer, Error, Origin, OriginConsumer, OriginList, StatsHandle as MoqStats, Track, - TrackConsumer, + AsPath, BroadcastConsumer, Error, Origin, OriginConsumer, OriginList, StatsHandle as MoqStats, Subscription, + Timescale, TrackConsumer, coding::{Stream, Writer}, lite::{ self, @@ -357,15 +357,19 @@ impl Publisher { track_stats: crate::PublisherTrack, version: Version, ) -> Result<(), Error> { - let track = Track { - name: subscribe.track.to_string(), - priority: subscribe.priority, - }; - let broadcast = consumer.ok_or(Error::NotFound)?; - let track = broadcast.subscribe_track(&track)?; - // TODO wait until track.info() to get the *real* priority + // Await the publisher's authoritative Track properties (priority, + // timescale) before responding with SUBSCRIBE_OK. + let track = broadcast + .subscribe_track( + &subscribe.track, + Subscription { + priority: subscribe.priority, + timeout: std::time::Duration::ZERO, + }, + ) + .await?; let info = lite::SubscribeOk { priority: track.priority, @@ -373,6 +377,7 @@ impl Publisher { max_latency: std::time::Duration::ZERO, start_group: None, end_group: None, + timescale: track.timescale.as_u64(), }; stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?; @@ -423,7 +428,16 @@ impl Publisher { let priority = priority.insert(track.priority, sequence); tasks.push( - Self::serve_group(session.clone(), msg, priority, group, track_stats.clone(), version).map(|_| ()), + Self::serve_group( + session.clone(), + msg, + priority, + group, + track_stats.clone(), + version, + track.timescale, + ) + .map(|_| ()), ); } } @@ -435,6 +449,7 @@ impl Publisher { mut group: GroupConsumer, track_stats: std::sync::Arc, version: Version, + track_timescale: Timescale, ) -> Result<(), Error> { // TODO add a way to open in priority order. let stream = session.open_uni().await.map_err(Error::from_transport)?; @@ -445,6 +460,9 @@ impl Publisher { stream.encode(&msg).await?; track_stats.group(); + // Previous frame timestamp on this group's stream, for zigzag-delta encoding on Lite05+. + let mut prev_ts: u64 = 0; + loop { let frame = tokio::select! { biased; @@ -462,6 +480,21 @@ impl Publisher { None => break, }; + if version.has_timestamps() { + // Verify per-frame timestamp scale matches the track's negotiated timescale. + let ts = frame.timestamp; + if !ts.is_unspecified() && ts.scale() != track_timescale { + return Err(Error::ProtocolViolation); + } + let curr = ts.value(); + let delta: i64 = (curr as i128 - prev_ts as i128) + .try_into() + .map_err(|_| Error::BoundsExceeded(crate::coding::BoundsExceeded))?; + let zz = crate::coding::VarInt::from_zigzag(delta).map_err(crate::coding::EncodeError::from)?; + stream.encode(&zz).await?; + prev_ts = curr; + } + stream.encode(&frame.size).await?; track_stats.frame(); diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index a4966dde5..fb00230ee 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -72,13 +72,17 @@ impl Message for Subscribe<'_> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct SubscribeOk { pub priority: u8, pub ordered: bool, pub max_latency: std::time::Duration, pub start_group: Option, pub end_group: Option, + /// Units per second for frame timestamps on this track. `0` (the default) + /// means unspecified. Carried on the wire for [`Version::Lite05`] and later; + /// older versions decode it as `0`. + pub timescale: u64, } impl Message for SubscribeOk { @@ -88,12 +92,20 @@ 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)?; + } _ => { 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.timescale.encode(w, version)?; } } @@ -104,24 +116,32 @@ impl Message for SubscribeOk { match version { Version::Lite01 => Ok(Self { priority: u8::decode(r, version)?, - ordered: false, - max_latency: std::time::Duration::ZERO, - start_group: None, - end_group: None, - }), - Version::Lite02 => Ok(Self { - priority: 0, - ordered: false, - max_latency: std::time::Duration::ZERO, - start_group: None, - end_group: None, + ..Self::default() }), + Version::Lite02 => Ok(Self::default()), + 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, + timescale: 0, + }) + } _ => { 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)?; + let timescale = u64::decode(r, version)?; Ok(Self { priority, @@ -129,6 +149,7 @@ impl Message for SubscribeOk { max_latency, start_group, end_group, + timescale, }) } } @@ -326,3 +347,58 @@ impl Decode for SubscribeResponse { } } } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + + fn roundtrip_ok(version: Version, original: SubscribeOk) -> SubscribeOk { + let mut buf = BytesMut::new(); + original.encode_msg(&mut buf, version).unwrap(); + let mut bytes = buf.freeze(); + SubscribeOk::decode_msg(&mut bytes, version).unwrap() + } + + #[test] + fn subscribe_ok_lite04_drops_timescale() { + // On Lite04, timescale is not serialized; it should round-trip as 0. + let ok = SubscribeOk { + priority: 7, + ordered: true, + max_latency: std::time::Duration::from_millis(500), + start_group: Some(2), + end_group: Some(10), + timescale: 1_000_000, + }; + let decoded = roundtrip_ok(Version::Lite04, ok); + assert_eq!(decoded.priority, 7); + assert!(decoded.ordered); + assert_eq!(decoded.start_group, Some(2)); + assert_eq!(decoded.end_group, Some(10)); + assert_eq!(decoded.timescale, 0); + } + + #[test] + fn subscribe_ok_lite05_carries_timescale() { + let ok = SubscribeOk { + priority: 3, + ordered: false, + max_latency: std::time::Duration::from_millis(100), + start_group: None, + end_group: None, + timescale: 1_000_000, + }; + let decoded = roundtrip_ok(Version::Lite05, ok); + assert_eq!(decoded.priority, 3); + assert_eq!(decoded.timescale, 1_000_000); + } + + #[test] + fn subscribe_ok_lite05_unspecified_timescale() { + // timescale = 0 round-trips on Lite05 (still serialized, just as 0). + let ok = SubscribeOk::default(); + let decoded = roundtrip_ok(Version::Lite05, ok); + assert_eq!(decoded.timescale, 0); + } +} diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 855f2db76..8bdee0e0a 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -7,7 +7,8 @@ use futures::{StreamExt, stream::FuturesUnordered}; use crate::{ AsPath, BandwidthProducer, Broadcast, BroadcastDynamic, Error, Frame, FrameProducer, Group, GroupProducer, - OriginProducer, Path, PathOwned, StatsHandle, SubscriberStats, SubscriberTrack, TrackProducer, + OriginProducer, Path, PathOwned, StatsHandle, SubscriberStats, SubscriberTrack, Timescale, Track, TrackProducer, + TrackRequest, coding::{Reader, Stream}, lite, model::BroadcastProducer, @@ -307,9 +308,9 @@ impl Subscriber { 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 track = tokio::select! { - producer = broadcast.requested_track() => match producer { - Ok(producer) => producer, + let request = tokio::select! { + request = broadcast.requested_track() => match request { + Ok(request) => request, Err(err) => { tracing::debug!(%err, "broadcast closed"); break; @@ -324,105 +325,130 @@ impl Subscriber { let path = path.clone(); let broadcast = broadcast.clone(); web_async::spawn(async move { - this.run_subscribe(id, path, broadcast, track).await; + this.run_subscribe(id, path, broadcast, request).await; this.subscribes.lock().remove(&id); }); } } - async fn run_subscribe(&mut self, id: u64, path: PathOwned, broadcast: BroadcastDynamic, mut track: TrackProducer) { + async fn run_subscribe(&mut self, id: u64, path: PathOwned, broadcast: BroadcastDynamic, request: TrackRequest) { + let track_name = request.name().to_string(); + let priority = request.subscription().priority; + // 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 // is tracked separately by the announce loop's `stats_guards`. - let abs = self.origin.as_ref().unwrap().absolute(&path); - let track_stats = Arc::new(self.stats.broadcast(&abs).subscriber_track(&track.name)); + let abs = self.origin.as_ref().unwrap().absolute(&path).to_owned(); + let track_stats = Arc::new(self.stats.broadcast(&abs).subscriber_track(&track_name)); - self.subscribes.lock().insert( - id, - TrackEntry { - producer: track.clone(), - stats: track_stats.clone(), - }, - ); + tracing::info!(id, broadcast = %self.log_path(&path), track = %track_name, "subscribe started"); let msg = lite::Subscribe { id, broadcast: path.as_path(), - track: (&track.name).into(), - priority: track.priority, + track: (&track_name).into(), + priority, ordered: true, max_latency: std::time::Duration::ZERO, start_group: None, end_group: None, }; - tracing::info!(id, broadcast = %self.log_path(&path), track = %track.name, "subscribe started"); - - tokio::select! { - _ = track.unused() => { - tracing::info!(id, broadcast = %self.log_path(&path), track = %track.name, "subscribe cancelled"); - let _ = track.abort(Error::Cancel); - } - err = broadcast.closed() => { - tracing::info!(id, broadcast = %self.log_path(&path), track = %track.name, "broadcast closed"); - let _ = track.abort(err); + // Send SUBSCRIBE and wait for SUBSCRIBE_OK to get the publisher's + // Track properties. Then convert the request into a TrackProducer. + let mut stream = match Stream::open(&self.session, self.version).await { + Ok(s) => s, + Err(err) => { + request.deny(err); + return; } - res = self.run_track(msg) => match res { - Ok(()) => { - tracing::info!(id, broadcast = %self.log_path(&path), track = %track.name, "subscribe complete"); - let _ = track.finish(); - } - Err(err) => { - tracing::warn!(id, broadcast = %self.log_path(&path), track = %track.name, %err, "subscribe error"); - let _ = track.abort(err); - } - }, + }; + if let Err(err) = stream.writer.encode(&lite::ControlType::Subscribe).await { + request.deny(err); + return; } - } - - async fn run_track(&mut self, msg: lite::Subscribe<'_>) -> Result<(), Error> { - let mut stream = Stream::open(&self.session, self.version).await?; - stream.writer.encode(&lite::ControlType::Subscribe).await?; - - if let Err(err) = self.run_track_stream(&mut stream, msg).await { + if let Err(err) = stream.writer.encode(&msg).await { stream.writer.abort(&err); - return Err(err); + request.deny(err); + return; } - stream.writer.finish()?; - stream.writer.closed().await - } + let info: lite::SubscribeOk = match stream.reader.decode::().await { + Ok(lite::SubscribeResponse::Ok(info)) => info, + Ok(_) => { + request.deny(Error::ProtocolViolation); + stream.writer.abort(&Error::ProtocolViolation); + return; + } + Err(err) => { + let cause = err; + stream.writer.abort(&cause); + request.deny(cause); + return; + } + }; - async fn run_track_stream( - &mut self, - stream: &mut Stream, - msg: lite::Subscribe<'_>, - ) -> Result<(), Error> { - stream.writer.encode(&msg).await?; + let track_struct = Track { + name: track_name.clone(), + priority: info.priority, + timescale: Timescale::new(info.timescale), + }; - // The first response MUST be a SUBSCRIBE_OK. - let resp: lite::SubscribeResponse = stream.reader.decode().await?; - let lite::SubscribeResponse::Ok(_info) = resp else { - return Err(Error::ProtocolViolation); + let mut track = match request.accept(track_struct) { + Ok(track) => track, + Err(err) => { + stream.writer.abort(&err); + return; + } }; - // TODO handle additional SUBSCRIBE_OK and SUBSCRIBE_DROP messages. - stream.reader.closed().await?; + self.subscribes.lock().insert( + id, + TrackEntry { + producer: track.clone(), + stats: track_stats.clone(), + }, + ); - Ok(()) + tokio::select! { + _ = track.unused() => { + tracing::info!(id, broadcast = %self.log_path(&path), track = %track_name, "subscribe cancelled"); + let _ = track.abort(Error::Cancel); + stream.writer.abort(&Error::Cancel); + } + err = broadcast.closed() => { + tracing::info!(id, broadcast = %self.log_path(&path), track = %track_name, "broadcast closed"); + let _ = track.abort(err.clone()); + stream.writer.abort(&err); + } + res = stream.reader.closed() => { + match res { + Ok(()) => { + tracing::info!(id, broadcast = %self.log_path(&path), track = %track_name, "subscribe complete"); + let _ = track.finish(); + } + Err(err) => { + tracing::warn!(id, broadcast = %self.log_path(&path), track = %track_name, %err, "subscribe error"); + let _ = track.abort(err); + } + } + let _ = stream.writer.finish(); + } + } } 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) = { + let (mut group, track, track_stats, timescale) = { 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()) + let timescale = entry.producer.timescale; + (group, entry.producer.clone(), entry.stats.clone(), timescale) }; // Bump groups counter for this incoming group on the subscriber side. @@ -431,7 +457,7 @@ impl Subscriber { let res = tokio::select! { err = track.closed() => Err(err), err = group.closed() => Err(err), - res = self.run_group(stream, group.clone(), track_stats.clone()) => res, + res = self.run_group(stream, group.clone(), track_stats.clone(), timescale) => res, }; match res { @@ -455,9 +481,33 @@ impl Subscriber { stream: &mut Reader, mut group: GroupProducer, track_stats: Arc, + track_timescale: Timescale, ) -> Result<(), Error> { - while let Some(size) = stream.decode_maybe::().await? { - let mut frame = group.create_frame(Frame { size })?; + // Previous frame's raw timestamp value, for zigzag-delta decoding on Lite05+. + let mut prev_ts: u64 = 0; + + loop { + let (size, timestamp) = if self.version.has_timestamps() { + let Some(zz) = stream.decode_maybe::().await? else { + break; + }; + let delta = zz.to_zigzag(); + let next = (prev_ts as i128 + delta as i128) + .try_into() + .map_err(|_| Error::BoundsExceeded(crate::coding::BoundsExceeded))?; + prev_ts = next; + let size = stream.decode::().await?; + let ts = crate::Timestamp::new(next, track_timescale) + .map_err(|_| Error::BoundsExceeded(crate::coding::BoundsExceeded))?; + (size, ts) + } else { + let Some(size) = stream.decode_maybe::().await? else { + break; + }; + (size, crate::Timestamp::ZERO) + }; + + let mut frame = group.create_frame(Frame { size, timestamp })?; track_stats.frame(); if let Err(err) = self.run_frame(stream, &mut frame, &track_stats).await { diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 07de44e68..b3602f711 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -7,6 +7,22 @@ pub enum Version { Lite02, Lite03, Lite04, + /// Lite05 adds per-track timescale to SUBSCRIBE_OK and zigzag-delta timestamps + /// to per-frame headers. + Lite05, +} + +impl Version { + /// Whether this version carries per-frame timestamps and per-track timescale + /// on the wire. + #[allow(clippy::match_like_matches_macro)] + pub fn has_timestamps(self) -> bool { + // Match form is used so future versions default forward (CLAUDE.md convention). + match self { + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, + _ => true, + } + } } impl fmt::Display for Version { @@ -16,18 +32,17 @@ impl fmt::Display for Version { Self::Lite02 => write!(f, "moq-lite-02"), Self::Lite03 => write!(f, "moq-lite-03"), Self::Lite04 => write!(f, "moq-lite-04"), + // Mirrors `ALPN_LITE_05`: kept distinct from the eventual stable + // `moq-lite-05` identifier so peers parsing this string don't pick + // up the unfinalized wire format by accident. + Self::Lite05 => write!(f, "moq-lite-05-wip"), } } } impl From for crate::Version { fn from(v: Version) -> Self { - match v { - Version::Lite01 => crate::Version::Lite(Version::Lite01), - Version::Lite02 => crate::Version::Lite(Version::Lite02), - Version::Lite03 => crate::Version::Lite(Version::Lite03), - Version::Lite04 => crate::Version::Lite(Version::Lite04), - } + crate::Version::Lite(v) } } diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index d866f7fc5..6e45c2a6e 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -4,7 +4,9 @@ use std::{ task::{Poll, ready}, }; -use crate::{Error, TrackConsumer, TrackProducer, model::track::TrackWeak}; +use tokio::sync::oneshot; + +use crate::{Error, Subscription, TrackConsumer, TrackProducer, model::track::TrackWeak}; use super::{OriginList, Track}; @@ -32,19 +34,35 @@ impl Broadcast { } } -#[derive(Default, Clone)] +/// A pending subscription request, waiting for a publisher to call +/// [`TrackRequest::accept`] (or [`TrackRequest::deny`]) to resolve it. +struct PendingRequest { + subscription: Subscription, + /// Resolvers waiting for the producer to be created. Each call to + /// [`BroadcastConsumer::subscribe_track`] for the same name during the + /// pending window adds an entry here so they all see the same producer. + resolvers: Vec>>, +} + +#[derive(Default)] struct State { - // Weak references for deduplication. Doesn't prevent track auto-close. + /// Weak references to live producers, used to dedupe subscribe_track calls + /// that target a name already being served. tracks: HashMap, - // Dynamic tracks that have been requested. - requests: Vec, + /// Pending requests by track name. Used both to fan out the resolved + /// producer to multiple awaiting subscribers and as the queue that + /// [`BroadcastDynamic::requested_track`] drains. + requests: HashMap, + + /// Names in `requests` ordered FIFO for the dynamic handler. + request_order: Vec, - // The current number of dynamic producers. - // If this is 0, requests must be empty. + /// The current number of dynamic producers. + /// If this is 0, requests must be empty. dynamic: usize, - // The error that caused the broadcast to be aborted, if any. + /// The error that caused the broadcast to be aborted, if any. abort: Option, } @@ -64,6 +82,16 @@ impl State { entry.insert(weak); Ok(()) } + + /// Drop the named pending request and notify all resolvers with `err`. + fn deny_request(&mut self, name: &str, err: Error) { + if let Some(pending) = self.requests.remove(name) { + self.request_order.retain(|n| n != name); + for tx in pending.resolvers { + let _ = tx.send(Err(err.clone())); + } + } + } } /// Manages tracks within a broadcast. @@ -124,18 +152,15 @@ impl BroadcastProducer { /// /// Generates names like `0{suffix}`, `1{suffix}`, etc. and picks the first /// one not already used in this broadcast. - pub fn unique_track(&mut self, suffix: &str) -> Result { + pub fn unique_name(&self, suffix: &str) -> String { let state = self.state.read(); - let mut name = String::new(); for i in 0u32.. { - name = format!("{i}{suffix}"); + let name = format!("{i}{suffix}"); if !state.tracks.contains_key(&name) { - break; + return name; } } - drop(state); - - self.create_track(Track { name, priority: 0 }) + unreachable!("u32 namespace exhausted"); } /// Create a dynamic producer that handles on-demand track requests from consumers. @@ -163,8 +188,12 @@ impl BroadcastProducer { // Abort any pending dynamic track requests; their producers are owned // by the broadcast and would otherwise leave consumers stuck forever. - for mut request in guard.requests.drain(..) { - request.abort(err.clone()).ok(); + for name in std::mem::take(&mut guard.request_order) { + if let Some(pending) = guard.requests.remove(&name) { + for tx in pending.resolvers { + let _ = tx.send(Err(err.clone())); + } + } } guard.abort = Some(err); @@ -189,11 +218,104 @@ impl BroadcastProducer { } } +/// A pending track subscription. Hold this until you have constructed the full +/// [`Track`] and call [`Self::accept`], or [`Self::deny`] if the request can't +/// be served. Dropping without calling either implicitly denies with +/// [`Error::Cancel`]. +pub struct TrackRequest { + name: String, + subscription: Subscription, + state: conducer::Producer, + /// `None` after [`Self::accept`] or [`Self::deny`] has been called, so the + /// Drop impl knows not to double-deny. + completed: bool, +} + +impl TrackRequest { + /// The track name requested by the subscriber(s). + pub fn name(&self) -> &str { + &self.name + } + + /// The first subscriber's requested [`Subscription`]. Use this as a hint + /// for how to configure the [`Track`] (priority, timescale, etc.). Once + /// the request is accepted, the full aggregate becomes visible via + /// [`TrackProducer::max_priority`] / [`TrackProducer::max_timeout`]. + pub fn subscription(&self) -> &Subscription { + &self.subscription + } + + /// Fulfill the request with the given track. The track's `name` must match + /// [`Self::name`]; returns [`Error::NotFound`] otherwise. + pub fn accept(mut self, track: Track) -> Result { + if track.name != self.name { + return Err(Error::NotFound); + } + + let producer = TrackProducer::new(track); + self.completed = true; + + let mut state = modify(&self.state)?; + let pending = state.requests.remove(&self.name).ok_or(Error::Cancel)?; + + // Insert the producer's weak so future subscribe_track calls dedupe. + state.insert_track(producer.weak()).ok(); + + // Fan out a TrackConsumer to each waiting subscriber, carrying their + // own Subscription (the first subscriber's subscription matches the + // request; remaining resolvers in the queue already added theirs). + let weak = producer.weak(); + let mut resolvers = pending.resolvers.into_iter(); + if let Some(tx) = resolvers.next() { + let _ = tx.send(Ok(weak.consume_with(pending.subscription.clone()))); + } + for tx in resolvers { + let _ = tx.send(Ok(weak.consume_with(Subscription::default()))); + } + + // Spawn the cleanup task that removes the entry once nobody is consuming. + let consumer_state = self.state.clone(); + let weak_for_cleanup = producer.weak(); + web_async::spawn(async move { + let _ = weak_for_cleanup.unused().await; + let Ok(mut state) = consumer_state.write() else { return }; + + if let Some(current) = state.tracks.remove(&weak_for_cleanup.info.name) + && !current.is_clone(&weak_for_cleanup) + { + state.tracks.insert(current.info.name.clone(), current); + } + }); + + Ok(producer) + } + + /// Reject the request with the given error, waking all waiting subscribers. + pub fn deny(mut self, err: Error) { + self.completed = true; + if let Ok(mut state) = self.state.write() { + state.deny_request(&self.name, err); + } + } +} + +impl Drop for TrackRequest { + fn drop(&mut self) { + if !self.completed + && let Ok(mut state) = self.state.write() + { + state.deny_request(&self.name, Error::Cancel); + } + } +} + /// Handles on-demand track creation for a broadcast. /// -/// When a consumer requests a track that doesn't exist, a [TrackProducer] is created -/// and queued for the dynamic producer to fulfill via [Self::requested_track]. -/// Dropped when no longer needed; pending requests are automatically aborted. +/// When a consumer requests a track that doesn't exist, the dynamic producer +/// can pick up the request via [`Self::requested_track`] and either +/// [`TrackRequest::accept`] it with a concrete [`Track`] or +/// [`TrackRequest::deny`] it. Dropped when no longer needed; pending requests +/// are automatically aborted. pub struct BroadcastDynamic { info: Broadcast, state: conducer::Producer, @@ -245,17 +367,30 @@ impl BroadcastDynamic { }) } - /// Poll for the next consumer-requested track, without blocking. The returned producer - /// is preconfigured with the requested track's name and priority. - pub fn poll_requested_track(&mut self, waiter: &conducer::Waiter) -> Poll> { - self.poll(waiter, |state| match state.requests.pop() { - Some(producer) => Poll::Ready(producer), - None => Poll::Pending, + /// Poll for the next consumer-requested track. + pub fn poll_requested_track(&mut self, waiter: &conducer::Waiter) -> Poll> { + let state_clone = self.state.clone(); + self.poll(waiter, |state| { + let Some(name) = state.request_order.first().cloned() else { + return Poll::Pending; + }; + let pending = state.requests.get(&name).expect("request_order must mirror requests"); + let subscription = pending.subscription.clone(); + state.request_order.remove(0); + Poll::Ready((name, subscription)) + }) + .map(|res| { + res.map(|(name, subscription)| TrackRequest { + name, + subscription, + state: state_clone, + completed: false, + }) }) } - /// Block until a consumer requests a track, returning its producer. - pub async fn requested_track(&mut self) -> Result { + /// Block until a consumer requests a track, returning a request handle. + pub async fn requested_track(&mut self) -> Result { conducer::wait(|waiter| self.poll_requested_track(waiter)).await } @@ -282,10 +417,12 @@ impl BroadcastDynamic { pub fn abort(&mut self, err: Error) -> Result<(), Error> { let mut guard = modify(&self.state)?; - // Abort any pending dynamic track requests; their producers are owned - // by the broadcast and would otherwise leave consumers stuck forever. - for mut request in guard.requests.drain(..) { - request.abort(err.clone()).ok(); + for name in std::mem::take(&mut guard.request_order) { + if let Some(pending) = guard.requests.remove(&name) { + for tx in pending.resolvers { + let _ = tx.send(Err(err.clone())); + } + } } guard.abort = Some(err); @@ -309,8 +446,12 @@ impl Drop for BroadcastDynamic { } // Abort all pending requests since there's no dynamic producer to handle them. - for mut request in state.requests.drain(..) { - request.abort(Error::Cancel).ok(); + for name in std::mem::take(&mut state.request_order) { + if let Some(pending) = state.requests.remove(&name) { + for tx in pending.resolvers { + let _ = tx.send(Err(Error::Cancel)); + } + } } } } @@ -321,7 +462,7 @@ use futures::FutureExt; #[cfg(test)] impl BroadcastDynamic { - pub fn assert_request(&mut self) -> TrackProducer { + pub fn assert_request(&mut self) -> TrackRequest { self.requested_track() .now_or_never() .expect("should not have blocked") @@ -351,60 +492,60 @@ impl Deref for BroadcastConsumer { impl BroadcastConsumer { /// Subscribe to a track on this broadcast. /// - /// Reuses an existing producer if one is already publishing the track; otherwise - /// queues a new dynamic request that the broadcast's producer will service via - /// [`BroadcastDynamic::requested_track`]. Returns [`Error::NotFound`] if the - /// broadcast has no dynamic producer to handle requests. - pub fn subscribe_track(&self, track: &Track) -> Result { - // Upgrade to a temporary producer so we can modify the state. - let producer = self - .state - .produce() - .ok_or_else(|| self.state.read().abort.clone().unwrap_or(Error::Dropped))?; - let mut state = modify(&producer)?; - - if let Some(weak) = state.tracks.get(&track.name) { - if !weak.is_closed() { - return Ok(weak.consume()); + /// Returns once the publisher has resolved the track's properties (priority + /// and timescale) via the dynamic handler's [`TrackRequest::accept`]. + /// Reuses an existing producer if one is already publishing the track; + /// otherwise queues a new dynamic request. Returns [`Error::NotFound`] if + /// no dynamic producer exists to service the request. + /// + /// The returned [`TrackConsumer`] dereferences to a [`Track`] with the + /// publisher's authoritative properties. The subscription is tracked + /// internally and contributes to the producer's + /// [`TrackProducer::max_priority`] / [`TrackProducer::max_timeout`] + /// aggregates; call [`TrackConsumer::update_subscription`] to update. + pub async fn subscribe_track(&self, name: &str, subscription: Subscription) -> Result { + let rx = { + // Upgrade to a temporary producer so we can modify the state. + let producer = self + .state + .produce() + .ok_or_else(|| self.state.read().abort.clone().unwrap_or(Error::Dropped))?; + let mut state = modify(&producer)?; + + // Reuse an existing producer if one is already live. + if let Some(weak) = state.tracks.get(name) { + if !weak.is_closed() { + return Ok(weak.consume_with(subscription)); + } + // Stale entry; remove and treat as a new request. + state.tracks.remove(name); } - // Remove the stale entry - state.tracks.remove(&track.name); - } - // Otherwise we have never seen this track before and need to create a new producer. - let producer = track.clone().produce(); - let consumer = producer.consume(); - - if state.dynamic == 0 { - return Err(Error::NotFound); - } - - // Insert a weak reference for deduplication. - let weak = producer.weak(); - state.tracks.insert(producer.name.clone(), weak.clone()); - state.requests.push(producer); - - // Remove the track from the lookup when it's unused. - let consumer_state = self.state.clone(); - web_async::spawn(async move { - let _ = weak.unused().await; - - let Some(producer) = consumer_state.produce() else { - return; - }; - let Ok(mut state) = producer.write() else { - return; - }; - - // Remove the entry, but reinsert if it was replaced by a different reference. - if let Some(current) = state.tracks.remove(&weak.info.name) - && !current.is_clone(&weak) - { - state.tracks.insert(current.info.name.clone(), current); + let (tx, rx) = oneshot::channel(); + + // Coalesce with an in-flight request for the same name. + if let Some(pending) = state.requests.get_mut(name) { + pending.resolvers.push(tx); + rx + } else if state.dynamic == 0 { + return Err(Error::NotFound); + } else { + state.requests.insert( + name.to_string(), + PendingRequest { + subscription: subscription.clone(), + resolvers: vec![tx], + }, + ); + state.request_order.push(name.to_string()); + rx } - }); + }; - Ok(consumer) + match rx.await { + Ok(res) => res, + Err(_) => Err(self.state.read().abort.clone().unwrap_or(Error::Cancel)), + } } /// Block until the broadcast is closed and return the cause. @@ -438,10 +579,6 @@ impl BroadcastConsumer { #[cfg(test)] impl BroadcastConsumer { - pub fn assert_subscribe_track(&self, track: &Track) -> TrackConsumer { - self.subscribe_track(track).expect("should not have errored") - } - pub fn assert_not_closed(&self) { assert!(self.closed().now_or_never().is_none(), "should not be closed"); } @@ -466,14 +603,20 @@ mod test { let consumer = producer.consume(); - let mut track1_sub = consumer.assert_subscribe_track(&Track::new("track1")); + let mut track1_sub = consumer + .subscribe_track("track1", Subscription::default()) + .await + .expect("should subscribe"); track1_sub.assert_group(); let mut track2 = Track::new("track2").produce(); producer.assert_insert_track(&track2); let consumer2 = producer.consume(); - let mut track2_consumer = consumer2.assert_subscribe_track(&Track::new("track2")); + let mut track2_consumer = consumer2 + .subscribe_track("track2", Subscription::default()) + .await + .expect("should subscribe"); track2_consumer.assert_no_group(); track2.append_group().unwrap(); @@ -489,18 +632,17 @@ mod test { let consumer = producer.consume(); consumer.assert_not_closed(); - // Create a new track and insert it into the broadcast. + // Create a new track and insert it into the broadcast so subscribe_track + // can resolve immediately. let track1 = producer.assert_create_track(&Track::new("track1")); - let track1c = consumer.assert_subscribe_track(&track1); - let track2 = consumer.assert_subscribe_track(&Track::new("track2")); + let track1c = consumer + .subscribe_track("track1", Subscription::default()) + .await + .expect("should subscribe"); // Aborting the broadcast must NOT cascade to externally-owned tracks. producer.abort(Error::Cancel).unwrap(); - // track2's producer was owned by the broadcast (a pending dynamic - // request), so the consumer surfaces the abort. - track2.assert_error(); - // track1's producer is held outside the broadcast, so it survives. assert!(!track1.is_closed()); track1c.assert_not_closed(); @@ -509,39 +651,52 @@ mod test { #[tokio::test] async fn requests() { let mut producer = Broadcast::new().produce().dynamic(); - let consumer = producer.consume(); let consumer2 = consumer.clone(); - let mut track1 = consumer.assert_subscribe_track(&Track::new("track1")); - track1.assert_not_closed(); - track1.assert_no_group(); + // Spawn the subscriber tasks since subscribe_track now awaits resolution. + let sub1 = tokio::spawn({ + let consumer = consumer.clone(); + async move { consumer.subscribe_track("track1", Subscription::default()).await } + }); + let sub2 = tokio::spawn({ + let consumer = consumer2.clone(); + async move { consumer.subscribe_track("track1", Subscription::default()).await } + }); - // Make sure we deduplicate requests while track1 is still active. - let mut track2 = consumer2.assert_subscribe_track(&Track::new("track1")); - track2.assert_is_clone(&track1); + // Give the spawned tasks a chance to register their requests. + tokio::task::yield_now().await; + tokio::task::yield_now().await; - // Get the requested track, and there should only be one. - let mut track3 = producer.assert_request(); + // Get the requested track, and there should only be one (deduped). + let request = producer.assert_request(); + assert_eq!(request.name(), "track1"); producer.assert_no_request(); - // Make sure the consumer is the same. - track3.consume().assert_is_clone(&track1); + let mut track_producer = request.accept(Track::new("track1")).unwrap(); + + let mut track1 = sub1.await.unwrap().expect("should resolve"); + let mut track2 = sub2.await.unwrap().expect("should resolve"); + track1.assert_is_clone(&track2); - // Append a group and make sure they all get it. - track3.append_group().unwrap(); + // Append a group and make sure both consumers receive it. + track_producer.append_group().unwrap(); track1.assert_group(); track2.assert_group(); - // Make sure that tracks are cancelled when the producer is dropped. - let track4 = consumer.assert_subscribe_track(&Track::new("track2")); + // Subscribe to a new track and drop the dynamic — the pending sub aborts. + let sub3 = tokio::spawn({ + let consumer = consumer.clone(); + async move { consumer.subscribe_track("track2", Subscription::default()).await } + }); + tokio::task::yield_now().await; drop(producer); - // Make sure the track is errored, not closed. - track4.assert_error(); + assert!(sub3.await.unwrap().is_err(), "request should be cancelled"); - let track5 = consumer2.subscribe_track(&Track::new("track3")); - assert!(track5.is_err(), "should have errored"); + // Subscribing now should return NotFound (no dynamic). + let res = consumer2.subscribe_track("track3", Subscription::default()).await; + assert!(res.is_err(), "should have errored"); } #[tokio::test] @@ -549,86 +704,44 @@ mod test { let mut broadcast = Broadcast::new().produce().dynamic(); let consumer = broadcast.consume(); - // Subscribe to a track, creating a request - let track1 = consumer.assert_subscribe_track(&Track::new("track1")); + // Subscribe in the background. + let sub1 = tokio::spawn({ + let consumer = consumer.clone(); + async move { consumer.subscribe_track("track1", Subscription::default()).await } + }); + tokio::task::yield_now().await; + + let request = broadcast.assert_request(); + let mut producer1 = request.accept(Track::new("track1")).unwrap(); + + let track1 = sub1.await.unwrap().expect("should resolve"); - // Get the requested producer and close it (simulating publisher disconnect) - let mut producer1 = broadcast.assert_request(); producer1.append_group().unwrap(); producer1.finish().unwrap(); drop(producer1); - // The consumer should see the track as closed + // Consumer should see the track as closed. track1.assert_closed(); - // Subscribe again to the same track - should get a NEW producer, not the stale one - let mut track2 = consumer.assert_subscribe_track(&Track::new("track1")); - track2.assert_not_closed(); - track2.assert_not_clone(&track1); - - // There should be a new request for the track - let mut producer2 = broadcast.assert_request(); + // Subscribe again to the same track — should get a NEW producer. + let sub2 = tokio::spawn({ + let consumer = consumer.clone(); + async move { consumer.subscribe_track("track1", Subscription::default()).await } + }); + tokio::task::yield_now().await; + // Give the cleanup task a tick. + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + tokio::task::yield_now().await; + let request2 = broadcast.assert_request(); + let mut producer2 = request2.accept(Track::new("track1")).unwrap(); producer2.append_group().unwrap(); - // The new consumer should receive the new group + let mut track2 = sub2.await.unwrap().expect("should resolve"); + track2.assert_not_closed(); + track2.assert_not_clone(&track1); track2.assert_group(); } - #[tokio::test] - async fn requested_unused() { - let mut broadcast = Broadcast::new().produce().dynamic(); - - // Subscribe to a track that doesn't exist - this creates a request - let consumer1 = broadcast.consume().assert_subscribe_track(&Track::new("unknown_track")); - - // Get the requested track producer - let producer1 = broadcast.assert_request(); - - // The track producer should NOT be unused yet because there's a consumer - assert!( - producer1.unused().now_or_never().is_none(), - "track producer should be used" - ); - - // Making a new consumer will keep the producer alive - let consumer2 = broadcast.consume().assert_subscribe_track(&Track::new("unknown_track")); - consumer2.assert_is_clone(&consumer1); - - // Drop the consumer subscription - drop(consumer1); - - // The track producer should NOT be unused yet because there's a consumer - assert!( - producer1.unused().now_or_never().is_none(), - "track producer should be used" - ); - - // Drop the second consumer, now the producer should be unused - drop(consumer2); - - // BUG: The track producer should become unused after dropping the consumer, - // but it won't because the broadcast keeps a reference in the lookup HashMap - // This assertion will fail, demonstrating the bug - assert!( - producer1.unused().now_or_never().is_some(), - "track producer should be unused after consumer is dropped" - ); - - // TODO Unfortunately, we need to sleep for a little bit to detect when unused. - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - - // Now the cleanup task should have run and we can subscribe again to the unknown track. - let consumer3 = broadcast.consume().subscribe_track(&Track::new("unknown_track")); - let producer2 = broadcast.assert_request(); - - // Drop the consumer, now the producer should be unused - drop(consumer3); - assert!( - producer2.unused().now_or_never().is_some(), - "track producer should be unused after consumer is dropped" - ); - } - // Cloning a `BroadcastDynamic` and dropping the clone must not flip // `state.dynamic` to zero. The relay's lite subscriber clones the // dynamic per spawned subscribe; if Clone skipped the increment, the @@ -643,6 +756,16 @@ mod test { drop(clone); // Original handle is still live, so requests must still be accepted. - consumer.assert_subscribe_track(&Track::new("track1")); + let sub = tokio::spawn({ + let consumer = consumer.clone(); + async move { consumer.subscribe_track("track1", Subscription::default()).await } + }); + tokio::task::yield_now().await; + + // The request must still be there. + let mut broadcast = broadcast; + let request = broadcast.assert_request(); + request.accept(Track::new("track1")).unwrap(); + sub.await.unwrap().expect("should resolve"); } } diff --git a/rs/moq-net/src/model/frame.rs b/rs/moq-net/src/model/frame.rs index 2912a87d2..e7fa052db 100644 --- a/rs/moq-net/src/model/frame.rs +++ b/rs/moq-net/src/model/frame.rs @@ -5,17 +5,24 @@ use std::task::{Poll, ready}; use bytes::buf::UninitSlice; use bytes::{BufMut, Bytes}; -use crate::{Error, Result}; +use crate::{Error, Result, Timestamp}; -/// A chunk of data with an upfront size. +/// A chunk of data with an upfront size and presentation timestamp. /// /// Note that this is just the header. /// You use [FrameProducer] and [FrameConsumer] to deal with the frame payload, potentially chunked. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Frame { /// Total payload size in bytes. Declared up front so consumers can preallocate. pub size: u64, + /// Presentation timestamp in the parent track's timescale. + /// + /// Defaults to [`Timestamp::ZERO`] (unspecified scale). Producers should set this + /// to a value at the same scale as [`crate::Track::timescale`] before writing the + /// frame; the wire encoder errors on serialization if the scales disagree (for + /// protocols that delta-encode timestamps on the wire). + pub timestamp: Timestamp, } impl Frame { @@ -27,25 +34,37 @@ impl Frame { impl From for Frame { fn from(size: usize) -> Self { - Self { size: size as u64 } + Self { + size: size as u64, + timestamp: Timestamp::ZERO, + } } } impl From for Frame { fn from(size: u64) -> Self { - Self { size } + Self { + size, + timestamp: Timestamp::ZERO, + } } } impl From for Frame { fn from(size: u32) -> Self { - Self { size: size as u64 } + Self { + size: size as u64, + timestamp: Timestamp::ZERO, + } } } impl From for Frame { fn from(size: u16) -> Self { - Self { size: size as u64 } + Self { + size: size as u64, + timestamp: Timestamp::ZERO, + } } } @@ -437,7 +456,7 @@ mod test { #[test] fn single_chunk_roundtrip() { - let mut producer = Frame { size: 5 }.produce(); + let mut producer = Frame::from(5u64).produce(); producer.write(Bytes::from_static(b"hello")).unwrap(); producer.finish().unwrap(); @@ -448,7 +467,7 @@ mod test { #[test] fn multi_chunk_read_all() { - let mut producer = Frame { size: 10 }.produce(); + let mut producer = Frame::from(10u64).produce(); producer.write(Bytes::from_static(b"hello")).unwrap(); producer.write(Bytes::from_static(b"world")).unwrap(); producer.finish().unwrap(); @@ -460,7 +479,7 @@ mod test { #[test] fn read_chunk_sequential() { - let mut producer = Frame { size: 10 }.produce(); + let mut producer = Frame::from(10u64).produce(); producer.write(Bytes::from_static(b"hello")).unwrap(); // Each read_chunk returns whatever is new since the last call, // which may span multiple writes. @@ -479,7 +498,7 @@ mod test { #[test] fn read_all_chunks() { - let mut producer = Frame { size: 10 }.produce(); + let mut producer = Frame::from(10u64).produce(); producer.write(Bytes::from_static(b"hello")).unwrap(); producer.write(Bytes::from_static(b"world")).unwrap(); producer.finish().unwrap(); @@ -492,7 +511,7 @@ mod test { #[test] fn finish_checks_remaining() { - let mut producer = Frame { size: 5 }.produce(); + let mut producer = Frame::from(5u64).produce(); producer.write(Bytes::from_static(b"hi")).unwrap(); let err = producer.finish().unwrap_err(); assert!(matches!(err, Error::WrongSize)); @@ -500,14 +519,14 @@ mod test { #[test] fn write_too_many_bytes() { - let mut producer = Frame { size: 3 }.produce(); + let mut producer = Frame::from(3u64).produce(); let err = producer.write(Bytes::from_static(b"toolong")).unwrap_err(); assert!(matches!(err, Error::WrongSize)); } #[test] fn abort_propagates() { - let mut producer = Frame { size: 5 }.produce(); + let mut producer = Frame::from(5u64).produce(); let mut consumer = producer.consume(); producer.abort(Error::Cancel).unwrap(); @@ -517,7 +536,7 @@ mod test { #[test] fn empty_frame() { - let mut producer = Frame { size: 0 }.produce(); + let mut producer = Frame::from(0u64).produce(); producer.finish().unwrap(); let mut consumer = producer.consume(); @@ -527,7 +546,7 @@ mod test { #[tokio::test] async fn pending_then_ready() { - let mut producer = Frame { size: 5 }.produce(); + let mut producer = Frame::from(5u64).produce(); let mut consumer = producer.consume(); // Consumer blocks because no data yet. @@ -543,7 +562,7 @@ mod test { #[test] fn buf_mut_roundtrip() { // Exercise the BufMut path that the receive loop uses via `read_buf`. - let mut producer = Frame { size: 12 }.produce(); + let mut producer = Frame::from(12u64).produce(); assert_eq!(producer.remaining_mut(), 12); producer.put_slice(b"hello"); assert_eq!(producer.remaining_mut(), 7); @@ -559,14 +578,14 @@ mod test { #[test] #[should_panic(expected = "advance_mut past frame.size")] fn buf_mut_advance_past_capacity_panics() { - let mut producer = Frame { size: 4 }.produce(); + let mut producer = Frame::from(4u64).produce(); // Safety violation on purpose: cnt > remaining_mut(). unsafe { producer.advance_mut(5) }; } #[test] fn read_chunk_streams_partial_writes() { - let mut producer = Frame { size: 6 }.produce(); + let mut producer = Frame::from(6u64).produce(); let mut consumer = producer.consume(); producer.write(Bytes::from_static(b"foo")).unwrap(); @@ -586,7 +605,7 @@ mod test { #[test] fn cloned_consumer_independent_cursor() { - let mut producer = Frame { size: 10 }.produce(); + let mut producer = Frame::from(10u64).produce(); let mut c1 = producer.consume(); producer.write(Bytes::from_static(b"hello")).unwrap(); diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index b3b08a136..973e51934 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -166,18 +166,15 @@ impl GroupProducer { /// But an upfront size is required. pub fn write_frame>(&mut self, frame: B) -> Result<()> { let data = frame.into(); - let frame = Frame { - size: data.len() as u64, - }; - let mut frame = self.create_frame(frame)?; + let mut frame = self.create_frame(data.len())?; frame.write(data)?; frame.finish()?; Ok(()) } /// Create a frame with an upfront size - pub fn create_frame(&mut self, info: Frame) -> Result { - let frame = info.produce(); + pub fn create_frame(&mut self, info: impl Into) -> Result { + let frame = info.into().produce(); self.append_frame(frame.clone())?; Ok(frame) } @@ -404,7 +401,7 @@ mod test { #[test] fn read_frame_chunks() { let mut producer = Group { sequence: 0 }.produce(); - let mut frame = producer.create_frame(Frame { size: 10 }).unwrap(); + let mut frame = producer.create_frame(10u64).unwrap(); frame.write(Bytes::from_static(b"hello")).unwrap(); frame.write(Bytes::from_static(b"world")).unwrap(); frame.finish().unwrap(); diff --git a/rs/moq-net/src/model/time.rs b/rs/moq-net/src/model/time.rs index c24629cff..20c8ed083 100644 --- a/rs/moq-net/src/model/time.rs +++ b/rs/moq-net/src/model/time.rs @@ -1,65 +1,183 @@ use rand::Rng; -use crate::Error; use crate::coding::{Decode, DecodeError, Encode, EncodeError, VarInt}; use std::sync::LazyLock; use std::time::{SystemTime, UNIX_EPOCH}; -/// A timestamp representing the presentation time in milliseconds. -/// -/// The underlying implementation supports any scale, but everything uses milliseconds by default. -pub type Time = Timescale<1_000>; - -/// Returned when a [`Timescale`] operation would exceed the QUIC VarInt range -/// (`2^62 - 1`) or overflow during scale conversion or arithmetic. +/// Returned when a [`Timestamp`] operation would exceed the QUIC VarInt range +/// (`2^62 - 1`), overflow during scale conversion or arithmetic, hit a divide +/// by zero from an unspecified ([`Timescale::UNKNOWN`]) scale, or attempt +/// arithmetic between timestamps with mismatched scales. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[error("time overflow")] pub struct TimeOverflow; -/// A timestamp representing the presentation time in a given scale. ex. 1000 for milliseconds. +/// Units per second used by a track for frame timestamps. /// -/// All timestamps within a track are relative, so zero for one track is not zero for another. -/// Values are constrained to fit within a QUIC VarInt (2^62) so they can be encoded and decoded easily. +/// Newtype around `u64`. The wire encoding is a plain QUIC varint. +/// Use the named constants ([`Self::SECOND`], [`Self::MILLI`], [`Self::MICRO`], +/// [`Self::NANO`]) instead of writing raw integers at call sites. /// -/// This is [std::time::Instant] and [std::time::Duration] merged into one type for simplicity. -#[derive(Clone, Default, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +/// [`Self::UNKNOWN`] (raw value `0`) denotes an unspecified scale, produced by +/// peers that don't negotiate a timescale (older moq-lite versions, older +/// moq-transport drafts). Unit conversions against an unknown scale return +/// [`TimeOverflow`]. +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Timescale(VarInt); +pub struct Timescale(pub u64); + +impl Timescale { + /// Unspecified scale. Conversions involving this return [`TimeOverflow`]. + pub const UNKNOWN: Self = Self(0); + /// One unit per second (`1`). + pub const SECOND: Self = Self(1); + /// 1,000 units per second (`1_000`). + pub const MILLI: Self = Self(1_000); + /// 1,000,000 units per second (`1_000_000`). Common default for media tracks. + pub const MICRO: Self = Self(1_000_000); + /// 1,000,000,000 units per second (`1_000_000_000`). + pub const NANO: Self = Self(1_000_000_000); + + /// Construct a timescale from a raw value (units per second). `0` means [`Self::UNKNOWN`]. + pub const fn new(units_per_second: u64) -> Self { + Self(units_per_second) + } + + /// The raw units-per-second value. + pub const fn as_u64(self) -> u64 { + self.0 + } + + /// Whether this is [`Self::UNKNOWN`] (raw value `0`). + pub const fn is_unknown(self) -> bool { + self.0 == 0 + } +} + +impl From for Timescale { + fn from(units_per_second: u64) -> Self { + Self(units_per_second) + } +} -impl Timescale { - /// The maximum representable instant. - pub const MAX: Self = Self(VarInt::MAX); +impl From for u64 { + fn from(scale: Timescale) -> Self { + scale.0 + } +} - /// The minimum representable instant. - pub const ZERO: Self = Self(VarInt::ZERO); +impl std::fmt::Debug for Timescale { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match *self { + Self::UNKNOWN => write!(f, "Timescale::UNKNOWN"), + Self::SECOND => write!(f, "Timescale::SECOND"), + Self::MILLI => write!(f, "Timescale::MILLI"), + Self::MICRO => write!(f, "Timescale::MICRO"), + Self::NANO => write!(f, "Timescale::NANO"), + Self(n) => write!(f, "Timescale({n})"), + } + } +} - /// Construct a timestamp directly from a value in this scale's units. Infallible - /// because any `u32` fits within the 62-bit varint range. - pub const fn new(value: u32) -> Self { - Self(VarInt::from_u32(value)) +impl std::fmt::Display for Timescale { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) } +} - /// Construct a timestamp directly from a value in this scale's units. Returns - /// [`TimeOverflow`] if `value` exceeds the 62-bit varint range. - pub const fn new_u64(value: u64) -> Result { +impl Decode for Timescale { + fn decode(r: &mut R, version: crate::Version) -> Result { + Ok(Self(u64::decode(r, version)?)) + } +} + +impl Encode for Timescale { + fn encode(&self, w: &mut W, version: crate::Version) -> Result<(), EncodeError> { + self.0.encode(w, version) + } +} + +impl Decode for Timescale { + fn decode(r: &mut R, version: crate::lite::Version) -> Result { + Ok(Self(u64::decode(r, version)?)) + } +} + +impl Encode for Timescale { + fn encode(&self, w: &mut W, version: crate::lite::Version) -> Result<(), EncodeError> { + self.0.encode(w, version) + } +} + +/// A timestamp in a track's timescale (units per second). +/// +/// All timestamps within a track are relative, so zero for one track is not zero for another. +/// The underlying value is constrained to fit within a QUIC VarInt (`2^62 - 1`) so it can be +/// encoded and decoded easily; the scale is carried out-of-band (via [`crate::Track::timescale`]) +/// and not serialized per-timestamp. +/// +/// [`Timescale::UNKNOWN`] denotes an unspecified timescale, produced by [`Timestamp::ZERO`] +/// and by peers that don't negotiate a timescale (older moq-lite versions, older +/// moq-transport drafts without track properties). Unit conversions and arithmetic against +/// an unknown scale return [`TimeOverflow`] to avoid divide by zero. +#[derive(Clone, Default, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Timestamp { + value: VarInt, + scale: Timescale, +} + +impl Timestamp { + /// A zero timestamp with an unspecified scale. + /// + /// Useful as a sentinel min: comparisons against unspecified-scale timestamps + /// compare raw values, so `Timestamp::ZERO < t` is true for any `t` whose + /// `value > 0`. See [`Self::partial_cmp`]. + pub const ZERO: Self = Self { + value: VarInt::ZERO, + scale: Timescale::UNKNOWN, + }; + + /// The maximum representable timestamp value, with an unspecified scale. + /// + /// Useful as a sentinel max: `t < Timestamp::MAX` is true for any `t` whose + /// `value < VarInt::MAX`. See [`Self::partial_cmp`]. + pub const MAX: Self = Self { + value: VarInt::MAX, + scale: Timescale::UNKNOWN, + }; + + /// Construct a timestamp directly from a raw value at the given scale. + pub const fn new(value: u64, scale: Timescale) -> Result { match VarInt::from_u64(value) { - Some(varint) => Ok(Self(varint)), + Some(value) => Ok(Self { value, scale }), None => Err(TimeOverflow), } } - /// Convert a number of seconds to a timestamp, returning an error if the timestamp would overflow. - pub const fn from_secs(seconds: u64) -> Result { - // Not using from_scale because it'll be slightly faster - match seconds.checked_mul(SCALE) { - Some(value) => Self::new_u64(value), + /// Convert `value` measured at `source` (units per second) to a timestamp at `target`. + /// + /// Returns [`TimeOverflow`] on overflow or if `source` is [`Timescale::UNKNOWN`]. + pub const fn from_scale(value: u64, source: Timescale, target: Timescale) -> Result { + if source.0 == 0 { + return Err(TimeOverflow); + } + match (value as u128).checked_mul(target.0 as u128) { + Some(scaled) => match VarInt::from_u128(scaled / source.0 as u128) { + Some(value) => Ok(Self { value, scale: target }), + None => Err(TimeOverflow), + }, None => Err(TimeOverflow), } } - /// Like [`Self::from_secs`] but panics on overflow. Intended for `const` - /// initializers where overflow indicates a bug, not a runtime condition. + /// Convert a number of seconds to a timestamp at [`Timescale::SECOND`]. + pub const fn from_secs(seconds: u64) -> Result { + Self::new(seconds, Timescale::SECOND) + } + + /// Like [`Self::from_secs`] but panics on overflow. pub const fn from_secs_unchecked(seconds: u64) -> Self { match Self::from_secs(seconds) { Ok(time) => time, @@ -67,187 +185,232 @@ impl Timescale { } } - /// Convert a number of milliseconds to a timestamp, returning an error if the timestamp would overflow. + /// Convert a number of milliseconds to a timestamp at [`Timescale::MILLI`]. pub const fn from_millis(millis: u64) -> Result { - Self::from_scale(millis, 1000) + Self::new(millis, Timescale::MILLI) } /// Like [`Self::from_millis`] but panics on overflow. pub const fn from_millis_unchecked(millis: u64) -> Self { - Self::from_scale_unchecked(millis, 1000) + match Self::from_millis(millis) { + Ok(time) => time, + Err(_) => panic!("time overflow"), + } } - /// Convert a number of microseconds to a timestamp, returning an error on overflow. + /// Convert a number of microseconds to a timestamp at [`Timescale::MICRO`]. pub const fn from_micros(micros: u64) -> Result { - Self::from_scale(micros, 1_000_000) + Self::new(micros, Timescale::MICRO) } /// Like [`Self::from_micros`] but panics on overflow. pub const fn from_micros_unchecked(micros: u64) -> Self { - Self::from_scale_unchecked(micros, 1_000_000) + match Self::from_micros(micros) { + Ok(time) => time, + Err(_) => panic!("time overflow"), + } } - /// Convert a number of nanoseconds to a timestamp, returning an error on overflow. + /// Convert a number of nanoseconds to a timestamp at [`Timescale::NANO`]. pub const fn from_nanos(nanos: u64) -> Result { - Self::from_scale(nanos, 1_000_000_000) + Self::new(nanos, Timescale::NANO) } /// Like [`Self::from_nanos`] but panics on overflow. pub const fn from_nanos_unchecked(nanos: u64) -> Self { - Self::from_scale_unchecked(nanos, 1_000_000_000) + match Self::from_nanos(nanos) { + Ok(time) => time, + Err(_) => panic!("time overflow"), + } } - /// Construct from `value` measured at the given `scale` (units per second), rescaling - /// to `SCALE`. Returns [`TimeOverflow`] if the rescaled value exceeds 2^62. - pub const fn from_scale(value: u64, scale: u64) -> Result { - match VarInt::from_u128(value as u128 * SCALE as u128 / scale as u128) { - Some(varint) => Ok(Self(varint)), - None => Err(TimeOverflow), - } + /// The raw value in the timestamp's own scale. + pub const fn value(self) -> u64 { + self.value.into_inner() } - /// Like [`Self::from_scale`] but accepts a `u128` source value. - pub const fn from_scale_u128(value: u128, scale: u64) -> Result { - match value.checked_mul(SCALE as u128) { - Some(value) => match VarInt::from_u128(value / scale as u128) { - Some(varint) => Ok(Self(varint)), - None => Err(TimeOverflow), - }, - None => Err(TimeOverflow), - } + /// The scale (units per second) attached to this timestamp. + pub const fn scale(self) -> Timescale { + self.scale } - /// Like [`Self::from_scale`] but panics on overflow. - pub const fn from_scale_unchecked(value: u64, scale: u64) -> Self { - match Self::from_scale(value, scale) { - Ok(time) => time, - Err(_) => panic!("time overflow"), + /// Whether the scale is [`Timescale::UNKNOWN`]. Unit conversions and cross-scale + /// arithmetic against this timestamp return [`TimeOverflow`]. + pub const fn is_unspecified(self) -> bool { + self.scale.0 == 0 + } + + /// Whether the raw value is zero. Does not consider scale. + pub const fn is_zero(self) -> bool { + self.value.into_inner() == 0 + } + + /// Re-express this timestamp at a new scale. Returns [`TimeOverflow`] if the new + /// value would exceed `2^62 - 1`, the source scale is unspecified, or `new_scale` + /// is [`Timescale::UNKNOWN`]. + pub const fn convert(self, new_scale: Timescale) -> Result { + if self.scale.0 == 0 || new_scale.0 == 0 { + return Err(TimeOverflow); } + if self.scale.0 == new_scale.0 { + return Ok(self); + } + Self::from_scale(self.value.into_inner(), self.scale, new_scale) } - /// Get the timestamp as seconds. - pub const fn as_secs(self) -> u64 { - self.0.into_inner() / SCALE + /// The value re-expressed at `target` as a `u128`. Returns [`TimeOverflow`] + /// if the source scale is unspecified or `target` is [`Timescale::UNKNOWN`]. + pub const fn as_scale(self, target: Timescale) -> Result { + if self.scale.0 == 0 || target.0 == 0 { + return Err(TimeOverflow); + } + Ok(self.value.into_inner() as u128 * target.0 as u128 / self.scale.0 as u128) } - /// Get the timestamp as milliseconds. - // - // This returns a u128 to avoid a possible overflow when SCALE < 250 - pub const fn as_millis(self) -> u128 { - self.as_scale(1000) + /// The value re-expressed in seconds. Returns [`TimeOverflow`] if the scale is + /// unspecified. + pub const fn as_secs(self) -> Result { + if self.scale.0 == 0 { + return Err(TimeOverflow); + } + Ok(self.value.into_inner() / self.scale.0) } - /// Get the timestamp as microseconds. - pub const fn as_micros(self) -> u128 { - self.as_scale(1_000_000) + /// The value re-expressed in milliseconds. Returns [`TimeOverflow`] if the scale + /// is unspecified. + pub const fn as_millis(self) -> Result { + self.as_scale(Timescale::MILLI) } - /// Get the timestamp as nanoseconds. - pub const fn as_nanos(self) -> u128 { - self.as_scale(1_000_000_000) + /// The value re-expressed in microseconds. Returns [`TimeOverflow`] if the scale + /// is unspecified. + pub const fn as_micros(self) -> Result { + self.as_scale(Timescale::MICRO) } - /// Convert this timestamp to the given `scale` (units per second). - pub const fn as_scale(self, scale: u64) -> u128 { - self.0.into_inner() as u128 * scale as u128 / SCALE as u128 + /// The value re-expressed in nanoseconds. Returns [`TimeOverflow`] if the scale + /// is unspecified. + pub const fn as_nanos(self) -> Result { + self.as_scale(Timescale::NANO) } - /// Get the maximum of two timestamps. + /// Return the larger of two timestamps. + /// + /// Panics if the scales differ. Use [`Self::convert`] first if you need to compare + /// across scales. pub const fn max(self, other: Self) -> Self { - if self.0.into_inner() > other.0.into_inner() { + assert!(self.scale.0 == other.scale.0, "mismatched timestamp scales"); + if self.value.into_inner() > other.value.into_inner() { self } else { other } } - /// Add two timestamps, returning [`TimeOverflow`] if the sum exceeds 2^62. + /// Add two timestamps. Returns [`TimeOverflow`] if the sum exceeds `2^62 - 1` or + /// if the scales differ. pub const fn checked_add(self, rhs: Self) -> Result { - let lhs = self.0.into_inner(); - let rhs = rhs.0.into_inner(); - match lhs.checked_add(rhs) { - Some(result) => Self::new_u64(result), + if self.scale.0 != rhs.scale.0 { + return Err(TimeOverflow); + } + match self.value.into_inner().checked_add(rhs.value.into_inner()) { + Some(result) => Self::new(result, self.scale), None => Err(TimeOverflow), } } - /// Subtract `rhs` from `self`, returning [`TimeOverflow`] if `rhs > self`. + /// Subtract `rhs` from `self`. Returns [`TimeOverflow`] if `rhs > self` or if the + /// scales differ. pub const fn checked_sub(self, rhs: Self) -> Result { - let lhs = self.0.into_inner(); - let rhs = rhs.0.into_inner(); - match lhs.checked_sub(rhs) { - Some(result) => Self::new_u64(result), + if self.scale.0 != rhs.scale.0 { + return Err(TimeOverflow); + } + match self.value.into_inner().checked_sub(rhs.value.into_inner()) { + Some(result) => Self::new(result, self.scale), None => Err(TimeOverflow), } } - /// Whether this timestamp is [`Self::ZERO`]. - pub const fn is_zero(self) -> bool { - self.0.into_inner() == 0 - } - - /// Current time as a timestamp, derived from [`tokio::time::Instant::now`] so - /// it honors `tokio::time::pause` in tests. - pub fn now() -> Self { - // We use tokio so it can be stubbed for testing. - tokio::time::Instant::now().into() - } - - /// Convert this timestamp to a different scale. + /// Apply a signed delta in this timestamp's scale, returning the new timestamp. /// - /// This allows converting between different TimeScale types, for example from milliseconds to microseconds. - /// Note that converting to a coarser scale may lose precision due to integer division. - pub const fn convert(self) -> Result, TimeOverflow> { - let value = self.0.into_inner(); - // Convert from SCALE to NEW_SCALE: value * NEW_SCALE / SCALE - match (value as u128).checked_mul(NEW_SCALE as u128) { - Some(v) => match v.checked_div(SCALE as u128) { - Some(v) => match VarInt::from_u128(v) { - Some(varint) => Ok(Timescale(varint)), - None => Err(TimeOverflow), - }, - None => Err(TimeOverflow), - }, + /// Used by the moq-lite per-frame delta decoder: timestamps are encoded as zigzag + /// signed deltas (negative for B-frames). Returns [`TimeOverflow`] if the result + /// would underflow zero or overflow `2^62 - 1`. + pub const fn checked_add_delta(self, delta: i64) -> Result { + let current = self.value.into_inner() as i128; + let next = current + delta as i128; + if next < 0 { + return Err(TimeOverflow); + } + match VarInt::from_u128(next as u128) { + Some(value) => Ok(Self { + value, + scale: self.scale, + }), None => Err(TimeOverflow), } } - /// Encode this timestamp as a QUIC varint. Version-independent. - pub fn encode(&self, w: &mut W) -> Result<(), EncodeError> { - // Version-independent: uses QUIC varint encoding. - self.0.encode(w, crate::lite::Version::Lite01)?; - Ok(()) + /// The signed delta from `prev` to `self` in their shared scale. Returns + /// [`TimeOverflow`] on scale mismatch or if the delta is outside `i64::MIN..=i64::MAX`. + pub const fn checked_delta_from(self, prev: Self) -> Result { + if self.scale.0 != prev.scale.0 { + return Err(TimeOverflow); + } + let a = self.value.into_inner() as i128; + let b = prev.value.into_inner() as i128; + let delta = a - b; + if delta < i64::MIN as i128 || delta > i64::MAX as i128 { + return Err(TimeOverflow); + } + Ok(delta as i64) } - /// Decode a timestamp from a QUIC varint. Version-independent. - pub fn decode(r: &mut R) -> Result { - // Version-independent: uses QUIC varint encoding. - let v = VarInt::decode(r, crate::lite::Version::Lite01)?; - Ok(Self(v)) + /// Current time, expressed in microseconds ([`Timescale::MICRO`]). Uses + /// [`tokio::time::Instant::now`] so it honors `tokio::time::pause` in tests. + pub fn now() -> Self { + tokio::time::Instant::now().into() } } -impl TryFrom for Timescale { +impl TryFrom for Timestamp { type Error = TimeOverflow; + /// Convert a [`std::time::Duration`] into a nanosecond-scale timestamp. fn try_from(duration: std::time::Duration) -> Result { - Self::from_scale_u128(duration.as_nanos(), 1_000_000_000) + match VarInt::from_u128(duration.as_nanos()) { + Some(value) => Ok(Self { + value, + scale: Timescale::NANO, + }), + None => Err(TimeOverflow), + } } } -impl From> for std::time::Duration { - fn from(time: Timescale) -> Self { - std::time::Duration::new(time.as_secs(), (time.as_nanos() % 1_000_000_000) as u32) +impl TryFrom for std::time::Duration { + type Error = TimeOverflow; + + fn try_from(time: Timestamp) -> Result { + let secs = time.as_secs()?; + let nanos = time.as_nanos()?; + Ok(std::time::Duration::new(secs, (nanos % 1_000_000_000) as u32)) } } -impl std::fmt::Debug for Timescale { +impl std::fmt::Debug for Timestamp { #[allow(clippy::manual_is_multiple_of)] // is_multiple_of is unstable in Rust 1.85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let nanos = self.as_nanos(); + if self.scale.0 == 0 { + return write!(f, "{}/?", self.value.into_inner()); + } - // Choose the largest unit where we don't need decimal places - // Check from largest to smallest unit + let nanos = match self.as_nanos() { + Ok(n) => n, + Err(_) => return write!(f, "{}/{}", self.value.into_inner(), self.scale), + }; + + // Choose the largest unit where we don't need decimal places. if nanos % 1_000_000_000 == 0 { write!(f, "{}s", nanos / 1_000_000_000) } else if nanos % 1_000_000 == 0 { @@ -260,29 +423,51 @@ impl std::fmt::Debug for Timescale { } } -impl std::ops::Add for Timescale { +impl PartialOrd for Timestamp { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Timestamp { + /// Compare by raw value. Debug-asserts that scales are compatible (same, or + /// one is unspecified). In release, cross-scale comparisons return a result + /// based on the raw value, which is meaningful only when one side is a + /// [`Timescale::UNKNOWN`] sentinel ([`Self::ZERO`] or [`Self::MAX`]). + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + debug_assert!( + self.scale.0 == other.scale.0 || self.scale.0 == 0 || other.scale.0 == 0, + "comparing timestamps with mismatched scales: {} vs {}", + self.scale, + other.scale, + ); + self.value.cmp(&other.value) + } +} + +impl std::ops::Add for Timestamp { type Output = Self; fn add(self, rhs: Self) -> Self { - self.checked_add(rhs).expect("time overflow") + self.checked_add(rhs).expect("time overflow or scale mismatch") } } -impl std::ops::AddAssign for Timescale { +impl std::ops::AddAssign for Timestamp { fn add_assign(&mut self, rhs: Self) { *self = *self + rhs; } } -impl std::ops::Sub for Timescale { +impl std::ops::Sub for Timestamp { type Output = Self; fn sub(self, rhs: Self) -> Self { - self.checked_sub(rhs).expect("time overflow") + self.checked_sub(rhs).expect("time overflow or scale mismatch") } } -impl std::ops::SubAssign for Timescale { +impl std::ops::SubAssign for Timestamp { fn sub_assign(&mut self, rhs: Self) { *self = *self - rhs; } @@ -297,43 +482,51 @@ static TIME_ANCHOR: LazyLock<(std::time::Instant, SystemTime)> = LazyLock::new(| (std::time::Instant::now(), SystemTime::now() - jitter) }); -// Convert an Instant to a Unix timestamp -impl From for Timescale { +impl From for Timestamp { + /// Convert an [`std::time::Instant`] into a microsecond-scale timestamp anchored to a + /// jittered wall-clock reference (see `TIME_ANCHOR`). fn from(instant: std::time::Instant) -> Self { let (anchor_instant, anchor_system) = *TIME_ANCHOR; - // Conver the instant to a SystemTime. let system = match instant.checked_duration_since(anchor_instant) { Some(forward) => anchor_system + forward, None => anchor_system - anchor_instant.duration_since(instant), }; - // Convert the SystemTime to a Unix timestamp in nanoseconds. - // We'll then convert that to the desired scale. - system + let duration = system .duration_since(UNIX_EPOCH) - .expect("dude your clock is earlier than 1970") - .try_into() - .expect("dude your clock is later than 2116") + .expect("dude your clock is earlier than 1970"); + + Self::from_micros(duration.as_micros() as u64).expect("dude your clock is later than 2116") } } -impl From for Timescale { +impl From for Timestamp { fn from(instant: tokio::time::Instant) -> Self { instant.into_std().into() } } -impl Decode for Timescale { +/// Decode a timestamp's raw value as a varint, attaching [`Timescale::UNKNOWN`]. +/// +/// Callers that need a meaningful scale should decode the raw value via [`u64::decode`] +/// (or [`crate::coding::VarInt::decode`]) and then call [`Timestamp::new`] with the +/// track's [`Timescale`]. +impl Decode for Timestamp { fn decode(r: &mut R, version: crate::Version) -> Result { - let v = VarInt::decode(r, version)?; - Ok(Self(v)) + let value = VarInt::decode(r, version)?; + Ok(Self { + value, + scale: Timescale::UNKNOWN, + }) } } -impl Encode for Timescale { +/// Encode a timestamp's raw value as a varint. The scale is **not** serialized; it +/// is conveyed out-of-band via [`crate::Track::timescale`]. +impl Encode for Timestamp { fn encode(&self, w: &mut W, version: crate::Version) -> Result<(), EncodeError> { - self.0.encode(w, version)?; + self.value.encode(w, version)?; Ok(()) } } @@ -344,377 +537,251 @@ mod tests { #[test] fn test_from_secs() { - let time = Time::from_secs(5).unwrap(); - assert_eq!(time.as_secs(), 5); - assert_eq!(time.as_millis(), 5000); - assert_eq!(time.as_micros(), 5_000_000); - assert_eq!(time.as_nanos(), 5_000_000_000); + let time = Timestamp::from_secs(5).unwrap(); + assert_eq!(time.scale(), Timescale::SECOND); + assert_eq!(time.as_secs().unwrap(), 5); + assert_eq!(time.as_millis().unwrap(), 5000); + assert_eq!(time.as_micros().unwrap(), 5_000_000); + assert_eq!(time.as_nanos().unwrap(), 5_000_000_000); } #[test] fn test_from_millis() { - let time = Time::from_millis(5000).unwrap(); - assert_eq!(time.as_secs(), 5); - assert_eq!(time.as_millis(), 5000); + let time = Timestamp::from_millis(5000).unwrap(); + assert_eq!(time.scale(), Timescale::MILLI); + assert_eq!(time.as_secs().unwrap(), 5); + assert_eq!(time.as_millis().unwrap(), 5000); } #[test] fn test_from_micros() { - let time = Time::from_micros(5_000_000).unwrap(); - assert_eq!(time.as_secs(), 5); - assert_eq!(time.as_millis(), 5000); - assert_eq!(time.as_micros(), 5_000_000); + let time = Timestamp::from_micros(5_000_000).unwrap(); + assert_eq!(time.scale(), Timescale::MICRO); + assert_eq!(time.as_secs().unwrap(), 5); + assert_eq!(time.as_micros().unwrap(), 5_000_000); } #[test] fn test_from_nanos() { - let time = Time::from_nanos(5_000_000_000).unwrap(); - assert_eq!(time.as_secs(), 5); - assert_eq!(time.as_millis(), 5000); - assert_eq!(time.as_micros(), 5_000_000); - assert_eq!(time.as_nanos(), 5_000_000_000); + let time = Timestamp::from_nanos(5_000_000_000).unwrap(); + assert_eq!(time.scale(), Timescale::NANO); + assert_eq!(time.as_secs().unwrap(), 5); + assert_eq!(time.as_nanos().unwrap(), 5_000_000_000); } #[test] - fn test_zero() { - let time = Time::ZERO; - assert_eq!(time.as_secs(), 0); - assert_eq!(time.as_millis(), 0); - assert_eq!(time.as_micros(), 0); - assert_eq!(time.as_nanos(), 0); + fn test_zero_unspecified() { + let time = Timestamp::ZERO; + assert!(time.is_unspecified()); assert!(time.is_zero()); + assert!(time.as_secs().is_err()); + assert!(time.as_millis().is_err()); + assert!(time.as_micros().is_err()); + assert!(time.as_nanos().is_err()); } #[test] - fn test_roundtrip_millis() { - let values = [0, 1, 100, 1000, 999999, 1_000_000_000]; - for &val in &values { - let time = Time::from_millis(val).unwrap(); - assert_eq!(time.as_millis(), val as u128); - } + fn test_zero_at_scale() { + let time = Timestamp::from_millis(0).unwrap(); + assert!(!time.is_unspecified()); + assert!(time.is_zero()); + assert_eq!(time.as_millis().unwrap(), 0); } #[test] - fn test_roundtrip_micros() { - // Note: values < 1000 will lose precision when converting to milliseconds (SCALE=1000) - let values = [0, 1000, 1_000_000, 1_000_000_000]; - for &val in &values { - let time = Time::from_micros(val).unwrap(); - assert_eq!(time.as_micros(), val as u128); - } + fn test_convert_to_finer() { + let time_ms = Timestamp::from_millis(5000).unwrap(); + let time_us = time_ms.convert(Timescale::MICRO).unwrap(); + assert_eq!(time_us.scale(), Timescale::MICRO); + assert_eq!(time_us.as_micros().unwrap(), 5_000_000); } #[test] - fn test_different_scale_seconds() { - type TimeInSeconds = Timescale<1>; - let time = TimeInSeconds::from_secs(5).unwrap(); - assert_eq!(time.as_secs(), 5); - assert_eq!(time.as_millis(), 5000); + fn test_convert_to_coarser() { + let time_ms = Timestamp::from_millis(5000).unwrap(); + let time_s = time_ms.convert(Timescale::SECOND).unwrap(); + assert_eq!(time_s.scale(), Timescale::SECOND); + assert_eq!(time_s.as_secs().unwrap(), 5); } #[test] - fn test_different_scale_microseconds() { - type TimeInMicros = Timescale<1_000_000>; - let time = TimeInMicros::from_micros(5_000_000).unwrap(); - assert_eq!(time.as_secs(), 5); - assert_eq!(time.as_micros(), 5_000_000); + fn test_convert_precision_loss() { + // 1234 ms = 1.234 s, rounds down to 1 s + let time_ms = Timestamp::from_millis(1234).unwrap(); + let time_s = time_ms.convert(Timescale::SECOND).unwrap(); + assert_eq!(time_s.as_secs().unwrap(), 1); } #[test] - fn test_scale_conversion() { - // Converting 5000 milliseconds at scale 1000 to scale 1000 (should be identity) - let time = Time::from_scale(5000, 1000).unwrap(); - assert_eq!(time.as_millis(), 5000); - assert_eq!(time.as_secs(), 5); - - // Converting 5 seconds at scale 1 to scale 1000 - let time = Time::from_scale(5, 1).unwrap(); - assert_eq!(time.as_millis(), 5000); - assert_eq!(time.as_secs(), 5); + fn test_convert_roundtrip() { + let original = Timestamp::from_millis(5000).unwrap(); + let as_micros = original.convert(Timescale::MICRO).unwrap(); + let back = as_micros.convert(Timescale::MILLI).unwrap(); + assert_eq!(original.value(), back.value()); + assert_eq!(original.scale(), back.scale()); } #[test] - fn test_add() { - let a = Time::from_secs(3).unwrap(); - let b = Time::from_secs(2).unwrap(); - let c = a + b; - assert_eq!(c.as_secs(), 5); - assert_eq!(c.as_millis(), 5000); + fn test_convert_same_scale() { + let time = Timestamp::from_millis(5000).unwrap(); + let converted = time.convert(Timescale::MILLI).unwrap(); + assert_eq!(time, converted); } #[test] - fn test_sub() { - let a = Time::from_secs(5).unwrap(); - let b = Time::from_secs(2).unwrap(); - let c = a - b; - assert_eq!(c.as_secs(), 3); - assert_eq!(c.as_millis(), 3000); + fn test_convert_unspecified_rejected() { + let zero = Timestamp::ZERO; + assert!(zero.convert(Timescale::MILLI).is_err()); + + let time = Timestamp::from_millis(5).unwrap(); + assert!(time.convert(Timescale::UNKNOWN).is_err()); } #[test] - fn test_checked_add() { - let a = Time::from_millis(1000).unwrap(); - let b = Time::from_millis(2000).unwrap(); + fn test_add_same_scale() { + let a = Timestamp::from_millis(1000).unwrap(); + let b = Timestamp::from_millis(2000).unwrap(); let c = a.checked_add(b).unwrap(); - assert_eq!(c.as_millis(), 3000); + assert_eq!(c.as_millis().unwrap(), 3000); + assert_eq!(c.scale(), Timescale::MILLI); } #[test] - fn test_checked_sub() { - let a = Time::from_millis(5000).unwrap(); - let b = Time::from_millis(2000).unwrap(); - let c = a.checked_sub(b).unwrap(); - assert_eq!(c.as_millis(), 3000); + fn test_add_mismatched_scale() { + let a = Timestamp::from_millis(1000).unwrap(); + let b = Timestamp::from_micros(1000).unwrap(); + assert!(a.checked_add(b).is_err()); } #[test] - fn test_checked_sub_underflow() { - let a = Time::from_millis(1000).unwrap(); - let b = Time::from_millis(2000).unwrap(); + fn test_sub_underflow() { + let a = Timestamp::from_millis(1000).unwrap(); + let b = Timestamp::from_millis(2000).unwrap(); assert!(a.checked_sub(b).is_err()); } #[test] - fn test_max() { - let a = Time::from_secs(5).unwrap(); - let b = Time::from_secs(10).unwrap(); + fn test_max_same_scale() { + let a = Timestamp::from_secs(5).unwrap(); + let b = Timestamp::from_secs(10).unwrap(); assert_eq!(a.max(b), b); assert_eq!(b.max(a), b); } #[test] - fn test_duration_conversion() { - let duration = std::time::Duration::from_secs(5); - let time: Time = duration.try_into().unwrap(); - assert_eq!(time.as_secs(), 5); - assert_eq!(time.as_millis(), 5000); - - let duration_back: std::time::Duration = time.into(); - assert_eq!(duration_back.as_secs(), 5); - } - - #[test] - fn test_duration_with_nanos() { - let duration = std::time::Duration::new(5, 500_000_000); // 5.5 seconds - let time: Time = duration.try_into().unwrap(); - assert_eq!(time.as_millis(), 5500); - - let duration_back: std::time::Duration = time.into(); - assert_eq!(duration_back.as_millis(), 5500); + #[should_panic(expected = "mismatched timestamp scales")] + fn test_max_mismatched_scale_panics() { + let a = Timestamp::from_millis(1).unwrap(); + let b = Timestamp::from_secs(1).unwrap(); + let _ = a.max(b); } #[test] - fn test_fractional_conversion() { - // Test that 1500 millis = 1.5 seconds - let time = Time::from_millis(1500).unwrap(); - assert_eq!(time.as_secs(), 1); // Integer division - assert_eq!(time.as_millis(), 1500); - assert_eq!(time.as_micros(), 1_500_000); - } - - #[test] - fn test_precision_loss() { - // When converting from a finer scale to coarser, we lose precision - // 1234 micros = 1.234 millis, which rounds down to 1 millisecond internally - // When converting back, we get 1000 micros, not the original 1234 - let time = Time::from_micros(1234).unwrap(); - assert_eq!(time.as_millis(), 1); // 1234 micros = 1.234 millis, rounds to 1 - assert_eq!(time.as_micros(), 1000); // Precision lost: 1 milli = 1000 micros - } - - #[test] - fn test_scale_boundaries() { - // Test values near scale boundaries - let time = Time::from_millis(999).unwrap(); - assert_eq!(time.as_secs(), 0); - assert_eq!(time.as_millis(), 999); - - let time = Time::from_millis(1000).unwrap(); - assert_eq!(time.as_secs(), 1); - assert_eq!(time.as_millis(), 1000); - - let time = Time::from_millis(1001).unwrap(); - assert_eq!(time.as_secs(), 1); - assert_eq!(time.as_millis(), 1001); - } - - #[test] - fn test_large_values() { - // Test with large but valid values - let large_secs = 1_000_000_000u64; // ~31 years - let time = Time::from_secs(large_secs).unwrap(); - assert_eq!(time.as_secs(), large_secs); + fn test_ordering_same_scale() { + let a = Timestamp::from_secs(1).unwrap(); + let b = Timestamp::from_secs(2).unwrap(); + assert!(a < b); + assert!(b > a); + assert_eq!(a, a); } #[test] - fn test_new() { - let time = Time::new(5000); // 5000 in the current scale (millis) - assert_eq!(time.as_millis(), 5000); - assert_eq!(time.as_secs(), 5); + fn test_ordering_against_sentinels() { + // ZERO and MAX act as universal sentinels because their scale is 0. + let t = Timestamp::from_millis(100).unwrap(); + assert!(Timestamp::ZERO < t); + assert!(t < Timestamp::MAX); + assert!(Timestamp::ZERO < Timestamp::MAX); } #[test] - fn test_new_u64() { - let time = Time::new_u64(5000).unwrap(); - assert_eq!(time.as_millis(), 5000); + fn test_delta_positive() { + let prev = Timestamp::from_millis(100).unwrap(); + let curr = Timestamp::from_millis(150).unwrap(); + assert_eq!(curr.checked_delta_from(prev).unwrap(), 50); } #[test] - fn test_ordering() { - let a = Time::from_secs(1).unwrap(); - let b = Time::from_secs(2).unwrap(); - assert!(a < b); - assert!(b > a); - assert_eq!(a, a); + fn test_delta_negative() { + let prev = Timestamp::from_millis(150).unwrap(); + let curr = Timestamp::from_millis(100).unwrap(); + assert_eq!(curr.checked_delta_from(prev).unwrap(), -50); } #[test] - fn test_unchecked_variants() { - let time = Time::from_secs_unchecked(5); - assert_eq!(time.as_secs(), 5); - - let time = Time::from_millis_unchecked(5000); - assert_eq!(time.as_millis(), 5000); - - let time = Time::from_micros_unchecked(5_000_000); - assert_eq!(time.as_micros(), 5_000_000); - - let time = Time::from_nanos_unchecked(5_000_000_000); - assert_eq!(time.as_nanos(), 5_000_000_000); - - let time = Time::from_scale_unchecked(5000, 1000); - assert_eq!(time.as_millis(), 5000); + fn test_delta_mismatched_scale() { + let prev = Timestamp::from_millis(100).unwrap(); + let curr = Timestamp::from_micros(150).unwrap(); + assert!(curr.checked_delta_from(prev).is_err()); } #[test] - fn test_as_scale() { - let time = Time::from_secs(1).unwrap(); - // 1 second in scale 1000 = 1000 - assert_eq!(time.as_scale(1000), 1000); - // 1 second in scale 1 = 1 - assert_eq!(time.as_scale(1), 1); - // 1 second in scale 1_000_000 = 1_000_000 - assert_eq!(time.as_scale(1_000_000), 1_000_000); + fn test_add_delta_positive() { + let t = Timestamp::from_millis(100).unwrap(); + let next = t.checked_add_delta(50).unwrap(); + assert_eq!(next.as_millis().unwrap(), 150); } #[test] - fn test_convert_to_finer() { - // Convert from milliseconds to microseconds (coarser to finer) - type TimeInMillis = Timescale<1_000>; - type TimeInMicros = Timescale<1_000_000>; - - let time_millis = TimeInMillis::from_millis(5000).unwrap(); - let time_micros: TimeInMicros = time_millis.convert().unwrap(); - - assert_eq!(time_micros.as_millis(), 5000); - assert_eq!(time_micros.as_micros(), 5_000_000); + fn test_add_delta_negative() { + let t = Timestamp::from_millis(150).unwrap(); + let next = t.checked_add_delta(-50).unwrap(); + assert_eq!(next.as_millis().unwrap(), 100); } #[test] - fn test_convert_to_coarser() { - // Convert from milliseconds to seconds (finer to coarser) - type TimeInMillis = Timescale<1_000>; - type TimeInSeconds = Timescale<1>; - - let time_millis = TimeInMillis::from_millis(5000).unwrap(); - let time_secs: TimeInSeconds = time_millis.convert().unwrap(); - - assert_eq!(time_secs.as_secs(), 5); - assert_eq!(time_secs.as_millis(), 5000); + fn test_add_delta_underflow() { + let t = Timestamp::from_millis(50).unwrap(); + assert!(t.checked_add_delta(-100).is_err()); } #[test] - fn test_convert_precision_loss() { - // Converting 1234 millis to seconds loses precision - type TimeInMillis = Timescale<1_000>; - type TimeInSeconds = Timescale<1>; - - let time_millis = TimeInMillis::from_millis(1234).unwrap(); - let time_secs: TimeInSeconds = time_millis.convert().unwrap(); + fn test_duration_conversion() { + let duration = std::time::Duration::from_secs(5); + let time: Timestamp = duration.try_into().unwrap(); + assert_eq!(time.scale(), Timescale::NANO); + assert_eq!(time.as_secs().unwrap(), 5); - // 1234 millis = 1.234 seconds, rounds down to 1 second - assert_eq!(time_secs.as_secs(), 1); - assert_eq!(time_secs.as_millis(), 1000); // Lost 234 millis + let duration_back: std::time::Duration = time.try_into().unwrap(); + assert_eq!(duration_back.as_secs(), 5); } #[test] - fn test_convert_roundtrip() { - // Converting to finer and back should preserve value - type TimeInMillis = Timescale<1_000>; - type TimeInMicros = Timescale<1_000_000>; - - let original = TimeInMillis::from_millis(5000).unwrap(); - let as_micros: TimeInMicros = original.convert().unwrap(); - let back_to_millis: TimeInMillis = as_micros.convert().unwrap(); + fn test_debug_format_units() { + let t = Timestamp::from_millis(100_000).unwrap(); + assert_eq!(format!("{:?}", t), "100s"); - assert_eq!(original.as_millis(), back_to_millis.as_millis()); - } + let t = Timestamp::from_millis(100).unwrap(); + assert_eq!(format!("{:?}", t), "100ms"); - #[test] - fn test_convert_same_scale() { - // Converting to the same scale should be identity - type TimeInMillis = Timescale<1_000>; + let t = Timestamp::from_micros(1500).unwrap(); + assert_eq!(format!("{:?}", t), "1500µs"); - let time = TimeInMillis::from_millis(5000).unwrap(); - let converted: TimeInMillis = time.convert().unwrap(); + let t = Timestamp::from_micros(1000).unwrap(); + assert_eq!(format!("{:?}", t), "1ms"); - assert_eq!(time.as_millis(), converted.as_millis()); + let t = Timestamp::ZERO; + assert_eq!(format!("{:?}", t), "0/?"); } #[test] - fn test_convert_microseconds_to_nanoseconds() { - type TimeInMicros = Timescale<1_000_000>; - type TimeInNanos = Timescale<1_000_000_000>; - - let time_micros = TimeInMicros::from_micros(5_000_000).unwrap(); - let time_nanos: TimeInNanos = time_micros.convert().unwrap(); - - assert_eq!(time_nanos.as_micros(), 5_000_000); - assert_eq!(time_nanos.as_nanos(), 5_000_000_000); + fn test_new() { + let t = Timestamp::new(5000, Timescale::MILLI).unwrap(); + assert_eq!(t.value(), 5000); + assert_eq!(t.scale(), Timescale::MILLI); + assert_eq!(t.as_millis().unwrap(), 5000); } #[test] - fn test_convert_custom_scales() { - // Test with unusual custom scales - type TimeScale60 = Timescale<60>; // 60Hz - type TimeScale90 = Timescale<90>; // 90Hz - - let time60 = TimeScale60::from_scale(120, 60).unwrap(); // 2 seconds at 60Hz - let time90: TimeScale90 = time60.convert().unwrap(); - - // Both should represent 2 seconds - assert_eq!(time60.as_secs(), 2); - assert_eq!(time90.as_secs(), 2); + fn test_from_scale_custom() { + // 120 units at 60Hz = 2 seconds, expressed at 1000Hz = 2000 ms. + let t = Timestamp::from_scale(120, Timescale::new(60), Timescale::MILLI).unwrap(); + assert_eq!(t.scale(), Timescale::MILLI); + assert_eq!(t.as_millis().unwrap(), 2000); } #[test] - fn test_debug_format_units() { - // Test that Debug chooses appropriate units based on value - - // Milliseconds that are clean seconds - let t = Time::from_millis(100000).unwrap(); - assert_eq!(format!("{:?}", t), "100s"); - - let t = Time::from_millis(1000).unwrap(); - assert_eq!(format!("{:?}", t), "1s"); - - // Milliseconds that are clean milliseconds - let t = Time::from_millis(100).unwrap(); - assert_eq!(format!("{:?}", t), "100ms"); - - let t = Time::from_millis(5500).unwrap(); - assert_eq!(format!("{:?}", t), "5500ms"); - - // Zero - let t = Time::ZERO; - assert_eq!(format!("{:?}", t), "0s"); - - // Test with microsecond-scale time - type TimeMicros = Timescale<1_000_000>; - let t = TimeMicros::from_micros(1500).unwrap(); - assert_eq!(format!("{:?}", t), "1500µs"); - - let t = TimeMicros::from_micros(1000).unwrap(); - assert_eq!(format!("{:?}", t), "1ms"); + fn test_from_scale_zero_source() { + assert!(Timestamp::from_scale(5, Timescale::UNKNOWN, Timescale::MILLI).is_err()); } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 312baaa68..6646ac8b9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -12,12 +12,13 @@ //! //! The track is closed with [Error] when all writers or readers are dropped. -use crate::{Error, Result, coding}; +use crate::{Error, Result, Timescale, coding}; use super::{Group, GroupConsumer, GroupProducer}; use std::{ collections::{HashSet, VecDeque}, + sync::{Arc, Mutex}, task::{Poll, ready}, time::Duration, }; @@ -26,22 +27,31 @@ use std::{ // TODO: Replace with a configurable cache size. const MAX_GROUP_AGE: Duration = Duration::from_secs(5); -/// A track is a collection of groups, delivered out-of-order until expired. -#[derive(Clone, Debug, PartialEq, Eq)] +/// Publisher-side properties of a track. +/// +/// These properties are fixed by the publisher when the track is created and +/// do not change while the track is alive. Subscribers learn them via +/// [`BroadcastConsumer::subscribe_track`](crate::BroadcastConsumer::subscribe_track), +/// which returns once the publisher's response has been received. +#[derive(Clone, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Track { /// Identifier within a broadcast. Unique per [`crate::Broadcast`]. pub name: String, /// Delivery priority. Higher values preempt lower ones when bandwidth is constrained. pub priority: u8, + /// Units per second for frame timestamps on this track. [`Timescale::UNKNOWN`] + /// means the publisher hasn't specified a scale (older protocol versions). + pub timescale: Timescale, } impl Track { - /// Create a track with the given name and default priority (`0`). + /// Create a track with the given name, default priority (`0`), and unknown timescale. pub fn new>(name: T) -> Self { Self { name: name.into(), priority: 0, + timescale: Timescale::UNKNOWN, } } @@ -51,6 +61,32 @@ impl Track { } } +/// Subscriber-side preferences for receiving a track. +/// +/// Held by each subscriber and aggregated across all live subscribers by the +/// publisher's [`TrackProducer`] (see [`TrackProducer::max_priority`] / +/// [`TrackProducer::max_timeout`]). The publisher can react to the aggregate +/// to adapt delivery (priority bumping, cancelling tracks that no one wants +/// urgently, etc). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Subscription { + /// Delivery priority requested by this subscriber. + pub priority: u8, + /// Maximum delay before this subscriber considers a group stale. + /// `Duration::ZERO` means no timeout (the subscriber will wait forever). + pub timeout: Duration, +} + +impl Subscription { + /// Create a subscription with the given priority and no timeout. + pub fn new(priority: u8) -> Self { + Self { + priority, + timeout: Duration::ZERO, + } + } +} + #[derive(Default)] struct State { /// Groups in arrival order. `None` entries are tombstones for evicted groups. @@ -60,6 +96,11 @@ struct State { max_sequence: Option, final_sequence: Option, abort: Option, + /// Active subscriptions, used by the producer to aggregate + /// [`TrackProducer::max_priority`] and [`TrackProducer::max_timeout`]. + /// `Arc>` so individual subscribers can update their + /// preferences in place. + subscriptions: Vec>>, } impl State { @@ -203,6 +244,11 @@ impl State { } /// A producer for a track, used to create new groups. +/// +/// The publisher's [`Track`] properties (name, priority, timescale) are fixed +/// at construction and cannot be mutated through this handle. Subscribers see +/// these properties on the [`TrackConsumer`] returned from +/// [`BroadcastConsumer::subscribe_track`](crate::BroadcastConsumer::subscribe_track). pub struct TrackProducer { info: Track, state: conducer::Producer, @@ -325,17 +371,61 @@ impl TrackProducer { Ok(()) } - /// Create a new consumer for the track, starting at the beginning. - pub fn consume(&self) -> TrackConsumer { + /// Create a new consumer for the track, starting at the beginning, with the + /// given subscriber-side preferences. The publisher can observe an aggregate + /// of every consumer's preferences via [`Self::max_priority`] and + /// [`Self::max_timeout`]. + pub fn consume_with(&self, subscription: Subscription) -> TrackConsumer { + let sub = Arc::new(Mutex::new(subscription)); + if let Ok(mut state) = self.state.write() { + state.subscriptions.push(sub.clone()); + } TrackConsumer { info: self.info.clone(), state: self.state.consume(), + subscription: sub, index: 0, min_sequence: 0, next_sequence: 0, } } + /// Create a new consumer with the default ([`Subscription::default`]) preferences. + pub fn consume(&self) -> TrackConsumer { + self.consume_with(Subscription::default()) + } + + /// The highest [`Subscription::priority`] across all live consumers, or `0` + /// if there are none. + pub fn max_priority(&self) -> u8 { + let state = self.state.read(); + state + .subscriptions + .iter() + .filter_map(|s| s.lock().ok().map(|s| s.priority)) + .max() + .unwrap_or(0) + } + + /// The largest [`Subscription::timeout`] across all live consumers, or + /// `Duration::ZERO` if there are none. `Duration::ZERO` means at least + /// one consumer requested no timeout, which dominates. + pub fn max_timeout(&self) -> Duration { + let state = self.state.read(); + state + .subscriptions + .iter() + .filter_map(|s| s.lock().ok().map(|s| s.timeout)) + .reduce(|a, b| { + if a.is_zero() || b.is_zero() { + Duration::ZERO + } else { + a.max(b) + } + }) + .unwrap_or(Duration::ZERO) + } + /// Block until there are no active consumers. pub async fn unused(&self) -> Result<()> { self.state @@ -410,16 +500,29 @@ impl TrackWeak { self.state.is_closed() } - pub fn consume(&self) -> TrackConsumer { + pub fn consume_with(&self, subscription: Subscription) -> TrackConsumer { + let sub = Arc::new(Mutex::new(subscription)); + // Register the subscription if we can still grab a producer handle. + if let Some(producer) = self.state.produce() + && let Ok(mut state) = producer.write() + { + state.subscriptions.push(sub.clone()); + } TrackConsumer { info: self.info.clone(), state: self.state.consume(), + subscription: sub, index: 0, min_sequence: 0, next_sequence: 0, } } + #[allow(dead_code)] + pub fn consume(&self) -> TrackConsumer { + self.consume_with(Subscription::default()) + } + pub async fn unused(&self) -> crate::Result<()> { self.state .unused() @@ -437,6 +540,9 @@ impl TrackWeak { pub struct TrackConsumer { info: Track, state: conducer::Consumer, + /// This consumer's subscription, shared with the producer's aggregate via + /// [`State::subscriptions`]. + subscription: Arc>, /// Arrival-order cursor used by [`Self::recv_group`]. index: usize, /// Minimum sequence to return from any `recv` method. Set by [`Self::start_at`]. @@ -467,6 +573,20 @@ impl TrackConsumer { }) } + /// Update this consumer's subscription preferences. The publisher's + /// [`TrackProducer::max_priority`] / [`TrackProducer::max_timeout`] reflect + /// the new value on the next read. + pub fn update_subscription(&self, subscription: Subscription) { + if let Ok(mut s) = self.subscription.lock() { + *s = subscription; + } + } + + /// Snapshot this consumer's current subscription. + pub fn subscription(&self) -> Subscription { + self.subscription.lock().map(|s| s.clone()).unwrap_or_default() + } + /// Poll for the next group in arrival order, without blocking. /// /// Returns every group exactly once in the order it landed on the wire — which may be @@ -602,6 +722,7 @@ impl TrackConsumer { } /// Create a weak reference that doesn't prevent auto-close. + #[allow(dead_code)] pub(crate) fn weak(&self) -> TrackWeak { TrackWeak { info: self.info.clone(), @@ -1174,4 +1295,54 @@ mod test { assert!(matches!(producer.append_group(), Err(Error::BoundsExceeded(_)))); } + + #[test] + fn max_priority_aggregates_subscriptions() { + let producer = Track::new("test").produce(); + let _c1 = producer.consume_with(Subscription::new(3)); + let _c2 = producer.consume_with(Subscription::new(7)); + let _c3 = producer.consume_with(Subscription::new(5)); + + assert_eq!(producer.max_priority(), 7); + } + + #[test] + fn max_timeout_zero_dominates() { + let producer = Track::new("test").produce(); + let _c1 = producer.consume_with(Subscription { + priority: 0, + timeout: Duration::from_secs(1), + }); + let _c2 = producer.consume_with(Subscription { + priority: 0, + timeout: Duration::ZERO, + }); + // Duration::ZERO (no timeout) dominates the aggregate. + assert_eq!(producer.max_timeout(), Duration::ZERO); + } + + #[test] + fn max_timeout_picks_largest_finite() { + let producer = Track::new("test").produce(); + let _c1 = producer.consume_with(Subscription { + priority: 0, + timeout: Duration::from_secs(2), + }); + let _c2 = producer.consume_with(Subscription { + priority: 0, + timeout: Duration::from_secs(5), + }); + assert_eq!(producer.max_timeout(), Duration::from_secs(5)); + } + + #[test] + fn update_subscription_changes_aggregate() { + let producer = Track::new("test").produce(); + let c1 = producer.consume_with(Subscription::new(2)); + let _c2 = producer.consume_with(Subscription::new(4)); + assert_eq!(producer.max_priority(), 4); + + c1.update_subscription(Subscription::new(9)); + assert_eq!(producer.max_priority(), 9); + } } diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 08b23d9aa..e68423276 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1,6 +1,6 @@ use crate::{ - ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_LITE, ALPN_LITE_03, ALPN_LITE_04, Error, NEGOTIATED, - OriginConsumer, OriginProducer, Session, StatsHandle, Version, Versions, + ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_LITE, ALPN_LITE_03, ALPN_LITE_04, ALPN_LITE_05, Error, + NEGOTIATED, OriginConsumer, OriginProducer, Session, StatsHandle, Version, Versions, coding::{Decode, Encode, Stream}, ietf, lite, setup, }; @@ -119,6 +119,22 @@ impl Server { .ok_or(Error::Version)?; (v, v.into()) } + Some(ALPN_LITE_05) => { + self.versions + .select(Version::Lite(lite::Version::Lite05)) + .ok_or(Error::Version)?; + + let recv_bw = lite::start( + session.clone(), + None, + self.publish.clone(), + self.consume.clone(), + self.stats.clone(), + lite::Version::Lite05, + )?; + + return Ok(Session::new(session, lite::Version::Lite05.into(), recv_bw)); + } Some(ALPN_LITE_04) => { self.versions .select(Version::Lite(lite::Version::Lite04)) diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 6643b1694..a15fee567 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -76,7 +76,7 @@ impl SetupVersion { Version::Ietf(ietf::Version::Draft15) | Version::Ietf(ietf::Version::Draft16) => Self::Draft15Plus, Version::Ietf(ietf::Version::Draft17) | Version::Ietf(ietf::Version::Draft18) => Self::Modern, Version::Lite(lite::Version::Lite01) | Version::Lite(lite::Version::Lite02) => Self::LiteLegacy, - Version::Lite(lite::Version::Lite03 | lite::Version::Lite04) => Self::Unsupported, + Version::Lite(lite::Version::Lite03 | lite::Version::Lite04 | lite::Version::Lite05) => Self::Unsupported, } } } diff --git a/rs/moq-net/src/stats.rs b/rs/moq-net/src/stats.rs index 5d5b06d98..16f837de7 100644 --- a/rs/moq-net/src/stats.rs +++ b/rs/moq-net/src/stats.rs @@ -597,6 +597,7 @@ async fn run_publisher(weak: Weak) { broadcast.create_track(Track { name: name.into(), priority: 0, + timescale: crate::Timescale::UNKNOWN, }) }; let ext_pub = match make("publisher.json") { diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index 7c4872c1f..9c226e37f 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -15,6 +15,13 @@ pub(crate) const NEGOTIATED: [Version; 3] = [ ]; /// ALPN strings for supported versions. +/// +/// `ALPN_LITE_05` (`moq-lite-05-wip`) is intentionally **not** in this list: +/// Lite05 is still in development and we don't want a client to accidentally +/// pick it during ALPN negotiation. Peers that explicitly opt in (e.g. by +/// setting `client.version = [moq-lite-05-wip]`) will still negotiate it via +/// the version enum, but the default advertisement only covers shipped +/// versions. pub const ALPNS: &[&str] = &[ ALPN_LITE_04, ALPN_LITE_03, @@ -30,6 +37,10 @@ pub const ALPNS: &[&str] = &[ pub(crate) const ALPN_LITE: &str = "moql"; pub(crate) const ALPN_LITE_03: &str = "moq-lite-03"; pub(crate) const ALPN_LITE_04: &str = "moq-lite-04"; +/// Work-in-progress ALPN for Lite05. Kept distinct from a stable `moq-lite-05` +/// identifier so clients that pin to that string today don't accidentally +/// negotiate the unfinalized wire format. Renamed when Lite05 is finalized. +pub(crate) const ALPN_LITE_05: &str = "moq-lite-05-wip"; pub(crate) const ALPN_14: &str = "moq-00"; pub(crate) const ALPN_15: &str = "moqt-15"; pub(crate) const ALPN_16: &str = "moqt-16"; @@ -52,6 +63,7 @@ impl Version { 0xff0dad02 => Some(Self::Lite(lite::Version::Lite02)), 0xff0dad03 => Some(Self::Lite(lite::Version::Lite03)), 0xff0dad04 => Some(Self::Lite(lite::Version::Lite04)), + 0xff0dad05 => Some(Self::Lite(lite::Version::Lite05)), 0xff00000e => Some(Self::Ietf(ietf::Version::Draft14)), 0xff00000f => Some(Self::Ietf(ietf::Version::Draft15)), 0xff000010 => Some(Self::Ietf(ietf::Version::Draft16)), @@ -68,6 +80,7 @@ impl Version { Self::Lite(lite::Version::Lite02) => 0xff0dad02, Self::Lite(lite::Version::Lite03) => 0xff0dad03, Self::Lite(lite::Version::Lite04) => 0xff0dad04, + Self::Lite(lite::Version::Lite05) => 0xff0dad05, Self::Ietf(ietf::Version::Draft14) => 0xff00000e, Self::Ietf(ietf::Version::Draft15) => 0xff00000f, Self::Ietf(ietf::Version::Draft16) => 0xff000010, @@ -85,6 +98,7 @@ impl Version { ALPN_LITE => None, // Multiple versions share this ALPN, need SETUP negotiation ALPN_LITE_03 => Some(Self::Lite(lite::Version::Lite03)), ALPN_LITE_04 => Some(Self::Lite(lite::Version::Lite04)), + ALPN_LITE_05 => Some(Self::Lite(lite::Version::Lite05)), ALPN_14 => Some(Self::Ietf(ietf::Version::Draft14)), ALPN_15 => Some(Self::Ietf(ietf::Version::Draft15)), ALPN_16 => Some(Self::Ietf(ietf::Version::Draft16)), @@ -97,6 +111,7 @@ impl Version { /// Returns the ALPN string for this version. pub fn alpn(&self) -> &'static str { match self { + Self::Lite(lite::Version::Lite05) => ALPN_LITE_05, Self::Lite(lite::Version::Lite04) => ALPN_LITE_04, Self::Lite(lite::Version::Lite03) => ALPN_LITE_03, Self::Lite(lite::Version::Lite01 | lite::Version::Lite02) => ALPN_LITE, @@ -152,6 +167,7 @@ impl FromStr for Version { "moq-lite-02" => Ok(Self::Lite(lite::Version::Lite02)), "moq-lite-03" => Ok(Self::Lite(lite::Version::Lite03)), "moq-lite-04" => Ok(Self::Lite(lite::Version::Lite04)), + "moq-lite-05-wip" => Ok(Self::Lite(lite::Version::Lite05)), "moq-transport-14" => Ok(Self::Ietf(ietf::Version::Draft14)), "moq-transport-15" => Ok(Self::Ietf(ietf::Version::Draft15)), "moq-transport-16" => Ok(Self::Ietf(ietf::Version::Draft16)), @@ -216,6 +232,12 @@ pub struct Versions(Vec); impl Versions { /// All supported versions exposed by default. + /// + /// `lite::Version::Lite05` is intentionally omitted: it is work-in-progress + /// (see the `ALPN_LITE_05` / `moq-lite-05-wip` ALPN) and would be picked up + /// by ALPN negotiation if included. Callers that explicitly opt in (e.g. + /// via `--client-version moq-lite-05-wip`) still get it; the default just + /// doesn't advertise it. pub fn all() -> Self { Self(vec![ Version::Lite(lite::Version::Lite04), diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 61f4c48f2..9bbd0e74e 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -506,11 +506,6 @@ async fn serve_fetch( tracing::info!(%broadcast, %track, "fetching track"); - let track = moq_net::Track { - name: track, - priority: 0, - }; - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30); let result = tokio::time::timeout_at(deadline, async { @@ -518,10 +513,13 @@ async fn serve_fetch( // Block until the broadcast has been announced (within the fetch deadline) so // freshly-connected subscribers don't get a spurious 404 before gossip arrives. let broadcast = origin.announced_broadcast("").await.ok_or(StatusCode::NOT_FOUND)?; - let mut track = broadcast.subscribe_track(&track).map_err(|err| match err { - moq_net::Error::NotFound => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - })?; + let mut track = broadcast + .subscribe_track(&track, moq_net::Subscription::default()) + .await + .map_err(|err| match err { + moq_net::Error::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + })?; let group = match params.group { FetchGroup::Latest => match track.latest() { Some(sequence) => track.get_group(sequence).await,