From 11e558b0c37d0fb0d22cca014f12c10dd1da6c70 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 12:37:52 -0700 Subject: [PATCH 1/2] quest: claim quest/m0/release-22/wall-clock-omit Co-Authored-By: Claude Opus 5.5 From 2d72383f528a922103bca9549e29adc35ae5987a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 12:58:59 -0700 Subject: [PATCH 2/2] fix(mux): an unset host clock publishes without a wall mapping Backport of #5429 to the 0.17 line, keeping moq-mux's public API. A host whose wall clock reads before 2020 now publishes without a root catalog `clock` instead of panicking in `Clock::new()`. The js/publish `CatalogProducer` omits `clock` the same way. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/catalog.test.ts | 21 ++++++++ js/publish/src/catalog.ts | 14 +++-- rs/moq-mux/src/catalog/producer.rs | 49 +++++++++++++++++- rs/moq-mux/src/clock.rs | 82 +++++++++++++++++++++++++++--- rs/moq-mux/src/error.rs | 3 +- 5 files changed, 154 insertions(+), 15 deletions(-) diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 215da4f89c7..3bc6d6c824e 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -94,6 +94,27 @@ test("catalog producer advertises the page clock from the first snapshot", async effect.close(); }); +test("catalog producer omits the clock when the page's wall clock is unset", async () => { + // A device booted before it synced its clock: the page began in 1970. + Object.defineProperty(performance, "timeOrigin", { value: 86_400_000, configurable: true }); + try { + const catalog = new CatalogProducer(); + catalog.mutate((c) => { + c.video = { renditions: {} }; + }); + + const effect = new Effect(); + const track = new Track.Producer("catalog.json"); + catalog.serve(track, effect); + const consumer = new Json.Snapshot.Consumer({ track: track.subscribe() }); + expect(await consumer.next()).toEqual({ video: { renditions: {} } }); + effect.close(); + } finally { + // Drops the override, uncovering the real getter. + Reflect.deleteProperty(performance, "timeOrigin"); + } +}); + test("a reconnecting subscriber is seeded with the full current catalog", async () => { const catalog = new CatalogProducer(); catalog.mutate((c) => { diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 225a4e47155..c23f383ea72 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -14,10 +14,12 @@ import type { Effect } from "@moq/signals"; * * The root `clock` is advertised from the first snapshot: every js/publish timestamp is * `performance.now()` in microseconds, so PTS zero is `performance.timeOrigin`. The mapping is fixed - * for the page, so a system-clock adjustment never retimes the broadcast. + * for the page, so a system-clock adjustment never retimes the broadcast. A page whose wall clock + * reads before 2020 has not set it yet (a device booted before it synced), so it omits `clock` + * rather than advertise a false one. */ export class CatalogProducer { - #value: Catalog.Root = { clock: pageClock() }; + #value: Catalog.Root = pageRoot(); #outputs = new Set>(); /** Edit the catalog in place; the result is published to all current subscribers. */ @@ -78,8 +80,10 @@ function advertised( }; } -// The wall time of `performance.now() === 0`, the zero every js/publish timestamp counts from. -function pageClock(): Catalog.Clock { +// An empty root advertising the wall time of `performance.now() === 0`, the zero every js/publish +// timestamp counts from, when the page's wall clock is set. +function pageRoot(): Catalog.Root { const wall = Math.round((performance.timeOrigin - Catalog.MOQ_EPOCH_UNIX_MILLIS) * 1000); - return { wall: Catalog.u53(wall), timescale: 1_000_000 }; + if (wall < 0) return {}; + return { clock: { wall: Catalog.u53(wall), timescale: 1_000_000 } }; } diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 0811704b1be..c06b63cac74 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -320,9 +320,9 @@ impl Producer { // The broadcast clock is advertised at the catalog root from the first snapshot, // independently of any archive timeline: a live-only publisher exposes its mapping - // without creating a segment index. + // without creating a segment index. A host with an unset wall clock advertises none. let mut catalog = config.catalog; - catalog.clock = Some(config.clock.wall()); + catalog.clock = config.clock.mapping(); // The contents are `Send + Sync` natively; on wasm moq-net's handles are // `Rc`-backed, so clippy sees a pointlessly atomic `Arc`. Keeping one type for @@ -1266,6 +1266,51 @@ mod test { assert_eq!(snapshot.clock, Some(catalog.clock().wall())); } + /// A host whose wall clock reads before 2020 (booted before NTP sync) publishes its media on the + /// monotonic timeline with no root `clock`, rather than advertise a false one or abort. + #[test] + fn an_unset_host_clock_publishes_without_a_wall() { + use std::time::{Duration, SystemTime}; + + crate::clock::fake_host_wall(SystemTime::UNIX_EPOCH + Duration::from_secs(86_400)); + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); + assert_eq!(catalog.snapshot().clock, None); + + let net = broadcast + .create_track("video0", catalog.track_info(hang::catalog::PRIORITY.video)) + .unwrap(); + let mut video = catalog + .video( + net, + crate::catalog::hang::Container::Legacy(crate::container::Kind::Video), + VideoConfig::new(hang::catalog::VideoCodec::VP8), + ) + .unwrap(); + let mut subscriber = video.consume(); + let timestamp = catalog.timestamp(None).unwrap(); + video + .write(crate::container::Frame { + timestamp, + duration: None, + payload: Bytes::from_static(b"keyframe"), + keyframe: true, + }) + .unwrap(); + + let snapshot = catalog.snapshot(); + assert!(snapshot.video.renditions.contains_key("video0")); + assert_eq!(snapshot.clock, None, "publishing never invents a wall"); + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the frame's group is published"); + }; + assert!( + matches!(group.poll_read_frame(&waiter), Poll::Ready(Ok(Some(_)))), + "the frame is published" + ); + } + #[test] fn with_clock_names_the_contents_real_start() { use std::time::{Duration, SystemTime}; diff --git a/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs index ac813e0edfd..d3f7dc9c153 100644 --- a/rs/moq-mux/src/clock.rs +++ b/rs/moq-mux/src/clock.rs @@ -10,7 +10,9 @@ //! [`Clock::TIMESCALE`] units since the moq epoch (2020-01-01). A consumer derives any //! timestamp's wall time as `wall + pts` after converting it into this timescale. The mapping //! is fixed at construction and never overwritten: a discontinuity marker is a delivery event, -//! not a new epoch, and a system-clock adjustment never retimes it. +//! not a new epoch, and a system-clock adjustment never retimes it. A host whose wall clock reads +//! before 2020 has not set it yet (a device booted before NTP sync), so its clock carries no +//! mapping and the catalog omits `clock` rather than advertise a false one. //! //! A source with its own zero (a file, a restarted encoder) is translated onto the mapping with //! [`SourceMap`]: the first frame anchors onto the live edge, and a reset re-anchors forward, @@ -23,6 +25,27 @@ use std::time::{Duration, Instant, SystemTime}; use hang::catalog::{MAX_SAFE_INTEGER, MOQ_EPOCH_UNIX_MILLIS}; +/// The host's wall clock. +fn host_wall() -> SystemTime { + #[cfg(test)] + if let Some(now) = HOST_WALL.get() { + return now; + } + SystemTime::now() +} + +#[cfg(test)] +thread_local! { + /// What [`host_wall`] reads on this thread instead of the system clock. + static HOST_WALL: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Make this thread's host wall clock read `now`, so a test can fake one that was never set. +#[cfg(test)] +pub(crate) fn fake_host_wall(now: SystemTime) { + HOST_WALL.set(Some(now)); +} + /// The catalog clock for PTS zero at `wall`. fn wall_clock(wall: SystemTime) -> crate::Result { let unix_micros = wall @@ -52,7 +75,8 @@ fn wall_clock(wall: SystemTime) -> crate::Result { #[derive(Clone, Copy, Debug)] pub struct Clock { epoch: Instant, - wall: hang::catalog::Clock, + /// `None` when the host's wall clock was unset at construction. + wall: Option, } impl Clock { @@ -72,14 +96,20 @@ impl Clock { /// Start a clock at the current instant, with PTS zero ten seconds earlier on both the /// monotonic and the wall clock, so earlier-stamped frames of a source anchored now still map. + /// + /// Without a wall mapping when the host's wall clock is unset, or reads a time the mapping can't + /// name: media needs only the monotonic timeline. pub fn new() -> Self { - let (now, wall) = (Instant::now(), SystemTime::now()); + let (now, wall) = (Instant::now(), host_wall()); // Shortly after boot the monotonic clock may not reach back that far; start at now then. let (epoch, wall) = match (now.checked_sub(Self::LEAD), wall.checked_sub(Self::LEAD)) { (Some(epoch), Some(wall)) => (epoch, wall), _ => (now, wall), }; - Self::at(epoch, wall).expect("the current wall time is representable as a broadcast clock") + Self { + epoch, + wall: wall_clock(wall).ok(), + } } /// Start a clock at an explicit monotonic epoch and wall time. @@ -89,7 +119,7 @@ impl Clock { pub fn at(epoch: Instant, wall: SystemTime) -> crate::Result { Ok(Self { epoch, - wall: wall_clock(wall)?, + wall: Some(wall_clock(wall)?), }) } @@ -138,16 +168,30 @@ impl Clock { } /// The catalog root section advertising this clock. + /// + /// # Panics + /// + /// If the host's wall clock was unset (before 2020) when this clock was created by + /// [`new`](Self::new), leaving no mapping to advertise. pub fn wall(&self) -> hang::catalog::Clock { + self.wall + .expect("the host wall clock was set when this clock was created") + } + + /// The catalog root section advertising this clock, or `None` if the host's wall clock was unset. + pub(crate) fn mapping(&self) -> Option { self.wall } /// The wall-clock time of `pts` under this broadcast's fixed mapping. /// /// Pure in the stored epoch: a system-clock adjustment after construction changes nothing. - /// Refuses an unrepresentable result rather than truncating it. + /// Refuses an unrepresentable result rather than truncating it, and a clock without a mapping. pub fn wall_clock(&self, pts: moq_net::Timestamp) -> crate::Result { - self.wall.wall_clock(pts).map_err(crate::Error::from) + let wall = self + .wall + .ok_or_else(|| crate::Error::UnmappableTimestamp("the host wall clock was unset".to_string()))?; + wall.wall_clock(pts).map_err(crate::Error::from) } /// Translate a source with its own zero onto this broadcast's mapping. @@ -481,6 +525,30 @@ mod tests { assert_eq!(Clock::at(epoch(), moq_epoch()).unwrap().wall().wall.value(), 0); } + /// A host wall clock before 2020 is unset: the clock keeps its monotonic timeline without a wall + /// mapping, and refuses whatever needs one rather than invent it. + #[test] + fn an_unset_host_clock_has_no_wall() { + fake_host_wall(SystemTime::UNIX_EPOCH + Duration::from_secs(86_400)); + + let clock = Clock::new(); + assert_eq!(clock.mapping(), None); + assert!(matches!( + clock.wall_clock(clock.now()), + Err(crate::Error::UnmappableTimestamp(_)) + )); + + // A source still translates onto the monotonic timeline. + let mut source = clock.source(); + assert_eq!( + source.translate_at(us(5_000_000), 100_000).unwrap().as_micros(), + 100_000 + ); + + // An explicit wall needs no host clock. + assert!(Clock::at(epoch(), moq_epoch()).unwrap().mapping().is_some()); + } + #[test] fn delayed_first_frame_anchors_live() { let clock = Clock::at(epoch(), moq_epoch()).unwrap(); diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index fd676051a9b..e7620f175fc 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -215,7 +215,8 @@ pub enum Error { InvalidTimescale(u32), /// A source timestamp cannot be mapped onto the broadcast clock: it would land before the - /// broadcast began or outside the representable range. + /// broadcast began or outside the representable range, or the host's wall clock was unset so + /// the clock has no wall mapping. #[error("timestamp cannot be mapped onto the broadcast clock: {0}")] UnmappableTimestamp(String), /// Tried to set an application catalog section whose name collides with a