diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 00ff7db554b..e128eb16e37 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -28,6 +28,12 @@ Pass the new `moq::ContainerFormat::kCmaf` to keep publishing CMAF. `BroadcastConsumer::resolve(reference)` becomes `resolve(reference, epoch)`; pass the rendition's `epoch` alongside its `broadcast`, or `std::nullopt` to resolve whichever instance answers. Resolving, and the rendition-based consumers, fail with `moq::Error::kMux` for a rendition whose broadcast is proven to serve another epoch. +### Timestamps stop at 2^61 - 1 microseconds (#6168) + +Writing a frame, or any other call that takes a timestamp, fails with `TimeOverflow` past `2^61 - 1` microseconds instead of `2^62 - 1`, the most moq-lite-05/06 can carry. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `2^61 - 1`. + ### Finished tracks stay until the broadcast ends (#6157) A finished track now stays listed, with its catalog rendition and demand, until its broadcast closes or the new `BroadcastProducer::finish` lets go. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 2b49aeef8cc..d466a24a91f 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -34,6 +34,12 @@ Pass the new `ContainerFormat.cmaf` to keep publishing CMAF. `BroadcastConsumer.resolve(reference:)` requires `epoch:` too; pass the rendition's `epoch` alongside its `broadcast`, or `null` to resolve whichever instance answers. Resolving, and the rendition-based consumers, throw for a rendition whose broadcast is proven to serve another epoch. +### Timestamps stop at 2^61 - 1 microseconds (#6168) + +Writing a frame, or any other call that takes a timestamp, fails with `TimeOverflow` past `2^61 - 1` microseconds instead of `2^62 - 1`, the most moq-lite-05/06 can carry. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `2^61 - 1`. + ### Finished tracks stay until the broadcast ends (#6157) A finished track now stays listed, with its catalog rendition and demand, until its broadcast closes or the new `BroadcastProducer.finish()` lets go. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 0c747eb7012..08890fd1b7e 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -182,6 +182,8 @@ A Group Stream normally starts at frame 0, but MAY start later when the publishe Each frame of a timed Track carries a presentation timestamp expressed in the Track's `Timescale` (see [TRACK_INFO](#track-info)), used by the moq-lite layer for [expiration](#expiration) decisions. A Track without a `Timescale` is untimed: its frames and datagrams carry no timestamp. +A timestamp MUST be less than 2^61: lite-05 and lite-06 zigzag a Group's first [Timestamp Delta](#frame) into a QUIC varint, which carries no more, and the bound lets a relay forward any timestamp to any version. +A receiver treats a larger timestamp as a malformed Track, even one it discards. A publisher MUST NOT start a timed Group's frame 0 earlier than that of any lower-sequence Group it publishes. lite-05 and lite-06 cannot encode an untimed Track, so a publisher on those versions declares a `Timescale` of 1000 for it and stamps every frame and datagram with its send time in milliseconds. A receiver cannot tell such send times from presentation timestamps, and they need not rise with sequence, so it discards every timestamp received on those versions and treats the Track as untimed. @@ -1777,6 +1779,9 @@ The `Message Length` describes the payload size on the wire. - Replaced ANNOUNCE_OK Active Count with an ordered, empty SYNCED event (0x4), marking the initial set without retaining routes that end before delivery. - A publisher must not start a timed Group's frame 0 earlier than a lower-sequence Group's, and a receiver may refuse a Group that does. A receiver discards the timestamps it receives over lite-05/06, which may be send times, and a relay serves a Track whose timestamps it cannot vouch for untimed. + +- Bounded every timestamp below 2^61, since a lite-05/06 Group's first Timestamp Delta carries no more, so a relay never holds one it cannot forward. A larger one is a malformed Track. + - Added the reliable ordered RTCDataChannel binding, with one Qmux Record per binary message and no datagrams. - Added `Group Order` to SUBSCRIBE and SUBSCRIBE_UPDATE: descending (0x0, the previous fixed order) or ascending (0x1). A relay requests descending upstream unless every subscription it serves is ascending. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 68287cb9129..765675dbae7 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -34,6 +34,12 @@ Pass the new `media.ContainerFormatCmaf` to keep publishing CMAF. `BroadcastConsumer.Resolve(ctx, reference)` becomes `Resolve(ctx, reference, epoch)`; pass the rendition's `Epoch` alongside its `Broadcast`, or `nil` to resolve whichever instance answers. Resolving, and the rendition-based consumers, error for a rendition whose broadcast is proven to serve another epoch. +### Timestamps stop at 2^61 - 1 microseconds (#6168) + +Writing a frame, or any other call that takes a timestamp, fails with `ErrTimeOverflow` past `2^61 - 1` microseconds instead of `2^62 - 1`, the most moq-lite-05/06 can carry. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `2^61 - 1`. + ### Finished tracks stay until the broadcast ends (#6157) A finished track now stays listed, with its catalog rendition and demand, until its broadcast closes or the new `BroadcastProducer.Finish()` lets go. diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 214011b3f42..6e1db97c770 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -34,6 +34,12 @@ Pass the new `ContainerFormat.CMAF` to keep publishing CMAF. `BroadcastConsumer.resolve(reference)` becomes `resolve(reference, epoch)`; pass the rendition's `epoch` alongside its `broadcast`, or `null` to resolve whichever instance answers. Resolving, and the rendition-based consumers, throw `MoqException.Mux` for a rendition whose broadcast is proven to serve another epoch. +### Timestamps stop at 2^61 - 1 microseconds (#6168) + +Writing a frame, or any other call that takes a timestamp, fails with `TimeOverflow` past `2^61 - 1` microseconds instead of `2^62 - 1`, the most moq-lite-05/06 can carry. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `2^61 - 1`. + ### Finished tracks stay until the broadcast ends (#6157) A finished track now stays listed, with its catalog rendition and demand, until its broadcast ends or the new `BroadcastProducer.finish()` lets go. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 81a829b3690..b9a85594a45 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -34,6 +34,12 @@ Pass the new `ContainerFormat.CMAF` to keep publishing CMAF. `BroadcastConsumer.resolve(reference)` becomes `resolve(reference, epoch)`; pass the rendition's `epoch` alongside its `broadcast`, or `None` to resolve whichever instance answers. Resolving, and the rendition-based consumers, raise for a rendition whose broadcast is proven to serve another epoch. +### Timestamps stop at 2^61 - 1 microseconds (#6168) + +Writing a frame, or any other call that takes a timestamp, fails with `TimeOverflow` past `2^61 - 1` microseconds instead of `2^62 - 1`, the most moq-lite-05/06 can carry. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `2^61 - 1`. + ### Finished tracks stay until the broadcast ends (#6157) A finished track now stays listed, with its catalog rendition and demand, until its broadcast closes or the new `BroadcastProducer.finish()` lets go. diff --git a/quest/m1/README.md b/quest/m1/README.md index 9388d63fe5b..810bb355789 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -99,7 +99,6 @@ tracks, so per-group stats stop costing cluster announces. - [Pause an IETF SUBSCRIBE](/quest/m1/ietf-subscribe-pause.md) - Forward=0 pauses a SUBSCRIBE without demand upstream and Forward=1 resumes it, instead of ending it - [A listing says whether a removed track finished](/quest/m1/listing-end-reason.md) - a listing removal carries whether the track finished, at every hop, so the IETF answerer ends a finished track's PUBLISH with TRACK_ENDED and a Rust-to-Rust IETF listing removes it without a reset - [Compressed tracks](/quest/m1/compress/README.md) - a track flagged `compress` is DEFLATEd per group by moq-net, forwarded compressed, and inflated once on the relay for peers that cannot signal it; `catalog.json.z` and moq-flate go away -- [lite-06 max sequence](/quest/m1/lite06-max-sequence.md) - group sequence 2^62-1 reaches a lite-06 subscriber or is refused loudly, instead of vanishing - [Finalize moq-lite-07](/quest/m1/lite07-finalize.md) - when the maintainer cuts it, lite-07 negotiates as `moq-lite-07` and the next release ships it - [Moxygen minimal interop](/quest/m1/smoke-moxygen.md) - waiting on Moxygen to send a registered filter type, then the smoke lane stops hiding its failure - [More tests under load](/quest/m1/test-flakes-2/README.md) - the second round of load-only failures, one quest per flake, fixed at the cause diff --git a/quest/m1/lite06-max-sequence.md b/quest/m1/lite06-max-sequence.md deleted file mode 100644 index 8545cbb70a9..00000000000 --- a/quest/m1/lite06-max-sequence.md +++ /dev/null @@ -1,49 +0,0 @@ -# [S] Resolve lite-06's max-sequence browser gap - -## Goal - -A group whose sequence is 2^62-1, the largest QUIC varint, either reaches a moq-lite-06 -subscriber through the relay or is refused loudly where it cannot be carried. In the -browser fixture the publisher accepts it and the subscriber waits forever, while -moq-lite-02 and moq-transport-19 deliver it and 2^53+1 crosses all three. That fixture -also uses the sequence as the frame timestamp; isolate the two before changing behavior. - -## Plan - -Found by the browser binding spike (#5838): `test/wasm/uniffi/cases.ts` publishes the -ladder 5, 2^32, 2^53-1, 2^53+1, 2^62-1 from one moq-ffi session to another through each -relay flavour. On lite-06 it records 2^62-1 as a known gap, and the case fails once the -gap closes. - -Reproduce natively first, with a moq-net test over the memory transport and then through -moq-relay, to find whether the publisher, the relay, or the subscriber drops it. Fail -loud if the value is illegal on lite-06; otherwise deliver it. Land a regression test -with the fix, then remove the browser case's known-gap branch. - -Public API: none expected. Wire: none if the value is legal; otherwise the refusal -matches the published draft. - -Native reproduction on `0955b19c1` separates the two values the browser fixture couples: - -- The whole sequence ladder, including 2^62-1, arrives over moq-net memory transport - both directly and through a forwarding origin when timestamps are representable. -- The browser also writes `timestampUs = sequence`. Published lite-06 - [FRAME section 7.20](https://datatracker.ietf.org/doc/html/draft-lcurley-moq-lite-06#section-7.20) - zigzags the first timestamp from zero. Its largest positive first-frame timestamp is - 2^61-1 in the track's timescale; 2^62-1 encodes as 2^63-2 and cannot fit a QUIC varint. -- The actual moq-relay `Connection` over memory transport reproduces the browser hang - with `Timescale::MICRO` and `Timestamp::from_micros(sequence)`. On paused Tokio time, - `connection::largest_quic_sequence_reaches_the_subscriber` fails at the final lite-06 - group with `moq-lite-06 sequence=4611686018427387903 hung`. To reproduce from the - retained test, replace its timestamp's `sequence.min((1 << 61) - 1)` with `sequence`. - The command was - `nix develop --max-jobs 2 --command env CARGO_BUILD_JOBS=2 NEXTEST_TEST_THREADS=4 just rs test -p moq-relay --test integration -E 'test(largest_quic_sequence)'`. - -Keep the sequence tests independent of unsupported timestamps: use the same ladder -with each timestamp capped at 2^61-1 and check exact timestamp delivery where carried. -The browser known-gap branch remains until browser verification and an explicit -decision about surfacing an unencodable timestamp. The wire encoder already refuses -it, but resetting its group stream leaves the live subscription waiting when that -group has not become visible. Decide the refusal boundary and recovery contract -before changing it; do not reinterpret the published lite-05/06 framing or silently -alter the independent SUBSCRIBE_DROP work. diff --git a/quest/m1/lite07-finalize.md b/quest/m1/lite07-finalize.md index fe62f21d807..5a38e6f4f92 100644 --- a/quest/m1/lite07-finalize.md +++ b/quest/m1/lite07-finalize.md @@ -86,10 +86,9 @@ identifier. This release adopts the new routing contract end to end. Public API: the lite-07 version constant and ALPN lose `-wip`. Wire: lite-07 is published; older versions are unchanged. -Decided 2026-10-22 (maintainer): lite-07 also waits for the lite-06 -max-sequence quest, since it may change what the final wire carries. Timestamp provenance landed in -[#6150](https://github.com/moq-dev/moq/pull/6150). +[#6150](https://github.com/moq-dev/moq/pull/6150), and the 2^61 - 1 +timestamp bound in [#6168](https://github.com/moq-dev/moq/pull/6168). ## Required @@ -100,7 +99,6 @@ Timestamp provenance landed in - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - SUBSCRIBE carries ranges and an order and lite FETCH is gone, in Rust and JS - [Announcement takeover](/quest/m1/announce-takeover.md) - ANNOUNCE_TAKEOVER preserves a proven instance under a new independent route - [Listing end reason](/quest/m1/listing-end-reason.md) - lite-07's TRACKS_FINISH says a listed track finished, so an IETF answerer can end it with TRACK_ENDED -- [lite-06 max sequence](/quest/m1/lite06-max-sequence.md) - the refusal boundary for an uncarriable sequence is settled before lite-07 freezes ## Related diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index f064ca2f464..2a7b6e6571d 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -28,6 +28,12 @@ moq-transport and moq-lite-03/04 sessions are unchanged. A container import with `MOQ_CONTAINER_FORMAT_FMP4` now publishes one Legacy frame per sample instead of one CMAF frame per fragment. Pass the new `MOQ_CONTAINER_FORMAT_CMAF` to keep publishing CMAF. +### Timestamps stop at 2^61 - 1 microseconds (#6168) + +Writing a frame, or any other call that takes a timestamp, fails with `MOQ_ERROR_TIMESTAMP_OVERFLOW` past `2^61 - 1` microseconds instead of `2^62 - 1`, the most moq-lite-05/06 can carry. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `2^61 - 1`. + ### Finished tracks stay until the broadcast ends (#6157) `moq_publish_track_finish`, `moq_publish_media_finish`, `moq_publish_container_finish`, and the encoder finishes now release the handle while the broadcast keeps the finished track listed, with its catalog rendition and demand, until `moq_publish_close` or the new `moq_publish_finish_close`. diff --git a/rs/moq-cli/src/play/timeline.rs b/rs/moq-cli/src/play/timeline.rs index 72f9b35b3fb..477915f4d5f 100644 --- a/rs/moq-cli/src/play/timeline.rs +++ b/rs/moq-cli/src/play/timeline.rs @@ -519,9 +519,9 @@ pub(super) fn timestamp(timestamp: Timestamp) -> Duration { /// A duration from the start of the track as a wire timestamp, the inverse of /// [`timestamp`]. fn media(duration: Duration) -> Timestamp { - // A wire timestamp caps at 2^62 - 1, which is ~146,000 years of microseconds. - const MAX: u128 = (1 << 62) - 1; - Timestamp::from_micros(duration.as_micros().min(MAX) as u64).expect("clamped to the wire maximum") + // A timestamp caps at 2^61 - 1, which is ~73,000 years of microseconds. + const MAX: u128 = (1 << 61) - 1; + Timestamp::from_micros(duration.as_micros().min(MAX) as u64).expect("clamped to the timestamp maximum") } /// Where the audio track has reached, measured from its own origin so diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 7454c8f0f3a..22f2f3e8c47 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -986,8 +986,8 @@ fn secrets_stay_out_of_debug() { fn failed_group_write_does_not_burn_nonce() { let mut pair = pair("video"); let mut group = pair.producer.append_group().unwrap(); - // (2^62-1) seconds overflows conversion to the milli-scale track. - let huge = moq_net::Timestamp::from_secs((1 << 62) - 1).unwrap(); + // (2^61-1) seconds overflows conversion to the milli-scale track. + let huge = moq_net::Timestamp::from_secs((1 << 61) - 1).unwrap(); assert!(group.write_frame(huge, b"bad").is_err()); assert_eq!(group.invocations(), 0); let big = vec![0u8; MAX_GROUPED_PLAINTEXT + 1]; diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 11ad49bbd94..8ed40b7a0a6 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -35,6 +35,12 @@ Pass the new `MoqContainerFormat::Cmaf` to keep publishing CMAF. `MoqBroadcastConsumer::resolve` takes the rendition's `epoch` after `reference`; pass it alongside, or `None` to resolve whichever instance answers. Resolving, and the rendition-based consumers, fail a rendition whose broadcast is proven to serve another epoch with `Mux`. +### Timestamps stop at 2^61 - 1 microseconds (#6168) + +Every `timestamp_us` input, such as `MoqTrackProducer::write_frame` or `MoqAnchor::Time`, fails with `MoqError::TimeOverflow` past `2^61 - 1` instead of `2^62 - 1`, the most moq-lite-05/06 can carry. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `2^61 - 1`. + ### Finished tracks stay until the broadcast ends (#6157) A finished track, raw or media, now stays listed, with its catalog rendition and demand, until its broadcast closes or the new `MoqBroadcastProducer::finish` lets go, instead of unlisting when its producer finishes or is released. diff --git a/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs index a89db2d210d..546a583d189 100644 --- a/rs/moq-mux/src/clock.rs +++ b/rs/moq-mux/src/clock.rs @@ -460,7 +460,7 @@ mod tests { fn wall_clock_validates_bounds() { let clock = Clock::at(epoch(), moq_epoch()).unwrap(); // The largest representable broadcast timestamp still maps. - let max = moq_net::Timestamp::from_micros((1u64 << 62) - 1).unwrap(); + let max = moq_net::Timestamp::from_micros((1u64 << 61) - 1).unwrap(); assert!(clock.wall_clock(max).is_err(), "past the JSON-safe range is refused"); } } diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 11aa62bf313..dc4c1d42490 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -102,6 +102,12 @@ moq-transport sessions, and moq-lite-03/04 sessions (whose announce streams mark Replace `Frame { timestamp, payload }` with `Frame::new(timestamp, payload)`. `timestamp` and `payload` stay public fields. +### Timestamps stop at 2^61 - 1 (#6168) + +`Timestamp::new`, its `from_*` constructors, `convert`, `checked_add`, and `TryFrom` return `TimeOverflow` past `2^61 - 1` instead of `2^62 - 1`, the most lite-05/06 can carry as a group's first frame. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `(1 << 61) - 1`. + ### A dropped or cut group ends its reader with an error (#6143) A group the publisher cut, or named in `SUBSCRIBE_DROP`, after a `track::Subscriber` saw it is now handed out, and reading it fails with the publisher's error; before, it was skipped or ended cleanly. diff --git a/rs/moq-net/src/ietf/object.rs b/rs/moq-net/src/ietf/object.rs index 3853f24194e..eb74e055509 100644 --- a/rs/moq-net/src/ietf/object.rs +++ b/rs/moq-net/src/ietf/object.rs @@ -692,7 +692,7 @@ mod tests { let decoded = read(&list(limit), Version::Draft22, milli).unwrap(); assert_eq!(decoded.properties.heap(), limit as u64); assert!(check_frame(&decoded.properties, milli).is_ok()); - let latest = Timestamp::new((1 << 62) - 1, Timescale::MILLI).unwrap(); + let latest = Timestamp::new((1 << 61) - 1, Timescale::MILLI).unwrap(); let written = write( &decoded.properties, Some(Version::Draft22), diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 817fb1d9f01..e89e3d706f8 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -3097,9 +3097,10 @@ mod tests { ); } - /// Receive group 5 stamped 500ms over `version` into the copy a subscriber makes there, - /// whose group 4 started at 1000ms, returning the stream's result and the track. - async fn receive_rewind(version: Version) -> (Result<(), Error>, track::Producer) { + /// Receive group 5 over `version`, one frame per millisecond timestamp delta, into the + /// copy a subscriber makes there, whose group 4 started at 1000ms, returning the stream's + /// result and the track. + async fn receive_group(version: Version, deltas: &[i64]) -> (Result<(), Error>, track::Producer) { use crate::transport::poll::Session as _; let mut script = Vec::new(); @@ -3111,9 +3112,11 @@ mod tests { } .encode(&mut encoder, version) .unwrap(); - // Frame 0 at 500ms: the first delta is absolute. - encoder.varint(crate::coding::varint::zigzag(500)).unwrap(); - encoder.bytes(b"rewound").unwrap(); + // The first delta is absolute. + for delta in deltas { + encoder.varint(crate::coding::varint::zigzag(*delta)).unwrap(); + encoder.bytes(b"frame").unwrap(); + } let mut session = crate::lite::test_transport::ScriptedSession::eof(script); let subscriber = Subscriber::new(SubscriberConfig { runtime: crate::time::sim(), @@ -3164,14 +3167,15 @@ mod tests { #[moq_clock::test] async fn lite06_timestamps_are_dropped() { for version in [Version::Lite05, Version::Lite06] { - let (res, track) = receive_rewind(version).await; + // Frame 0 at 500ms, before group 4's 1000ms. + let (res, track) = receive_group(version, &[500]).await; res.unwrap_or_else(|err| panic!("{version}: {err:?}")); assert!(track.closed().now_or_never().is_none(), "{version}"); let mut consumer = track.subscribe(None); let mut group = consumer.recv_group().now_or_never().unwrap().unwrap().unwrap(); assert_eq!(group.sequence, 5, "{version}"); let frame = group.read_frame().now_or_never().unwrap().unwrap().unwrap(); - assert_eq!(frame.payload.as_ref(), b"rewound", "{version}"); + assert_eq!(frame.payload.as_ref(), b"frame", "{version}"); assert_eq!(frame.timestamp, None, "{version}"); } } @@ -3180,10 +3184,32 @@ mod tests { /// write and one that rewinds is refused. #[moq_clock::test] async fn a_lite07_rewind_is_refused() { - let (res, _track) = receive_rewind(Version::Lite07).await; + let (res, _track) = receive_group(Version::Lite07, &[500]).await; assert!(matches!(res, Err(Error::TimestampMismatch)), "{res:?}"); } + /// A received timestamp past 2^61 - 1 is refused at every version, so a relay never + /// holds one that lite-05/06 could not carry as a group's first frame. lite-06 reaches + /// it with a second delta, lite-07 with a first frame its 64-bit varint can encode. + /// lite-05/06 drop the timestamp, but a stream carrying one past the bound is still malformed. + #[moq_clock::test] + async fn a_received_timestamp_past_2_61_is_refused() { + let max: i64 = (1 << 61) - 1; + for version in [Version::Lite05, Version::Lite06, Version::Lite07] { + let (res, track) = receive_group(version, &[max]).await; + res.unwrap_or_else(|err| panic!("{version}: {err:?}")); + let mut group = track.consume().peek_group(5).unwrap(); + let frame = group.read_frame().now_or_never().unwrap().unwrap().unwrap(); + let kept = version.has_media_time().then_some(max as u64); + assert_eq!(frame.timestamp.map(Timestamp::value), kept, "{version}"); + + let (res, _) = receive_group(version, &[max, 1]).await; + assert!(matches!(res, Err(Error::BoundsExceeded(_))), "{version}: {res:?}"); + } + let (res, _) = receive_group(Version::Lite07, &[max + 1]).await; + assert!(matches!(res, Err(Error::BoundsExceeded(_))), "{res:?}"); + } + /// An idle copy that resubscribes gets its newest cached group again. That stream only /// duplicates the cache, but lite-07's SUBSCRIBE_END still counts it, so the tail must /// too, or the subscription waits out the grace before it ends. diff --git a/rs/moq-net/src/model/time.rs b/rs/moq-net/src/model/time.rs index cb406416404..86f49c0290b 100644 --- a/rs/moq-net/src/model/time.rs +++ b/rs/moq-net/src/model/time.rs @@ -2,19 +2,21 @@ use std::num::NonZero; use crate::coding::varint::MAX_QUIC; -/// `value`, or `None` past the QUIC varint limit (`2^62 - 1`), so every timestamp stays -/// encodable on moq-lite. -const fn quic(value: u128) -> Option { - if value <= MAX_QUIC as u128 { - Some(value as u64) - } else { - None - } +/// The largest [`Timestamp`] value, `2^61 - 1`. +/// +/// lite-05/06 zigzag a group's first timestamp from zero into a QUIC varint, which halves +/// its positive range. Bounding every timestamp here keeps each one encodable on every +/// version, including after a relay forwards it from lite-07 or moq-transport. +const MAX_TIMESTAMP: u64 = (1 << 61) - 1; + +/// `value`, or `None` past `max`. +const fn bounded(value: u128, max: u64) -> Option { + if value <= max as u128 { Some(value as u64) } else { None } } -/// Returned when a [`Timestamp`] operation would exceed the QUIC varint range -/// (`2^62 - 1`), overflow during scale conversion or arithmetic, or attempt -/// arithmetic between timestamps with mismatched scales. +/// Returned when a [`Timestamp`] operation would exceed its range (`2^61 - 1`), a +/// [`Timescale`] would exceed the QUIC varint range (`2^62 - 1`), scale conversion or +/// arithmetic would overflow, or arithmetic mixes timestamps with mismatched scales. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[error("time overflow")] pub struct TimeOverflow; @@ -60,7 +62,7 @@ impl Timescale { pub const fn new(units_per_second: u64) -> Result { // Reject values that wouldn't fit in a QUIC varint, keeping the constraint // symmetric with Timestamp's raw value. - if quic(units_per_second as u128).is_none() { + if bounded(units_per_second as u128, MAX_QUIC).is_none() { return Err(TimeOverflow); } match NonZero::new(units_per_second) { @@ -126,8 +128,8 @@ impl std::fmt::Display for Timescale { /// 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 alongside so frames from different +/// The underlying value is at most `2^61 - 1`, the most every moq-lite version can encode as +/// a group's first frame; the scale is carried alongside so frames from different /// sources can be compared and converted without lossy detours through a single fixed scale. /// /// The scale is a [`Timescale`] (always non-zero), so unit conversions (`as_secs`, `as_millis`, @@ -172,9 +174,9 @@ impl Timestamp { pub const ZERO: Self = Self::new_const(0, Timescale::SECOND); /// Construct a timestamp directly from a raw value at the given scale. - /// Returns [`TimeOverflow`] if `value` exceeds `2^62 - 1`. + /// Returns [`TimeOverflow`] if `value` exceeds `2^61 - 1`. pub const fn new(value: u64, scale: Timescale) -> Result { - match quic(value as u128) { + match bounded(value as u128, MAX_TIMESTAMP) { Some(value) => Ok(Self { value, scale }), None => Err(TimeOverflow), } @@ -184,7 +186,7 @@ impl Timestamp { const fn new_const(value: u64, scale: Timescale) -> Self { match Self::new(value, scale) { Ok(time) => time, - Err(_) => panic!("timestamp value exceeds 2^62 - 1"), + Err(_) => panic!("timestamp value exceeds 2^61 - 1"), } } @@ -230,13 +232,13 @@ impl Timestamp { } /// Re-express this timestamp at a new scale. Returns [`TimeOverflow`] if the new - /// value would exceed `2^62 - 1`. + /// value would exceed `2^61 - 1`. pub const fn convert(self, new_scale: Timescale) -> Result { if self.scale.0.get() == new_scale.0.get() { return Ok(self); } match (self.value as u128).checked_mul(new_scale.0.get() as u128) { - Some(scaled) => match quic(scaled / self.scale.0.get() as u128) { + Some(scaled) => match bounded(scaled / self.scale.0.get() as u128, MAX_TIMESTAMP) { Some(value) => Ok(Self { value, scale: new_scale, @@ -272,7 +274,7 @@ impl Timestamp { self.as_scale(Timescale::NANO) } - /// Add two timestamps. Returns [`TimeOverflow`] if the sum exceeds `2^62 - 1` or + /// Add two timestamps. Returns [`TimeOverflow`] if the sum exceeds `2^61 - 1` or /// if the scales differ. pub const fn checked_add(self, rhs: Self) -> Result { if self.scale.0.get() != rhs.scale.0.get() { @@ -312,7 +314,7 @@ impl TryFrom for Timestamp { /// Convert a [`std::time::Duration`] into a nanosecond-scale timestamp. fn try_from(duration: std::time::Duration) -> Result { - match quic(duration.as_nanos()) { + match bounded(duration.as_nanos(), MAX_TIMESTAMP) { Some(value) => Ok(Self { value, scale: Timescale::NANO, @@ -440,6 +442,21 @@ mod tests { assert!(Timescale::new((1u64 << 62) - 1).is_ok()); } + /// A timestamp is refused where any version could not carry it as a group's first frame, + /// which lite-05/06 zigzag from zero into a QUIC varint. + #[test] + fn test_timestamp_fits_a_lite06_first_frame() { + let max = Timestamp::new(MAX_TIMESTAMP, Timescale::MICRO).unwrap(); + assert!(crate::coding::varint::zigzag(max.value() as i64) <= MAX_QUIC); + + for value in [MAX_TIMESTAMP + 1, MAX_QUIC] { + assert!(Timestamp::from_micros(value).is_err(), "{value}"); + } + assert!(max.checked_add(Timestamp::from_micros(1).unwrap()).is_err()); + assert!(max.convert(Timescale::NANO).is_err()); + assert!(Timestamp::try_from(std::time::Duration::from_nanos(MAX_TIMESTAMP + 1)).is_err()); + } + #[test] fn test_timescale_try_from_nonzero_enforces_varint() { use std::num::NonZero; diff --git a/rs/moq-net/tests/integration/wide_sequence.rs b/rs/moq-net/tests/integration/wide_sequence.rs index 745329c806d..6eecf2c6171 100644 --- a/rs/moq-net/tests/integration/wide_sequence.rs +++ b/rs/moq-net/tests/integration/wide_sequence.rs @@ -39,7 +39,7 @@ async fn largest_quic_sequence_reaches_the_subscriber() { let remote = client.consume().request_broadcast("ladder", None).await.unwrap(); let mut sub = remote.track("data").unwrap().subscribe(None).await.unwrap(); for sequence in [5, 1 << 32, (1 << 53) - 1, (1 << 53) + 1, (1 << 62) - 1] { - // Lite05/06 zigzag the first timestamp from zero, halving its positive range. + // Timestamps stop at 2^61 - 1, the most lite-05/06 can zigzag as a first frame. let timestamp = Timestamp::from_micros(sequence.min((1 << 61) - 1)).unwrap(); let mut group = track.create_group(moq_net::group::Info { sequence }).unwrap(); group.write_frame(timestamp, b"ladder".as_ref()).unwrap(); diff --git a/rs/moq-relay/tests/integration/connection.rs b/rs/moq-relay/tests/integration/connection.rs index 45a0133c185..c815699e013 100644 --- a/rs/moq-relay/tests/integration/connection.rs +++ b/rs/moq-relay/tests/integration/connection.rs @@ -47,7 +47,7 @@ async fn largest_quic_sequence_reaches_the_subscriber() { let remote = subscriber.consume().request_broadcast("ladder", None).await.unwrap(); let mut sub = remote.track("data").unwrap().subscribe(None).await.unwrap(); for sequence in [5, 1 << 32, (1 << 53) - 1, (1 << 53) + 1, (1 << 62) - 1] { - // Lite05/06 zigzag the first timestamp from zero, halving its positive range. + // Timestamps stop at 2^61 - 1, the most lite-05/06 can zigzag as a first frame. let timestamp = moq_net::Timestamp::from_micros(sequence.min((1 << 61) - 1)).unwrap(); let mut group = track.create_group(moq_net::group::Info { sequence }).unwrap(); group.write_frame(timestamp, b"ladder".as_ref()).unwrap(); diff --git a/rs/moq-video/src/capture/channel.rs b/rs/moq-video/src/capture/channel.rs index 94029d03c00..c0463b423ae 100644 --- a/rs/moq-video/src/capture/channel.rs +++ b/rs/moq-video/src/capture/channel.rs @@ -368,7 +368,7 @@ mod tests { let at = |millis| chan.epoch + std::time::Duration::from_millis(millis); chan.push_native_at(frame(1), us(0), at(0)); - chan.push_native_at(frame(2), us((1 << 62) - 1), at(1)); + chan.push_native_at(frame(2), us((1 << 61) - 1), at(1)); chan.push_native_at(frame(3), us(0), at(2)); assert!(matches!(chan.recv().await, Err(Error::TimeOverflow(_)))); diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 8c55896b050..ea2f0d9cce8 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -34,6 +34,12 @@ Pass the new `ContainerFormat.cmaf` to keep publishing CMAF. `BroadcastConsumer.resolve(_:)` becomes `resolve(_:epoch:)`; pass the rendition's `epoch` alongside its `broadcast`, or `nil` to resolve whichever instance answers. Resolving, and the rendition-based consumers, throw for a rendition whose broadcast is proven to serve another epoch. +### Timestamps stop at 2^61 - 1 microseconds (#6168) + +Writing a frame, or any other call that takes a timestamp, fails with `TimeOverflow` past `2^61 - 1` microseconds instead of `2^62 - 1`, the most moq-lite-05/06 can carry. +A received timestamp past it fails the group as a malformed track. +Clamp timestamps you build from unbounded input to `2^61 - 1`. + ### Finished tracks stay until the broadcast ends (#6157) A finished track now stays listed, with its catalog rendition and demand, until its broadcast closes or the new `BroadcastProducer.finish()` lets go. diff --git a/test/wasm/generator.md b/test/wasm/generator.md index bb5e94733c7..a1c9c5ac685 100644 --- a/test/wasm/generator.md +++ b/test/wasm/generator.md @@ -249,7 +249,8 @@ targets, over one dual-annotated crate. The measurements above used a workaround in the pin patch. Since [#5681](https://github.com/moq-dev/moq/pull/5681), moq-mux reads moq-clock's browser wall clock instead, and the cases pass without the workaround. -- **On moq-lite-06, group sequence 2^62-1 never arrives.** lite-02 and transport-19 - carry it, and 2^53+1 crosses all three. Nothing errors: the subscriber waits forever. - Both moq-ffi and the slice hit it under both generators. Tracked in - [lite-06 max sequence](/quest/m1/lite06-max-sequence.md). +- **On moq-lite-06, group sequence 2^62-1 never arrived.** The cases also stamped it as + the frame timestamp, which lite-05/06 cannot zigzag as a group's first frame, so the + publisher dropped the group and the subscriber waited forever. Both moq-ffi and the + slice hit it under both generators. Timestamps now stop at 2^61-1, so `writeFrame` + refuses the unencodable one and the sequence itself crosses every version. diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts index f50a476c159..be937ef60db 100644 --- a/test/wasm/uniffi/cases.ts +++ b/test/wasm/uniffi/cases.ts @@ -51,6 +51,12 @@ export interface Report { /** Above 2^53, so a lossy `number` conversion anywhere changes it; the largest QUIC varint. */ const HIGH = [2n ** 53n + 1n, 2n ** 62n - 1n]; +/** The largest timestamp, which lite-05/06 can still zigzag as a group's first frame. */ +const MAX_TIMESTAMP = 2n ** 61n - 1n; + +/** The ladder's timestamp for `sequence`, capped where a timestamp stops. */ +const stamp = (sequence: bigint) => (sequence < MAX_TIMESTAMP ? sequence : MAX_TIMESTAMP); + /** Microsecond ticks, so `timestampUs` passes through unscaled. */ const TIMED = MoqTrackInfo.create({ timescale: 1_000_000n }); @@ -131,13 +137,24 @@ const local: Record Promise> = { for (const sequence of HIGH) { const { group, consumer } = localGroup(sequence); if (consumer.sequence() !== sequence) throw new Error(`sequence ${consumer.sequence()} != ${sequence}`); - group.writeFrame({ payload: payload(1), timestampUs: sequence }); + group.writeFrame({ payload: payload(1), timestampUs: stamp(sequence) }); const frame = await consumer.readFrame(); - if (frame?.timestampUs !== sequence) throw new Error(`timestamp ${frame?.timestampUs} != ${sequence}`); + if (frame?.timestampUs !== stamp(sequence)) + throw new Error(`timestamp ${frame?.timestampUs} != ${stamp(sequence)}`); } return undefined; }, + "writeFrame refuses a timestamp past 2^61 - 1": async () => { + const { group } = localGroup(); + try { + group.writeFrame({ payload: payload(1), timestampUs: MAX_TIMESTAMP + 1n }); + } catch (err) { + return (err as Error).message; + } + throw new Error("an unencodable timestamp was written"); + }, + "AbortSignal rejects a pending read without stealing the next frame": async () => { const { group, consumer } = localGroup(); const abort = new AbortController(); @@ -251,24 +268,15 @@ const remote: Record Promise> = { const seen: string[] = []; for (const sequence of [5n, 2n ** 32n, 2n ** 53n - 1n, ...HIGH]) { const group = track.createGroup(sequence); - group.writeFrame({ payload: payload(160), timestampUs: sequence }); + group.writeFrame({ payload: payload(160), timestampUs: stamp(sequence) }); group.finish(); - const next = within(sub.recvGroup(), 5000, `recvGroup ${sequence} after [${seen}]`); - // Known: on moq-lite-06 the largest varint never arrives, while lite-02 and IETF carry it. - if (sequence === HIGH[1] && relay.version === "moq-lite-06") { - const outcome = await next.then( - (group) => `arrived as ${group?.sequence()}`, - (err: unknown) => (err as Error).message, - ); - if (outcome === `arrived as ${sequence}`) - throw new Error("known lite-06 gap now passes; update the case"); - return `known gap: 2^62-1 ${outcome}`; - } - const got = (await next) as MoqGroupConsumerLike | undefined; + const got = (await within(sub.recvGroup(), 5000, `recvGroup ${sequence} after [${seen}]`)) as + | MoqGroupConsumerLike + | undefined; if (got?.sequence() !== sequence) throw new Error(`sequence ${got?.sequence()} != ${sequence}`); const frame = await got.readFrame(); // Lite-02 carries no timestamp and a lite-05/06 subscriber drops it; the rest keep it exact. - const expected = /^moq-lite-0[1-6]$/.test(relay.version) ? undefined : sequence; + const expected = /^moq-lite-0[1-6]$/.test(relay.version) ? undefined : stamp(sequence); if (frame?.timestampUs !== expected) throw new Error(`timestamp ${frame?.timestampUs} != ${expected}`); seen.push(String(sequence)); }