Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions js/publish/src/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Catalog.Root>({ track: track.subscribe() });
expect(await consumer.next()).toEqual({ video: { renditions: {} } });
effect.close();
} finally {
// Drops the override, uncovering the real getter.
Reflect.deleteProperty(performance, "timeOrigin");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the original timeOrigin after the test

Under Bun, performance.timeOrigin is a configurable own data property rather than a prototype getter, so deleting this override leaves performance.timeOrigin as undefined. Every subsequent new CatalogProducer() in this file then computes a NaN wall value and throws in Catalog.u53, causing the remaining tests to fail. Save and restore the original property descriptor instead of deleting it.

AGENTS.md reference: js/AGENTS.md:L41-L41

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applying this one. Under Bun (v1.4.2, the repo's runner), performance has no own timeOrigin property: Object.getOwnPropertyDescriptor(performance, "timeOrigin") is undefined and the value comes from the prototype getter. Deleting the own override therefore uncovers that getter again rather than leaving undefined. bun test src/catalog.test.ts passes all 15 tests, including the later ones that build a CatalogProducer and would throw in Catalog.u53 on NaN. main uses the same cleanup (#5429).

(Written by Claude Opus 5.5)

}
});

test("a reconnecting subscriber is seeded with the full current catalog", async () => {
const catalog = new CatalogProducer();
catalog.mutate((c) => {
Expand Down
14 changes: 9 additions & 5 deletions js/publish/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Json.Snapshot.Producer<Catalog.Root>>();

/** Edit the catalog in place; the result is published to all current subscribers. */
Expand Down Expand Up @@ -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 } };
}
49 changes: 47 additions & 2 deletions rs/moq-mux/src/catalog/producer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,9 @@ impl<E: CatalogExt> Producer<E> {

// 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
Expand Down Expand Up @@ -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};
Expand Down
82 changes: 75 additions & 7 deletions rs/moq-mux/src/clock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Option<SystemTime>> = 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<hang::catalog::Clock> {
let unix_micros = wall
Expand Down Expand Up @@ -52,7 +75,8 @@ fn wall_clock(wall: SystemTime) -> crate::Result<hang::catalog::Clock> {
#[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<hang::catalog::Clock>,
}

impl Clock {
Expand All @@ -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.
Expand All @@ -89,7 +119,7 @@ impl Clock {
pub fn at(epoch: Instant, wall: SystemTime) -> crate::Result<Self> {
Ok(Self {
epoch,
wall: wall_clock(wall)?,
wall: Some(wall_clock(wall)?),
})
}

Expand Down Expand Up @@ -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<hang::catalog::Clock> {
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<SystemTime> {
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.
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion rs/moq-mux/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading