Skip to content
Draft
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
2 changes: 1 addition & 1 deletion doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ broadcast.announce();
- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`.
- **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing. Publishers reserve against it so their targets sum to the estimate.
- **One track per name.** Concurrent subscriptions share one request and producer. An on-demand producer that replaces an ended one continues the name's group and datagram sequences; only a new broadcast restarts them. `createTrack` and `insertTrack` start at 0.
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Unlike Rust, omitting it means untimed. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers.
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers.
- **Subscriber staleness is `maxDelay`.** It is media time. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`.
- **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts).
- **A graceful close waits.** `await connection.close()` withdraws announcements and gives finished tracks up to one second. `abort()` ends immediately.
Expand Down
5 changes: 5 additions & 0 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ These land with the next breaking release, not the 2026-09-23 train.
`Option<Timestamp>`. The `write_frame`, `append_datagram`, and
`insert_datagram` calls take `impl Into<Option<Timestamp>>`, so passing a
`Timestamp` still compiles.
An undeclared timescale is untimed: `track::Info::default()`,
`create_track(name, None)`, and `accept(None)` no longer default to
milliseconds, and `Timescale` has no `Default`. A track you write
timestamps to declares one, such as
`Info::default().with_timescale(Timescale::MILLI)`.
- moq-e2ee: `Frame` and `Datagram` timestamps are optional, and its writers
take `impl Into<Option<Timestamp>>`.
- moq-archive: recording an untimed track fails with `Error::Untimed`, so
Expand Down
2 changes: 1 addition & 1 deletion js/net/src/lite/track.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ test("TrackInfo round-trips on draft-05", async () => {
});

test("TrackInfo for Rust's default Info matches cross-language wire bytes", async () => {
// Rust's `Info::default()` is millisecond-timed; JS has no default timescale.
// Neither language has a default timescale; an untimed track declares milliseconds on lite-05.
const model = infoDefaults({ timescale: Timescale.MILLI });
const info = new TrackInfo({ ...model, timescale: model.timescale ?? undefined });
expect(await bytes((w) => info.encode(w, Version.DRAFT_05), Version.DRAFT_05)).toEqual(
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ were done or not worth their cost.
- [Held group wakes](/quest/m1/held-group-wakes.md) - a group held across a route switch wakes when its successor's first timestamp or abort makes it stale; cache-max-age's wall-clock budget rides the same wake
- [Upstream position regression](/quest/m1/largest-regression.md) - a relay copy that sees upstream's largest group go backwards on moq-transport or epochless lite-07 fails loud instead of serving the old instance's cache
- [Untimed decisions](/quest/m1/untimed-decisions.md) - the maintainer decides whether moq-archive keeps refusing untimed tracks, and whether a malformed FETCH object ends its track
- [Untimed by default in Rust](/quest/m1/rust-untimed-default.md) - an undeclared Rust timescale means untimed, and shared-clock publishers declare milliseconds
- [Shared import clock](/quest/m1/shared-clock.md) - an importer joining a clock already in use offsets its PTS instead of moving it, so captures and imports share one timeline
- [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, flate, audio, and video namespaces built from the handle below
- [Publishing never invents a timestamp](/quest/m1/publish-timestamp.md) - no Rust or binding publish API fills in a timestamp; an untimed payload goes out untimed
Expand Down
9 changes: 3 additions & 6 deletions quest/m1/lite-untimed.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ Decided (2026-10-02): one PR for both languages. Both model quests have
landed (#4822). Shipping one language first would break Rust-JS interop on
lite-07-wip in between.

Decided 2026-10-08: [Rust untimed default](/quest/m1/rust-untimed-default.md)
lands first, since both edit the same Timescale defaults.
Rust has no `Timescale` default: the lite encoder and the datagram decoder
name `Timescale::MILLI` for an untimed track's send times, which this quest
replaces on lite-07.

Draft work:

Expand All @@ -46,7 +47,3 @@ JS-to-Rust on lite-07. lite-06 still receives a timestamp. Run
`just drafts check` and `just test interop --all`.

Public API: none. Wire: lite-07-wip only, which is unpublished.

## Required

- [Rust untimed default](/quest/m1/rust-untimed-default.md) - touches the same Timescale defaults in `rs/moq-net` and lands first; this rebases onto it
4 changes: 0 additions & 4 deletions quest/m1/publish-timestamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,6 @@ Swift wrappers default them to 0.
Public API: breaking. Wire: none here; absence on the wire lands
with the untimed implementation quests.

## Required

- [An undeclared Rust timescale means untimed](/quest/m1/rust-untimed-default.md) - each track declares its timescale first, so a publisher that stops stamping writes onto a track that already says whether it is timed

## Related

- [FFI shape](/quest/m1/ffi-shape/README.md) - moves the data producers this changes into `json` and `flate` namespaces in the same merge, so this is ready once #4519 lands rather than waiting on the codec child (2026-10-06 audit)
47 changes: 0 additions & 47 deletions quest/m1/rust-untimed-default.md

This file was deleted.

7 changes: 1 addition & 6 deletions quest/m3/teleop-mavlink.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ naming imply one leg.
Read the header only: the 24-bit msgid in v2, 8-bit in v1. Payloads stay opaque
bytes and route to a delivery class through a config table, with an unknown
msgid falling to a default. Every track declares `Timescale::MILLI`
explicitly, since an undeclared Rust timescale means untimed
([Rust untimed default](/quest/m1/rust-untimed-default.md)).
explicitly, since an undeclared Rust timescale means untimed.

This is what makes frame-aware routing free of the dialect tax. CRC_EXTRA (the
per-message schema hash) is only needed to validate a frame, never to read its
Expand Down Expand Up @@ -90,7 +89,3 @@ small, real contribution and its own future quest.

- [Robot teleoperation primitive](/quest/m3/teleop-robot.md)
- [Operator arbitration](/quest/m3/teleop-arbitration.md)

## Related

- [Rust untimed default](/quest/m1/rust-untimed-default.md) - tracks that leave out their timescale are untimed
6 changes: 2 additions & 4 deletions quest/m3/teleop-robot.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,8 @@ documented in `doc/concept/hang.md` (#4461). Media and command tracks keep
their own timescales; convert PTS explicitly into the broadcast clock before
joining samples. The robot's video and telemetry share a clock; the operator's
command broadcast supplies its own mapping. Command and telemetry tracks
declare `Timescale::MILLI` explicitly: once an undeclared Rust timescale means
untimed ([Rust untimed default](/quest/m1/rust-untimed-default.md)), a track
that leaves it out carries no timestamps to join.
declare `Timescale::MILLI` explicitly: an undeclared Rust timescale means
untimed, so a track that leaves it out carries no timestamps to join.

The clock assumption: the two hosts' wall clocks are synchronized by the
deployment, not by the library. State this beside the API, since a join across
Expand All @@ -117,4 +116,3 @@ control then it is the wrong abstraction.
## Related

- [arbitration](/quest/m3/teleop-arbitration.md) - which controller is obeyed
- [Rust untimed default](/quest/m1/rust-untimed-default.md) - why command and telemetry tracks declare their timescale
5 changes: 4 additions & 1 deletion rs/hang/src/catalog/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ impl Catalog<()> {
///
/// Keeps the bare `moq_net` retention rather than the media one: the catalog is
/// snapshot mode, so the useful value is the live edge, which is always kept.
/// Timed in milliseconds, so snapshots share the broadcast clock with media.
pub fn default_track_info() -> moq_net::track::Info {
moq_net::track::Info::default().with_priority(PRIORITY.catalog)
moq_net::track::Info::default()
.with_timescale(moq_net::Timescale::MILLI)
.with_priority(PRIORITY.catalog)
}

/// The subscription preferences used for the catalog track (high priority so
Expand Down
7 changes: 4 additions & 3 deletions rs/hang/src/container/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ pub const TIMESCALE: Timescale = Timescale::MICRO;
/// [`create_track`](moq_net::broadcast::Producer::create_track) or
/// [`accept`](moq_net::track::Request::accept).
///
/// Pins the track's timescale to [`TIMESCALE`]. `moq_net::track::Info::default()` is milliseconds,
/// which would quantize the net-level frame timestamps that moq-lite-05 and later delta-encode on
/// the wire, even though the container prefix stays at microseconds. Chain
/// Pins the track's timescale to [`TIMESCALE`], since `moq_net::track::Info::default()` is
/// untimed and refuses timestamped frames. Microseconds match the container prefix, so the
/// net-level frame timestamps that moq-lite-05 and later delta-encode on the wire aren't
/// quantized. Chain
/// [`with_timescale`](moq_net::track::Info::with_timescale) for a container that carries the
/// source's own scale instead (CMAF and Matroska both do).
///
Expand Down
4 changes: 2 additions & 2 deletions rs/moq-audio/src/decode/consumer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ fn now() -> Instant {
/// So a packet is discontinuous when it misses `expected` by more than one unit of
/// the coarsest timescale on the path, plus one unit of the stamp's own scale for
/// the rounding in the arithmetic that produced `expected`. The coarsest timescale
/// is the stamp's own scale floored at [`Timescale::default`](moq_net::Timescale):
/// is the stamp's own scale floored at [`Timescale::MILLI`](moq_net::Timescale::MILLI):
/// the legacy hang container re-stamps every frame in microseconds whatever the
/// source used, and a wire that cannot carry a timescale at all (moq-lite before
/// 05, IETF moq-transport) falls back to milliseconds, so a millisecond is the
Expand All @@ -550,7 +550,7 @@ fn now() -> Instant {
/// swallows a lost one.
fn discontinuous(expected: moq_net::Timestamp, timestamp: moq_net::Timestamp) -> bool {
let scale = expected.scale().max(timestamp.scale());
let quantum = scale.min(moq_net::Timescale::default());
let quantum = scale.min(moq_net::Timescale::MILLI);
let tolerance = (scale.as_u64() as u128).div_ceil(quantum.as_u64() as u128) + 1;
expected.as_scale(scale).abs_diff(timestamp.as_scale(scale)) > tolerance
}
Expand Down
24 changes: 16 additions & 8 deletions rs/moq-bench/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ pub async fn run(ctx: Connection) {
continue;
}
};
let track = match broadcast.create_track(TRACK, None) {
let track = match broadcast.create_track(
TRACK,
moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI),
) {
Ok(track) => track,
Err(err) => {
tracing::error!(connection, %err, "failed to create track");
Expand Down Expand Up @@ -574,6 +577,11 @@ impl Drop for Gauge<'_> {
mod tests {
use super::*;

/// A millisecond track: the frames written here carry timestamps.
fn timed() -> moq_net::track::Info {
moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI)
}

async fn wait_for(counter: &AtomicU64, value: u64) {
while counter.load(Ordering::Relaxed) < value {
tokio::task::yield_now().await;
Expand Down Expand Up @@ -602,7 +610,7 @@ mod tests {

let stats = Arc::new(Stats::default());
let broadcast = broadcast::Info::new().produce();
let track = broadcast.create_track(TRACK, None).unwrap();
let track = broadcast.create_track(TRACK, timed()).unwrap();
let consumer = broadcast.consume();

// 10fps (100ms/frame), 8-byte frames, 2 payload frames per group.
Expand Down Expand Up @@ -646,7 +654,7 @@ mod tests {

let stats = Arc::new(Stats::default());
let broadcast = broadcast::Info::new().produce();
let track = broadcast.create_track(TRACK, None).unwrap();
let track = broadcast.create_track(TRACK, timed()).unwrap();
let consumer = broadcast.consume();

let task = tokio::spawn(produce(0, "bench/test".into(), rolled(10, 4, 0), track, stats.clone()));
Expand Down Expand Up @@ -677,7 +685,7 @@ mod tests {

let stats = Arc::new(Stats::default());
let broadcast = broadcast::Info::new().produce();
let track = broadcast.create_track(TRACK, None).unwrap();
let track = broadcast.create_track(TRACK, timed()).unwrap();
let consumer = broadcast.consume();

// 10fps, 300-byte messages, lone-keyframe groups (the chat shape).
Expand Down Expand Up @@ -710,7 +718,7 @@ mod tests {

let stats = Arc::new(Stats::default());
let broadcast = broadcast::Info::new().produce();
let track = broadcast.create_track(TRACK, None).unwrap();
let track = broadcast.create_track(TRACK, timed()).unwrap();
let consumer = broadcast.consume();

// 50 bytes is well under the serialized header (roughly 170 bytes).
Expand Down Expand Up @@ -755,7 +763,7 @@ mod tests {
// One legitimate peer under the bench namespace with a single finished group.
let peer = origin.create_broadcast("bench/current/0/0").unwrap();
peer.announce(Default::default()).unwrap();
let track = peer.create_track(TRACK, None).unwrap();
let track = peer.create_track(TRACK, timed()).unwrap();
let mut group = track.append_group().unwrap();
group
.write_frame(moq_net::Timestamp::now(), Bytes::from_static(b"{}"))
Expand All @@ -782,7 +790,7 @@ mod tests {

let broadcast = origin.create_broadcast("bench/run/chat").unwrap();
broadcast.announce(Default::default()).unwrap();
let track = broadcast.create_track(TRACK, None).unwrap();
let track = broadcast.create_track(TRACK, timed()).unwrap();
tokio::task::yield_now().await;
let mut group = track.append_group().unwrap();
let header = serde_json::json!({
Expand Down Expand Up @@ -819,7 +827,7 @@ mod tests {

let stats = Arc::new(Stats::default());
let broadcast = broadcast::Info::new().produce();
let mut track = broadcast.create_track(TRACK, None).unwrap();
let mut track = broadcast.create_track(TRACK, timed()).unwrap();
let consumer = broadcast.consume();

// Group 0 lands intact.
Expand Down
5 changes: 4 additions & 1 deletion rs/moq-boy/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ pub struct StatusPublisher {

impl StatusPublisher {
pub fn new(broadcast: &mut moq_net::broadcast::Producer) -> anyhow::Result<Self> {
let producer = broadcast.create_track("status", None)?;
let producer = broadcast.create_track(
"status",
moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI),
)?;

Ok(Self {
producer: moq_json::snapshot::Producer::new(producer, moq_json::snapshot::Config::default()),
Expand Down
5 changes: 4 additions & 1 deletion rs/moq-c/src/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,10 @@ impl Publish {
publish: impl FnOnce(&moq_mux::catalog::Producer<Extra>, moq_net::track::Producer) -> moq_mux::Result<T>,
) -> Result<T, Error> {
let broadcast = self.broadcasts.get_mut(broadcast).ok_or(Error::BroadcastNotFound)?;
let track = broadcast.producer.create_track(name, None)?;
let track = broadcast.producer.create_track(
name,
moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI),
)?;
Ok(publish(&broadcast.catalog, track)?)
}

Expand Down
1 change: 0 additions & 1 deletion rs/moq-c/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1620,7 +1620,6 @@ fn raw_track_datagram_publish_consume() {
)
});

// Millisecond-aligned so the value survives the default (millisecond) timescale exactly.
let payload = b"hello datagram";
let mut sequence: u64 = u64::MAX;
assert_eq!(
Expand Down
Loading
Loading