From 6ff9edf0956be5e494511f5b1d330cf82e774351 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Wed, 7 Oct 2026 10:59:12 +0200 Subject: [PATCH 1/4] feat(net)!: carry untimed tracks faithfully A track is all timed or all untimed: `track::Info.timescale` is an `Option`, frames and datagrams carry an `Option`, and a write whose timedness doesn't match its track is `TimestampMismatch`. No receiver fills in arrival time. Lite before 05 and moq-transport without TIMESCALE (every track on drafts 14-16) arrive untimed, an object-scope Timescale is ignored, and a TIMESCALE track's object without a Timestamp ends the track as malformed. A relay forwards an untimed track untimed, lite-05+ carries send times for it until lite-07 can say so, and a standalone FETCH keeps its objects' Timestamps. An untimed group is never media-stale, and a subscriber with no start begins at the latest group. moq-archive refuses an untimed track; moq-ffi and libmoq expose absence on the receive side. Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/src/durations.dart | 10 +- dart/moq/test/moq_test.dart | 2 +- dart/moq_ffi/lib/src/moq.dart | 36 +-- dart/moq_ffi/test/leak_test.dart | 2 +- dart/moq_ffi/test/smoke_test.dart | 4 +- doc/bin/cli.md | 4 +- doc/concept/moq-lite.md | 10 + doc/concept/standard.md | 12 +- doc/lib/c/index.md | 2 +- doc/lib/dart/index.md | 2 + doc/lib/go/index.md | 5 +- doc/lib/kt/index.md | 2 + doc/lib/py/index.md | 2 + doc/lib/rs/moq-net.md | 2 +- doc/lib/swift/index.md | 2 + doc/setup/upgrade.md | 35 +++ drafts/draft-lcurley-moq-lite.md | 3 + drafts/draft-lcurley-moq-timestamp.md | 37 ++- go/wrapper/moq_test.go | 35 +-- go/wrapper/reconnect_test.go | 2 +- .../kotlin/dev/moq/Durations.kt | 12 +- .../kotlin/dev/moq/SmokeTest.kt | 2 +- py/moq-ffi/tests/exit_inside_callback.py | 2 +- quest/m1/README.md | 1 - quest/m1/cache-max-age.md | 16 +- quest/m1/cmaf-frame-timestamp.md | 9 +- quest/m1/data-consumer-timestamps.md | 4 +- quest/m1/fetch-ok-properties.md | 15 +- quest/m1/js-data-consumer-timestamps.md | 2 +- quest/m1/js-publish-timestamp.md | 2 +- quest/m1/js-untimed-model.md | 8 +- quest/m1/lite-untimed.md | 5 +- quest/m1/plan-ts-pes-untimed.md | 12 +- quest/m1/publish-timestamp.md | 20 +- quest/m1/qos/publisher-timeliness.md | 5 +- quest/m1/subscribe-live-time.md | 1 - quest/m1/untimed-model.md | 163 ----------- quest/m2/watch-data-sync.md | 4 +- rs/hang/src/container/frame.rs | 6 +- rs/moq-archive/src/error.rs | 4 + rs/moq-archive/src/proof.rs | 2 +- rs/moq-archive/src/reader/mod.rs | 4 +- rs/moq-archive/src/reader/tests.rs | 4 +- rs/moq-archive/src/writer.rs | 38 ++- rs/moq-c/src/api.rs | 19 +- rs/moq-c/src/consume.rs | 23 +- rs/moq-c/src/publish.rs | 7 + rs/moq-c/src/test.rs | 71 +++++ rs/moq-cli/src/archive.rs | 5 +- rs/moq-e2ee/src/datagram.rs | 4 +- rs/moq-e2ee/src/group.rs | 20 +- rs/moq-e2ee/src/tests.rs | 2 +- rs/moq-e2ee/src/track.rs | 13 +- rs/moq-ffi/src/consumer.rs | 4 +- rs/moq-ffi/src/media.rs | 14 +- rs/moq-ffi/src/producer.rs | 29 +- rs/moq-ffi/src/test.rs | 121 ++++---- rs/moq-flate/src/snapshot/mod.rs | 2 +- rs/moq-flate/src/stream/mod.rs | 6 +- rs/moq-gst/src/sink/pad.rs | 14 +- rs/moq-json/src/snapshot/mod.rs | 4 +- rs/moq-json/src/stream/mod.rs | 4 +- rs/moq-json/src/window/mod.rs | 2 +- rs/moq-mux/src/binary.rs | 2 +- rs/moq-mux/src/catalog/producer.rs | 4 +- rs/moq-mux/src/container/fmp4/import.rs | 2 +- rs/moq-mux/src/container/fmp4/mod.rs | 2 +- rs/moq-mux/src/container/loc/mod.rs | 2 +- rs/moq-mux/src/json.rs | 2 +- rs/moq-net/benches/group.rs | 2 +- rs/moq-net/src/coding/reader.rs | 2 +- rs/moq-net/src/error.rs | 8 +- rs/moq-net/src/fuzz.rs | 2 +- rs/moq-net/src/ietf/fetch.rs | 4 +- rs/moq-net/src/ietf/group.rs | 35 +-- rs/moq-net/src/ietf/publisher.rs | 35 ++- rs/moq-net/src/ietf/subscriber.rs | 191 +++++++++---- rs/moq-net/src/lite/publisher.rs | 82 ++++-- rs/moq-net/src/lite/subscriber.rs | 29 +- rs/moq-net/src/lite/track.rs | 2 +- rs/moq-net/src/model/datagram.rs | 6 +- rs/moq-net/src/model/frame.rs | 17 +- rs/moq-net/src/model/group.rs | 202 +++++++++----- rs/moq-net/src/model/resume.rs | 10 +- rs/moq-net/src/model/subscription.rs | 18 +- rs/moq-net/src/model/time.rs | 5 +- rs/moq-net/src/model/track.rs | 182 +++++++++--- rs/moq-net/tests/datagram.rs | 33 ++- rs/moq-net/tests/rejoin.rs | 10 + rs/moq-net/tests/untimed.rs | 262 ++++++++++++++++++ rs/moq-tokio/tests/broadcast.rs | 12 +- 91 files changed, 1401 insertions(+), 676 deletions(-) delete mode 100644 quest/m1/untimed-model.md create mode 100644 rs/moq-net/tests/untimed.rs diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart index 02b3904777..a546916144 100644 --- a/dart/moq/lib/src/durations.dart +++ b/dart/moq/lib/src/durations.dart @@ -38,8 +38,9 @@ extension TrackInfoDuration on MoqTrackInfo { /// Duration view over a raw frame's presentation time. extension FrameDuration on MoqFrame { - /// Presentation timestamp. - Duration get timestamp => Duration(microseconds: timestampUs); + /// Presentation timestamp, or null for an untimed frame. + Duration? get timestamp => + timestampUs == null ? null : Duration(microseconds: timestampUs!); } /// Duration view over a media frame's presentation time. @@ -50,6 +51,7 @@ extension MediaFrameDuration on MoqMediaFrame { /// Duration view over a datagram's presentation time. extension DatagramDuration on MoqDatagram { - /// Presentation timestamp. - Duration get timestamp => Duration(microseconds: timestampUs); + /// Presentation timestamp, or null for an untimed datagram. + Duration? get timestamp => + timestampUs == null ? null : Duration(microseconds: timestampUs!); } diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 296e8ba896..d5f9fc3cb3 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -59,7 +59,7 @@ void main() { final producer = track.appendGroup(); producer.writeFrame( - frame: MoqFrame(payload: utf8.encode('dart round trip')), + frame: MoqFrame(payload: utf8.encode('dart round trip'), timestampUs: 0), ); producer.finish(); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index c8b5e6337b..a2556afc98 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; - import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -825,9 +823,13 @@ class FfiConverterMoqContainerInit { class MoqDatagram { final int sequence; - final int timestampUs; + final int? timestampUs; final Uint8List payload; - MoqDatagram({this.sequence = 0, this.timestampUs = 0, required this.payload}); + MoqDatagram({ + this.sequence = 0, + this.timestampUs = null, + required this.payload, + }); } class FfiConverterMoqDatagram { @@ -842,7 +844,7 @@ class FfiConverterMoqDatagram { ); final sequence = sequence_lifted.value; new_offset += sequence_lifted.bytesRead; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestampUs_lifted = FfiConverterOptionalUInt64.read( Uint8List.view(buf.buffer, new_offset), ); final timestampUs = timestampUs_lifted.value; @@ -865,7 +867,7 @@ class FfiConverterMoqDatagram { static RustBuffer lower(MoqDatagram value) { final total_length = FfiConverterUInt64.allocationSize(value.sequence) + - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + FfiConverterUint8List.allocationSize(value.payload) + 0; final buf = Uint8List(total_length); @@ -879,7 +881,7 @@ class FfiConverterMoqDatagram { value.sequence, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterUInt64.write( + new_offset += FfiConverterOptionalUInt64.write( value.timestampUs, Uint8List.view(buf.buffer, new_offset), ); @@ -892,7 +894,7 @@ class FfiConverterMoqDatagram { static int allocationSize(MoqDatagram value) { return FfiConverterUInt64.allocationSize(value.sequence) + - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + FfiConverterUint8List.allocationSize(value.payload) + 0; } @@ -959,8 +961,8 @@ class FfiConverterMoqDimensions { class MoqFrame { final Uint8List payload; - final int timestampUs; - MoqFrame({required this.payload, this.timestampUs = 0}); + final int? timestampUs; + MoqFrame({required this.payload, this.timestampUs = null}); } class FfiConverterMoqFrame { @@ -975,7 +977,7 @@ class FfiConverterMoqFrame { ); final payload = payload_lifted.value; new_offset += payload_lifted.bytesRead; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestampUs_lifted = FfiConverterOptionalUInt64.read( Uint8List.view(buf.buffer, new_offset), ); final timestampUs = timestampUs_lifted.value; @@ -989,7 +991,7 @@ class FfiConverterMoqFrame { static RustBuffer lower(MoqFrame value) { final total_length = FfiConverterUint8List.allocationSize(value.payload) + - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1002,7 +1004,7 @@ class FfiConverterMoqFrame { value.payload, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterUInt64.write( + new_offset += FfiConverterOptionalUInt64.write( value.timestampUs, Uint8List.view(buf.buffer, new_offset), ); @@ -1011,7 +1013,7 @@ class FfiConverterMoqFrame { static int allocationSize(MoqFrame value) { return FfiConverterUint8List.allocationSize(value.payload) + - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + 0; } } @@ -12850,7 +12852,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 2442) { + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 51857) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqgrouprequest_abort() != 26970) { @@ -12913,7 +12915,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_append_datagram() != - 6272) { + 31895) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_append_group() != 45225) { @@ -12946,7 +12948,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackproducer_used() != 19906) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame() != 18663) { + if (uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame() != 1418) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackrequest_abort() != 62713) { diff --git a/dart/moq_ffi/test/leak_test.dart b/dart/moq_ffi/test/leak_test.dart index fe8ce19431..c0df265b31 100644 --- a/dart/moq_ffi/test/leak_test.dart +++ b/dart/moq_ffi/test/leak_test.dart @@ -64,7 +64,7 @@ void main() { final track = broadcast.publishTrack(name: 'frames', info: null); final consumer = track.consume(subscription: null); final producer = track.appendGroup(); - producer.writeFrame(frame: MoqFrame(payload: payload)); + producer.writeFrame(frame: MoqFrame(payload: payload, timestampUs: 0)); producer.finish(); final group = await consumer.nextGroup(); final frame = await group!.readFrame(); diff --git a/dart/moq_ffi/test/smoke_test.dart b/dart/moq_ffi/test/smoke_test.dart index 6265d206fd..a7d937ec69 100644 --- a/dart/moq_ffi/test/smoke_test.dart +++ b/dart/moq_ffi/test/smoke_test.dart @@ -40,7 +40,9 @@ void main() { final nextGroup = consumer.nextGroup(); final producer = track.appendGroup(); - producer.writeFrame(frame: MoqFrame(payload: utf8.encode('dart'))); + producer.writeFrame( + frame: MoqFrame(payload: utf8.encode('dart'), timestampUs: 0), + ); producer.finish(); final group = await nextGroup; diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 2ad9a9022a..579661c9e9 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -357,7 +357,9 @@ Every track gets its own timeline, stored in spans cut at group boundaries between 2s and 10s. The catalog and every text, JSON, and binary track are sparse data, so each of their groups is stored as soon as it finishes, and a group that never closes is stored in pieces as it grows. It refuses a rendition served -from another broadcast, and one that returns after the catalog dropped it. The +from another broadcast, one that returns after the catalog dropped it, and an +untimed track, which has no timestamps to record (any track over moq-lite before +05, or moq-transport without `TIMESCALE`, such as drafts 14–16). The stage ends once the broadcast does. A store URL that already holds a recording is continued: each track resumes after its newest stored span. `--retention 1h` keeps only the last hour (a DVR), deleting expired objects, and timeline objects no longer needed to recover it, diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 3a9da934da..093c1f94f7 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -323,6 +323,16 @@ anything on its own. Both ends apply it: the publisher skips a group rather than sending it, and the subscriber skips it again as it reads, since the publisher only ever sees the most tolerant budget across its subscribers. +A track is all timed or all untimed: it declares a timescale or none, and a +frame that doesn't match is refused. An untimed track stays untimed at every +hop: no receiver fills in arrival time, which would change whenever a route +fails over. An untimed group is never too old, so the cache's own expiry is what +reclaims it, and a new subscriber to an untimed track starts at its latest group +unless it names a start. Tracks from lite before 05, or from moq-transport +without a `TIMESCALE` (every track on drafts 14–16), arrive untimed. Lite cannot +mark a track untimed yet, so a lite-05 or later publisher sends each frame of an +untimed track with its send time. + A route failover is invisible to max age: a group open across the change carries on from the new route at the frame where the old one stopped, and a group only a replaced route that went quiet still holds is given up once it falls a full diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 9b4730a795..10ec306c40 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -57,8 +57,16 @@ before `FETCH_OK`, and aborts an incomplete group instead of caching it as whole Drafts 14–16 use `FETCH_CANCEL`; drafts 17–19 stop and reset the request stream. A range touching several groups is refused with `NOT_SUPPORTED`, as is any `FETCH` on draft-20 and later, which moved the range into -`LOCATION_FILTER`. A standalone `FETCH` -carries no timestamps, since no `SUBSCRIBE_OK` declared a timescale for it. +`LOCATION_FILTER`. A standalone `FETCH` keeps each object's Timestamp, in the +units the track's `SUBSCRIBE_OK` declares. `FETCH_OK` doesn't declare them yet, +so a reader that never subscribed reads the objects untimed. + +A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed: its objects +arrive untimed, and an object-scope Timescale or a Timestamp on them is +ignored, as on imquic's LOC objects. A relay serves such a track downstream +untimed, without a `TIMESCALE` of its own. On a track that declares one, an +object without a Timestamp is malformed. Drafts 14–16 can't carry `TIMESCALE`, +so every track there is untimed. On drafts 14–19, the Rust publisher also serves relative and absolute joining `FETCH` requests for `NextObject` subscriptions, for the subscription group's diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index cfd0fb660e..cdd05db93e 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -52,7 +52,7 @@ pkg-config file included. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. - **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge, so a cache linger never delays it. Only a relay keeps what it already delivered warm for 30 seconds, and a returning subscriber is served that cache only once the publisher confirms it is still current. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. -- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), `moq_publish_close` (ends the broadcast for good and releases its handle), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a `moq_announce_config` (NULL or zeroed for everything): a literal prefix and an optional relative pattern filter; each `moq_announce_event` has a `kind` (`MOQ_ANNOUNCE_KIND_START`, `_UPDATE`, or `_END`); `prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); set `hidden` or name the dot segment in `prefix` to list them. +- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams (one read from an untimed track has `timestamp_present` false), JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), `moq_publish_close` (ends the broadcast for good and releases its handle), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a `moq_announce_config` (NULL or zeroed for everything): a literal prefix and an optional relative pattern filter; each `moq_announce_event` has a `kind` (`MOQ_ANNOUNCE_KIND_START`, `_UPDATE`, or `_END`); `prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); set `hidden` or name the dot segment in `prefix` to list them. ```c moq_client_config config; diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index d78a636884..a2d98d474b 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -148,4 +148,6 @@ not the same as zero. `rttUs` is microseconds; the `rtt` extension reads it as a Raw track publisher metadata has an optional maximum age. Omitting it imposes no publisher age limit; zero keeps the live edge. Local cache limits still apply, and media imports explicitly retain 30 seconds. See [publisher retention](/concept/moq-lite). +A raw `MoqFrame` or `MoqDatagram` has a null `timestampUs` (and `timestamp`) when it was read from an untimed track; see [untimed tracks](/concept/moq-lite#subscriptions). A raw track you publish is always timed. + Await `session.shutdown()` or `moq.close()` to drain finished tracks before disconnecting. These futures fail if delivery has not completed within one second. `session.cancel(code: 0)` remains immediate. Finish or abort live tracks before shutdown. IETF media streams are not drained yet. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 72ed046ade..f080ae30d1 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -62,7 +62,8 @@ for event, err := range announced.All(ctx) { // opusInit, packet, pts, and rgba come from your encoder or capture source. broadcast, _ := client.CreateBroadcast("my-stream.hang") audio, _ := broadcast.PublishAudio(moq.AudioFormatOpus, opusInit) -_ = audio.WriteFrame(moq.Frame{Payload: packet, TimestampUs: 20_000}) +pts := uint64(20_000) +_ = audio.WriteFrame(moq.Frame{Payload: packet, TimestampUs: &pts}) track := "camera" video, _ := broadcast.EncodeVideo( @@ -181,4 +182,6 @@ available, which is not the same as zero. Raw track publisher metadata has an optional maximum age. Omitting it imposes no publisher age limit; zero keeps the live edge. Local cache limits still apply, and media imports explicitly retain 30 seconds. See [publisher retention](/concept/moq-lite). +A raw `Frame` or `Datagram` has a nil `TimestampUs` when it was read from an untimed track; see [untimed tracks](/concept/moq-lite#subscriptions). A raw track you publish is always timed. + `session.Shutdown(ctx)` drains finished tracks and returns a delivery error if the one-second deadline expires. Cancelling the context aborts immediately. `client.Close()` waits for shutdown and returns the same error; `session.Cancel(code)` remains immediate. Finish or abort live tracks before shutdown. IETF media streams are not drained yet. diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index bd25ea3d87..4933b8b84a 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -150,4 +150,6 @@ reads it as a `kotlin.time.Duration`. Raw track publisher metadata has an optional maximum age. Omitting it imposes no publisher age limit; zero keeps the live edge. Local cache limits still apply, and media imports explicitly retain 30 seconds. See [publisher retention](/concept/moq-lite). +A raw `Frame` or `Datagram` has a null `timestampUs` (and `timestamp`) when it was read from an untimed track; see [untimed tracks](/concept/moq-lite#subscriptions). A raw track you publish is always timed. + Call suspending `session.shutdown()` or `moq.shutdown()` to drain finished tracks before disconnecting. They throw if delivery has not completed within one second. Finish or abort live tracks first. `cancel(0u)` and synchronous `Moq.close()` remain immediate; `use { }` therefore cancels on exit. IETF media streams are not drained yet. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 29de56c16f..a057a64de6 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -166,4 +166,6 @@ not the same as zero. Raw track publisher metadata has an optional maximum age. Omitting it imposes no publisher age limit; zero keeps the live edge. Local cache limits still apply, and media imports explicitly retain 30 seconds. See [publisher retention](/concept/moq-lite). +A raw `Frame` or `Datagram` has `timestamp_us` of `None` when it was read from an untimed track; see [untimed tracks](/concept/moq-lite#subscriptions). A raw track you publish is always timed. + Await `session.shutdown()` to drain finished tracks before disconnecting. It raises if delivery has not completed within one second. `cancel(code)` stays immediate. Session and client async context managers await shutdown on a clean exit and cancel on an error, so the body's exception survives; finish or abort live tracks first. IETF media streams are not drained yet. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 15718e7551..85dff1de2f 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -19,7 +19,7 @@ above ([hang](/lib/rs/hang)); relays and CDNs implement only this. - **Broadcasts** are created unannounced and invisible to everyone, then announced as an exact route, or served below a prefix with `dynamic`. A consumer of the same origin sees exactly what a peer sees. Discovery accepts pattern unions; events carry the advertised prefix and captures for a complete match. - **Epochs** identify publisher instances with a canonical UUIDv7 carried on each route (`Route::epoch`): the newest wins a path, and only routes with the same epoch resume a subscription; see [publisher epochs](/concept/moq-lite#publisher-epochs). - **Patterns** (`Pattern`, `Patterns`) are re-exported from [`moq-pattern`](https://docs.rs/moq-pattern). Literal `Path` stays a coordinate. -- **Tracks** carry groups with a priority, an optional publisher retention window (`Info::max_age`), and a timescale. Subscribers set their own priority and max age and can change them live. +- **Tracks** carry groups with a priority, an optional publisher retention window (`Info::max_age`), and a timescale, or none for an untimed track whose frames carry no timestamp. Subscribers set their own priority and max age and can change them live. - **Groups** are written frame by frame and delivered on independent streams. Old groups are cached for fetch-by-sequence; stale groups are skipped per the subscriber's budget. - **Track ends**: `finish()` ends a track at its live edge, while `finish_at(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber awaits it with `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** send a single small frame unreliably on moq-lite 05+ and moq-transport. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index a79c185193..9f66c5b948 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -151,4 +151,6 @@ not the same as zero. Raw track publisher metadata has an optional maximum age. Omitting it imposes no publisher age limit; zero keeps the live edge. Local cache limits still apply, and media imports explicitly retain 30 seconds. See [publisher retention](/concept/moq-lite). +A raw `Frame` or `Datagram` has a nil `timestampUs` when it was read from an untimed track; see [untimed tracks](/concept/moq-lite#subscriptions). A raw track you publish is always timed. + Use `try await session.shutdown()` to drain finished tracks before disconnecting. It throws if delivery has not completed within one second. `session.cancel(code: 0)` remains immediate. Finish or abort live tracks before shutdown. IETF media streams are not drained yet. diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 6e7d547303..f4bc62b987 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -126,6 +126,41 @@ These land with the next breaking release, not the 2026-09-23 train. - **@moq/publish drops `OpusConfig.usedtx`.** Chromium's DTX output shifts the audio timeline, so Opus DTX is always off (the WebCodecs default). Remove the field; a plain-JS caller still passing it is ignored. +- **A track is timed or untimed, and nothing fills in a timestamp on + receive.** An untimed track (no timescale) arrives untimed, except over + moq-lite 05 and later, which can't encode absence yet and carry a send time + instead; see [untimed tracks](/concept/moq-lite#subscriptions). A write whose + timedness doesn't match its track fails with `TimestampMismatch`. + - moq-net: `track::Info.timescale` is `Option`, `None` for an + untimed track (`with_timescale` takes `impl Into>`), and + `group::{Producer,Consumer}::timescale()` return `Option`. + `Frame.timestamp`, `frame::Info.timestamp`, and `Datagram.timestamp` are + `Option`. The `write_frame`, `append_datagram`, and + `insert_datagram` calls take `impl Into>`, so passing a + `Timestamp` still compiles. + - moq-e2ee: `Frame` and `Datagram` timestamps are optional, and its writers + take `impl Into>`. + - moq-archive: recording an untimed track fails with `Error::Untimed`, so + `moq export archive` over moq-lite before 05 or moq-transport drafts + 14–16 now fails instead of recording arrival times. + - moq-ffi: `MoqFrame.timestamp_us` and `MoqDatagram.timestamp_us` are + optional, null on a frame read from an untimed track, and default to null. + A raw track you publish is timed, so a raw write without one now fails + with `TimestampMismatch` instead of going out at 0. + `MoqMediaProducer::write_frame` without one used to pass a PTS of 0; it + now passes none, so the importer derives the time itself (elapsed since + its first frame) or refuses a codec that needs one (avc1, hvc1). + `MoqTrackInfo.timescale` is null on a received untimed track. In Go, + `Frame.TimestampUs` and `Datagram.TimestampUs` are `*uint64`; in Kotlin + and Dart, `Frame.timestamp` and `Datagram.timestamp` are nullable. + - libmoq: `moq_frame` and `moq_datagram` gain `timestamp_present`, which + changes their size, so recompile against the new `moq.h`. + - Receivers: tracks from moq-lite before 05, and moq-transport tracks + without `TIMESCALE` (every track on drafts 14–16), arrive untimed instead + of stamped with their arrival time; an object-scope Timescale is ignored. + On a moq-transport track with `TIMESCALE`, an object without a Timestamp + is malformed. A new subscriber with no start on an untimed track starts at + the latest group. ## Wire diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index e9bccd9014..4606552148 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -172,6 +172,8 @@ This index is not transmitted per frame; it is implied by position within the Gr A Group Stream normally starts at frame 0, but MAY start later when the publisher only holds (or was only asked for) part of the Group. Each frame carries a presentation timestamp expressed in the parent Track's `Timescale` (see [TRACK_INFO](#track-info)), used by the moq-lite layer for [expiration](#expiration) decisions. +This version cannot mark a Track as having no presentation time, which a relay forwarding a Track received untimed over another protocol may need. +Every frame and datagram of such a Track carries the publisher's send time instead, and its TRACK_INFO declares any `Timescale` for those send times. ## Positions {#positions} A Position is a (Group Sequence, Frame Index) pair identifying one frame within a Track. @@ -1385,6 +1387,7 @@ The `Message Length` describes the payload size on the wire. - Made TRACK_INFO Publisher Max Age optional, encoded as milliseconds plus one with zero meaning no limit. - Added `Largest Group` and `Largest Frame` to SUBSCRIBE_OK: the publisher's largest position when it answers, which a subscriber takes as where the live feed is. A publisher MUST answer at once when the requested start is past it. Earlier versions carry no such position, so a subscriber takes the first frame instead. - Added `Epoch` to ANNOUNCE_START, SUBSCRIBE, TRACK, and FETCH: a UUIDv7 naming the publisher instance, or empty. A path and an Epoch name one Broadcast. A relay MAY move a subscription between routes with the same Epoch, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between routes with different Epochs or none. The newest Epoch wins a path and ends subscriptions to the older one. Replaces the first-hop identity. +- An untimed Track's frames and datagrams carry their send time. lite-05 and lite-06 publishers do the same. - Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`. - Switched every variable-length integer, including SETUP parameter values, from QUIC's two-bit length prefix to moq-transport's leading-ones encoding, widening the range to 64 bits. diff --git a/drafts/draft-lcurley-moq-timestamp.md b/drafts/draft-lcurley-moq-timestamp.md index 4a4e3dd36b..3d95a1e87c 100644 --- a/drafts/draft-lcurley-moq-timestamp.md +++ b/drafts/draft-lcurley-moq-timestamp.md @@ -79,15 +79,16 @@ Common values include `1000` (milliseconds), `1000000` (microseconds), `48000` ( A value of `0` is invalid; a receiver MUST treat a track that declares it as carrying no timeline. Absence is meaningful and is not an error. -A track with no TIMESCALE has no media timeline: a receiver MUST NOT infer units for it, and MUST use wall-clock arrival time for age-based decisions on that track. +A track with no TIMESCALE is untimed ({{untimed}}): a receiver MUST NOT infer units for it, and every object on it is untimed, even one that carries a TIMESTAMP. A publisher that emits Timestamps MUST send TIMESCALE, even for units a receiver might otherwise assume. -{{loc}} permits a bare Timestamp and reads it as microseconds; a receiver that also implements LOC MAY apply that interpretation to a track that omits TIMESCALE, and MUST NOT apply any other. +{{loc}} permits a bare Timestamp and reads it as microseconds; a receiver following this document MUST NOT apply that interpretation, or any other, to a track that omits TIMESCALE. The Timescale is fixed for the lifetime of the track and MUST NOT change. -{{loc}} also registers TIMESCALE with Object scope; a receiver that implements both applies such an override to that object alone, and a publisher following this document SHOULD NOT send one. +{{loc}} also registers TIMESCALE with Object scope. +Whether a track is timed, and its units, are properties of the track, so a receiver MUST ignore an object-scope TIMESCALE, and a publisher SHOULD NOT send one. The track's properties are delivered in SUBSCRIBE_OK or TRACK_STATUS ({{moqt}} Section 12). -Until they arrive, a receiver MUST fall back to wall-clock arrival time for any age-based decision. +A receiver decides whether a track is timed when it accepts the track; a track accepted without its properties is untimed. # TIMESTAMP Object Property @@ -108,11 +109,20 @@ Any value (including 0) is valid. Each Timestamp is absolute, not delta-encoded against a previous object: {{moqt}} does not guarantee delivery of every object, and a delta would be corrupted by any missing predecessor. -On a track that declares a TIMESCALE, a publisher SHOULD attach TIMESTAMP to every object that has a media time. -For an object with no TIMESTAMP, a receiver MUST treat its effective time as the wall-clock arrival time, which avoids stalling expiration on objects that intentionally carry no timestamp (e.g. keep-alives or gap markers). -The same fallback covers every object on a track that declares no TIMESCALE, so a receiver needs one rule, not two. +On a track that declares a TIMESCALE, a publisher MUST attach TIMESTAMP to every object with a payload, including an empty one. +A receiver MUST treat such an object without a TIMESTAMP as malformed ({{moqt}}) rather than substitute a time. +An object that only signals a status, such as End of Group or End of Track, carries no media time and needs no TIMESTAMP. -## Age-Based Dropping +## Untimed Tracks {#untimed} +A track that declares no TIMESCALE is untimed: it has no media timeline, and none of its objects has a media time. +A track is either timed or untimed for its whole lifetime; no track mixes timed and untimed objects. +A receiver MUST NOT substitute a time for an untimed object, such as its arrival time, and a relay MUST forward an untimed track untimed, declaring no TIMESCALE downstream. +Arrival time differs at every hop and changes when a route fails over, so a substituted time would not be the same timeline downstream. + +Age-based dropping ({{age}}) never drops an untimed object; a relay bounds how long it keeps one with its own cache policy. +A new subscription to an untimed track that names no start begins at the latest group, since no age says how far back to reach. + +## Age-Based Dropping {#age} Given two objects on the same track, both with TIMESTAMP, a relay computes their relative age as the difference of their timestamps divided by the Timescale. A relay serving a live subscription MAY drop an object whose age relative to the most recent object on the track exceeds a locally configured or application-supplied threshold, resetting the corresponding stream per {{moqt}}. This decision is identical at every hop because it depends only on values embedded in the objects, not on arrival time. @@ -146,6 +156,17 @@ An earlier version of this document requested its own code points (`0x915C0` and --- back +# Appendix A: Changelog +{:numbered="false"} + +## moq-timestamp-02 +{:numbered="false"} + +- Replaced the wall-clock arrival-time fallback with untimed tracks: a track without TIMESCALE has no media time, its objects are never dropped for age, and a relay forwards it untimed. +- On a track with TIMESCALE, a publisher MUST attach TIMESTAMP to every object with a payload, and a receiver treats one without it as malformed. +- A receiver ignores an object-scope TIMESCALE, and no longer reads a bare TIMESTAMP on a track without TIMESCALE as microseconds. +- A new subscription to an untimed track that names no start begins at the latest group. + # Acknowledgments {:numbered="false"} diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index d469f71d1f..a069684f4c 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -110,14 +110,14 @@ func TestDynamicBroadcastRequest(t *testing.T) { defer trackConsumer.Cancel() payload := []byte("served dynamically") - if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 0}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: us(0)}); err != nil { t.Fatal(err) } frame, err := trackConsumer.ReadFrame(ctx) if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != string(payload) || frame.TimestampUs != 0 { + if frame == nil || string(frame.Payload) != string(payload) || frame.TimestampUs == nil || *frame.TimestampUs != 0 { t.Fatalf("frame = %+v, want payload=%q ts=0", frame, payload) } @@ -138,7 +138,7 @@ func TestPublishAudioLifecycle(t *testing.T) { if err != nil { t.Fatal(err) } - if err := media.WriteFrame(moq.Frame{Payload: []byte("opus frame"), TimestampUs: 1000}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: []byte("opus frame"), TimestampUs: us(1000)}); err != nil { t.Fatal(err) } if err := media.Finish(); err != nil { @@ -410,7 +410,7 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { if err != nil { t.Fatal(err) } - if err := cached.WriteFrame(moq.Frame{Payload: []byte("cached"), TimestampUs: 0}); err != nil { + if err := cached.WriteFrame(moq.Frame{Payload: []byte("cached"), TimestampUs: us(0)}); err != nil { t.Fatal(err) } if err := cached.Finish(); err != nil { @@ -451,7 +451,7 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { if err != nil { t.Fatal(err) } - if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), TimestampUs: request.Sequence() * 20_000}); err != nil { + if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), TimestampUs: us(request.Sequence() * 20_000)}); err != nil { t.Fatal(err) } if err := produced.Finish(); err != nil { @@ -540,7 +540,7 @@ func TestLocalPublishConsumeAudio(t *testing.T) { defer mediaConsumer.Cancel() payload := []byte("opus audio payload data") - if err := media.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 1_000_000}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: payload, TimestampUs: us(1_000_000)}); err != nil { t.Fatal(err) } @@ -574,7 +574,7 @@ func TestTrackPublishConsume(t *testing.T) { } defer consumer.Cancel() - if err := track.WriteFrame(moq.Frame{Payload: []byte("hello"), TimestampUs: 12_345}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: []byte("hello"), TimestampUs: us(12_345)}); err != nil { t.Fatal(err) } @@ -585,7 +585,7 @@ func TestTrackPublishConsume(t *testing.T) { if frame == nil { t.Fatal("expected a frame") } - if string(frame.Payload) != "hello" || frame.TimestampUs != 12_345 { + if string(frame.Payload) != "hello" || frame.TimestampUs == nil || *frame.TimestampUs != 12_345 { t.Fatalf("frame = %+v, want payload=hello ts=12345", frame) } @@ -598,7 +598,7 @@ func TestTrackPublishConsume(t *testing.T) { t.Fatal(err) } defer groupConsumer.Cancel() - if err := group.WriteFrame(moq.Frame{Payload: []byte("group"), TimestampUs: 23_456}); err != nil { + if err := group.WriteFrame(moq.Frame{Payload: []byte("group"), TimestampUs: us(23_456)}); err != nil { t.Fatal(err) } if err := group.Finish(); err != nil { @@ -611,7 +611,7 @@ func TestTrackPublishConsume(t *testing.T) { if frame == nil { t.Fatal("expected a group frame") } - if string(frame.Payload) != "group" || frame.TimestampUs != 23_456 { + if string(frame.Payload) != "group" || frame.TimestampUs == nil || *frame.TimestampUs != 23_456 { t.Fatalf("frame = %+v, want payload=group ts=23456", frame) } } @@ -641,7 +641,7 @@ func TestReadFrameSkipsEmptyThenPopulatedGroups(t *testing.T) { if err := empty.Finish(); err != nil { t.Fatal(err) } - if err := track.WriteFrame(moq.Frame{Payload: []byte("populated"), TimestampUs: 2_000}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: []byte("populated"), TimestampUs: us(2_000)}); err != nil { t.Fatal(err) } @@ -649,7 +649,7 @@ func TestReadFrameSkipsEmptyThenPopulatedGroups(t *testing.T) { if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != "populated" || frame.TimestampUs != 2_000 { + if frame == nil || string(frame.Payload) != "populated" || frame.TimestampUs == nil || *frame.TimestampUs != 2_000 { t.Fatalf("frame = %+v, want payload=populated ts=2000", frame) } } @@ -871,7 +871,7 @@ func TestDynamicTrackRequest(t *testing.T) { t.Fatal(err) } payload := []byte("hello dynamic track") - if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 0}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: us(0)}); err != nil { t.Fatal(err) } @@ -891,7 +891,7 @@ func TestDynamicTrackRequest(t *testing.T) { if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != string(payload) || frame.TimestampUs != 0 { + if frame == nil || string(frame.Payload) != string(payload) || frame.TimestampUs == nil || *frame.TimestampUs != 0 { t.Fatalf("frame = %+v, want payload=%q ts=0", frame, payload) } if err := track.Finish(); err != nil { @@ -970,7 +970,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { defer mediaConsumer.Cancel() payload := []byte("dynamic opus frame") - if err := media.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 20_000}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: payload, TimestampUs: us(20_000)}); err != nil { t.Fatal(err) } @@ -1228,7 +1228,7 @@ func TestUsedCancelKeepsTheTrack(t *testing.T) { } payload := []byte("still publishing") - if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 0}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: us(0)}); err != nil { t.Fatal(err) } frame, err := consumer.ReadFrame(readCtx) @@ -1494,3 +1494,6 @@ func TestDynamicServesARequestUnderAPrefix(t *testing.T) { t.Fatal(err) } } + +// us is a raw frame's optional timestamp in microseconds. +func us(v uint64) *uint64 { return &v } diff --git a/go/wrapper/reconnect_test.go b/go/wrapper/reconnect_test.go index 89378ebda3..24b00e19a3 100644 --- a/go/wrapper/reconnect_test.go +++ b/go/wrapper/reconnect_test.go @@ -186,7 +186,7 @@ func TestReconnectAcrossRelayRestart(t *testing.T) { return default: } - _ = track.WriteFrame(moq.Frame{Payload: fmt.Appendf(nil, "frame-%d", i)}) + _ = track.WriteFrame(moq.Frame{Payload: fmt.Appendf(nil, "frame-%d", i), TimestampUs: us(0)}) time.Sleep(10 * time.Millisecond) } }() diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt index 6631d166e1..0cb341e3ab 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt @@ -45,17 +45,17 @@ val VideoDecoderOutput.maxAge: Duration? val AudioEncoderOutput.frameDuration: Duration get() = frameDurationUs.toLong().microseconds -/** Presentation timestamp. */ -val Frame.timestamp: Duration - get() = timestampUs.toLong().microseconds +/** Presentation timestamp, or null for an untimed frame. */ +val Frame.timestamp: Duration? + get() = timestampUs?.toLong()?.microseconds /** Presentation timestamp. */ val MediaFrame.timestamp: Duration get() = timestampUs.toLong().microseconds -/** Presentation timestamp. */ -val Datagram.timestamp: Duration - get() = timestampUs.toLong().microseconds +/** Presentation timestamp, or null for an untimed datagram. */ +val Datagram.timestamp: Duration? + get() = timestampUs?.toLong()?.microseconds /** Presentation timestamp of the first sample. */ val AudioFrame.timestamp: Duration diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index ba21628c2f..2f67bd9fa6 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -173,7 +173,7 @@ class SmokeTest { BroadcastProducer().use { broadcast -> val track = broadcast.publishTrack("events", null) val group = track.appendGroup() - group.writeFrame(Frame(payload = "cached".encodeToByteArray())) + group.writeFrame(Frame(payload = "cached".encodeToByteArray(), timestampUs = 0uL)) group.finish() val fetched = broadcast.consume().fetchGroup( diff --git a/py/moq-ffi/tests/exit_inside_callback.py b/py/moq-ffi/tests/exit_inside_callback.py index eefe461bf7..7821b9b12a 100644 --- a/py/moq-ffi/tests/exit_inside_callback.py +++ b/py/moq-ffi/tests/exit_inside_callback.py @@ -62,7 +62,7 @@ async def main() -> None: subscriber = await consumer.subscribe_track("data", None) # Written once the read is parked, so the runtime thread delivers the wake. - asyncio.get_running_loop().call_later(0.05, track.write_frame, moq_ffi.MoqFrame(payload=b"hello")) + asyncio.get_running_loop().call_later(0.05, track.write_frame, moq_ffi.MoqFrame(payload=b"hello", timestamp_us=0)) frame = await subscriber.read_frame() assert frame is not None # Flushed now: a write left for exit would hand the GIL over early. diff --git a/quest/m1/README.md b/quest/m1/README.md index 5b3caebb26..6b595e6326 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -27,7 +27,6 @@ blocks. The quests that gated m0 lines moved under them. ## Required -- [Untimed model](/quest/m1/untimed-model.md) - a track is all timed or all untimed (`Info.timescale` is optional, frames carry an optional timestamp), and an untimed track reaches every subscriber untimed; no receiver fills in arrival time - [One max_age meaning](/quest/m1/cache-max-age.md) - a superseded group goes stale on wall clock since its successor arrived or on media time, whichever is first, in Rust and js/net; fixes the untimed failover stall - [JS untimed model](/quest/m1/js-untimed-model.md) - the untimed model in @moq/net - [CMAF frame timestamp](/quest/m1/cmaf-frame-timestamp.md) - CMAF decoders time samples from the moq-lite frame timestamp, using `tfdt` only within the fragment diff --git a/quest/m1/cache-max-age.md b/quest/m1/cache-max-age.md index 41d02eb89a..0cfbf1b148 100644 --- a/quest/m1/cache-max-age.md +++ b/quest/m1/cache-max-age.md @@ -39,8 +39,8 @@ Decided (2026-10-06, maintainer): group is never evicted. - Applies to both retention and subscriber budgets, which share `is_stale` in `rs/moq-net/src/model/track.rs` today. -- Supersedes two earlier decisions: the [untimed - model](/quest/m1/untimed-model.md)'s 2026-10-01 rejection of `max_age` on +- Supersedes two earlier decisions: the untimed model's ([#4822](https://github.com/moq-dev/moq/pull/4822)) + 2026-10-01 rejection of `max_age` on max(wall, pts), whose worry was a congestion stall (answered by starting the clock at the successor), and the retired untimed-failover quest's no-clock rule, which this rule replaces. @@ -94,8 +94,12 @@ Facts, and work carried over: about to fill the group isn't cut short. A FETCH reader has no budget and stays as it is. - Start resolution: the untimed model starts an untimed track at the latest - group. Replace that special case with the normal rule, replaying the - cached groups that aren't stale. + group (`TrackState::untimed_start`). Replace that special case with the + normal rule, replaying the cached groups that aren't stale. Until then a + reader rejoining an untimed track starts at a stale cached group and reads + on from there, so `rejoin_during_the_cancel_skips_the_cache` + (`rs/moq-net/tests/rejoin.rs`) checks only the versions whose tracks arrive + timed. Make it check every version again. Tests, with mocked time: @@ -113,10 +117,6 @@ Tests, with mocked time: Public API: no signature change; `max_age` behaviour changes. Wire: no encoding change; Max Age semantics in the lite draft change. -## Required - -- [Untimed model](/quest/m1/untimed-model.md) - introduces untimed groups and the failover stall this fixes - ## Related - [Subscriber max-delay](/quest/m1/subscriber-max-delay.md) - renames the subscriber budget to `max_delay`; the rule applies under either name diff --git a/quest/m1/cmaf-frame-timestamp.md b/quest/m1/cmaf-frame-timestamp.md index b5b7af719d..0a3e26717a 100644 --- a/quest/m1/cmaf-frame-timestamp.md +++ b/quest/m1/cmaf-frame-timestamp.md @@ -31,9 +31,8 @@ shifts `trun` data offsets. - `main`: a behavior fix with no API change. Open PR [#4826](https://github.com/moq-dev/moq/pull/4826) implements this. It -lands after [#4822](https://github.com/moq-dev/moq/pull/4822) -([untimed model](/quest/m1/untimed-model.md)), which brings untimed frames to -`main`. Timedness is per track there, so the decoder can check the track +lands after [#4822](https://github.com/moq-dev/moq/pull/4822) (the untimed +model), which brings untimed frames to `main`. Timedness is per track there, so the decoder can check the track rather than each frame. Test: in Rust and JS, a fragment whose `tfdt` disagrees with its frame @@ -43,10 +42,6 @@ an untimed fragment decodes at its `tfdt`. Public API: none. Wire: none; this states what the frame timestamp already means. -## Required - -- [Untimed model](/quest/m1/untimed-model.md) - untimed frames reach `main`, so the `tfdt` fallback has something to decode - ## Related - [Shared clock](/quest/m1/shared-clock.md) - the first publisher to rely on it diff --git a/quest/m1/data-consumer-timestamps.md b/quest/m1/data-consumer-timestamps.md index ee2612975b..11df5f2a68 100644 --- a/quest/m1/data-consumer-timestamps.md +++ b/quest/m1/data-consumer-timestamps.md @@ -22,8 +22,7 @@ Decided: `next()` and `poll_next()` return `Timed`, the type the producers take in [Publishing never invents a timestamp](/quest/m1/publish-timestamp.md). `at` is the frame's media timestamp on the track's timescale, and `None` for an untimed frame: absence survives the wire rather than becoming arrival -time (decided 2026-10-01, see -[moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md)). A republisher passes `at` straight to a moq-mux data producer. +time (decided 2026-10-01, landed in [#4822](https://github.com/moq-dev/moq/pull/4822)). A republisher passes `at` straight to a moq-mux data producer. Decided (2026-10-01): snapshot consumers get two reads, both returning `Timed`. Today the moq-json snapshot consumer applies every buffered delta @@ -48,4 +47,3 @@ Public API: breaking. Wire: none. ## Required - [Publishing never invents a timestamp](/quest/m1/publish-timestamp.md) - gives `Timed.at` its untimed meaning, the type this returns -- [moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md) - the model must carry an absent timestamp to consumers diff --git a/quest/m1/fetch-ok-properties.md b/quest/m1/fetch-ok-properties.md index c2b03a2f72..df6a7408a7 100644 --- a/quest/m1/fetch-ok-properties.md +++ b/quest/m1/fetch-ok-properties.md @@ -26,13 +26,14 @@ sends an empty block. Ascending order and priority 128 where SUBSCRIBE_OK says Descending. - FETCH preserves object properties, Timestamp included. Decided in the 2026-10-05 audit (maintainer: "FETCH must send stamped objects? It's not - legal to remove the property."): the standalone FETCH path sending its - objects unstamped because no SUBSCRIBE declared a timescale - (`ietf/publisher.rs`, around line 1346) is a bug to fix here. FETCH_OK declares the track's TIMESCALE, and every - fetched object keeps its Timestamp, so a fetch-only reader is timed exactly - when the track is, as the [untimed model](/quest/m1/untimed-model.md) - requires. Rejected: omitting timescale from FETCH_OK and leaving fetch-only - readers untimed. + legal to remove the property."). [#4822](https://github.com/moq-dev/moq/pull/4822) makes the standalone FETCH keep + each object's Timestamp in the track's units, since a subscribed relay + treats a timed track's object without one as malformed. What's left here is + FETCH_OK declaring the track's TIMESCALE, so a fetch-only reader is timed + exactly when the track is, as the untimed model requires. Today our + subscriber takes the units only from SUBSCRIBE_OK, and a fetch-only reader + is untimed. Rejected: omitting timescale from FETCH_OK and leaving + fetch-only readers untimed. - Honour INCLUDE_PROPERTIES (0x35) on FETCH from draft 20, once [Draft-20 FETCH](/quest/m1/ietf-fetch-location.md) serves draft-20 FETCH at all (today our publisher refuses every one). It defaults to sending the diff --git a/quest/m1/js-data-consumer-timestamps.md b/quest/m1/js-data-consumer-timestamps.md index 6263856038..6cae2c8768 100644 --- a/quest/m1/js-data-consumer-timestamps.md +++ b/quest/m1/js-data-consumer-timestamps.md @@ -17,7 +17,7 @@ Decided (2026-10-01): `next()` and the async iterator yield `@moq/net`'s [JS publishing never invents a timestamp](/quest/m1/js-publish-timestamp.md). `at` is the frame's timestamp, absent for an untimed frame. Decided (2026-10-05, types settled 2026-10-06): timedness is per track, as the -[untimed model](/quest/m1/untimed-model.md) decided and `@moq/net` mirrors: +untimed model ([#4822](https://github.com/moq-dev/moq/pull/4822)) decided and `@moq/net` mirrors: `timescale` is optional, an untimed track's frames have no `at`, and a frame whose timedness doesn't match its track is refused. Snapshot consumers get the same two reads as Rust: `next()` yields every state in diff --git a/quest/m1/js-publish-timestamp.md b/quest/m1/js-publish-timestamp.md index 84754beec4..3f3fd54be0 100644 --- a/quest/m1/js-publish-timestamp.md +++ b/quest/m1/js-publish-timestamp.md @@ -22,7 +22,7 @@ Callers in the repository (js/hang catalog, js/publish, js/room) pass their clock's now explicitly. Update `doc/lib/js/{json,flate,net}.md`. Decided (2026-10-05, types settled 2026-10-06): timedness is per track, as -the [untimed model](/quest/m1/untimed-model.md) decided and `@moq/net` +the untimed model ([#4822](https://github.com/moq-dev/moq/pull/4822)) decided and `@moq/net` mirrors: `timescale` is optional, frames keep an optional timestamp, and a frame whose timedness doesn't match its track is refused. An absent `at` therefore belongs on an untimed track. diff --git a/quest/m1/js-untimed-model.md b/quest/m1/js-untimed-model.md index aa4ff75607..dfa93b899f 100644 --- a/quest/m1/js-untimed-model.md +++ b/quest/m1/js-untimed-model.md @@ -2,8 +2,7 @@ ## Goal -`@moq/net` mirrors [moq-net carries untimed frames -faithfully](/quest/m1/untimed-model.md). An untimed track stays untimed from +`@moq/net` mirrors the Rust untimed model ([#4822](https://github.com/moq-dev/moq/pull/4822)). An untimed track stays untimed from publisher to consumer, and js/net never fills in `Timestamp.now()` on receive. Covers `@moq/net` and its in-repo callers (`@moq/hang`, `@moq/loc`, `@moq/watch`). @@ -14,10 +13,10 @@ Decided (2026-10-02): a hand-written change now, rather than waiting for [Generated @moq/net](/quest/m1/rs2ts/README.md). That line is long-running and would stall the JS timestamp quests. Whichever lands second absorbs the other. The semantics, the end-marker rule and the reasons are in -the Rust quest; keep the two in step. +the Rust quest, deleted by [#4822](https://github.com/moq-dev/moq/pull/4822); keep the two in step. Decided (2026-10-05, types settled 2026-10-06): timedness is per track, as -the [untimed model](/quest/m1/untimed-model.md) decided and `@moq/net` +the untimed model decided and `@moq/net` mirrors: `timescale` is optional, frames keep an optional timestamp, and a frame whose timedness doesn't match its track is refused. Where the notes below assume groups that mix timed and untimed frames, this wins. @@ -47,5 +46,4 @@ changes. ## Related -- [moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md) - the Rust side and the decisions - [Generated @moq/net](/quest/m1/rs2ts/README.md) - will replace this code with generated code diff --git a/quest/m1/lite-untimed.md b/quest/m1/lite-untimed.md index bf6c1be7e1..f5e6b1562c 100644 --- a/quest/m1/lite-untimed.md +++ b/quest/m1/lite-untimed.md @@ -9,8 +9,8 @@ peers. Covers `rs/moq-net`, `js/net` and `drafts/draft-lcurley-moq-lite.md`. ## Plan -Decided (2026-10-05, maintainer): timedness is per track ([untimed -model](/quest/m1/untimed-model.md)), and an untimed track sends no +Decided (2026-10-05, maintainer): timedness is per track (the untimed +model, [#4822](https://github.com/moq-dev/moq/pull/4822)), and an untimed track sends no TIMESCALE and no Timestamp. On lite-07 that likely means an optional Timescale in TRACK_INFO and no Timestamp fields on an untimed track. The per-frame shift below was planned for an absent timestamp inside a timed @@ -50,5 +50,4 @@ Public API: none. Wire: lite-07-wip only, which is unpublished. ## Required -- [moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md) - the Rust model must hold an absent timestamp before the wire can carry one - [@moq/net carries untimed frames faithfully](/quest/m1/js-untimed-model.md) - the same for JS diff --git a/quest/m1/plan-ts-pes-untimed.md b/quest/m1/plan-ts-pes-untimed.md index 3bfb2fca19..70d698f12f 100644 --- a/quest/m1/plan-ts-pes-untimed.md +++ b/quest/m1/plan-ts-pes-untimed.md @@ -11,12 +11,10 @@ recorded reason to keep today's behaviour. Today the importer stamps a PES without a PTS at the track's live edge, or at 0 before the first frame. `drafts/draft-lcurley-moq-mpegts.md` says 0. -Both are invented times, which -[moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md) rules -out for moq-net itself. +Both are invented times, which the untimed model ([#4822](https://github.com/moq-dev/moq/pull/4822)) rules out for +moq-net itself. -Timedness is per track since 2026-10-05 ([untimed -model](/quest/m1/untimed-model.md)): a timed track refuses an untimed +Timedness is per track since 2026-10-05: a timed track refuses an untimed frame, so a PES without a PTS can only go out untimed on an untimed track. Weigh that in the carriage decision. @@ -39,7 +37,3 @@ container contract. Test plan for the implementation quest: importer to encoded track to exporter, for a PES without a PTS and for one with a real PTS of 0. - -## Required - -- [moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md) - a frame must be able to carry no timestamp diff --git a/quest/m1/publish-timestamp.md b/quest/m1/publish-timestamp.md index d756658359..2584cfe757 100644 --- a/quest/m1/publish-timestamp.md +++ b/quest/m1/publish-timestamp.md @@ -14,8 +14,8 @@ Swift, Kotlin, Go, Dart). Not libmoq, which the Decided (2026-10-01), reversing the 2026-09-30 "timestamp required" plan: the timestamp is optional end to end, on the wire and in every API, and absent means untimed. A library never substitutes now, wall clock, or arrival time. -How absence travels through the model and over the wire is -[moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md) and +How absence travels through the model and over the wire is the untimed model +([#4822](https://github.com/moq-dev/moq/pull/4822)) and [lite-07 encodes an absent timestamp](/quest/m1/lite-untimed.md); this quest removes every place that fills one in. @@ -27,7 +27,7 @@ data producers fill the broadcast clock's `now()`. The FFI raw - `moq_net::Timed` drops its unused clock parameter `T` (decided 2026-10-06: here rather than in #4822, which is already large). That's a breaking change and gets an upgrade note. Timedness is per track - ([untimed model](/quest/m1/untimed-model.md), decided 2026-10-05), so an + (the untimed model, [#4822](https://github.com/moq-dev/moq/pull/4822), decided 2026-10-05), so an untimed payload belongs on an untimed track, and one appended to a timed track is refused. The json/flate consumers return the same type (see [Data consumer timestamps](/quest/m1/data-consumer-timestamps.md)). @@ -42,9 +42,13 @@ data producers fill the broadcast clock's `now()`. The FFI raw - moq-ffi: data producers take an optional timestamp in microseconds on the broadcast clock, unchecked, as Rust does. moq-ffi exposes the broadcast clock's `now()` so callers have a value to stamp with. The raw frame and - datagram records' `timestamp_us` is already optional after - [moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md); publishing one without it - goes out untimed instead of at `default = 0`. + datagram records' `timestamp_us` is optional since [#4822](https://github.com/moq-dev/moq/pull/4822), but only + on the receive side (decided 2026-10-07 by OneTooMany): a raw track + published through moq-ffi is always timed, and a write without a timestamp + is refused. Add a way to publish an untimed raw track. `MoqTrackInfo`'s + null `timescale` means microseconds when publishing and an untimed track on + a received one, so a received info doesn't round-trip yet. The wrappers' + `= 0` defaults go with it. - Go (`go/wrapper/moq`) and Python (`py/moq-rs`) wrap only the JSON producers; [#4137](https://github.com/moq-dev/moq/pull/4137) added `publish_binary_snapshot` / `publish_binary_stream` (now @@ -57,10 +61,6 @@ data producers fill the broadcast clock's `now()`. The FFI raw Public API: breaking. Wire: none here; absence on the wire lands with the untimed implementation quests. -## Required - -- [moq-net carries untimed frames faithfully](/quest/m1/untimed-model.md) - the model must hold an untimed payload before producers stop filling in now; until lite-07 encodes absence, a lite encoder writes its send time, as producers effectively do today - ## Related - [JS publishing never invents a timestamp](/quest/m1/js-publish-timestamp.md) - the same change in the JS packages diff --git a/quest/m1/qos/publisher-timeliness.md b/quest/m1/qos/publisher-timeliness.md index e40c22e2bb..5760c94c39 100644 --- a/quest/m1/qos/publisher-timeliness.md +++ b/quest/m1/qos/publisher-timeliness.md @@ -42,8 +42,8 @@ Sample where the relay completes a frame: the moq-lite subscriber's zigzag timestamp delta) and the IETF subscriber's object path. Leave untimed tracks out of the histogram rather than report a fiction: today pre-lite-05 peers without a timescale are stamped with `Timestamp::now()` on arrival, -which would read as perfectly on time, and after -[Untimed model](/quest/m1/untimed-model.md) they arrive untimed. Update the stats section of `doc/bin/relay/config.md`. +which would read as perfectly on time, and after the untimed model +([#4822](https://github.com/moq-dev/moq/pull/4822)) they arrive untimed. Update the stats section of `doc/bin/relay/config.md`. Tests: a paced publisher lands in the lowest bucket; publishers whose clocks run 100 ppm slow and 100 ppm fast both stay in the lowest bucket over a @@ -58,6 +58,5 @@ excluded. ## Related -- [Untimed model](/quest/m1/untimed-model.md) - pre-lite-05 tracks arrive untimed instead of stamped on arrival - [Media stats](/quest/m1/stats/schema.md) - the publisher's own view of the same uplink, in the transport section of its stats track diff --git a/quest/m1/subscribe-live-time.md b/quest/m1/subscribe-live-time.md index b3fe3b09b7..dc9484d438 100644 --- a/quest/m1/subscribe-live-time.md +++ b/quest/m1/subscribe-live-time.md @@ -55,5 +55,4 @@ Decided in planning (2026-10-03): ## Related -- [Untimed model](/quest/m1/untimed-model.md) - absent timestamps end to end; only real pairs feed the estimate - [Shared clock](/quest/m1/shared-clock.md) - the hang catalog's `{wall, timescale}` anchor sits a layer up; this stays media-agnostic diff --git a/quest/m1/untimed-model.md b/quest/m1/untimed-model.md deleted file mode 100644 index 93387fa29c..0000000000 --- a/quest/m1/untimed-model.md +++ /dev/null @@ -1,163 +0,0 @@ -# [L] moq-net carries untimed frames faithfully - -## Goal - -A track published untimed reaches every subscriber untimed, through any -number of relays. A track is all timed or all untimed, decided by whether -`track::Info.timescale` (now an `Option`) is set. Frames and datagrams carry -an `Option`, and a write whose timedness doesn't match its track is -refused, as `Error::TimestampMismatch` already refuses a mismatched -timescale. No receiver fills in arrival time, and a relay forwards an untimed -track as untimed. IETF tracks -accepted without TIMESCALE, every track on drafts 14-16, a standalone FETCH -that learns no units, and lite-01 to lite-04 tracks arrive untimed. Covers -moq-net, moq-relay, every in-repo caller of the model, and -`drafts/draft-lcurley-moq-timestamp.md`. - -Out of scope: publishers that fill in now -([Publishing never invents a timestamp](/quest/m1/publish-timestamp.md)), the -lite-07 encoding of absence ([lite-07 encodes an absent -timestamp](/quest/m1/lite-untimed.md)), and JS -([@moq/net carries untimed frames faithfully](/quest/m1/js-untimed-model.md)). - -## Plan - -Decided (2026-10-05, maintainer): timedness is per track, not per frame. -The track's property is learned when it is accepted. Drafts and versions -that can't declare it up front are untimed: IETF drafts 14-16, a standalone -FETCH that learns no timescale, and lite-01 to lite-04. Groups that mix timed -and untimed frames, and object-scope timing on an untimed track, are out. -Rejected: receiver-made arrival stamps on untimed tracks. - -Decided (2026-10-06, maintainer): the per-track rule is enforced at runtime, -with `Option` types, and lands in -[#4822](https://github.com/moq-dev/moq/pull/4822) itself, so contributors -and callers take one breaking change. A spike typed the publish side -(`track::Info`, `track::Producer`, `group::Producer` over -`Timescale`, `Untimed`, and `Option`) and was rejected: -everything that learns the timeline at runtime (the wire, relays, fetches, -FFI) stayed on the runtime form and needed `erase()`/`timed()` casts, generic -code couldn't call `write_frame`, an uninferred `T` made `write_frame` -ambiguous, and only 4 publisher call sites got cleaner. Rust consumers -outside moq-net rarely read a net frame's timestamp (media time comes from -the payload), so the `Option` costs little. Rejected along with it: typed -frames (`Frame`) and a subscriber typed by SUBSCRIBE_OK. This supersedes -the 2026-10-05 rejection of a per-frame `Option`. With no mixed -groups, #4822's open questions on mixed groups and the untimed start -snapshot are moot. Notes below that assume mixing are superseded. - -Decided (2026-10-01, maintainer): faithful absence on both the publish and -the subscribe side. Today every receiver stamps local arrival time, as the -timestamp draft mandates, and on lite-05 and later a relay forwards those -stamps as if they were real. Any route failover then changes -them, so the timeline jumps, and the "one clock per broadcast" property -breaks. Rejected: first-hop arrival (the failover jump stays), 0 as a -sentinel (collides with a real pts of 0), and `max_age` on max(wall, pts) -(`max_age` stays media-time staleness, so a congestion stall can't age -content out, and the pool's wall-clock expiry is the bound). That last -rejection is superseded 2026-10-06 by [One max_age -meaning](/quest/m1/cache-max-age.md), which lands after this quest. - -Decided (2026-10-02, planning this split): - -- Superseded 2026-10-05: on a track accepted without TIMESCALE, an object's - own object-scope TIMESCALE and Timestamp are ignored, and the track stays - untimed. imquic's LOC examples publish this way, so they arrive untimed and - keep playing. Object-scope units are never applied, on any track. - Rejected: refusing them. -- On a track that declares TIMESCALE, an object with no Timestamp is - malformed. The receiver handles it under moq-transport's malformed-track - rules rather than inventing a time. Rejected: repeating the latest - timestamp, and falling back to arrival time. -- A legacy or LOC end marker (an empty frame) that arrives untimed is ignored. - The group then ends without a precise end bound. Its frames still play - from their payload timestamps, and only the last frame's duration is - estimated. Refusing would turn streams that play today into failures. -- Until lite-07 encodes absence, a lite encoder writes its send time for an - untimed frame, on every lite version from lite-05. That's what producers - effectively do today, so nothing regresses. Document it in the lite draft - as a downgrade for lite-05 and lite-06. - -Untimed semantics, from the decision: - -- An untimed group is never media-stale. The pool's wall-clock expiry still - reclaims it. [One max_age meaning](/quest/m1/cache-max-age.md) later gives - it a wall-clock staleness rule. -- Start resolution on an untimed track picks the latest group rather than - replaying the cache, as pre-lite-06 sessions already do. - -Things to look out for: - -- Several model decisions read "has a timestamp" as "has a frame". - `group.rs`'s `poll_timestamp` is the clearest case, and `GroupExpiry` uses - it. An untimed frame must still count as a frame there. Key "has - presented" off frames written (or fin), and keep the group's - `Option` for media time only, so an open group and a group of - untimed frames don't share one `None`. -- The live edge skips unstamped groups. Reach and successor search - (`track.rs`) deliberately stop at the immediate successor and - leave the bound unknown while it is unstamped, because skipping ahead - could expire content that is still valid. Preserve that, and check that an - untimed track neither stalls a cursor nor replays everything. -- `track::Info.timescale` always has a value today. A relay subscribed to an - IETF track without TIMESCALE currently announces one downstream. It must - not claim a timeline the source never had, so it becomes an `Option`. -- Model docs that recommend `Timestamp::now` for untimed data - (`model/{frame,group,track,subscription}.rs`) change with the type. -- In-repo callers follow the type change: - - moq-mux container consumers and end markers; - - moq-e2ee; - - libmoq; - - moq-ffi's frame and datagram records and track info; - - the binding wrappers. - - Media consumers that need a time refuse an untimed frame, except for the - end-marker rule above. -- A FETCH learns track units from SUBSCRIBE_OK (a joining or fill FETCH). - A standalone FETCH that learns none when it is accepted is untimed (decided - 2026-10-05), until [FETCH_OK properties](/quest/m1/fetch-ok-properties.md) - or TRACK_STATUS ([Fetch without SUBSCRIBE](/quest/m1/ietf-fetch-only.md)) - carry them. Test that a track accepted with units still yields - timestamps, alongside the untimed cases. -- Which objects the malformed rule covers. Status-only objects (End of - Group, End of Track), an empty LOC end marker, and the keep-alives and gap - markers the draft exempts today carry no media time. Before landing, check - that our publishers and any interop peer that sends TIMESCALE stamp every - object the rule covers. - -Interop facts (2026-10-02): - -- The in-tree interop matrix has no external peers. -- In the community runner: - - moxygen sends neither TIMESCALE nor Timestamp, so all its objects become - untimed. - - imquic uses the object scope, which is ignored, so its objects arrive - untimed. - - libquicr sends neither. - - MOQtail is undetermined. - -Draft: the changes to `draft-lcurley-moq-timestamp.md` ship in this PR, with -the implementation. Four per-object rules change: a publisher stamping every -object on a TIMESCALE track becomes a requirement rather than a SHOULD, a -missing Timestamp is malformed instead of falling back to arrival time, -receivers stop applying object-scope TIMESCALE overrides, and a LOC receiver -may no longer read a bare Timestamp on a track without TIMESCALE as -microseconds. Rewrite the remaining arrival-time mandates (no TIMESCALE, -before track properties arrive) as "untimed". An untimed object has no media -time, is never media-stale, starts at the latest group, and is forwarded as -untimed. Check the lite draft's per-track rule too, and update it if it -differs. - -Tests: each receive path (lite before lite-05, IETF subgroup, fetch and -datagram) yields an untimed frame, and a relay forwards one untimed. - -Public API: breaking (`track::Info.timescale`, frame and datagram -timestamps become `Option`). Wire: no encoding change. Receive semantics on -published drafts change as the timestamp draft says. - -## Related - -- [Fetch without SUBSCRIBE](/quest/m1/ietf-fetch-only.md) - opting out of properties today falls back to arrival time -- [Publisher timeliness](/quest/m1/qos/publisher-timeliness.md) - relay ingest measures arrival minus timestamp, and must skip untimed frames -- [One max_age meaning](/quest/m1/cache-max-age.md) - gives untimed groups a wall-clock staleness rule, fixes the failover stall this introduces, and replaces the latest-group start -- [Translator](/quest/m1/rs2ts/translator.md) - flags the nested `Option` in first-start resolution this touches diff --git a/quest/m2/watch-data-sync.md b/quest/m2/watch-data-sync.md index 1fad2e0ad2..c9c5339883 100644 --- a/quest/m2/watch-data-sync.md +++ b/quest/m2/watch-data-sync.md @@ -18,8 +18,8 @@ releases it. from the in-order states the consumer yields; stream tracks release every record in order. - An untimed track's payloads add no timestamp wait; they apply in order as - they arrive. Since 2026-10-05 a track is all timed or all untimed ([untimed - model](/quest/m1/untimed-model.md)), so no stream mixes the two. + they arrive. Since 2026-10-05 a track is all timed or all untimed (the untimed + model, [#4822](https://github.com/moq-dev/moq/pull/4822)), so no stream mixes the two. - OneTooMany is the application this waited for (2026-10-01): their web frontend holds KLV and MAVLink telemetry back to the video playhead with its own sync code, which this replaces. In m2 rather than m1 because diff --git a/rs/hang/src/container/frame.rs b/rs/hang/src/container/frame.rs index 384f2ed2a3..88dfbdb93b 100644 --- a/rs/hang/src/container/frame.rs +++ b/rs/hang/src/container/frame.rs @@ -106,7 +106,7 @@ impl Frame { // simply don't put it on the wire. let info = moq_net::frame::Info { size, - timestamp: self.timestamp, + timestamp: Some(self.timestamp), }; let mut chunked = group.create_frame(info)?; chunked.write(header)?; @@ -172,7 +172,7 @@ mod test { #[test] fn track_info_uses_container_timescale() { - assert_eq!(track_info(crate::catalog::PRIORITY.video).timescale, TIMESCALE); + assert_eq!(track_info(crate::catalog::PRIORITY.video).timescale, Some(TIMESCALE)); } #[test] @@ -184,7 +184,7 @@ mod test { // Retimescaling for a container that carries the source's own scale keeps it, since that // is the shape that would otherwise reach for `Info::default()` and lose the retention. let at = track_info(crate::catalog::PRIORITY.video).with_timescale(Timescale::MILLI); - assert_eq!(at.timescale, Timescale::MILLI); + assert_eq!(at.timescale, Some(Timescale::MILLI)); assert_eq!(at.max_age, Some(MAX_AGE)); } diff --git a/rs/moq-archive/src/error.rs b/rs/moq-archive/src/error.rs index 0b7b396ba9..0c8215a15b 100644 --- a/rs/moq-archive/src/error.rs +++ b/rs/moq-archive/src/error.rs @@ -82,6 +82,10 @@ pub enum Error { #[error("track already enrolled: {0}")] Enrolled(String), + /// The track is untimed, and a recording needs a timestamp on every frame. + #[error("untimed track: {0}")] + Untimed(String), + /// The source broadcast or one of its tracks failed. #[error("source: {0}")] Source(String), diff --git a/rs/moq-archive/src/proof.rs b/rs/moq-archive/src/proof.rs index 15d396e47f..9937884c80 100644 --- a/rs/moq-archive/src/proof.rs +++ b/rs/moq-archive/src/proof.rs @@ -187,7 +187,7 @@ async fn fetch(broadcast: &broadcast::Producer, track: &str, sequence: u64) -> m let mut frames = Vec::new(); while let Some(frame) = group.read_frame().await? { frames.push(( - frame.timestamp.convert(Timescale::MILLI).unwrap().value(), + frame.timestamp.unwrap().convert(Timescale::MILLI).unwrap().value(), frame.payload, )); } diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index 5dc4cc4a6b..11efac4bed 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -268,7 +268,7 @@ impl Timeline { Err(moq_net::Error::Duplicate) => continue, Err(err) => return Err(err.into()), }; - let timescale = producer.timescale(); + let timescale = producer.timescale().expect("`track_info` declares a timescale"); for frame in &stored.frames { producer.write_frame(timestamp(frame.timestamp, timescale)?, frame.payload.clone())?; } @@ -322,7 +322,7 @@ async fn serve_track(shared: Arc>, request: track::Req }, }; - let timescale = info.timescale; + let timescale = info.timescale.expect("`track_info` declares a timescale"); let dynamic = request.dynamic(); let demand = dynamic.demand(); let _producer = request.accept(info); diff --git a/rs/moq-archive/src/reader/tests.rs b/rs/moq-archive/src/reader/tests.rs index 1d625488a8..8b674b29ba 100644 --- a/rs/moq-archive/src/reader/tests.rs +++ b/rs/moq-archive/src/reader/tests.rs @@ -203,7 +203,7 @@ async fn fetch( let mut group = track.fetch_group(sequence, options).await?; let mut frames = Vec::new(); while let Some(frame) = group.read_frame().await? { - frames.push((frame.timestamp.value(), frame.payload)); + frames.push((frame.timestamp.unwrap().value(), frame.payload)); } Ok(frames) } @@ -251,7 +251,7 @@ async fn fetch_replays_original_groups() { let track = broadcast.consume().track("video").unwrap(); let info = track.query().await.unwrap(); assert_eq!(info.priority, 1); - assert_eq!(info.timescale.as_u64(), 1000); + assert_eq!(info.timescale.unwrap().as_u64(), 1000); } #[tokio::test] diff --git a/rs/moq-archive/src/writer.rs b/rs/moq-archive/src/writer.rs index e0e4cdf8e4..4c6575b018 100644 --- a/rs/moq-archive/src/writer.rs +++ b/rs/moq-archive/src/writer.rs @@ -390,7 +390,10 @@ impl Control { .subscribe(replay.clone()) .await .map_err(source_error)?; - let timescale = subscriber.info().timescale; + let timescale = subscriber + .info() + .timescale + .ok_or_else(|| Error::Untimed(name.to_string()))?; let info = Info::new(subscriber.info().priority, timescale.as_u64())?; shared.store.put_info(name, &info).await?; @@ -408,7 +411,11 @@ impl Control { .await .map_err(timeline_error_net)? .ordered(); - let info = Info::new(groups.info().priority, groups.info().timescale.as_u64())?; + let timeline_scale = groups + .info() + .timescale + .ok_or_else(|| Error::Untimed(timeline.to_string()))?; + let info = Info::new(groups.info().priority, timeline_scale.as_u64())?; shared.store.put_info(&timeline, &info).await?; let resume = shared.recovered.lock().unwrap().remove(name); @@ -713,8 +720,10 @@ fn handle( Ok(Some(frame)) => { let index = incoming.next; incoming.next += 1; + // The track is timed, so every frame on it is. let timestamp = frame .timestamp + .ok_or_else(|| malformed(&name, sequence, Error::Untimed(name.clone())))? .convert(track.timescale) .map_err(|_| malformed(&name, sequence, Error::Overflow))? .value(); @@ -870,7 +879,11 @@ impl Committer { /// instead keeps a recording's bytes a function of its content alone. fn read_timeline(&mut self, pts: Timestamp) -> Result { let waiter = kio::Waiter::noop(); - let timescale = self.groups.info().timescale; + let timescale = self + .groups + .info() + .timescale + .ok_or_else(|| Error::Timeline("untimed timeline track".to_string()))?; let mut groups = Vec::new(); while let Poll::Ready(result) = self.groups.poll_next_group(&waiter) { let Some(mut group) = result.map_err(timeline_error_net)? else { @@ -1153,6 +1166,25 @@ mod tests { assert!(store.get_info("ignored").await.is_err()); } + /// A recording needs a timestamp on every frame, so an untimed track is refused. + #[tokio::test] + async fn an_untimed_track_is_refused() { + let source = broadcast::Info::new().produce(); + let _data = source + .create_track("data", track::Info::default().with_timescale(None)) + .unwrap(); + + let store = Store::new(InMemory::new(), "rec"); + let writer = Writer::new(store.clone(), source.consume(), Config::default()) + .await + .unwrap(); + assert_eq!( + writer.control().track("data", sparse()).await, + Err(Error::Untimed("data".into())) + ); + assert!(store.get_info("data").await.is_err(), "nothing is recorded"); + } + #[tokio::test] async fn tracks_cut_independently() { let source = broadcast::Info::new().produce(); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 1fb0173258..6cc52853a1 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -466,9 +466,14 @@ pub struct moq_frame { pub payload: *const u8, pub payload_size: usize, - /// The presentation timestamp of the frame in microseconds + /// The presentation timestamp of the frame in microseconds, or 0 when + /// `timestamp_present` is false. pub timestamp_us: u64, + /// Whether the frame carries a timestamp. Media frames always do; a raw frame read + /// from an untimed track does not. + pub timestamp_present: bool, + /// Whether this frame opens a group or is a video keyframe; audio is true only at a group start. pub keyframe: bool, } @@ -481,9 +486,13 @@ pub struct moq_datagram { pub payload: *const u8, pub payload_size: usize, - /// The presentation timestamp of the datagram in microseconds. + /// The presentation timestamp of the datagram in microseconds, or 0 when + /// `timestamp_present` is false. pub timestamp_us: u64, + /// Whether the datagram carries a timestamp. One read from an untimed track does not. + pub timestamp_present: bool, + /// Per-track sequence number, drawn from the same namespace as groups. pub sequence: u64, } @@ -3597,7 +3606,8 @@ pub unsafe extern "C" fn moq_consume_track_update(track: u32, subscription: *con /// /// Fills `dst.payload` / `dst.payload_size`; the pointer is valid until the /// frame is released with [moq_consume_frame_free]. `dst.timestamp_us` is the -/// frame presentation timestamp in microseconds. `dst.keyframe` is reported as +/// frame presentation timestamp in microseconds, when `dst.timestamp_present` says it +/// has one. `dst.keyframe` is reported as /// false because raw tracks do not parse codec metadata. /// /// Returns a zero on success, or a negative code on failure. @@ -3674,7 +3684,8 @@ pub unsafe extern "C" fn moq_consume_datagrams( /// Read a datagram delivered via the [moq_consume_datagrams] callback. /// /// Fills `dst.payload` / `dst.payload_size` (valid until the datagram is released with -/// [moq_consume_datagram_free]), plus `dst.timestamp_us` and `dst.sequence`. +/// [moq_consume_datagram_free]), plus `dst.timestamp_us` (when `dst.timestamp_present`) +/// and `dst.sequence`. /// /// Returns a zero on success, or a negative code on failure. /// diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 01167c9c79..772143fd0c 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -94,6 +94,14 @@ enum RawStep { Stop, } +/// A raw frame or datagram timestamp in microseconds, `None` when untimed. +fn timestamp_us(timestamp: Option) -> Result, Error> { + timestamp + .map(|timestamp| timestamp.as_micros().try_into()) + .transpose() + .map_err(|_| Error::TimestampOverflow(moq_net::TimeOverflow)) +} + #[derive(Default)] pub struct Consume { /// Active broadcast consumers. @@ -562,6 +570,7 @@ impl Consume { payload: f.payload.as_ptr(), payload_size: f.payload.len(), timestamp_us, + timestamp_present: true, keyframe: f.keyframe, }; @@ -754,16 +763,13 @@ impl Consume { /// frame is released with [`Self::raw_frame_close`]. pub fn raw_frame(&self, frame: Id, dst: &mut moq_frame) -> Result<(), Error> { let frame = self.raw_frame.get(frame).ok_or(Error::FrameNotFound)?; - let timestamp_us = frame - .timestamp - .as_micros() - .try_into() - .map_err(|_| Error::TimestampOverflow(moq_net::TimeOverflow))?; + let timestamp_us = timestamp_us(frame.timestamp)?; *dst = moq_frame { payload: frame.payload.as_ptr(), payload_size: frame.payload.len(), - timestamp_us, + timestamp_us: timestamp_us.unwrap_or_default(), + timestamp_present: timestamp_us.is_some(), keyframe: false, }; @@ -854,10 +860,13 @@ impl Consume { pub fn datagram(&self, datagram: Id, dst: &mut moq_datagram) -> Result<(), Error> { let value = self.datagram.get(datagram).ok_or(Error::FrameNotFound)?; + let timestamp_us = timestamp_us(value.timestamp)?; + *dst = moq_datagram { payload: value.payload.as_ptr(), payload_size: value.payload.len(), - timestamp_us: value.timestamp.as_micros() as u64, + timestamp_us: timestamp_us.unwrap_or_default(), + timestamp_present: timestamp_us.is_some(), sequence: value.sequence, }; diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 5cdc78f2f8..9c8b583205 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -401,6 +401,13 @@ impl Publish { Ok(()) } + /// The raw track producer behind a handle, for tests that publish what the C API + /// can't express (an untimed frame). + #[cfg(test)] + pub(crate) fn track_producer(&mut self, track: Id) -> Option<&mut moq_net::track::Producer> { + self.tracks.get_mut(track) + } + /// A watch-only handle to a raw track's subscriber demand. pub fn track_demand(&self, track: Id) -> Result { Ok(self.tracks.get(track).ok_or(Error::TrackNotFound)?.demand()) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 66a75439b3..2b66d79c96 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1081,10 +1081,12 @@ fn raw_loc_video_uses_the_declared_catalog_container() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_frame(frame_id, &mut frame) }, 0); assert_eq!(frame.timestamp_us, timestamp_us); + assert!(frame.timestamp_present, "a media frame is always timed"); assert!(frame.keyframe); assert_eq!( unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }, @@ -1428,6 +1430,63 @@ fn publish_track_with_info_rejects_invalid_timescale() { assert_eq!(moq_publish_close(broadcast), 0); } +/// A raw frame read from an untimed track reports no timestamp. The C API only publishes +/// timed tracks, so the track and its frame come from moq-net. +#[test] +fn an_untimed_raw_frame_reads_untimed() { + let origin = id(moq_origin_create()); + let path = b"raw-untimed"; + let broadcast = publish_broadcast(origin, path); + + let track_name = b"data"; + let track = State::lock() + .publish + .track( + Id::try_from(broadcast).unwrap(), + "data", + Some(moq_net::track::Info::default().with_timescale(None)), + ) + .unwrap(); + + let consume = request_broadcast(origin, path); + let frame_cb = Callback::new(); + let consumer = id(unsafe { + moq_consume_track( + consume, + track_name.as_ptr() as *const c_char, + track_name.len(), + std::ptr::null(), + Some(channel_callback), + frame_cb.ptr, + ) + }); + + State::lock() + .publish + .track_producer(track) + .unwrap() + .write_frame(None, &b"untimed"[..]) + .unwrap(); + let frame_id = id(frame_cb.recv()); + let mut frame = moq_frame { + payload: std::ptr::null(), + payload_size: 0, + timestamp_us: 7, + timestamp_present: true, + keyframe: false, + }; + assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); + assert!(!frame.timestamp_present, "an untimed frame reads untimed"); + assert_eq!(frame.timestamp_us, 0); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + + assert_eq!(moq_consume_track_cancel(consumer), 0); + assert_eq!(frame_cb.recv_terminal(), 0, "clean close delivers terminal 0"); + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn raw_track_publish_consume() { let origin = id(moq_origin_create()); @@ -1482,12 +1541,14 @@ fn raw_track_publish_consume() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: true, // should be overwritten with false }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; assert_eq!(received, payload); assert_eq!(frame.timestamp_us, timestamp_us); + assert!(frame.timestamp_present); assert!(!frame.keyframe, "raw frames have no keyframe flag"); assert_eq!(moq_consume_track_frame_free(frame_id), 0); @@ -1508,6 +1569,7 @@ fn raw_track_publish_consume() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -1571,6 +1633,7 @@ fn raw_track_datagram_publish_consume() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, sequence: 0, }; assert_eq!(unsafe { moq_consume_datagram(dg_id, &mut datagram) }, 0); @@ -1676,6 +1739,7 @@ fn raw_track_subscription_options_and_update() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -1695,6 +1759,7 @@ fn raw_track_subscription_options_and_update() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -2502,6 +2567,7 @@ fn dynamic_serves_track_requests() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -2834,6 +2900,7 @@ fn local_publish_consume() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_frame(frame_id, &mut frame) }, 0); @@ -2995,6 +3062,7 @@ fn consume_audio_follows_a_sibling_broadcast_reference() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_frame(frame_id, &mut frame) }, 0); @@ -3124,6 +3192,7 @@ fn video_publish_consume() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_frame(frame_id, &mut frame) }, 0); @@ -3948,6 +4017,7 @@ fn multiple_frames_ordering() { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_frame(frame_id, &mut frame) }, 0); @@ -4873,6 +4943,7 @@ fn read_raw_frames(consume: u32, name: &[u8], count: usize) -> Vec> { payload: std::ptr::null(), payload_size: 0, timestamp_us: 0, + timestamp_present: false, keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); diff --git a/rs/moq-cli/src/archive.rs b/rs/moq-cli/src/archive.rs index 6aa0653993..237c4e39b7 100644 --- a/rs/moq-cli/src/archive.rs +++ b/rs/moq-cli/src/archive.rs @@ -408,7 +408,10 @@ mod tests { let mut group = audio.fetch_group(sequence, None).await.unwrap(); for offset in [0, 500] { let frame = group.read_frame().await.unwrap().expect("a frame"); - assert_eq!(frame.timestamp.as_millis(), u128::from(sequence * 1000 + offset)); + assert_eq!( + frame.timestamp.unwrap().as_millis(), + u128::from(sequence * 1000 + offset) + ); assert_eq!(frame.payload, format!("{sequence}+{offset}")); } assert!(group.read_frame().await.unwrap().is_none()); diff --git a/rs/moq-e2ee/src/datagram.rs b/rs/moq-e2ee/src/datagram.rs index a7e7e5e268..514d868ecf 100644 --- a/rs/moq-e2ee/src/datagram.rs +++ b/rs/moq-e2ee/src/datagram.rs @@ -7,8 +7,8 @@ use bytes::Bytes; pub struct Datagram { /// Per-track sequence, shared with the group namespace. pub sequence: u64, - /// Presentation timestamp. - pub timestamp: moq_net::Timestamp, + /// Presentation timestamp, or `None` when untimed. + pub timestamp: Option, /// Decrypted application bytes. pub plaintext: Bytes, } diff --git a/rs/moq-e2ee/src/group.rs b/rs/moq-e2ee/src/group.rs index 6c95a05354..f6516148ee 100644 --- a/rs/moq-e2ee/src/group.rs +++ b/rs/moq-e2ee/src/group.rs @@ -14,8 +14,8 @@ use crate::limits::MAX_GROUPED_PAYLOAD; /// A decrypted grouped frame. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Frame { - /// Presentation timestamp. - pub timestamp: moq_net::Timestamp, + /// Presentation timestamp, or `None` when untimed. + pub timestamp: Option, /// Decrypted application bytes. pub plaintext: Bytes, } @@ -58,14 +58,22 @@ impl Producer { /// /// [`Error::Identity`] if the next frame exceeds 32 bits, [`Error::Exhausted`], /// [`Error::Oversize`], or a net write error. - pub fn write_frame(&mut self, timestamp: moq_net::Timestamp, plaintext: &[u8]) -> Result<()> { + pub fn write_frame(&mut self, timestamp: impl Into>, plaintext: &[u8]) -> Result<()> { + let timestamp = timestamp.into(); let frame = self.next_frame; if frame == u32::MAX { return Err(Error::Identity); } - timestamp - .convert(self.inner.timescale()) - .map_err(|_| Error::Net(moq_net::Error::TimestampMismatch))?; + // Checked before the frame identity is spent: a track is all timed or all untimed. + match (timestamp, self.inner.timescale()) { + (Some(timestamp), Some(timescale)) => { + timestamp + .convert(timescale) + .map_err(|_| Error::Net(moq_net::Error::TimestampMismatch))?; + } + (None, None) => {} + _ => return Err(Error::Net(moq_net::Error::TimestampMismatch)), + } let payload = self.key.lock().expect("track key").protect( self.inner.sequence, u64::from(frame), diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 5b54a14413..9e85a9f854 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -373,7 +373,7 @@ fn datagram_roundtrip() { match recv_datagram(&mut pair.consumer) { Event::Datagram(d) => { assert_eq!(d.sequence, 0); - assert_eq!(d.timestamp, ms(5)); + assert_eq!(d.timestamp, Some(ms(5))); assert_eq!(&d.plaintext[..], b"opus"); } other => panic!("{other:?}"), diff --git a/rs/moq-e2ee/src/track.rs b/rs/moq-e2ee/src/track.rs index c8ff2100ea..e10fea6882 100644 --- a/rs/moq-e2ee/src/track.rs +++ b/rs/moq-e2ee/src/track.rs @@ -65,7 +65,11 @@ impl Producer { /// # Errors /// /// [`Error::Identity`], [`Error::Exhausted`], [`Error::Oversize`], or a net write error. - pub fn append_datagram(&mut self, timestamp: moq_net::Timestamp, plaintext: &[u8]) -> Result { + pub fn append_datagram( + &mut self, + timestamp: impl Into>, + plaintext: &[u8], + ) -> Result { let sequence = self.next; self.insert_datagram(sequence, timestamp, plaintext)?; Ok(sequence) @@ -81,7 +85,12 @@ impl Producer { /// /// [`Error::Reuse`] if `sequence` was already allocated, [`Error::Identity`], /// [`Error::Exhausted`], [`Error::Oversize`], or a net write error. - pub fn insert_datagram(&mut self, sequence: u64, timestamp: moq_net::Timestamp, plaintext: &[u8]) -> Result<()> { + pub fn insert_datagram( + &mut self, + sequence: u64, + timestamp: impl Into>, + plaintext: &[u8], + ) -> Result<()> { self.reserve(sequence)?; let payload = self .datagram_key diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 14e441e320..b8a585c651 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -14,7 +14,7 @@ fn timestamp_us(timestamp: moq_net::Timestamp) -> Result { } fn raw_frame(frame: moq_net::frame::Frame) -> Result { - let timestamp_us = timestamp_us(frame.timestamp)?; + let timestamp_us = frame.timestamp.map(timestamp_us).transpose()?; Ok(MoqFrame { payload: frame.payload.to_vec(), timestamp_us, @@ -455,7 +455,7 @@ impl TrackInner { }; Poll::Ready(Ok(Some(MoqDatagram { sequence: datagram.sequence, - timestamp_us: timestamp_us(datagram.timestamp)?, + timestamp_us: datagram.timestamp.map(timestamp_us).transpose()?, payload: datagram.payload.to_vec(), }))) } diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 5042c51083..84213a118b 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -124,9 +124,10 @@ pub struct MoqAudio { pub struct MoqFrame { /// The frame payload. pub payload: Vec, - /// Presentation timestamp in microseconds. - #[uniffi(default = 0)] - pub timestamp_us: u64, + /// Presentation timestamp in microseconds, or null on a frame read from an untimed + /// track. A raw track published here is timed, so writing one needs it. + #[uniffi(default = None)] + pub timestamp_us: Option, } /// A [`MoqFrame`] plus the codec metadata a media track carries. @@ -149,9 +150,10 @@ pub struct MoqDatagram { /// Per-track sequence number, shared with groups. #[uniffi(default = 0)] pub sequence: u64, - /// Presentation timestamp in microseconds. - #[uniffi(default = 0)] - pub timestamp_us: u64, + /// Presentation timestamp in microseconds, or null on a datagram read from an untimed + /// track. A raw track published here is timed, so appending one needs it. + #[uniffi(default = None)] + pub timestamp_us: Option, /// Datagram payload, capped at 1200 bytes. pub payload: Vec, } diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 59de359329..c909f17c90 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -23,7 +23,8 @@ pub struct MoqTrackInfo { /// [`MoqSubscription::max_age_us`](crate::consumer::MoqSubscription::max_age_us). #[uniffi(default = None)] pub max_age_us: Option, - /// Per-frame timescale in ticks per second. Null uses microseconds. + /// Per-frame timescale in ticks per second. Null uses microseconds when publishing, and + /// means the source declared no timeline on a received track. #[uniffi(default = None)] pub timescale: Option, } @@ -65,7 +66,7 @@ impl TryFrom<&moq_net::track::Info> for MoqTrackInfo { Ok(Self { priority: info.priority, max_age_us, - timescale: Some(info.timescale.as_u64()), + timescale: info.timescale.map(|timescale| timescale.as_u64()), }) } } @@ -762,10 +763,12 @@ impl MoqTrackProducer { /// Write `frame` as a single-frame group. /// /// Raw tracks default to a microsecond timescale. Custom timescales may round - /// the timestamp during conversion. + /// the timestamp during conversion. A frame without one is refused with + /// [`MoqProtocolKind::TimestampMismatch`](crate::error::MoqProtocolKind::TimestampMismatch): raw + /// tracks are timed. pub fn write_frame(&self, frame: MoqFrame) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let timestamp = moq_net::Timestamp::from_micros(frame.timestamp_us)?; + let timestamp = frame.timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; track.write_frame(timestamp, frame.payload)?; @@ -775,10 +778,11 @@ impl MoqTrackProducer { /// Send `frame` as a best-effort datagram, returning the sequence number assigned to it. /// /// The payload must be at most 1200 bytes. Datagrams are only delivered on transports and - /// wire versions with a datagram channel; there is no stream fallback. + /// wire versions with a datagram channel; there is no stream fallback. Like a frame, a + /// datagram without a timestamp is refused. pub fn append_datagram(&self, frame: MoqFrame) -> Result { let _guard = crate::ffi::enter(); - let timestamp = moq_net::Timestamp::from_micros(frame.timestamp_us)?; + let timestamp = frame.timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; Ok(track.append_datagram(timestamp, frame.payload)?) @@ -845,10 +849,12 @@ impl MoqGroupProducer { /// Write `frame` into this group. /// /// Raw tracks default to a microsecond timescale. Custom timescales may round - /// the timestamp during conversion. + /// the timestamp during conversion. A frame without one is refused with + /// [`MoqProtocolKind::TimestampMismatch`](crate::error::MoqProtocolKind::TimestampMismatch): raw + /// tracks are timed. pub fn write_frame(&self, frame: MoqFrame) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let timestamp = moq_net::Timestamp::from_micros(frame.timestamp_us)?; + let timestamp = frame.timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; let mut guard = self.inner.lock().unwrap(); let group = guard.as_mut().ok_or(MoqError::Closed)?; group.write_frame(timestamp, frame.payload)?; @@ -957,10 +963,13 @@ impl MoqMediaProducer { let mut guard = self.inner.lock().unwrap(); let media = guard.as_mut().ok_or(MoqError::Closed)?; - let timestamp = hang::container::Timestamp::from_micros(frame.timestamp_us)?; + let timestamp = frame + .timestamp_us + .map(hang::container::Timestamp::from_micros) + .transpose()?; media .import - .decode(&frame.payload, Some(timestamp)) + .decode(&frame.payload, timestamp) .map_err(|err| MoqError::Codec(format!("decode failed: {err}")))?; Ok(()) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 6f6b1a751f..2f797efd8b 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -6,7 +6,7 @@ use crate::consumer::MoqBroadcastConsumer; use crate::consumer::MoqFetchGroupOptions; use crate::consumer::MoqSubscription; use crate::consumer::MoqTrackConsumer; -use crate::error::MoqError; +use crate::error::{MoqError, MoqProtocolError, MoqProtocolKind}; use crate::flate::MoqFlateConfig; use crate::json::{MoqJsonSnapshotConfig, MoqJsonStreamConfig}; use crate::media::{MoqAudioFormat, MoqAudioInit, MoqFrame, MoqVideoFormat, MoqVideoInit}; @@ -358,7 +358,7 @@ fn publish_media_lifecycle() { media .write_frame(MoqFrame { payload: b"opus frame".to_vec(), - timestamp_us: 1000, + timestamp_us: Some(1000), }) .unwrap(); media.finish().unwrap(); @@ -600,7 +600,7 @@ async fn raw_track_datagram_roundtrip() { let sequence = track .append_datagram(MoqFrame { payload: payload.clone(), - timestamp_us: 123_456, + timestamp_us: Some(123_456), }) .unwrap(); let datagram = tokio::time::timeout(TIMEOUT, consumer.recv_datagram()) @@ -610,7 +610,7 @@ async fn raw_track_datagram_roundtrip() { .expect("expected a datagram"); assert_eq!(datagram.sequence, sequence); - assert_eq!(datagram.timestamp_us, 123_456); + assert_eq!(datagram.timestamp_us, Some(123_456)); assert_eq!(datagram.payload, payload); } @@ -653,7 +653,7 @@ async fn raw_track_update_does_not_wait_for_pending_read() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 20_000, + timestamp_us: Some(20_000), }) .unwrap(); @@ -664,7 +664,7 @@ async fn raw_track_update_does_not_wait_for_pending_read() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 20_000); + assert_eq!(frame.timestamp_us, Some(20_000)); } #[tokio::test] @@ -856,7 +856,7 @@ async fn dynamic_track_request() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); @@ -873,10 +873,37 @@ async fn dynamic_track_request() { .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 0); + assert_eq!(frame.timestamp_us, Some(0)); track.finish().unwrap(); } +/// A raw track is timed, so a write or datagram without a timestamp is refused rather than +/// stamped. +#[test] +fn raw_writes_need_a_timestamp() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("status".into(), None).unwrap(); + let untimed = || MoqFrame { + payload: b"ready".to_vec(), + timestamp_us: None, + }; + let refused = |err: MoqError| { + matches!( + err, + MoqError::Protocol { + details: MoqProtocolError { + kind: MoqProtocolKind::TimestampMismatch, + .. + } + } + ) + }; + assert!(refused(track.write_frame(untimed()).unwrap_err())); + assert!(refused(track.append_datagram(untimed()).unwrap_err())); + let group = track.append_group().unwrap(); + assert!(refused(group.write_frame(untimed()).unwrap_err())); +} + #[tokio::test] async fn raw_frame_timestamps() { let broadcast = MoqBroadcastProducer::new().unwrap(); @@ -887,7 +914,7 @@ async fn raw_frame_timestamps() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 12_345, + timestamp_us: Some(12_345), }) .unwrap(); @@ -897,7 +924,7 @@ async fn raw_frame_timestamps() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 12_345); + assert_eq!(frame.timestamp_us, Some(12_345)); let group = track.append_group().unwrap(); let group_consumer = group.consume().unwrap(); @@ -905,7 +932,7 @@ async fn raw_frame_timestamps() { group .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 23_456, + timestamp_us: Some(23_456), }) .unwrap(); group.finish().unwrap(); @@ -916,7 +943,7 @@ async fn raw_frame_timestamps() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 23_456); + assert_eq!(frame.timestamp_us, Some(23_456)); track.finish().unwrap(); } @@ -946,7 +973,7 @@ async fn abort_after_finish_keeps_the_track_handle() { track .write_frame(MoqFrame { payload: b"late abort".to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); track.finish().unwrap(); @@ -964,7 +991,7 @@ async fn abort_after_finish_reaches_group_consumer() { group .write_frame(MoqFrame { payload: b"late abort".to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); group.finish().unwrap(); @@ -1020,13 +1047,13 @@ async fn fetches_cached_group_without_subscribing() { group .write_frame(MoqFrame { payload: b"first".to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); group .write_frame(MoqFrame { payload: b"second".to_vec(), - timestamp_us: 20_000, + timestamp_us: Some(20_000), }) .unwrap(); group.finish().unwrap(); @@ -1040,10 +1067,10 @@ async fn fetches_cached_group_without_subscribing() { assert_eq!(fetched.sequence(), 0); let frame = fetched.read_frame().await.unwrap().expect("expected first frame"); assert_eq!(frame.payload, b"first".to_vec()); - assert_eq!(frame.timestamp_us, 0); + assert_eq!(frame.timestamp_us, Some(0)); let frame = fetched.read_frame().await.unwrap().expect("expected second frame"); assert_eq!(frame.payload, b"second".to_vec()); - assert_eq!(frame.timestamp_us, 20_000); + assert_eq!(frame.timestamp_us, Some(20_000)); assert!(fetched.read_frame().await.unwrap().is_none()); } @@ -1208,7 +1235,7 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { group .write_frame(MoqFrame { payload: b"fetched".to_vec(), - timestamp_us: 100_000, + timestamp_us: Some(100_000), }) .unwrap(); group.finish().unwrap(); @@ -1221,7 +1248,7 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { assert_eq!(fetched.sequence(), 5); let frame = fetched.read_frame().await.unwrap().expect("expected fetched frame"); assert_eq!(frame.payload, b"fetched".to_vec()); - assert_eq!(frame.timestamp_us, 100_000); + assert_eq!(frame.timestamp_us, Some(100_000)); } #[tokio::test] @@ -1296,7 +1323,7 @@ async fn requested_track_dynamic_survives_accept() { group .write_frame(MoqFrame { payload: b"archive".to_vec(), - timestamp_us: 180_000, + timestamp_us: Some(180_000), }) .unwrap(); group.finish().unwrap(); @@ -1308,7 +1335,7 @@ async fn requested_track_dynamic_survives_accept() { .unwrap(); let frame = fetched.read_frame().await.unwrap().expect("expected archive frame"); assert_eq!(frame.payload, b"archive".to_vec()); - assert_eq!(frame.timestamp_us, 180_000); + assert_eq!(frame.timestamp_us, Some(180_000)); } #[tokio::test] @@ -1423,7 +1450,7 @@ async fn dynamic_track_request_can_publish_media() { media .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 20_000, + timestamp_us: Some(20_000), }) .unwrap(); media.flush(20_000).unwrap(); @@ -1898,7 +1925,7 @@ async fn local_publish_consume_audio() { media .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 1_000_000, + timestamp_us: Some(1_000_000), }) .unwrap(); @@ -1956,7 +1983,7 @@ async fn video_publish_consume() { media .write_frame(MoqFrame { payload: keyframe, - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); @@ -2395,7 +2422,7 @@ async fn multiple_frames_ordering() { media .write_frame(MoqFrame { payload: payload.into_bytes(), - timestamp_us: ts, + timestamp_us: Some(ts), }) .unwrap(); } @@ -2530,7 +2557,7 @@ async fn dynamic_broadcast_request() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 20_000, + timestamp_us: Some(20_000), }) .unwrap(); @@ -2540,7 +2567,7 @@ async fn dynamic_broadcast_request() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 20_000); + assert_eq!(frame.timestamp_us, Some(20_000)); track.finish().unwrap(); served.close().unwrap(); @@ -2657,7 +2684,7 @@ async fn raw_track_next_group_is_repeatable() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); @@ -2695,7 +2722,7 @@ async fn raw_track_group_order_commits_on_first_read() { track .write_frame(MoqFrame { payload: b"one".to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); @@ -2713,7 +2740,7 @@ async fn raw_track_group_order_commits_on_first_read() { track .write_frame(MoqFrame { payload: b"two".to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); let group = tokio::time::timeout(TIMEOUT, consumer.recv_group()) @@ -2727,7 +2754,7 @@ async fn raw_track_group_order_commits_on_first_read() { let sequence = track .append_datagram(MoqFrame { payload: b"beep".to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); let datagram = tokio::time::timeout(TIMEOUT, consumer.recv_datagram()) @@ -2753,14 +2780,14 @@ fn raw_track() -> ( fn datagram_frame(payload: &[u8]) -> MoqFrame { MoqFrame { payload: payload.to_vec(), - timestamp_us: 1, + timestamp_us: Some(1), } } fn group_frame(payload: &[u8]) -> MoqFrame { MoqFrame { payload: payload.to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), } } @@ -3044,7 +3071,7 @@ async fn raw_read_frame_skips_empty_group_on_open_track() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 1_000, + timestamp_us: Some(1_000), }) .unwrap(); @@ -3055,7 +3082,7 @@ async fn raw_read_frame_skips_empty_group_on_open_track() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 1_000); + assert_eq!(frame.timestamp_us, Some(1_000)); } /// Empty groups already in the cursor are skipped, then the next populated @@ -3073,7 +3100,7 @@ async fn raw_read_frame_skips_empty_then_populated_groups() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 2_000, + timestamp_us: Some(2_000), }) .unwrap(); @@ -3083,7 +3110,7 @@ async fn raw_read_frame_skips_empty_then_populated_groups() { .unwrap() .expect("expected the populated group's first frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 2_000); + assert_eq!(frame.timestamp_us, Some(2_000)); } /// Cancelling `read_frame` after it has taken the next group must not drop that @@ -3101,7 +3128,7 @@ async fn raw_read_frame_keeps_group_across_cancelled_call() { group .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 3_000, + timestamp_us: Some(3_000), }) .unwrap(); group.finish().unwrap(); @@ -3113,7 +3140,7 @@ async fn raw_read_frame_keeps_group_across_cancelled_call() { .unwrap() .expect("cancelled read_frame must not lose the group's first frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 3_000); + assert_eq!(frame.timestamp_us, Some(3_000)); assert!( tokio::time::timeout(TIMEOUT, consumer.read_frame()) @@ -3159,7 +3186,7 @@ async fn raw_read_frame_drops_aborted_pending_group() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 4_000, + timestamp_us: Some(4_000), }) .unwrap(); let frame = tokio::time::timeout(TIMEOUT, consumer.read_frame()) @@ -3168,7 +3195,7 @@ async fn raw_read_frame_drops_aborted_pending_group() { .unwrap() .expect("an aborted pending group must not block later groups"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 4_000); + assert_eq!(frame.timestamp_us, Some(4_000)); } /// `next_group` and `read_frame` share one ordered cursor. A cancelled @@ -3185,7 +3212,7 @@ async fn raw_read_frame_and_next_group_share_the_cursor() { first .write_frame(MoqFrame { payload: b"first".to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); first.finish().unwrap(); @@ -3205,7 +3232,7 @@ async fn raw_read_frame_and_next_group_share_the_cursor() { track .write_frame(MoqFrame { payload: b"second".to_vec(), - timestamp_us: 0, + timestamp_us: Some(0), }) .unwrap(); let frame = tokio::time::timeout(TIMEOUT, consumer.read_frame()) @@ -3317,7 +3344,7 @@ fn without_runtime() { media .write_frame(MoqFrame { payload: b"hello".to_vec(), - timestamp_us: 1000, + timestamp_us: Some(1000), }) .unwrap(); @@ -3418,7 +3445,7 @@ async fn server_client_roundtrip() { media .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: 1_000_000, + timestamp_us: Some(1_000_000), }) .unwrap(); @@ -4718,7 +4745,7 @@ async fn shutdown_delivers_the_finished_track() { crate::ffi::detached(async move { let group = track.append_group()?; group.write_frame(MoqFrame { - timestamp_us: 0, + timestamp_us: Some(0), payload: b"last".to_vec(), })?; group.finish()?; diff --git a/rs/moq-flate/src/snapshot/mod.rs b/rs/moq-flate/src/snapshot/mod.rs index d1fda575f1..236331aeb9 100644 --- a/rs/moq-flate/src/snapshot/mod.rs +++ b/rs/moq-flate/src/snapshot/mod.rs @@ -130,7 +130,7 @@ mod test { let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { panic!("expected a frame"); }; - assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(frame.timestamp.unwrap().as_micros(), captured.as_micros()); assert_eq!(size, frame.payload.len()); assert!(size < payload.len(), "the size is the compressed frame"); assert_eq!(drain(consume(track, true)), vec![payload]); diff --git a/rs/moq-flate/src/stream/mod.rs b/rs/moq-flate/src/stream/mod.rs index 0327947c6c..7ee8b913b6 100644 --- a/rs/moq-flate/src/stream/mod.rs +++ b/rs/moq-flate/src/stream/mod.rs @@ -114,12 +114,12 @@ mod test { let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { panic!("expected a frame"); }; - assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(frame.timestamp.unwrap().as_micros(), captured.as_micros()); assert_eq!(first, frame.payload.len(), "the size is the compressed frame"); let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { panic!("expected a frame"); }; - assert_ne!(frame.timestamp.as_micros(), captured.as_micros()); + assert_ne!(frame.timestamp.unwrap().as_micros(), captured.as_micros()); } #[test] @@ -190,7 +190,7 @@ mod test { /// allocating 32 MB to provoke it. Borrowed from moq-json's stream tests. fn rejecting_track() -> moq_net::track::Producer { let mut info = moq_net::track::Info::default(); - info.timescale = moq_net::Timescale::new((1u64 << 62) - 1).unwrap(); + info.timescale = Some(moq_net::Timescale::new((1u64 << 62) - 1).unwrap()); moq_net::broadcast::Info::new() .produce() diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index b606283142..6611c42292 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -1168,7 +1168,7 @@ mod tests { "the payload goes out untouched" ); assert_eq!( - std::time::Duration::from(frame.timestamp).as_micros(), + std::time::Duration::from(frame.timestamp.expect("timed")).as_micros(), 40_000, "the frame carries the PTS mapped through the segment" ); @@ -1180,7 +1180,10 @@ mod tests { let mut group = subscriber.next_group().await.unwrap().expect("a second group"); let frame = group.read_frame().await.unwrap().expect("a frame in the second group"); assert_eq!(frame.payload.as_ref(), b"second"); - assert_eq!(std::time::Duration::from(frame.timestamp).as_micros(), 80_000); + assert_eq!( + std::time::Duration::from(frame.timestamp.expect("timed")).as_micros(), + 80_000 + ); } #[tokio::test(start_paused = true)] @@ -1248,7 +1251,7 @@ mod tests { .subscribe(None) .await .expect("subscribe to the opaque track"); - assert_eq!(subscriber.info().timescale, moq_net::Timescale::MICRO); + assert_eq!(subscriber.info().timescale, Some(moq_net::Timescale::MICRO)); assert_eq!( subscriber.info().max_age, Some(std::time::Duration::from_secs(5)), @@ -1290,7 +1293,10 @@ mod tests { let mut group = subscriber.next_group().await.unwrap().expect("a group"); let frame = group.read_frame().await.unwrap().expect("a frame"); assert_eq!(frame.payload.as_ref(), b"no pts", "the unstamped buffer was published"); - assert_eq!(std::time::Duration::from(frame.timestamp).as_micros(), 25_000); + assert_eq!( + std::time::Duration::from(frame.timestamp.expect("timed")).as_micros(), + 25_000 + ); } #[test] diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 761ccaf009..dbb0fa5484 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -341,7 +341,7 @@ mod test { let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { panic!("expected a frame"); }; - assert_eq!(frame.timestamp.as_micros(), stamp.as_micros()); + assert_eq!(frame.timestamp.unwrap().as_micros(), stamp.as_micros()); assert_eq!(size, Some(frame.payload.len())); } } @@ -704,7 +704,7 @@ mod test { /// allocating 32 MB to provoke it. fn rejecting_track() -> moq_net::track::Producer { let mut info = moq_net::track::Info::default(); - info.timescale = moq_net::Timescale::new((1u64 << 62) - 1).unwrap(); + info.timescale = Some(moq_net::Timescale::new((1u64 << 62) - 1).unwrap()); moq_net::broadcast::Info::new() .produce() diff --git a/rs/moq-json/src/stream/mod.rs b/rs/moq-json/src/stream/mod.rs index 5c2e217fa3..e624e30488 100644 --- a/rs/moq-json/src/stream/mod.rs +++ b/rs/moq-json/src/stream/mod.rs @@ -132,7 +132,7 @@ mod test { let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { panic!("expected a frame"); }; - assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(frame.timestamp.unwrap().as_micros(), captured.as_micros()); assert_eq!(size, frame.payload.len()); } @@ -237,7 +237,7 @@ mod test { /// allocating 32 MB to provoke it. fn rejecting_track() -> moq_net::track::Producer { let mut info = moq_net::track::Info::default(); - info.timescale = moq_net::Timescale::new((1u64 << 62) - 1).unwrap(); + info.timescale = Some(moq_net::Timescale::new((1u64 << 62) - 1).unwrap()); moq_net::broadcast::Info::new() .produce() diff --git a/rs/moq-json/src/window/mod.rs b/rs/moq-json/src/window/mod.rs index 51d22b18c2..3eac3c3f7d 100644 --- a/rs/moq-json/src/window/mod.rs +++ b/rs/moq-json/src/window/mod.rs @@ -92,7 +92,7 @@ mod test { /// A track whose timestamp conversion rejects every frame after its group is published. fn rejecting_track() -> moq_net::track::Producer { let mut info = moq_net::track::Info::default(); - info.timescale = moq_net::Timescale::new((1u64 << 62) - 1).unwrap(); + info.timescale = Some(moq_net::Timescale::new((1u64 << 62) - 1).unwrap()); moq_net::broadcast::Info::new() .produce() diff --git a/rs/moq-mux/src/binary.rs b/rs/moq-mux/src/binary.rs index 8df86b9dd5..49ba04b3d3 100644 --- a/rs/moq-mux/src/binary.rs +++ b/rs/moq-mux/src/binary.rs @@ -416,7 +416,7 @@ mod test { let mut stamps = Vec::new(); while let Poll::Ready(Ok(Some(mut group))) = subscriber.poll_recv_group(&waiter) { while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) { - stamps.push(frame.timestamp.as_millis()); + stamps.push(frame.timestamp.unwrap().as_millis()); } } stamps diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 90c90a70ad..def17fb64c 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -1151,11 +1151,11 @@ mod test { let info = catalog.track_info(hang::catalog::PRIORITY.video); assert_eq!(info.max_age, Some(std::time::Duration::from_secs(3))); - assert_eq!(info.timescale, hang::container::TIMESCALE); + assert_eq!(info.timescale, Some(hang::container::TIMESCALE)); let at = info.with_timescale(moq_net::Timescale::MILLI); assert_eq!(at.max_age, Some(std::time::Duration::from_secs(3))); - assert_eq!(at.timescale, moq_net::Timescale::MILLI); + assert_eq!(at.timescale, Some(moq_net::Timescale::MILLI)); // Every handle mints under the same policy, whatever order it was taken in: the codec // paths hold a reservation and the container paths hold a clone. diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index b9b4eb732a..b1f74eac4f 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -928,7 +928,7 @@ impl Import { let fragment_len = fragment_bytes.len(); let mut frame = g.create_frame(moq_net::frame::Info { size: fragment_bytes.len() as u64, - timestamp, + timestamp: Some(timestamp), })?; frame.write(fragment_bytes)?; frame.finish()?; diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 94914d14a1..513d3e6f63 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -458,7 +458,7 @@ fn encode(group: &mut moq_net::group::Producer, frames: &[Frame], info: Fragment // fragment's earliest presentation time so a relay can order it. let mut writer = group.create_frame(moq_net::frame::Info { size: bytes.len() as u64, - timestamp: frames[0].timestamp, + timestamp: Some(frames[0].timestamp), })?; writer.write(bytes)?; writer.finish()?; diff --git a/rs/moq-mux/src/container/loc/mod.rs b/rs/moq-mux/src/container/loc/mod.rs index f3ef259f12..5a6e71b08b 100644 --- a/rs/moq-mux/src/container/loc/mod.rs +++ b/rs/moq-mux/src/container/loc/mod.rs @@ -35,7 +35,7 @@ impl Container for Wire { // timescale), so a relay sees it without parsing the LOC payload. let mut chunked = group.create_frame(moq_net::frame::Info { size: data.len() as u64, - timestamp: frame.timestamp, + timestamp: Some(frame.timestamp), })?; chunked.write(data)?; chunked.finish()?; diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 09f7240ae6..3daad4ffe1 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -546,7 +546,7 @@ mod test { let mut stamps = Vec::new(); while let Poll::Ready(Ok(Some(mut group))) = subscriber.poll_recv_group(&waiter) { while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) { - stamps.push(frame.timestamp.as_millis()); + stamps.push(frame.timestamp.unwrap().as_millis()); } } assert_eq!(stamps.len(), 1); diff --git a/rs/moq-net/benches/group.rs b/rs/moq-net/benches/group.rs index 19520bf205..a77bf4f395 100644 --- a/rs/moq-net/benches/group.rs +++ b/rs/moq-net/benches/group.rs @@ -75,7 +75,7 @@ fn bench_write(c: &mut Criterion) { |(mut ctx, mut buf)| { for _ in 0..n { let frame = frame::Frame { - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), payload: payload.clone(), }; // A full buffer hands the frame back: flush, then take it. diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 5b6898a9d1..9221fbef6c 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -437,7 +437,7 @@ mod tests { .create_frame_owned( crate::frame::Info { size: size as u64, - timestamp: crate::Timestamp::ZERO, + timestamp: Some(crate::Timestamp::ZERO), }, &Default::default(), ) diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index 5443205129..af188dcb0e 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -171,7 +171,8 @@ pub enum StreamError { #[error("group too large")] GroupTooLarge, - /// A frame's timestamp doesn't match its track's negotiated timescale. + /// A frame's timestamp doesn't match its track: missing on a timed track, present on + /// an untimed one, or out of range for the track's timescale. #[error("frame timestamp doesn't match track timescale")] TimestampMismatch, @@ -403,9 +404,8 @@ pub enum Error { #[error("frame already open")] FrameOpen, - /// A frame's timestamp doesn't match its track's negotiated timescale: it's - /// missing on a timed track, present on an untimed track, or carries a - /// different scale than the track advertised. + /// A frame's timestamp doesn't match its track: it's missing on a timed track, + /// present on an untimed track, or out of range for the track's timescale. #[error("frame timestamp doesn't match track timescale")] TimestampMismatch, diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 03fb24810e..9d72ce3bee 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -1049,7 +1049,7 @@ impl FrameRecv { let mut group = track.append_group().unwrap(); let info = frame::Info { size: size as u64, - timestamp: crate::Timestamp::ZERO, + timestamp: Some(crate::Timestamp::ZERO), }; let frame = group.create_frame_owned(info, &budget).unwrap(); groups.push(group); diff --git a/rs/moq-net/src/ietf/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index 0a50802c15..b16d0acd5b 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -364,8 +364,8 @@ impl Message for FetchOk { 0x04 => max_cache_duration: Option, 0x22 => group_order: Option, ); - // The timescale is read but not surfaced yet: a fetched object without an - // interpretable timestamp is stamped on arrival. + // The timescale is read but not surfaced yet: a fetched object without its + // own units arrives untimed. let mut properties = super::Properties::decode(buf, version)?; if version == Version::Draft15 { properties.max_cache_duration = max_cache_duration.map(std::time::Duration::from_millis); diff --git a/rs/moq-net/src/ietf/group.rs b/rs/moq-net/src/ietf/group.rs index 7ef2a032c4..23fd779f1d 100644 --- a/rs/moq-net/src/ietf/group.rs +++ b/rs/moq-net/src/ietf/group.rs @@ -7,7 +7,9 @@ use super::Version; use crate::ietf::Param; /// MOQ Object Property IDs (the MOQ Object Properties registry, shared with -/// draft-ietf-moq-loc-04). Even type ids carry a single varint value. +/// draft-ietf-moq-loc-04). Even type ids carry a single varint value. The object-scope +/// Timescale is only skipped on decode, so only tests name it. +#[cfg(test)] const PROP_TIMESCALE: u64 = 0x08; const PROP_TIMESTAMP: u64 = 0x10; @@ -52,16 +54,15 @@ fn encode_object_property_type(w: &mut Encoder<'_>, kind: u64, prev: u64, versio /// Decode the Timestamp (0x10) Object Property from an object's extension block, /// skipping any other properties. Returns `None` when no Timestamp property is present. /// -/// `timescale` is the track's declared units. An object-scope Timescale (0x08) overrides -/// it for that object alone, which draft-ietf-moq-loc-04 permits and we still honor on -/// decode even though we no longer write one. +/// `timescale` is the track's declared units. An object-scope Timescale (0x08), which +/// draft-ietf-moq-loc-04 permits, is ignored: timedness and units are per track, so the +/// track's TIMESCALE alone decides them. pub fn decode_object_time( r: &mut Decoder<'_>, timescale: Timescale, version: Version, ) -> Result, DecodeError> { let mut timestamp: Option = None; - let mut override_scale: Option = None; let mut prev_type: u64 = 0; let mut first = true; @@ -80,7 +81,6 @@ pub fn decode_object_time( let value = r.varint()?; match abs { PROP_TIMESTAMP | PROP_TIMESTAMP_DRAFT03 => timestamp = Some(value), - PROP_TIMESCALE => override_scale = Some(value), _ => {} } } else { @@ -90,16 +90,9 @@ pub fn decode_object_time( } } - let Some(value) = timestamp else { - return Ok(None); - }; - let scale = match override_scale { - Some(s) => Timescale::new(s).map_err(|_| DecodeError::InvalidValue)?, - None => timescale, - }; - Ok(Some( - Timestamp::new(value, scale).map_err(|_| DecodeError::InvalidValue)?, - )) + timestamp + .map(|value| Timestamp::new(value, timescale).map_err(|_| DecodeError::InvalidValue)) + .transpose() } #[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive, IntoPrimitive)] @@ -411,9 +404,9 @@ mod tests { assert_eq!(varints(&buf, Version::Draft15), [PROP_TIMESTAMP, 96_000]); } - /// An object-scope Timescale (which LOC permits) overrides the track's for that object. + /// An object-scope Timescale (which LOC permits) is ignored: the track's units apply. #[test] - fn test_object_time_honors_an_object_scope_timescale() { + fn test_object_time_ignores_an_object_scope_timescale() { let buf = from_varints( &[ PROP_TIMESCALE, @@ -426,10 +419,10 @@ mod tests { let decoded = decode_time(&buf, Timescale::MICRO, Version::Draft18).unwrap(); assert_eq!(decoded.value(), 42); - assert_eq!(decoded.scale(), Timescale::MILLI); + assert_eq!(decoded.scale(), Timescale::MICRO); } - /// Without an object-scope override, the track's timescale supplies the units. + /// The track's timescale supplies the units. #[test] fn test_object_time_defaults_to_the_track_scale() { let buf = from_varints(&[PROP_TIMESTAMP, 1234], Version::Draft18); @@ -449,7 +442,7 @@ mod tests { assert_eq!(decoded.scale(), Timescale::MICRO); } - /// No Timestamp property at all yields None (the caller wall-clock-stamps). + /// No Timestamp property at all yields None. #[test] fn test_object_time_absent() { assert!(decode_time(&[], Timescale::MICRO, Version::Draft18).is_none()); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 3fa99e8548..ccc5e73c54 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -61,6 +61,8 @@ struct FetchedGroup { frames: Vec, /// The group ended within the range, so every frame it will ever hold was read. complete: bool, + /// The track's timescale, or `None` for an untimed track. + timescale: Option, } impl FetchPrior { @@ -117,6 +119,7 @@ async fn read_fetch( first, frames, complete, + timescale: group.timescale(), }) } @@ -673,9 +676,11 @@ where let _ = track.update(subscription); // A Timestamp goes out only when this SUBSCRIBE_OK actually carries TIMESCALE. // Drafts 14-16 never write that property, so their objects stay unstamped. + // An untimed track declares none, and its objects carry no Timestamp. let timescale = msg .properties_wanted .then(|| track.info().timescale) + .flatten() .filter(|_| ietf::Properties::sends_timescale(self.version)); // Draft-20 replaced joining FETCH with subscription fills. Older drafts save @@ -1196,7 +1201,7 @@ where sequence: u64, object: u64, prior: FetchPrior, - timestamp: Timestamp, + timestamp: Option, timescale: Option, version: Version, ) -> Result<(), Error> { @@ -1206,6 +1211,8 @@ where let timescale = timescale.filter(|_| ietf::Properties::sends_timescale(version)); let properties = match timescale { Some(timescale) => { + // Every frame on a timed track is timed (see `track::Info::timescale`). + let timestamp = timestamp.ok_or(Error::TimestampMismatch)?; let mut properties = Vec::new(); ietf::encode_object_time( &mut Encoder::new(&mut properties, version.into()), @@ -1359,8 +1366,7 @@ where Err(err) => return self.reject_fetch(stream, msg.request_id, &err, &err.to_string()).await, }; - // No SUBSCRIBE declared a timescale for this request, so its objects go - // out unstamped. + // The track's timescale is only known once its group is read, below. (track, start, end, None, false) } FetchType::RelativeJoining { @@ -1444,6 +1450,15 @@ where None => return Ok(()), }; + // A standalone FETCH keeps each object's Timestamp, in the track's own units, as a + // subscriber learned them from SUBSCRIBE_OK. FETCH_OK doesn't declare them yet. + let timescale = match joined { + true => timescale, + false => group + .timescale + .filter(|_| ietf::Properties::sends_timescale(self.version)), + }; + let (end_location, end_of_track) = if joined { // The subscription starts at the saved Largest Object, so the prefix of that // group has to be here in full or the two leave a gap. @@ -2601,9 +2616,13 @@ impl TrackServe { let sequence = datagram.sequence; let properties = match self.timescale { Some(timescale) => { + // Every datagram on a timed track is timed (see `track::Info::timescale`). + let Some(timestamp) = datagram.timestamp else { + continue; + }; let mut properties = Vec::new(); let mut w = Encoder::new(&mut properties, self.version.into()); - if ietf::encode_object_time(&mut w, datagram.timestamp, timescale, self.version).is_err() { + if ietf::encode_object_time(&mut w, timestamp, timescale, self.version).is_err() { continue; } Some(properties) @@ -2930,7 +2949,7 @@ fn buffer_object_info( writer: &mut Writer, delta: u64, has_extensions: bool, - timestamp: Timestamp, + timestamp: Option, size: u64, timescale: Option, version: Version, @@ -2938,7 +2957,9 @@ fn buffer_object_info( writer.buffer_varint(delta)?; if let Some(timescale) = timescale.filter(|_| has_extensions) { - // Per-object extension headers carry the frame's presentation timestamp. + // Per-object extension headers carry the frame's presentation timestamp, which + // every frame on a timed track has (see `track::Info::timescale`). + let timestamp = timestamp.ok_or(Error::TimestampMismatch)?; let mut ext = Vec::new(); ietf::encode_object_time( &mut Encoder::new(&mut ext, version.into()), @@ -3249,7 +3270,7 @@ mod group_priority_test { let mut old = track.append_group().unwrap(); let mut frame = old .create_frame(frame::Info { - timestamp: crate::Timestamp::ZERO, + timestamp: Some(crate::Timestamp::ZERO), size: 2, }) .unwrap(); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 13e3a6da23..6215dc1c18 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -219,8 +219,8 @@ enum Fill { /// timestamps are in, and the fetch stream can arrive before it does. Requested, - /// Ready to be served, in these timestamp units. `None` means the publisher opted the - /// track out of timestamps, so its frames are stamped on arrival. + /// Ready to be served, in these timestamp units. `None` means the track declared none, + /// so its frames are untimed. Serving(Option), /// A fetch stream is writing the head. A second one answers no request of ours. @@ -471,8 +471,8 @@ struct TrackState { broadcast: PathOwned, // Units for this track's object Timestamps, from the TIMESCALE Track Property in - // SUBSCRIBE_OK. `None` until it arrives, and for a track that declares none: the - // publisher opted out of timestamps, so frames are stamped on arrival instead. + // SUBSCRIBE_OK. `None` until it arrives, and for a track that declares none: its + // frames are untimed. timescale: Option, // The SUBSCRIBE_OK Largest Location, which bounds a joining FETCH stitch. @@ -1971,8 +1971,10 @@ where priority, largest, } = accepted; + // Normalized to microseconds, but only for a track that declared a timescale: an + // untimed one must not gain a timeline downstream. let info = track::Info::default() - .with_timescale(Timescale::MICRO) + .with_timescale(timescale.map(|_| Timescale::MICRO)) .with_max_age(max_age) .with_priority(super::priority::from_wire(priority.unwrap_or(128))); // The copy already holds the track: it is current again up to the answer's Largest @@ -2677,6 +2679,13 @@ where // Return the refusal to the dispatcher so it sends STOP_SENDING. return Err(err); } + // The track is malformed, not just this group, so no later group is trusted. + Err(Error::MalformedTrack) => { + tracing::warn!(group = %producer.sequence, "malformed track"); + let _ = producer.abort(Error::MalformedTrack); + let _ = track.abort(Error::MalformedTrack); + return Err(Error::MalformedTrack); + } Err(err) => { tracing::debug!(%err, group = %producer.sequence, "group error"); let _ = producer.abort(err); @@ -2730,7 +2739,8 @@ where return Ok(()); }; - // Like a subgroup object: a track that declared no timescale is stamped on arrival. + // Like a subgroup object: a track that declared no timescale is untimed. One on a + // timed track without a Timestamp is refused by the model and dropped below. let timestamp = match (track.timescale, &datagram.properties) { (Some(timescale), Some(properties)) => { let mut properties = Decoder::new(properties, self.version.into()); @@ -2738,7 +2748,6 @@ where } _ => None, }; - let timestamp = timestamp.unwrap_or_else(|| crate::Timestamp::from(self.runtime.now())); let Some(producer) = track.producer.as_mut() else { return Ok(()); @@ -2991,7 +3000,6 @@ where /// the id delta, the extension headers (carrying the timestamp), the size, the /// status for empty objects, and the streamed payload. struct GroupIngest { - runtime: crate::time::Clock, has_extensions: bool, has_end: bool, timescale: Option, @@ -3031,7 +3039,6 @@ impl GroupIngest { start: u64, ) -> Self { Self { - runtime: subscriber.runtime.clone(), has_extensions: group.flags.has_extensions, has_end: group.flags.has_end, timescale, @@ -3331,8 +3338,8 @@ where keep: bool, ) -> Result { // The properties carry the frame's presentation timestamp (the Timestamp Object - // Property) in the units the track declared. A track that declared none opted - // out, so its frames are stamped on arrival instead. + // Property) in the units the track declared. A track that declared none is + // untimed, and an object on a timed track without one is malformed. let timestamp = match (properties, timescale) { (Some(properties), Some(timescale)) => { let mut properties = Decoder::new(&properties, self.version.into()); @@ -3340,7 +3347,6 @@ where } _ => None, }; - let timestamp = timestamp.unwrap_or_else(|| crate::Timestamp::from(self.runtime.now())); // A fetch object has no status field from draft-16 on; a zero length is simply // an empty object. Draft-14 and 15 still encode Normal (0) after a zero length. @@ -3358,6 +3364,7 @@ where return Ok(true); } + let timestamp = object_time(timescale, timestamp)?; // `create_frame_owned` is the allocation chokepoint: it rejects an oversized `size` // and allocates up front only within the budget, so no pre-check is needed. let mut frame = producer.create_frame_owned(frame::Info { size, timestamp }, &self.frames)?; @@ -3494,7 +3501,7 @@ where // have set it. FETCH_OK carries the same Track Properties, so the retention window // comes from it. let info = track::Info::default() - .with_timescale(Timescale::MICRO) + .with_timescale(timescale.map(|_| Timescale::MICRO)) .with_max_age(ok.properties.max_cache_duration); // Joined fetches still count until they pick the accepted group up from the cache. let joined = request.result.clone(); @@ -3917,6 +3924,18 @@ fn settle_join_live(fill: &kio::Producer, live: Live) { } } +/// A Normal object's timestamp: none on an untimed track, and required on a timed one, +/// where an object without a Timestamp is malformed. +fn object_time( + timescale: Option, + timestamp: Option, +) -> Result, Error> { + match (timescale, timestamp) { + (Some(_), None) => Err(Error::MalformedTrack), + (_, timestamp) => Ok(timestamp), + } +} + impl GroupIngest { /// `Ready(Ok(_))` once the stream FINs on an object boundary, or an explicit /// end-of-group or end-of-track status arrives. The caller finishes or aborts the @@ -3949,9 +3968,8 @@ impl GroupIngest { IngestPhase::ExtBytes { size } => { // Per-object extension headers may carry the frame's presentation // timestamp (the Timestamp Object Property), in the units the track - // declared. A track that declared no timescale opted out, so its - // objects are stamped on arrival even if one carries a Timestamp we - // could not interpret. + // declared. A track that declared no timescale is untimed, even if an + // object carries a Timestamp: it has no units to read it in. let ext = ready!(reader.poll_read_exact(&mut cx, *size))?; let timestamp = match self.timescale { Some(timescale) => { @@ -3976,7 +3994,7 @@ impl GroupIngest { // `create_frame_owned` is the allocation chokepoint: it rejects an // oversized `size` and allocates up front only within the budget, so // no pre-check is needed. - let timestamp = timestamp.unwrap_or_else(|| crate::Timestamp::from(self.runtime.now())); + let timestamp = object_time(self.timescale, *timestamp)?; let frame = group.create_frame_owned(frame::Info { size, timestamp }, &self.budget)?; self.phase = IngestPhase::Payload { frame }; } @@ -3984,7 +4002,7 @@ impl GroupIngest { let status = ready!(reader.poll_varint(&mut cx))?; if status == 0 { if !self.dropping { - let timestamp = timestamp.unwrap_or_else(|| crate::Timestamp::from(self.runtime.now())); + let timestamp = object_time(self.timescale, *timestamp)?; let frame = group.create_frame_owned(frame::Info { size: 0, timestamp }, &self.budget)?; frame.finish()?; } @@ -4611,7 +4629,7 @@ mod tests { .unwrap(); let received = consumer.recv_datagram().now_or_never().unwrap().unwrap().unwrap(); assert_eq!(received.sequence, 9, "the Group ID is the sequence"); - assert_eq!(received.timestamp, timestamp); + assert_eq!(received.timestamp, Some(timestamp)); assert_eq!(&received.payload[..], b"yes"); assert!( consumer.recv_datagram().now_or_never().is_none(), @@ -6984,15 +7002,12 @@ mod stitch_tests { /// A publisher's fill fetch stream: a FETCH_HEADER, then one object per payload /// numbered from the group's first. fn fill_stream(sequence: u64, payloads: &[&[u8]]) -> Vec { - fill_stream_for(REQUEST, &[(sequence, payloads)], true) + fill_stream_for(REQUEST, &[(sequence, payloads)]) } - /// A joining FETCH stream named by its own request id, possibly spanning groups. - /// - /// `timed` writes Timestamp properties. Multi-group tests leave them off so frames - /// stamped on arrival share one epoch with the live tail; a 1000µs presentation time - /// against a wall-clock tail would convict every earlier group as stale. - fn fill_stream_for>(request_id: RequestId, groups: &[(u64, &[B])], timed: bool) -> Vec { + /// A joining FETCH stream named by its own request id, possibly spanning groups. The + /// track is timed, so every object carries a Timestamp. + fn fill_stream_for>(request_id: RequestId, groups: &[(u64, &[B])]) -> Vec { let mut buf = Vec::new(); crate::coding::Encoder::new(&mut buf, VERSION.into()) .varint(ietf::FetchHeader::TYPE) @@ -7006,12 +7021,10 @@ mod stitch_tests { for &(sequence, payloads) in groups { for (index, payload) in payloads.iter().enumerate() { let payload = payload.as_ref(); - let properties = timed.then(|| { - let mut properties = Vec::new(); - let w = &mut Encoder::new(&mut properties, VERSION.into()); - ietf::encode_object_time(w, timestamp(object_index), Timescale::MICRO, VERSION).unwrap(); - properties - }); + let mut properties = Vec::new(); + let w = &mut Encoder::new(&mut properties, VERSION.into()); + ietf::encode_object_time(w, timestamp(object_index), Timescale::MICRO, VERSION).unwrap(); + let properties = Some(properties); // The first object of the stream carries the absolute Group ID. From // draft-18 on, the first object of a later group carries the ascending @@ -7045,7 +7058,8 @@ mod stitch_tests { } /// The subscription's own subgroup stream, starting at `start` because a strict - /// publisher delivers nothing before it: that head is the fill's job. + /// publisher delivers nothing before it: that head is the fill's job. Each object + /// carries the Timestamp of its Object ID. fn tail_stream(sequence: u64, start: u64, payloads: &[&[u8]]) -> Vec { let mut buf = Vec::new(); ietf::GroupHeader { @@ -7055,6 +7069,7 @@ mod stitch_tests { publisher_priority: 0, flags: ietf::GroupFlags { first_object: start == 0, + has_extensions: true, ..Default::default() }, } @@ -7071,6 +7086,18 @@ mod stitch_tests { crate::coding::Encoder::new(&mut buf, VERSION.into()) .varint(delta) .unwrap(); + let mut ext = Vec::new(); + let object = usize::try_from(start).unwrap() + index; + ietf::encode_object_time( + &mut Encoder::new(&mut ext, VERSION.into()), + timestamp(object), + Timescale::MICRO, + VERSION, + ) + .unwrap(); + let w = &mut crate::coding::Encoder::new(&mut buf, VERSION.into()); + w.varint(ext.len() as u64).unwrap(); + buf.extend_from_slice(&ext); crate::coding::Encoder::new(&mut buf, VERSION.into()) .varint(payload.len() as u64) .unwrap(); @@ -7092,6 +7119,12 @@ mod stitch_tests { impl Harness { fn new(fill: Fill, scripts: Vec>) -> Self { + Self::with_timescale(fill, scripts, Some(Timescale::MICRO)) + } + + /// A subscription whose SUBSCRIBE_OK declared `timescale`, or none for an untimed + /// track. + fn with_timescale(fill: Fill, scripts: Vec>, timescale: Option) -> Self { let session = ScriptedSession::per_stream_eof(scripts); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let tasks = TaskSet::new(); @@ -7110,11 +7143,11 @@ mod stitch_tests { Default::default(), ); - // The subscriber accepts every track at microseconds, matching `run_subscribe`. + // The subscriber accepts a timed track at microseconds, matching `run_subscribe`. let track = track::Producer::new( std::sync::Arc::new(crate::broadcast::Info::default()), "video", - track::Info::default().with_timescale(Timescale::MICRO), + track::Info::default().with_timescale(timescale.map(|_| Timescale::MICRO)), ); let fill = kio::Producer::new(fill); @@ -7124,7 +7157,7 @@ mod stitch_tests { REQUEST, TrackState { alias: Some(ALIAS), - timescale: Some(Timescale::MICRO), + timescale, ..TrackState::new(track.clone(), Path::new("broadcast").to_owned(), fill.clone(), None) }, ); @@ -7173,7 +7206,7 @@ mod stitch_tests { } /// Every frame of the next group, once it finishes. - async fn read_group(subscriber: &mut track::Subscriber) -> (u64, Vec<(Timestamp, Vec)>) { + async fn read_group(subscriber: &mut track::Subscriber) -> (u64, Vec<(Option, Vec)>) { let mut group = subscriber .recv_group() .await @@ -7220,10 +7253,9 @@ mod stitch_tests { assert_eq!( frames, vec![ - (timestamp(0), b"head-0".to_vec()), - (timestamp(1), b"head-1".to_vec()), - // The tail carries no timestamps of its own, so it is stamped on arrival. - (frames[2].0, b"tail-2".to_vec()), + (Some(timestamp(0)), b"head-0".to_vec()), + (Some(timestamp(1)), b"head-1".to_vec()), + (Some(timestamp(2)), b"tail-2".to_vec()), ] ); assert!(matches!(*h.fill.read(), Fill::Done), "the head was claimed"); @@ -7331,8 +7363,9 @@ mod stitch_tests { script.resize(script.len() + size, 0); script.extend_from_slice(&[1, 42]); } - // Over-limit lengths deliberately carry no extension bytes. - let h = Harness::new(Fill::Done, vec![script]); + // Over-limit lengths deliberately carry no extension bytes. The objects carry + // no Timestamp, so the track is untimed. + let h = Harness::with_timescale(Fill::Done, vec![script], None); let mut consumer = h.track.subscribe(None); let mut stream = h.stream().await; let result = h.subscriber.clone().recv_group(&mut stream).await; @@ -7361,7 +7394,8 @@ mod stitch_tests { /// Append an END_OF_TRACK object: delta 0, an empty payload, then its status. fn end_of_track(mut stream: Vec) -> Vec { - for value in [0u64, 0, END_OF_TRACK] { + // The id delta, an empty extension block, a zero size, and the status. + for value in [0u64, 0, 0, END_OF_TRACK] { crate::coding::Encoder::new(&mut stream, VERSION.into()) .varint(value) .unwrap(); @@ -7369,6 +7403,35 @@ mod stitch_tests { stream } + /// A subgroup object without a Timestamp on a timed track makes the whole track + /// malformed, so the track ends rather than only its group. + #[moq_net_sim::test] + async fn an_unstamped_subgroup_object_ends_the_track() { + use futures::FutureExt; + + let mut script = Vec::new(); + ietf::GroupHeader { + track_alias: ALIAS, + group_id: SEQUENCE, + sub_group_id: 0, + publisher_priority: 0, + flags: ietf::GroupFlags { + first_object: true, + ..Default::default() + }, + } + .encode(&mut crate::coding::Encoder::new(&mut script, VERSION.into()), VERSION) + .unwrap(); + // The id delta, the size, and the payload, with no extension block. + script.extend_from_slice(&[0, 1, 42]); + + let h = Harness::new(Fill::Done, vec![script]); + let mut stream = h.stream().await; + let res = h.subscriber.clone().recv_group(&mut stream).await; + assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); + assert!(matches!(h.track.closed().now_or_never(), Some(Error::MalformedTrack))); + } + /// END_OF_TRACK after a group's last object ends the track right after that group. #[moq_net_sim::test] async fn an_end_of_track_after_a_group_ends_the_track_after_it() { @@ -7756,7 +7819,7 @@ mod stitch_tests { let h = Harness::new( Fill::Serving(Some(Timescale::MICRO)), vec![ - fill_stream_for(FETCH, &[(SEQUENCE, &[b"head-0", b"head-1"])], true), + fill_stream_for(FETCH, &[(SEQUENCE, &[b"head-0", b"head-1"])]), tail_stream(SEQUENCE, 2, &[b"tail-2"]), ], ) @@ -7788,7 +7851,7 @@ mod stitch_tests { Fill::Serving(Some(Timescale::MICRO)), vec![ tail_stream(SEQUENCE, 0, &[b"whole-0"]), - fill_stream_for(FETCH, &[(SEQUENCE, &[b"head-0", b"head-1"])], true), + fill_stream_for(FETCH, &[(SEQUENCE, &[b"head-0", b"head-1"])]), ], ) .with_joining(JoiningFetch::Relative { group_offset: 0 }, FETCH, LIVE); @@ -7850,10 +7913,7 @@ mod stitch_tests { ]; let h = Harness::new( Fill::Serving(Some(Timescale::MICRO)), - vec![ - fill_stream_for(FETCH, groups, false), - tail_stream(LIVE_GROUP, 2, &[b"g10-2"]), - ], + vec![fill_stream_for(FETCH, groups), tail_stream(LIVE_GROUP, 2, &[b"g10-2"])], ) .with_joining(JoiningFetch::Absolute { group_id: START }, FETCH, largest); // Keep every fetched group: the default max-age of zero would drop each one as @@ -7895,7 +7955,7 @@ mod stitch_tests { let h = Harness::new( Fill::Serving(Some(Timescale::MICRO)), vec![ - fill_stream_for(FETCH, &[(START, &[b"g7-0", b"g7-1"])], true), + fill_stream_for(FETCH, &[(START, &[b"g7-0", b"g7-1"])]), tail_stream(10, 2, &[b"g10-2"]), ], ) @@ -8001,6 +8061,27 @@ mod stitch_tests { } } + /// On a timed track every Normal object carries a Timestamp, so one without is + /// malformed rather than stamped with its arrival time. + #[moq_net_sim::test] + async fn an_unstamped_object_on_a_timed_track_is_malformed() { + let mut run = GroupFetchRun::new(VERSION, group_fetch_objects(SEQUENCE, 0, &[b"a"])).await; + let track = track::Producer::new( + std::sync::Arc::new(crate::broadcast::Info::default()), + "timed", + track::Info::default().with_timescale(Timescale::MICRO), + ); + let group = track.create_group(group::Info { sequence: SEQUENCE }).unwrap(); + let slot = kio::Producer::new(GroupFetch::Ready { + producer: group, + timescale: Some(Timescale::MICRO), + start: 0, + end: None, + }); + let res = run.subscriber.recv_group_fetch(&mut run.stream, slot).await; + assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); + } + /// FETCH_OK's End Location inside the group promises every object before it. A stream /// that FINs short of it, or runs past it, fails the group instead of caching it. #[moq_net_sim::test] @@ -8088,7 +8169,7 @@ mod stitch_tests { let track = track::Producer::new( std::sync::Arc::new(crate::broadcast::Info::default()), "video", - track::Info::default(), + track::Info::default().with_timescale(None), ); Self { @@ -8494,7 +8575,7 @@ mod joining_fetch_tests { let track = track::Producer::new( std::sync::Arc::new(crate::broadcast::Info::default()), "video", - track::Info::default(), + track::Info::default().with_timescale(None), ); let dynamic = track.dynamic(); let consumer = track.consume(); @@ -8723,7 +8804,7 @@ mod joining_fetch_tests { let track = track::Producer::new( std::sync::Arc::new(crate::broadcast::Info::default()), "video", - track::Info::default(), + track::Info::default().with_timescale(None), ); let dynamic = track.dynamic(); let consumer = track.consume(); @@ -8872,7 +8953,7 @@ mod joining_fetch_tests { let track = track::Producer::new( std::sync::Arc::new(crate::broadcast::Info::default()), "video", - track::Info::default(), + track::Info::default().with_timescale(None), ); let dynamic = track.dynamic(); let consumer = track.consume(); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index fcf39467f4..4ed204a730 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -64,6 +64,9 @@ struct Shared { goaway: crate::goaway::Protocol, // Control streams still serving the peer data, which a draining close waits for. owed: AtomicUsize, + // The send time an untimed track's frames carry, since no lite version can mark them + // untimed yet (see `wire_timestamp`). + runtime: crate::time::Clock, } /// Largest millisecond duration every implementation can carry losslessly. @@ -167,6 +170,7 @@ impl Publisher { version: config.version, goaway: config.goaway, owed: AtomicUsize::new(0), + runtime: config.runtime.clone(), }), runtime: config.runtime, accept, @@ -1201,13 +1205,13 @@ impl Request for TrackInfoServe { ) -> Poll, Error>> { let info = ready!(self.querying.poll_ok(waiter))?; - // TRACK_INFO only flows on Lite05+ (the encode errors otherwise), where every - // track is timed, so the model's timescale and retention bound go on the wire - // verbatim. + // TRACK_INFO only flows on Lite05+ (the encode errors otherwise), where the + // timescale is mandatory. An untimed track declares the default, the scale its + // frames' send times go out at (see `wire_timestamp`). writer.buffer(&lite::TrackInfo { priority: info.priority, max_age: info.max_age, - timescale: info.timescale, + timescale: info.timescale.unwrap_or_default(), })?; Poll::Ready(Ok(ControlFlow::Break(()))) } @@ -1305,7 +1309,7 @@ impl Request for SubscribeServe { // "don't emit the prefix" (the frames still carry timestamps in the // model, just not on this wire). let timescale = if shared.version.has_track_stream() { - Some(track.info().timescale) + Some(track.info().timescale.unwrap_or_default()) } else { None }; @@ -1339,6 +1343,7 @@ impl Request for SubscribeServe { track_priority_seen: msg.priority, version: shared.version, timescale, + runtime: shared.runtime.clone(), opens: Default::default(), }; @@ -1453,7 +1458,7 @@ impl Request for FetchServe { // FETCH is gated to lite-05+, which learned the track timescale via // TRACK_INFO. let timescale = if shared.version.has_track_stream() { - Some(group.timescale()) + Some(group.timescale().unwrap_or_default()) } else { None }; @@ -1498,6 +1503,7 @@ impl Request for FetchServe { batched.payload.len() as u64, *timescale, prev_ts, + &shared.runtime, )?; let payload = std::mem::take(&mut batched.payload); if !payload.is_empty() { @@ -1517,7 +1523,14 @@ impl Request for FetchServe { } match ready!(group.poll_next_frame(waiter))? { Some(next) => { - buffer_frame_info(writer, next.timestamp, next.size, *timescale, prev_ts)?; + buffer_frame_info( + writer, + next.timestamp, + next.size, + *timescale, + prev_ts, + &shared.runtime, + )?; *frame = Some(next); return Poll::Ready(Ok(ControlFlow::Continue(()))); } @@ -2032,23 +2045,43 @@ mod announce_test { /// (catalogs, control channels, IETF transport). /// /// `prev_ts` carries the running baseline, so the first frame deltas against 0. The -/// model layer (`group::Producer::create_frame`) already converted the timestamp -/// into the track timescale, so its raw value goes straight onto the wire. Mirrors -/// the decode in the subscriber's `run_group`. +/// model layer (`group::Producer::create_frame`) already converted a timed frame into +/// the track timescale, so its raw value goes straight onto the wire. Mirrors the +/// decode in the subscriber's `run_group`. fn buffer_frame_info( writer: &mut Writer, - timestamp: crate::Timestamp, + timestamp: Option, size: u64, timescale: Option, prev_ts: &mut u64, + runtime: &crate::time::Clock, ) -> Result<(), Error> { - if timescale.is_some() { - buffer_zigzag_delta(writer, timestamp.value(), prev_ts)?; + if let Some(timescale) = timescale { + buffer_zigzag_delta(writer, wire_timestamp(timestamp, timescale, runtime)?, prev_ts)?; } writer.buffer_varint(size)?; Ok(()) } +/// A frame or datagram timestamp as its raw value at the wire `timescale`. +/// +/// No lite version encodes an absent timestamp yet, so a payload on an untimed track +/// carries its send time on `runtime` instead, at the default scale its TRACK_INFO +/// declares. A timed payload is already at the track's timescale. +fn wire_timestamp( + timestamp: Option, + timescale: crate::Timescale, + runtime: &crate::time::Clock, +) -> Result { + match timestamp { + Some(timestamp) => Ok(timestamp.value()), + None => crate::Timestamp::from(runtime.now()) + .convert(timescale) + .map(|now| now.value()) + .map_err(|_| Error::BoundsExceeded(crate::coding::BoundsExceeded)), + } +} + /// Buffer `curr` as a zigzag-mapped varint delta against `*prev`, then advance /// `*prev` to `curr`. fn buffer_zigzag_delta( @@ -2248,6 +2281,8 @@ struct Subscription { /// Negotiated timestamp scale for this track. `Some(_)` on lite-05+ after /// TRACK_INFO; used to validate per-frame timestamps before encoding. timescale: Option, + /// The clock an untimed track's send times are read from. + runtime: crate::time::Clock, /// The group streams this subscription opened, shared by every group it serves. opens: Arc, } @@ -2268,11 +2303,15 @@ impl Subscription { /// The datagram is dropped (there is no group fallback) if the encoded body doesn't fit the /// transport's datagram limit or the send fails (congestion / no capacity right now). fn serve_datagram(&mut self, datagram: crate::Datagram) { + // Datagrams are lite-05+, which always declares a timescale in TRACK_INFO. + let Ok(timestamp) = wire_timestamp(datagram.timestamp, self.timescale.unwrap_or_default(), &self.runtime) + else { + return; + }; let body = lite::Datagram { subscribe: self.id, sequence: datagram.sequence, - // Already at the track timescale (normalized by the model producer). - timestamp: datagram.timestamp.value(), + timestamp, payload: datagram.payload, }; // has_datagrams is checked before this runs, so encoding never hits the version guard. @@ -2794,6 +2833,7 @@ impl GroupServe { batched.payload.len() as u64, self.ctx.timescale, &mut self.prev_ts, + &self.ctx.runtime, ); if let Err(err) = buffered { break 'serve Err(err); @@ -2823,6 +2863,7 @@ impl GroupServe { next.size, self.ctx.timescale, &mut self.prev_ts, + &self.ctx.runtime, ); if let Err(err) = buffered { break 'serve Err(err); @@ -3032,6 +3073,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: Default::default(), }; @@ -3071,6 +3113,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: Default::default(), }; @@ -3114,6 +3157,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: Default::default(), }; @@ -3122,7 +3166,7 @@ mod serve_group_test { let mut old = track.append_group().unwrap(); let mut frame = old .create_frame(frame::Info { - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), size: 2, }) .unwrap(); @@ -3175,6 +3219,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: Default::default(), }; @@ -3245,6 +3290,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: Default::default(), }; @@ -3287,6 +3333,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: Default::default(), }; @@ -3328,6 +3375,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: Default::default(), }; @@ -3372,6 +3420,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite07, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: Default::default(), }; let bounds = Bounds { @@ -3441,6 +3490,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite07, timescale: Some(crate::Timescale::default()), + runtime: crate::time::Clock::sim(), opens: opens.clone(), }; let bounds = Bounds { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 97241c143b..4b236a200a 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -930,9 +930,8 @@ impl GroupRecv { /// Pumps bare FRAME messages from a reader into a group producer: the wire /// format shared by GROUP streams and FETCH responses. struct FrameIngest { - runtime: crate::time::Clock, - /// `Some` decodes the lite-05 zigzag-delta timestamp prefix; `None` stamps - /// local receive time (pre-lite-05). + /// `Some` decodes the lite-05 zigzag-delta timestamp prefix; `None` leaves frames + /// untimed (pre-lite-05). timescale: Option, /// Previous frame's raw timestamp value (in `timescale` units), for the /// zigzag-delta decode. The first frame's delta is absolute (prev = 0). @@ -958,7 +957,6 @@ impl FrameIngest { timescale, prev_ts: 0, phase: IngestPhase::Timing, - runtime: subscriber.runtime.clone(), budget: subscriber.frames.clone(), } } @@ -1000,10 +998,15 @@ impl FrameIngest { }; // `create_frame_owned` is the allocation chokepoint: it rejects an // oversized `size` and allocates up front only within the budget, so - // no pre-check is needed. No wire timestamp (pre-lite-05) means local - // receive time. - let timestamp = timestamp.unwrap_or_else(|| Timestamp::from(self.runtime.now())); - let frame = group.create_frame_owned(frame::Info { size, timestamp }, &self.budget)?; + // no pre-check is needed. No wire timestamp (pre-lite-05) means an + // untimed frame. + let frame = group.create_frame_owned( + frame::Info { + size, + timestamp: *timestamp, + }, + &self.budget, + )?; self.phase = IngestPhase::Payload { frame }; } IngestPhase::Payload { frame } => { @@ -3874,8 +3877,9 @@ impl TrackServeRun { info: TrackInfoFetch::new(&serve), } } else { - // Older wires declare no publisher retention limit. - let info = track::Info::default(); + // Older wires declare no publisher retention limit, and no timeline: their + // frames arrive untimed. + let info = track::Info::default().with_timescale(None); TrackRunState::Serve(ServeLoop::new(&serve, request, info, None)) }; Self { serve, state } @@ -3908,9 +3912,8 @@ impl kio::Task for TrackServeRun { match res { Ok(info) => { // Lite05 carries per-frame timestamps on the wire at this scale; - // `Some` tells the ingest to decode them instead of stamping - // local receive time. - let timescale = Some(info.timescale); + // `Some` tells the ingest to decode them. + let timescale = info.timescale; self.state = TrackRunState::Serve(ServeLoop::new(&self.serve, request, info, timescale)); } Err(err) => { diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs index 0b6baa2b7a..c34edfef26 100644 --- a/rs/moq-net/src/lite/track.rs +++ b/rs/moq-net/src/lite/track.rs @@ -212,7 +212,7 @@ mod test { let info = TrackInfo { priority: info.priority, max_age: info.max_age, - timescale: info.timescale, + timescale: info.timescale.unwrap(), }; let mut buf = Vec::new(); info.encode(&mut Encoder::new(&mut buf, Version::Lite05.into()), Version::Lite05) diff --git a/rs/moq-net/src/model/datagram.rs b/rs/moq-net/src/model/datagram.rs index a8c20b0979..7f92e0db3d 100644 --- a/rs/moq-net/src/model/datagram.rs +++ b/rs/moq-net/src/model/datagram.rs @@ -25,7 +25,7 @@ use crate::Timestamp; /// of 1200 bytes (e.g. a single audio frame). pub(crate) const MAX_DATAGRAM_PAYLOAD: usize = u16::MAX as usize; -/// A single unreliable payload on a track: a sequence number, a presentation timestamp, and the bytes. +/// A single unreliable payload on a track: a sequence number, an optional presentation timestamp, and the bytes. /// /// The sequence number is drawn from the same namespace as the track's groups, so a relay can forward /// a datagram while preserving the origin's numbering (see [`super::track::Producer::insert_datagram`]). @@ -33,8 +33,8 @@ pub(crate) const MAX_DATAGRAM_PAYLOAD: usize = u16::MAX as usize; pub struct Datagram { /// Per-track sequence number, shared with the group namespace. pub sequence: u64, - /// Presentation timestamp in the track's timescale. - pub timestamp: Timestamp, + /// Presentation timestamp in the track's timescale, or `None` when untimed. + pub timestamp: Option, /// The datagram payload. pub payload: Bytes, } diff --git a/rs/moq-net/src/model/frame.rs b/rs/moq-net/src/model/frame.rs index ca8f1e8702..db1775e6cb 100644 --- a/rs/moq-net/src/model/frame.rs +++ b/rs/moq-net/src/model/frame.rs @@ -1,4 +1,4 @@ -//! Frames are the leaf of the model: a sized, timestamped payload within a group. +//! Frames are the leaf of the model: a sized, optionally timestamped payload within a group. //! //! A group is a single ordered stream, so at most one frame is ever in flight. //! Completed frames are plain data ([`Frame`]); the in-flight frame is written @@ -17,7 +17,7 @@ use bytes::Bytes; use crate::group::{self, GroupState}; use crate::{Error, IntoBytes, Result, Timestamp, stats}; -/// A chunk of data with an upfront size and a presentation timestamp. +/// A chunk of data with an upfront size and an optional presentation timestamp. /// /// This is just the header; the payload is carried separately (as a completed /// [`Frame`] or streamed via [`Producer`] / [`Consumer`]). @@ -25,22 +25,23 @@ use crate::{Error, IntoBytes, Result, Timestamp, stats}; pub struct Info { /// Total payload size in bytes. Declared up front so consumers can preallocate. pub size: u64, - /// Presentation timestamp. + /// Presentation timestamp, or `None` on an untimed track. It must match the track + /// ([`Error::TimestampMismatch`] otherwise). /// /// [`group::Producer::create_frame`] converts it into the parent track's /// timescale, so the scale you build it with doesn't have to match the track. - /// For data without a presentation time, pass [`Timestamp::now`] explicitly. - pub timestamp: Timestamp, + /// An untimed frame reaches every subscriber untimed: no hop fills one in. + pub timestamp: Option, } -/// A completed frame: a timestamp and its full, contiguous payload. +/// A completed frame: an optional timestamp and its full, contiguous payload. /// /// This is the stored form of every finished frame in a group. The payload is a /// single [`Bytes`], so a consumer gets it with one zero-copy slice. #[derive(Clone, Debug)] pub struct Frame { - /// Presentation timestamp, at the parent track's timescale. - pub timestamp: Timestamp, + /// Presentation timestamp, at the parent track's timescale, or `None` when untimed. + pub timestamp: Option, /// The full frame payload. pub payload: Bytes, } diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index e6c5a15ed2..983aceefcc 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -70,7 +70,7 @@ pub struct Info { } impl Info { - /// Create an untimed producer for this group. + /// Create a producer for this group on a default (millisecond) track. /// /// Test-only: real groups are created via [`track::Producer`], which /// supplies the parent track's [`track::Info`]. This helper exists for in-crate @@ -79,6 +79,12 @@ impl Info { pub(crate) fn produce(self) -> Producer { Producer::new(self, track::Info::default(), Default::default()) } + + /// Create a producer for this group on an untimed track. Test-only, like [`Self::produce`]. + #[cfg(test)] + pub(crate) fn produce_untimed(self) -> Producer { + Producer::new(self, track::Info::default().with_timescale(None), Default::default()) + } } impl From for Info { @@ -114,7 +120,7 @@ impl From for Info { /// The in-flight (tail) frame being written. At most one exists at a time, since a /// group is a single ordered stream. pub(crate) struct Partial { - timestamp: Timestamp, + timestamp: Option, buf: FrameBuf, // How much of `buf` has been charged to the cache so far. charged: u64, @@ -154,17 +160,9 @@ pub(crate) struct GroupState { // against the byte budget tracks evict toward. charge: cache::Charge, - // The first frame's timestamp, recorded once and never revised: the group's - // presentation start. Kept here rather than read off `frames` so an abort - // doesn't erase where the group sat in time. `None` until the first frame is - // written, which is the only honest answer: an empty group has not presented - // anything yet. - timestamp: Option, - - // The newest frame's timestamp: the group's presentation end so far. A reader - // that has taken every frame sits here, which is what a drift budget measures it - // against. Kept alongside `timestamp` for the same reasons. - latest: Option, + // Where the group sits in presentation time, settled by its first frame. Kept here + // rather than read off `frames` so an abort doesn't erase it. + timeline: Timeline, // Once finalized, the total number of frames the group will ever contain. Recorded // at finish so the count outlives an abort that clears the cache. @@ -176,6 +174,23 @@ pub(crate) struct GroupState { pub(crate) abort: Option, } +/// Where a group sits in presentation time. +/// +/// Its first frame settles which: an empty group has not presented anything yet, which +/// is a different answer from a group on an untimed track, whose frames carry no time. +#[derive(Clone, Copy, Debug, Default)] +enum Timeline { + /// No frame opened yet. + #[default] + Empty, + /// The track is untimed, so the group has no place in media time. + Untimed, + /// `start` is the first frame's timestamp, never revised. `latest` is the newest + /// frame's: the group's presentation end so far, which a reader that has taken + /// every frame sits at and a drift budget measures it against. + Timed { start: Timestamp, latest: Timestamp }, +} + impl GroupState { /// Content still available to a reader of this group. /// @@ -279,11 +294,15 @@ impl GroupState { self.poll_terminal(index) } - /// Record where the group starts and currently ends in presentation time. - /// `timestamp` keeps the first frame only; `latest` follows every frame. - fn stamp(&mut self, timestamp: Timestamp) { - self.timestamp.get_or_insert(timestamp); - self.latest = Some(timestamp); + /// Record a frame's place in presentation time: the first frame starts the group, + /// and each later one extends a timed group's end. Every frame matches its track + /// ([`on_track`]), so a group never mixes the two. + fn stamp(&mut self, timestamp: Option) { + self.timeline = match (self.timeline, timestamp) { + (Timeline::Timed { start, .. }, Some(latest)) => Timeline::Timed { start, latest }, + (_, Some(start)) => Timeline::Timed { start, latest: start }, + (_, None) => Timeline::Untimed, + }; } /// Whether adding `extra_frames` totaling `extra_bytes` would exceed the group budget. @@ -316,6 +335,21 @@ impl GroupState { } } +/// `timestamp` at the track's `timescale`. +/// +/// A track is all timed or all untimed, so a frame must match it: a timed frame on an +/// untimed track, or an untimed one on a timed track, is [`Error::TimestampMismatch`], +/// as is a timestamp the track's scale can't hold. +pub(crate) fn on_track(timestamp: Option, timescale: Option) -> Result> { + match (timestamp, timescale) { + (Some(timestamp), Some(timescale)) => Ok(Some( + timestamp.convert(timescale).map_err(|_| Error::TimestampMismatch)?, + )), + (None, None) => Ok(None), + _ => Err(Error::TimestampMismatch), + } +} + fn modify(state: &kio::Producer) -> Result> { state.write().map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) } @@ -461,8 +495,8 @@ impl Producer { self.info } - /// The parent track's timescale. - pub fn timescale(&self) -> Timescale { + /// The parent track's timescale, or `None` when it declared no timeline. + pub fn timescale(&self) -> Option { self.track.timescale } @@ -502,12 +536,10 @@ impl Producer { /// If you want to write multiple chunks, use [Self::create_frame] to get a frame producer. /// But an upfront size is required. /// - /// `timestamp` is converted into the parent track's timescale. For data without - /// a presentation time, pass [`Timestamp::now`] explicitly. - pub fn write_frame(&mut self, timestamp: Timestamp, data: B) -> Result<()> { - let timestamp = timestamp - .convert(self.track.timescale) - .map_err(|_| Error::TimestampMismatch)?; + /// `timestamp` is converted into the parent track's timescale. Pass `None` on an + /// untimed track; a frame that doesn't match its track is [`Error::TimestampMismatch`]. + pub fn write_frame(&mut self, timestamp: impl Into>, data: B) -> Result<()> { + let timestamp = on_track(timestamp.into(), self.track.timescale)?; let payload = data.into_bytes(); if payload.len() as u64 > MAX_CACHE_BYTES { return Err(Error::FrameTooLarge); @@ -559,10 +591,7 @@ impl Producer { /// group, since appending around it would reorder the group. pub fn write_frames(&mut self, frames: &mut frame::Buffer) -> Result<()> { for frame in frames.filled() { - frame - .timestamp - .convert(self.track.timescale) - .map_err(|_| Error::TimestampMismatch)?; + on_track(frame.timestamp, self.track.timescale)?; if frame.payload.len() as u64 > MAX_CACHE_BYTES { return Err(Error::FrameTooLarge); } @@ -588,10 +617,7 @@ impl Producer { // The last frame's tick, reused below so settling does not re-read the clock. let mut now = None; for mut frame in frames.drain() { - frame.timestamp = frame - .timestamp - .convert(self.track.timescale) - .expect("timestamp scale checked above"); + frame.timestamp = on_track(frame.timestamp, self.track.timescale).expect("timestamp scale checked above"); let size = frame.payload.len() as u64; state.cache += size; now = state.charge.add(size); @@ -652,10 +678,7 @@ impl Producer { /// Its bytes are charged to the cache as they are written, not here: the declared /// size is only a promise until they arrive. fn open_frame(&mut self, frame: frame::Info, buf: &FrameBuf) -> Result { - let timestamp = frame - .timestamp - .convert(self.track.timescale) - .map_err(|_| Error::TimestampMismatch)?; + let timestamp = on_track(frame.timestamp, self.track.timescale)?; if frame.size > MAX_CACHE_BYTES { return Err(Error::FrameTooLarge); } @@ -859,21 +882,22 @@ impl Producer { (state.committed > state.offset).then_some(state.committed) } - /// Where the group starts in presentation time: its first frame's timestamp, - /// or `None` while no frame has been opened. + /// Where the group starts in presentation time: its first frame's timestamp, or + /// `None` while no frame has been opened or when that frame is untimed. /// /// Stamped once, when the group's first frame arrives, so it measures the group's /// place in the media timeline rather than when it happened to be delivered. That /// is what lets the track tell a burst of old content apart from live content (see - /// [`track::Subscriber`]). On protocols whose wire can't carry a timestamp the - /// receiver stamps frames with [`Timestamp::now`], which makes this the local - /// receive time instead: an estimate that a burst compresses. + /// [`track::Subscriber`]). pub(crate) fn timestamp(&self) -> Option { - self.state.read().timestamp + match self.state.read().timeline { + Timeline::Timed { start, .. } => Some(start), + Timeline::Empty | Timeline::Untimed => None, + } } - /// Where the group ends in presentation time: its newest frame's timestamp, or - /// `None` while no frame has been opened. + /// Where the group ends in presentation time: its newest timed frame's timestamp, or + /// `None` while no frame has been opened or when the group is untimed. /// /// This is what a drift budget measures an untouched group against. A group is not /// late because it *started* long ago: a two-second group whose tail is level with @@ -881,7 +905,10 @@ impl Producer { /// fallen behind is there nothing left worth delivering. Grows as the group does, so /// a group still receiving frames stays fresh and a stalled one ages in place. pub(crate) fn latest(&self) -> Option { - self.state.read().latest + match self.state.read().timeline { + Timeline::Timed { latest, .. } => Some(latest), + Timeline::Empty | Timeline::Untimed => None, + } } /// The group's full cached footprint (payload plus fixed overhead), used by the @@ -954,10 +981,11 @@ impl Producer { } } - /// Register for the first-frame timestamp while the group is still unstamped. - pub(crate) fn poll_timestamp(&self, waiter: &kio::Waiter) -> Poll<()> { + /// Register for the group's first frame, which settles its [`Self::timestamp`], while + /// none has been opened. + pub(crate) fn poll_started(&self, waiter: &kio::Waiter) -> Poll<()> { match self.state.poll(waiter, |state| { - if state.timestamp.is_some() || state.fin.is_some() || state.abort.is_some() { + if !matches!(state.timeline, Timeline::Empty) || state.fin.is_some() || state.abort.is_some() { Poll::Ready(()) } else { Poll::Pending @@ -1435,8 +1463,8 @@ impl Consumer { self.cursor.state.poll_closed(waiter) } - /// The parent track's timescale. - pub fn timescale(&self) -> Timescale { + /// The parent track's timescale, or `None` when it declared no timeline. + pub fn timescale(&self) -> Option { self.track.timescale } @@ -1924,7 +1952,7 @@ mod test { fn one_frame_fits_the_charged_slots() { let mut frames: VecDeque = VecDeque::new(); frames.push_back(Frame { - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), payload: Bytes::new(), }); let capacity = frames.capacity(); @@ -1976,10 +2004,54 @@ mod test { let mut consumer = producer.consume(); let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap(); - assert_eq!(frame.timestamp.as_micros(), 20_000); + assert_eq!(frame.timestamp.unwrap().as_micros(), 20_000); + assert_eq!(frame.payload, Bytes::from_static(b"hello")); + } + + /// An untimed frame reads back untimed, and still counts as the group's first frame: + /// an empty group and a group on an untimed track are different answers. + #[test] + fn an_untimed_frame_still_starts_the_group() { + let mut producer = Info { sequence: 0 }.produce_untimed(); + let waiter = kio::Waiter::noop(); + assert!(producer.poll_started(&waiter).is_pending(), "nothing presented yet"); + + producer.write_frame(None, Bytes::from_static(b"hello")).unwrap(); + producer.finish().unwrap(); + + assert!(producer.poll_started(&waiter).is_ready()); + assert_eq!(producer.timestamp(), None); + assert_eq!(producer.latest(), None); + + let mut consumer = producer.consume(); + let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap(); + assert_eq!(frame.timestamp, None); assert_eq!(frame.payload, Bytes::from_static(b"hello")); } + /// A track is all timed or all untimed, so a frame that doesn't match it is refused. + #[test] + fn a_mismatched_frame_is_refused() { + let mut untimed = Info { sequence: 0 }.produce_untimed(); + assert!(matches!( + untimed.write_frame(Timestamp::ZERO, Bytes::from_static(b"x")), + Err(Error::TimestampMismatch) + )); + + let mut timed = Info { sequence: 0 }.produce(); + assert!(matches!( + timed.write_frame(None, Bytes::from_static(b"x")), + Err(Error::TimestampMismatch) + )); + assert!(matches!( + timed.create_frame(frame::Info { + size: 1, + timestamp: None + }), + Err(Error::TimestampMismatch) + )); + } + #[test] fn chunked_frame_reads_whole() { let mut producer = Info { sequence: 0 }.produce(); @@ -1987,7 +2059,7 @@ mod test { let mut frame = producer .create_frame(frame::Info { size: 10, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), }) .unwrap(); frame.write(Bytes::from_static(b"hello")).unwrap(); @@ -2011,7 +2083,7 @@ mod test { let mut frame = producer .create_frame(frame::Info { size: 6, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), }) .unwrap(); frame.write(Bytes::from_static(b"foo")).unwrap(); @@ -2440,7 +2512,7 @@ mod test { let mut frame = producer .create_frame(frame::Info { size: 6, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), }) .unwrap(); let mut f = consumer.next_frame().await.unwrap().unwrap(); @@ -2469,11 +2541,11 @@ mod test { ); let frame = frame::Info { size: 3, - timestamp: Timestamp::from_millis(1).unwrap(), // 1ms -> 1000µs + timestamp: Some(Timestamp::from_millis(1).unwrap()), // 1ms -> 1000µs }; let writer = producer.create_frame(frame).unwrap(); - assert_eq!(writer.timestamp.scale(), Timescale::MICRO); - assert_eq!(writer.timestamp.value(), 1000); + assert_eq!(writer.timestamp.unwrap().scale(), Timescale::MICRO); + assert_eq!(writer.timestamp.unwrap().value(), 1000); } /// An explicit current timestamp is converted to the group's scale. @@ -2489,11 +2561,11 @@ mod test { let writer = producer .create_frame(frame::Info { size: 3, - timestamp: Timestamp::now(), + timestamp: Some(Timestamp::now()), }) .unwrap(); - assert_eq!(writer.timestamp.scale(), Timescale::MICRO); - assert!(!writer.timestamp.is_zero(), "local clock should be non-zero"); + assert_eq!(writer.timestamp.unwrap().scale(), Timescale::MICRO); + assert!(!writer.timestamp.unwrap().is_zero(), "local clock should be non-zero"); } /// A group can start partway in, so a route can serve the tail of a group whose @@ -2656,7 +2728,7 @@ mod test { let mut producer = Info { sequence: 0 }.produce(); let result = producer.create_frame(frame::Info { size: MAX_CACHE_BYTES + 1, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), }); assert!(matches!(result, Err(Error::FrameTooLarge))); } @@ -2664,7 +2736,7 @@ mod test { fn sized(size: u64) -> frame::Info { frame::Info { size, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), } } diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index dac2697a2c..3a18f66e9c 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1181,7 +1181,7 @@ mod test { let mut frame = open .create_frame(crate::frame::Info { size: 4, - timestamp: ts(0), + timestamp: Some(ts(0)), }) .unwrap(); frame.write(b"ab".as_ref()).unwrap(); @@ -1373,7 +1373,7 @@ mod test { let mut frame = open .create_frame(crate::frame::Info { size: 4, - timestamp: ts(0), + timestamp: Some(ts(0)), }) .unwrap(); frame.write(b"ab".as_ref()).unwrap(); @@ -1422,7 +1422,7 @@ mod test { let mut frame = open .create_frame(crate::frame::Info { size: 4, - timestamp: ts(0), + timestamp: Some(ts(0)), }) .unwrap(); frame.write(b"a".as_ref()).unwrap(); @@ -1435,7 +1435,7 @@ mod test { let mut replacement_frame = replacement .create_frame(crate::frame::Info { size: 4, - timestamp: ts(0), + timestamp: Some(ts(0)), }) .unwrap(); replacement_frame.write(b"ab".as_ref()).unwrap(); @@ -1752,7 +1752,7 @@ mod test { let mut frame = group .create_frame(crate::frame::Info { size: 4, - timestamp: ts(1), + timestamp: Some(ts(1)), }) .unwrap(); frame.write(b"ab".as_ref()).unwrap(); diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index cec094b20e..66c834a95c 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -50,19 +50,19 @@ pub struct Subscription { /// stalled group is bounded by its stamped successor the same way. Wall-clock /// reclamation of idle content is the cache's own policy, not this budget's. /// - /// Protocols whose wire can't carry a timestamp (pre-Lite05 moq-lite, moq-transport - /// without the Timestamp property) have their frames stamped on receipt, which makes - /// the measure burst-blind on the receiving side: thirty seconds of backlog delivered - /// in three reads as three. The publisher's copy is stamped as it produces, so the - /// gate there still holds; it is just the coarser of the two. + /// An untimed track (pre-Lite05 moq-lite, or moq-transport without TIMESCALE) has no + /// media time, so none of its groups is ever stale, and a new subscriber with no + /// [`Self::start`] begins at its latest group instead of replaying the cache. pub max_age: Duration, /// The lowest [`Position`] the publisher may deliver, or `None` for no floor. /// /// A floor, not a request: only [`Self::max_age`] asks for data, and the floor bounds - /// how far back it may reach. `None` and a floor of group 0 mean the same thing, since - /// nothing sits below group 0. Delivery starts at the oldest group at or above the - /// floor that the budget still considers fresh, so a floor above the live edge simply - /// waits there (a resumed subscription naming where it left off). + /// how far back it may reach. `None` and a floor of group 0 mean the same thing on a + /// timed track, since nothing sits below group 0. On an untimed track, which no budget + /// can measure, `None` starts at the latest group and a floor is honored as given. + /// Delivery starts at the oldest group at or above the floor that the budget still + /// considers fresh, so a floor above the live edge simply waits there (a resumed + /// subscription naming where it left off). /// /// Aggregated across every live subscriber (the loosest floor wins, and any subscriber /// without one clears it), so it says what the publisher sends, not what any one diff --git a/rs/moq-net/src/model/time.rs b/rs/moq-net/src/model/time.rs index 14a79235e9..0c616cd072 100644 --- a/rs/moq-net/src/model/time.rs +++ b/rs/moq-net/src/model/time.rs @@ -107,9 +107,8 @@ impl From for NonZero { } impl Default for Timescale { - /// Milliseconds ([`Self::MILLI`]). Every track has a timescale; this is the one - /// used when a producer doesn't pick one and the fallback for protocols whose wire - /// can't carry a timescale (pre-Lite05 moq-lite, IETF moq-transport). + /// Milliseconds ([`Self::MILLI`]): what a track uses when its producer doesn't pick + /// one, and what a lite encoder declares for an untimed track's send times. fn default() -> Self { Self::MILLI } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 4591d467e0..5685d2fe68 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -87,15 +87,18 @@ pub(crate) const IDLE_LINGER: Duration = Duration::from_secs(30); #[derive(Clone, Debug)] #[non_exhaustive] pub struct Info { - /// Units per second for per-frame timestamps on this track. + /// Units per second for per-frame timestamps on this track, or `None` for an untimed + /// track. /// - /// Every track is timed; this defaults to [`Timescale::MILLI`]. On Lite05+ it is - /// reported in TRACK_INFO and the publisher zigzag-delta encodes per-frame - /// timestamps at this scale on the wire. IETF draft-17 and later carry the same - /// value as the TIMESCALE track property, and object Timestamps use these units. - /// A wire that cannot carry it (pre-Lite05 moq-lite, IETF drafts 14-16) sends no - /// timestamps, and the receiver falls back to local monotonic milliseconds. - pub timescale: Timescale, + /// A track is all timed or all untimed: every frame and datagram on a timed track + /// carries a timestamp, none on an untimed one does, and a write that doesn't match + /// is refused with [`Error::TimestampMismatch`]. Defaults to [`Timescale::MILLI`]. On + /// Lite05+ it is reported in TRACK_INFO and the publisher zigzag-delta encodes + /// per-frame timestamps at this scale on the wire; on moq-transport draft 17 and + /// later it is the TIMESCALE Track Property. A track received without one + /// (pre-Lite05 moq-lite, moq-transport drafts 14-16, or moq-transport without + /// TIMESCALE) is untimed. + pub timescale: Option, /// How far behind the live edge a group may fall, in media timestamps, before it /// is stale. The newest group is always retained. /// @@ -127,7 +130,7 @@ pub struct Info { impl Default for Info { fn default() -> Self { Self { - timescale: Timescale::default(), + timescale: Some(Timescale::default()), max_age: None, priority: DEFAULT_PRIORITY, } @@ -135,12 +138,13 @@ impl Default for Info { } impl Info { - /// Set the per-frame timestamp scale, returning `self` for chaining. + /// Set the per-frame timestamp scale, or `None` for an untimed track, returning `self` + /// for chaining. /// /// Defaults to [`Timescale::MILLI`]. On Lite05+ this scale is reported in TRACK_INFO /// and used to encode per-frame timestamps on the wire. - pub fn with_timescale(mut self, timescale: Timescale) -> Self { - self.timescale = timescale; + pub fn with_timescale(mut self, timescale: impl Into>) -> Self { + self.timescale = timescale.into(); self } @@ -644,6 +648,23 @@ impl TrackState { }) } + /// Where a new reader with no explicit start begins on an untimed track: its newest + /// servable group. + /// + /// A drift budget resolves a timed track's start, but nothing is ever stale on an + /// untimed track, so the budget would replay the whole cache. `None` on a timed track, + /// or while nothing is servable yet. + fn untimed_start(&self) -> Option { + if self.info.as_ref()?.timescale.is_some() { + return None; + } + self.lookup + .iter() + .rev() + .find(|(_, slot)| slot.visible && !slot.group.is_aborted()) + .map(|(sequence, _)| *sequence) + } + /// This track's own edge under the exclusive `cap`, for measuring drift. fn drift_edge(&self, cap: Option) -> Edge { Edge { @@ -697,8 +718,8 @@ impl TrackState { /// A group's reach is bounded by its immediate successor (see [`Self::reach`]): it /// cannot present past where the next group begins. The candidate itself needs no /// timestamp: an empty group is bounded by its stamped successor the same way. Only - /// timestamps drive expiry; wall-clock reclamation of idle content is the cache's - /// own policy, not the budget's. + /// timestamps drive expiry, so nothing on an untimed track is ever stale; wall-clock + /// reclamation of idle content is the cache's own policy, not the budget's. /// /// The bound is exclusive, so the comparison is `>=` rather than `>`: the freshest frame /// a group could still hold sits just *below* its reach, so an age equal to the budget @@ -1516,7 +1537,14 @@ impl Producer { /// before 05, or stream-only transports like WebSocket) never deliver them. Keep payloads well under the 1200-byte minimum path MTU. An origin /// publisher uses this; a relay preserving upstream numbering uses /// [`Self::insert_datagram`]. - pub fn append_datagram(&mut self, timestamp: Timestamp, payload: B) -> Result { + /// + /// Pass `None` on an untimed track; a datagram that doesn't match its track is + /// [`Error::TimestampMismatch`]. + pub fn append_datagram( + &mut self, + timestamp: impl Into>, + payload: B, + ) -> Result { let payload = payload.into_bytes(); if payload.len() > super::datagram::MAX_DATAGRAM_PAYLOAD { return Err(Error::FrameTooLarge); @@ -1526,7 +1554,7 @@ impl Producer { let mut state = self.modify()?; // Normalize into the track's timescale, like frames (see `group::Producer::create_frame`). let timescale = state.info.as_ref().unwrap().timescale; - let timestamp = timestamp.convert(timescale).map_err(|_| Error::TimestampMismatch)?; + let timestamp = group::on_track(timestamp.into(), timescale)?; let sequence = match state.max_sequence { Some(s) => s.checked_add(1).ok_or(coding::BoundsExceeded)?, None => 0, @@ -1557,7 +1585,7 @@ impl Producer { pub fn insert_datagram( &mut self, sequence: u64, - timestamp: Timestamp, + timestamp: impl Into>, payload: B, ) -> Result<()> { let payload = payload.into_bytes(); @@ -1569,7 +1597,7 @@ impl Producer { let mut state = self.modify()?; // Normalize into the track's timescale, like frames (see `group::Producer::create_frame`). let timescale = state.info.as_ref().unwrap().timescale; - let timestamp = timestamp.convert(timescale).map_err(|_| Error::TimestampMismatch)?; + let timestamp = group::on_track(timestamp.into(), timescale)?; if let Some(fin) = state.final_sequence && sequence >= fin { @@ -1590,9 +1618,13 @@ impl Producer { /// Create a group with a single frame, at the given presentation timestamp. /// - /// The timestamp is converted into the track's timescale. For data without - /// a presentation time, pass [`Timestamp::now`] explicitly. - pub fn write_frame(&mut self, timestamp: Timestamp, frame: B) -> Result<()> { + /// The timestamp is converted into the track's timescale. Pass `None` on an untimed + /// track; a frame that doesn't match its track is [`Error::TimestampMismatch`]. + pub fn write_frame( + &mut self, + timestamp: impl Into>, + frame: B, + ) -> Result<()> { let frame = crate::IntoBytes::into_bytes(frame); if frame.len() as u64 > group::MAX_CACHE_BYTES { return Err(Error::FrameTooLarge); @@ -1848,7 +1880,8 @@ impl Producer { /// The read cursor starts at the group the subscription named (its floor), or 0. /// [`Subscription::max_age`] is what asks for data: delivery skips everything above /// the floor that the budget convicts, so the default budget of zero delivers only - /// the latest group and a larger one reaches back over what it can still use. + /// the latest group and a larger one reaches back over what it can still use. An + /// untimed track has nothing to convict, so it starts at its latest group. pub fn subscribe(&self, subscription: impl Into>) -> Subscriber { let preferences = subscription.into().unwrap_or_default(); @@ -2575,7 +2608,8 @@ impl Consumer { /// The read cursor starts at the group the subscription named (its floor), or 0. /// [`Subscription::max_age`] is what asks for data: delivery skips everything above /// the floor that the budget convicts, so the default budget of zero delivers only - /// the latest group and a larger one reaches back over what it can still use. + /// the latest group and a larger one reaches back over what it can still use. An + /// untimed track has nothing to convict, so it starts at its latest group. pub fn subscribe(&self, subscription: impl Into>) -> kio::Pending { let subscription = kio::Producer::new(subscription.into().unwrap_or_default()); @@ -3342,7 +3376,7 @@ impl group::Expiry for GroupExpiry { if let Some(slot) = state.lookup.get(&self.sequence) { let group = &slot.group; if group.timestamp().is_none() - && group.poll_timestamp(waiter).is_ready() + && group.poll_started(waiter).is_ready() && group.timestamp().is_some() { timestamp_raced = true; @@ -3360,7 +3394,7 @@ impl group::Expiry for GroupExpiry { .map(|slot| &slot.group); for group in successor.into_iter().chain(beyond) { if group.timestamp().is_none() - && group.poll_timestamp(waiter).is_ready() + && group.poll_started(waiter).is_ready() && group.timestamp().is_some() { timestamp_raced = true; @@ -3452,7 +3486,15 @@ struct Cursor { impl Cursor { fn new(state: kio::Consumer, subscription: kio::Producer) -> Self { - let min_sequence = floor_of(&subscription.read()); + // An explicit start says how far back to reach, so only an unfloored subscription + // jumps to an untimed track's latest group. + let min_sequence = { + let preferences = subscription.read(); + match preferences.start { + Some(_) => floor_of(&preferences), + None => state.read().untimed_start().unwrap_or(0), + } + }; Self { state, subscription, @@ -4498,7 +4540,7 @@ mod test { let got = recv_datagram(&mut dg); assert_eq!(got.sequence, seq); - assert_eq!(got.timestamp, ts); + assert_eq!(got.timestamp, Some(ts)); assert_eq!(&got.payload[..], b"hello"); } @@ -4715,8 +4757,8 @@ mod test { .append_datagram(Timestamp::from_millis(2).unwrap(), &b"z"[..]) .unwrap(); let got = recv_datagram(&mut dg); - assert_eq!(got.timestamp.scale(), Timescale::MICRO); - assert_eq!(got.timestamp.value(), 2_000); + assert_eq!(got.timestamp.unwrap().scale(), Timescale::MICRO); + assert_eq!(got.timestamp.unwrap().value(), 2_000); } #[test] @@ -4805,7 +4847,7 @@ mod test { let body = lite::Datagram { subscribe: 5, sequence: d.sequence, - timestamp: d.timestamp.value(), + timestamp: d.timestamp.unwrap().value(), payload: d.payload.clone(), } .encode_bytes(version) @@ -4825,7 +4867,7 @@ mod test { let got = recv_datagram(&mut downstream_dg); assert_eq!(got.sequence, seq); - assert_eq!(got.timestamp, ts); + assert_eq!(got.timestamp, Some(ts)); assert_eq!(&got.payload[..], b"payload"); } @@ -4992,7 +5034,7 @@ mod test { let mut frame = straggler .create_frame(frame::Info { size: 10, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), }) .unwrap(); // One chunk per half-window; the whole frame spans several windows. New @@ -5027,7 +5069,7 @@ mod test { .create_frame_owned( frame::Info { size: 3, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), }, &Default::default(), ) @@ -5292,7 +5334,7 @@ mod test { let mut frame = live .create_frame(frame::Info { size: 1, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), }) .unwrap(); @@ -6075,7 +6117,7 @@ mod test { let late = end.await.expect("the straggler is not truncated"); assert_eq!( late.map(|frame| frame.timestamp), - Some(Timestamp::from_millis(1950).unwrap()) + Some(Some(Timestamp::from_millis(1950).unwrap())) ); } @@ -6132,7 +6174,7 @@ mod test { let mut writing = source .create_frame(frame::Info { size: 6, - timestamp: Timestamp::ZERO, + timestamp: Some(Timestamp::ZERO), }) .unwrap(); writing.write(bytes::Bytes::from_static(b"old")).unwrap(); @@ -6284,6 +6326,74 @@ mod test { ); } + fn untimed_producer() -> Producer { + track_producer("test", Info::default().with_timescale(None)) + } + + /// A group of untimed frames. + fn append_untimed(producer: &mut Producer) -> u64 { + let mut group = producer.append_group().unwrap(); + group.write_frame(None, bytes::Bytes::from_static(b"x")).unwrap(); + group.finish().unwrap(); + group.sequence + } + + /// Nothing on an untimed track is ever stale, so a budget can't resolve where a new + /// subscriber starts. It takes the latest group instead of replaying the cache. + #[test] + fn an_untimed_track_starts_at_its_latest_group() { + let mut producer = untimed_producer(); + for _ in 0..5 { + append_untimed(&mut producer); + } + + let mut live = producer.subscribe(None); + assert_eq!(drain(&mut live), vec![4]); + let mut patient = producer.subscribe(replay()); + assert_eq!( + drain(&mut patient), + vec![4], + "no budget reaches back on an untimed track" + ); + + append_untimed(&mut producer); + assert_eq!(drain(&mut live), vec![5], "every later group is delivered"); + } + + /// An explicit start says how far back to reach, so it wins over the untimed jump to + /// the latest group (a resume after failover names the group it lacks). + #[test] + fn an_explicit_start_holds_on_an_untimed_track() { + let mut producer = untimed_producer(); + for _ in 0..5 { + append_untimed(&mut producer); + } + + let mut subscriber = producer.subscribe(Subscription::default().with_start(Position::group(2))); + assert_eq!(drain(&mut subscriber), vec![2, 3, 4]); + } + + /// A track is all timed or all untimed, so a datagram that doesn't match it is refused. + #[test] + fn a_mismatched_datagram_is_refused() { + let mut untimed = track_producer("untimed", Info::default().with_timescale(None)); + assert!(matches!( + untimed.append_datagram(Timestamp::ZERO, &b"x"[..]), + Err(Error::TimestampMismatch) + )); + untimed.append_datagram(None, &b"x"[..]).unwrap(); + + let mut timed = track_producer("timed", None); + assert!(matches!( + timed.append_datagram(None, &b"x"[..]), + Err(Error::TimestampMismatch) + )); + assert!(matches!( + timed.insert_datagram(5, None, &b"x"[..]), + Err(Error::TimestampMismatch) + )); + } + /// Datagrams are unordered by construction, so the sequence cursor carries them too: /// a track using both channels needs one subscription, not two. #[test] diff --git a/rs/moq-net/tests/datagram.rs b/rs/moq-net/tests/datagram.rs index 9386e4727d..72ad4001a2 100644 --- a/rs/moq-net/tests/datagram.rs +++ b/rs/moq-net/tests/datagram.rs @@ -90,7 +90,7 @@ async fn datagrams_reach_the_subscriber_in_order() { assert_eq!(&datagram.payload[..], PAYLOAD, "payload corrupted in transit"); assert_eq!( datagram.timestamp, - Timestamp::from_millis(datagram.sequence).unwrap(), + Some(Timestamp::from_millis(datagram.sequence).unwrap()), "timestamp did not survive the wire" ); if let Some(previous) = seen.last() { @@ -149,8 +149,15 @@ async fn ietf_delivers_datagrams_on(version: &str) { .unwrap(); let mut before = subscriber.recv_group().await.unwrap().unwrap(); assert_eq!(before.sequence, 0, "{version}"); - // Drafts without a track timescale stamp objects on arrival, datagrams included. - let stamped = before.next_frame().await.unwrap().unwrap().timestamp.value() == 0; + // Drafts 14 to 16 can't carry TIMESCALE, so the track arrives untimed, datagrams + // included. + let timed = !matches!(version, "moq-transport-14" | "moq-transport-16"); + let first = before.next_frame().await.unwrap().unwrap().timestamp; + assert_eq!( + first.map(|timestamp| timestamp.is_zero()), + timed.then_some(true), + "{version}" + ); producer .insert_datagram( @@ -162,14 +169,14 @@ async fn ietf_delivers_datagrams_on(version: &str) { let datagram = subscriber.recv_datagram().await.unwrap().unwrap(); assert_eq!(datagram.sequence, 5, "{version}: the relay must not renumber"); assert_eq!(&datagram.payload[..], PAYLOAD, "{version}"); - if stamped { - let expected = Timestamp::from_millis(7).unwrap(); - assert_eq!( - datagram.timestamp.convert(expected.scale()).unwrap(), - expected, - "{version}" - ); - } + let expected = Timestamp::from_millis(7).unwrap(); + assert_eq!( + datagram + .timestamp + .map(|timestamp| timestamp.convert(expected.scale()).unwrap()), + timed.then_some(expected), + "{version}" + ); // The group sequence continues past the datagram. producer @@ -206,14 +213,14 @@ async fn inserted_sequences_survive_the_lite_wire() { assert_eq!(first.sequence, 5); assert_eq!( first.timestamp, - Timestamp::from_millis(5).unwrap(), + Some(Timestamp::from_millis(5).unwrap()), "timestamp did not survive the wire" ); assert_eq!(&first.payload[..], PAYLOAD); let second = fixture.subscriber.recv_datagram().await.unwrap().unwrap(); assert_eq!(second.sequence, 2); - assert_eq!(second.timestamp, Timestamp::from_millis(2).unwrap()); + assert_eq!(second.timestamp, Some(Timestamp::from_millis(2).unwrap())); assert_eq!(&second.payload[..], PAYLOAD); }) .await diff --git a/rs/moq-net/tests/rejoin.rs b/rs/moq-net/tests/rejoin.rs index f8621f9cd6..d4b9d21953 100644 --- a/rs/moq-net/tests/rejoin.rs +++ b/rs/moq-net/tests/rejoin.rs @@ -371,6 +371,16 @@ async fn rejoin_during_the_cancel_skips_the_cache() { let mut sub = remote.track("video").unwrap().subscribe(None).await.unwrap(); let group = sub.recv_group().await.unwrap().unwrap(); + // A track that arrives untimed has no media age to skip the cached group by, so + // its reader starts at the newest group cached when it subscribed and reads on + // from there. + if sub.info().timescale.is_none() { + let mut sequence = group.sequence; + while sequence < 3 { + sequence = sub.recv_group().await.unwrap().unwrap().sequence; + } + return; + } assert_eq!( group.sequence, 3, "{version}: the rejoining reader got the stale cache first" diff --git a/rs/moq-net/tests/untimed.rs b/rs/moq-net/tests/untimed.rs new file mode 100644 index 0000000000..8b2f67e99c --- /dev/null +++ b/rs/moq-net/tests/untimed.rs @@ -0,0 +1,262 @@ +//! An untimed track reaches every subscriber untimed, through a relay too, on every wire +//! that can say so, and a timed track keeps its timestamps. +//! +//! No receiver fills in arrival time. Until lite-07 encodes absence, a lite-05+ encoder +//! writes its send time instead, so those hops are the one place an untimed track gains a +//! timeline. + +mod support; + +use std::time::Duration; + +use bytes::Bytes; +use moq_net::{Hop, Timescale, Timestamp, Version, origin, track}; +use support::harness::peer; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// What a subscriber reads for a timed and an untimed track. +#[derive(Debug, PartialEq, Eq)] +enum Arrives { + /// Both keep what the publisher wrote. + Faithful, + /// The untimed track carries the encoder's send times (lite-05+ before lite-07). + SendTime, + /// The wire declares no timescale, so even the timed track arrives untimed. + Untimed, +} + +fn expected(version: &str) -> Arrives { + match version { + "moq-lite-03" | "moq-lite-04" => Arrives::Untimed, + "moq-lite-05" | "moq-lite-06" | "moq-lite-07-wip" => Arrives::SendTime, + // Drafts 14 to 16 can't carry TIMESCALE. + "moq-transport-14" | "moq-transport-16" => Arrives::Untimed, + _ => Arrives::Faithful, + } +} + +const VERSIONS: &[&str] = &[ + "moq-lite-03", + "moq-lite-04", + "moq-lite-05", + "moq-lite-06", + "moq-lite-07-wip", + "moq-transport-14", + "moq-transport-16", + "moq-transport-17", + "moq-transport-20", +]; + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + support::harness::spawn(driver); + producer +} + +fn millis(ms: u64) -> Timestamp { + Timestamp::from_millis(ms).unwrap() +} + +fn untimed() -> track::Info { + track::Info::default().with_timescale(None) +} + +/// Origins chained by one session per hop, each hop on its own version. +struct Chain { + nodes: Vec, + _pairs: Vec, +} + +impl Chain { + async fn new(hops: &[&str]) -> Self { + let nodes: Vec<_> = (1..=hops.len() as u64 + 1).map(produce_origin).collect(); + let mut pairs = Vec::new(); + for (pair, version) in nodes.windows(2).zip(hops) { + pairs.push(peer(version.parse::().unwrap(), &pair[0], &pair[1]).await); + } + Self { nodes, _pairs: pairs } + } + + async fn subscribe(&self, track: &str) -> track::Subscriber { + let consumer = self.nodes.last().unwrap().consume(); + let broadcast = moq_net_sim::timeout(TIMEOUT, consumer.request_broadcast("room")) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + moq_net_sim::timeout(TIMEOUT, broadcast.track(track).unwrap().subscribe(None)) + .await + .expect("subscribe timeout") + .expect("subscribe") + } +} + +/// The first frame's timestamp of the next group. +async fn next_frame(subscriber: &mut track::Subscriber) -> Option { + let mut group = moq_net_sim::timeout(TIMEOUT, subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("a group"); + group.read_frame().await.unwrap().expect("a frame").timestamp +} + +/// Publish a single-frame group on a track with `info` through `hops`, returning what the +/// last node reads for it and the track info it received. +async fn frame_through(hops: &[&str], info: track::Info) -> (Option, track::Info) { + let timestamp = info.timescale.map(|_| millis(7)); + let chain = Chain::new(hops).await; + let broadcast = chain.nodes[0].create_broadcast("room").unwrap(); + let mut producer = broadcast.create_track("data", info).unwrap(); + broadcast.announce(Default::default()).unwrap(); + moq_net_sim::sleep(Duration::from_secs(1)).await; + + let mut subscriber = chain.subscribe("data").await; + producer.write_frame(timestamp, &b"frame"[..]).unwrap(); + let received = next_frame(&mut subscriber).await; + let received = received.map(|timestamp| timestamp.convert(Timescale::MILLI).unwrap()); + (received, subscriber.info().clone()) +} + +#[moq_net_sim::test] +async fn frames_arrive_as_published() { + for version in VERSIONS { + let (timed, timed_info) = frame_through(&[version], track::Info::default()).await; + let (untimed, untimed_info) = frame_through(&[version], untimed()).await; + match expected(version) { + Arrives::Faithful => { + assert_eq!(timed, Some(millis(7)), "{version}: the timestamp survives"); + assert_eq!(untimed, None, "{version}: absence survives"); + assert_eq!(untimed_info.timescale, None, "{version}: no timeline is declared"); + } + Arrives::SendTime => { + assert_eq!(timed, Some(millis(7)), "{version}: the timestamp survives"); + assert!(untimed.is_some(), "{version}: a lite encoder writes its send time"); + } + Arrives::Untimed => { + assert_eq!(timed, None, "{version}: no units, so no timestamp"); + assert_eq!(timed_info.timescale, None, "{version}: no timeline is declared"); + assert_eq!(untimed, None, "{version}: absence survives"); + } + } + } +} + +/// A relay forwards an untimed track untimed, and a track it received without a timeline +/// does not gain one downstream. +#[moq_net_sim::test] +async fn a_relay_forwards_an_untimed_track_untimed() { + let (frame, info) = frame_through(&["moq-transport-17", "moq-transport-20"], untimed()).await; + assert_eq!(frame, None, "the relay did not stamp it"); + assert_eq!(info.timescale, None, "the relay must not claim a timeline"); + + for hops in [ + &["moq-lite-03", "moq-transport-17"][..], + &["moq-transport-16", "moq-transport-20"][..], + ] { + let (frame, info) = frame_through(hops, track::Info::default()).await; + assert_eq!(frame, None, "{hops:?}: the first hop has no timestamps to forward"); + assert_eq!(info.timescale, None, "{hops:?}: the relay must not claim a timeline"); + } +} + +/// An explicit start on an untimed track is honored the same way on lite and +/// moq-transport: only an unfloored subscriber jumps to the latest group. +/// +/// The subscription asks for history the way a resume does, with a budget covering it: +/// lite-05+ carries send times, so downstream the groups are timed and a zero budget would +/// skip the older ones there, as it would on any timed track. moq-transport before draft +/// 20 can only join a past group through a joining FETCH, which our publisher refuses for +/// groups before the live one, so draft 20 stands in for it. +#[moq_net_sim::test] +async fn an_explicit_start_holds_on_an_untimed_track() { + for version in ["moq-lite-03", "moq-lite-06", "moq-lite-07-wip", "moq-transport-20"] { + let chain = Chain::new(&[version]).await; + let broadcast = chain.nodes[0].create_broadcast("room").unwrap(); + let mut producer = broadcast.create_track("data", untimed()).unwrap(); + broadcast.announce(Default::default()).unwrap(); + for _ in 0..5 { + producer.write_frame(None, &b"untimed"[..]).unwrap(); + } + moq_net_sim::sleep(Duration::from_secs(1)).await; + + let consumer = chain.nodes[1].consume().request_broadcast("room").await.unwrap(); + let subscription = track::Subscription::default() + .with_start(track::Position::group(2)) + .with_max_age(Duration::from_secs(30)); + let mut subscriber = moq_net_sim::timeout(TIMEOUT, consumer.track("data").unwrap().subscribe(subscription)) + .await + .expect("subscribe timeout") + .expect("subscribe"); + + let mut got = Vec::new(); + while got.len() < 3 { + let Ok(next) = moq_net_sim::timeout(TIMEOUT, subscriber.recv_group()).await else { + break; + }; + got.push(next.unwrap().expect("a group").sequence); + } + got.sort(); + assert_eq!(got, vec![2, 3, 4], "{version}"); + } +} + +/// An untimed track's datagram arrives untimed on moq-transport, and with a send time on +/// lite. +#[moq_net_sim::test] +async fn datagrams_arrive_as_published() { + for version in ["moq-lite-05", "moq-lite-07-wip", "moq-transport-16", "moq-transport-20"] { + let chain = Chain::new(&[version]).await; + let broadcast = chain.nodes[0].create_broadcast("room").unwrap(); + let mut producer = broadcast.create_track("data", untimed()).unwrap(); + broadcast.announce(Default::default()).unwrap(); + moq_net_sim::sleep(Duration::from_secs(1)).await; + + let mut subscriber = chain.subscribe("data").await; + // A group first, so the subscription is bound before any datagram lands. + producer.write_frame(None, &b"before"[..]).unwrap(); + next_frame(&mut subscriber).await; + + producer.append_datagram(None, &b"untimed"[..]).unwrap(); + let datagram = moq_net_sim::timeout(TIMEOUT, subscriber.recv_datagram()) + .await + .expect("datagram timeout") + .unwrap() + .expect("a datagram"); + let lite = version.starts_with("moq-lite"); + assert_eq!(datagram.timestamp.is_some(), lite, "{version}"); + } +} + +/// A standalone FETCH of an untimed track answers with untimed objects. +#[moq_net_sim::test] +async fn a_fetched_untimed_frame_arrives_untimed() { + for version in ["moq-lite-06", "moq-transport-19"] { + let chain = Chain::new(&[version]).await; + let broadcast = chain.nodes[0].create_broadcast("room").unwrap(); + let mut dynamic = broadcast.dynamic(); + broadcast.announce(Default::default()).unwrap(); + moq_net_sim::sleep(Duration::from_secs(1)).await; + + let consumer = chain.nodes[1].consume().request_broadcast("room").await.unwrap(); + let mut waiting = Box::pin(consumer.track("video").unwrap().fetch_group(0, None)); + let request = match futures::future::select(std::pin::pin!(dynamic.requested_track()), &mut waiting).await { + futures::future::Either::Left((request, _)) => request.expect("the track request reaches the publisher"), + futures::future::Either::Right(_) => panic!("nothing answered the track"), + }; + let groups = request.dynamic(); + let _track = request.accept(untimed()); + let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), + futures::future::Either::Right(_) => panic!("nothing answered the fetch"), + }; + let mut group = request.accept(None).unwrap(); + group.write_frame(None, Bytes::from_static(b"segment")).unwrap(); + group.finish().unwrap(); + + let mut fetched = waiting.await.unwrap(); + let frame = fetched.read_frame().await.unwrap().unwrap(); + let lite = version.starts_with("moq-lite"); + assert_eq!(frame.timestamp.is_some(), lite, "{version}"); + } +} diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 9141fca8b1..c522ccd31c 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -154,7 +154,7 @@ async fn lite05_timestamp_roundtrip(scheme: &str) { let payload = format!("frame@{us}").into_bytes(); let frame = moq_tokio::moq_net::frame::Info { size: payload.len() as u64, - timestamp: Timestamp::new(us, Timescale::MICRO).unwrap(), + timestamp: Some(Timestamp::new(us, Timescale::MICRO).unwrap()), }; let mut writer = group.create_frame(frame).expect("failed to create frame"); writer @@ -228,7 +228,7 @@ async fn lite05_timestamp_roundtrip(scheme: &str) { .expect("next_frame failed") .expect("group closed prematurely"); - let ts = frame_sub.timestamp; + let ts = frame_sub.timestamp.expect("timed frame"); assert_eq!(ts.scale(), Timescale::MICRO); assert_eq!(ts.value(), expected_us); @@ -277,7 +277,7 @@ async fn lite05_fetch_roundtrip(scheme: &str) { let payload = format!("frame@{us}").into_bytes(); let frame = moq_tokio::moq_net::frame::Info { size: payload.len() as u64, - timestamp: Timestamp::new(us, Timescale::MICRO).unwrap(), + timestamp: Some(Timestamp::new(us, Timescale::MICRO).unwrap()), }; let mut writer = group.create_frame(frame).expect("failed to create frame"); writer @@ -346,7 +346,7 @@ async fn lite05_fetch_roundtrip(scheme: &str) { .expect("next_frame failed") .expect("group closed prematurely"); - let ts = frame_sub.timestamp; + let ts = frame_sub.timestamp.expect("timed frame"); assert_eq!(ts.scale(), Timescale::MICRO); assert_eq!(ts.value(), expected_us); @@ -524,7 +524,7 @@ async fn lite05_fetch_during_subscribe(scheme: &str) { fn timestamped_frame(us: u64, payload: &str) -> moq_net::frame::Info { moq_net::frame::Info { size: payload.len() as u64, - timestamp: Timestamp::new(us, Timescale::MICRO).unwrap(), + timestamp: Some(Timestamp::new(us, Timescale::MICRO).unwrap()), } } @@ -730,7 +730,7 @@ async fn broadcast_moq_lite_05_default_timescale() { .expect("next_frame failed") .expect("group closed"); - let ts = frame_sub.timestamp; + let ts = frame_sub.timestamp.expect("timed frame"); assert_eq!(ts.scale(), Timescale::MILLI, "default timescale is milliseconds"); drop(connection); From 06fe669b783a119c3c74c043df0c58b8e1282213 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Wed, 7 Oct 2026 12:16:43 +0200 Subject: [PATCH 2/4] quest: maintainer decides two untimed follow-ups Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 + quest/m1/untimed-decisions.md | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 quest/m1/untimed-decisions.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 6b595e6326..e058e97183 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -28,6 +28,7 @@ blocks. The quests that gated m0 lines moved under them. ## Required - [One max_age meaning](/quest/m1/cache-max-age.md) - a superseded group goes stale on wall clock since its successor arrived or on media time, whichever is first, in Rust and js/net; fixes the untimed failover stall +- [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 - [JS untimed model](/quest/m1/js-untimed-model.md) - the untimed model in @moq/net - [CMAF frame timestamp](/quest/m1/cmaf-frame-timestamp.md) - CMAF decoders time samples from the moq-lite frame timestamp, using `tfdt` only within the fragment - [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 diff --git a/quest/m1/untimed-decisions.md b/quest/m1/untimed-decisions.md new file mode 100644 index 0000000000..667b3e2356 --- /dev/null +++ b/quest/m1/untimed-decisions.md @@ -0,0 +1,41 @@ +# [XS] The maintainer decides two untimed follow-ups + +## Goal + +kixelated decides two questions [#4822](https://github.com/moq-dev/moq/pull/4822) +(the untimed model) raised, and each answer becomes a quest or is dropped. +Requested by an external consumer (OneTooMany), who leaves these calls to the +maintainer. This quest waits on that decision; delete it once both are +answered. + +## Plan + +1. **moq-archive and untimed tracks.** `Writer` refuses an untimed track with + `Error::Untimed`, because the format stores a timestamp per frame. So + `moq export archive` over moq-lite before 05, or moq-transport without + TIMESCALE (every track on drafts 14-16), now fails where it used to record + arrival times. + - Recommended: keep refusing. Playback needs media time, and send or + arrival times would mislead a reader. Revisit when someone needs to + archive over those wire versions. + - Alternative: bump the format so a frame's timestamp is optional, and + replay an untimed track untimed. +2. **A malformed object in a FETCH.** On a TIMESCALE track, a subgroup object + without a Timestamp ends the whole track with `MalformedTrack` + (`rs/moq-net/src/ietf/subscriber.rs`, `run_group`), as js/net does + ([#4968](https://github.com/moq-dev/moq/pull/4968)). The same object in a + group FETCH (`recv_fetch_payload`) fails only that group. + - Recommended: end the track too. The publisher broke the track, however + the object arrived. + - Alternative: keep it to the group, so a one-off fetch response can't tear + down a live subscription. + +Settled while planning this (2026-10-07): MOQtail (HEAD `d642d335`) never sends +TIMESCALE as a track property; it puts the scale in its catalog JSON and stamps +objects with LOC's old Timestamp id 0x06. Its tracks arrive untimed, so the +malformed rule can't reach them, and nothing is left to decide there. + +## Related + +- [One max_age meaning](/quest/m1/cache-max-age.md) - the other untimed follow-up, already planned +- [FETCH_OK carries the track's properties](/quest/m1/fetch-ok-properties.md) - also touches how a FETCH learns timedness From e0ab33c1cc1bbd2587332e9de544135ed3a249c1 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Wed, 7 Oct 2026 12:24:07 +0200 Subject: [PATCH 3/4] fix(net): refuse before appending, end the track on a malformed fill Co-Authored-By: Claude Opus 5.5 --- quest/m1/untimed-decisions.md | 5 ++-- rs/moq-net/src/ietf/subscriber.rs | 38 +++++++++++++++++++++++++++++++ rs/moq-net/src/model/frame.rs | 4 +++- rs/moq-net/src/model/track.rs | 15 ++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/quest/m1/untimed-decisions.md b/quest/m1/untimed-decisions.md index 667b3e2356..dec9a21529 100644 --- a/quest/m1/untimed-decisions.md +++ b/quest/m1/untimed-decisions.md @@ -23,8 +23,9 @@ answered. 2. **A malformed object in a FETCH.** On a TIMESCALE track, a subgroup object without a Timestamp ends the whole track with `MalformedTrack` (`rs/moq-net/src/ietf/subscriber.rs`, `run_group`), as js/net does - ([#4968](https://github.com/moq-dev/moq/pull/4968)). The same object in a - group FETCH (`recv_fetch_payload`) fails only that group. + ([#4968](https://github.com/moq-dev/moq/pull/4968)), and so does one in a + joining FETCH's fill. The same object in a standalone group FETCH, which a + relay sends to fill a cache miss (`run_group_fetch`), fails only that group. - Recommended: end the track too. The publisher broke the track, however the object arrived. - Alternative: keep it to the group, so a one-off fetch response can't tear diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 6215dc1c18..fabc42dba2 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3154,6 +3154,10 @@ where if let Ok(mut state) = fill.write() { *state = Fill::Done; } + // As for a subgroup object: the track is malformed, not just this fill. + if matches!(err, Error::MalformedTrack) { + let _ = track.abort(Error::MalformedTrack); + } return Err(err); } }; @@ -7403,6 +7407,40 @@ mod stitch_tests { stream } + /// A fill object without a Timestamp on a timed track ends the track, as a subgroup + /// object does: the fill is the head of a group the subscription carries. + #[moq_net_sim::test] + async fn an_unstamped_fill_object_ends_the_track() { + use futures::FutureExt; + + let mut script = Vec::new(); + crate::coding::Encoder::new(&mut script, VERSION.into()) + .varint(ietf::FetchHeader::TYPE) + .unwrap(); + ietf::FetchHeader { request_id: REQUEST } + .encode(&mut crate::coding::Encoder::new(&mut script, VERSION.into()), VERSION) + .unwrap(); + ietf::FetchObject::Object { + subgroup: ietf::FetchSubgroup::Zero, + group: Some(SEQUENCE), + object: Some(0), + priority: Some(0), + properties: None, + } + .encode(&mut crate::coding::Encoder::new(&mut script, VERSION.into()), VERSION) + .unwrap(); + crate::coding::Encoder::new(&mut script, VERSION.into()) + .varint(1u64) + .unwrap(); + script.push(42); + + let h = Harness::new(Fill::Serving(Some(Timescale::MICRO)), vec![script]); + let mut stream = h.stream().await; + let res = h.subscriber.clone().recv_fill(&mut stream).await; + assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); + assert!(matches!(h.track.closed().now_or_never(), Some(Error::MalformedTrack))); + } + /// A subgroup object without a Timestamp on a timed track makes the whole track /// malformed, so the track ends rather than only its group. #[moq_net_sim::test] diff --git a/rs/moq-net/src/model/frame.rs b/rs/moq-net/src/model/frame.rs index db1775e6cb..b0acdcf928 100644 --- a/rs/moq-net/src/model/frame.rs +++ b/rs/moq-net/src/model/frame.rs @@ -30,7 +30,9 @@ pub struct Info { /// /// [`group::Producer::create_frame`] converts it into the parent track's /// timescale, so the scale you build it with doesn't have to match the track. - /// An untimed frame reaches every subscriber untimed: no hop fills one in. + /// No receiver fills one in, so an untimed track stays untimed across hops, except + /// over moq-lite 05 and later, which can't mark a track untimed yet and carries the + /// encoder's send time instead. pub timestamp: Option, } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 5685d2fe68..0f74140bb2 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1629,6 +1629,9 @@ impl Producer { if frame.len() as u64 > group::MAX_CACHE_BYTES { return Err(Error::FrameTooLarge); } + // Checked before the group exists, so a refused frame leaves no empty group behind. + let timescale = self.modify()?.info.as_ref().unwrap().timescale; + let timestamp = group::on_track(timestamp.into(), timescale)?; let mut group = self.append_group()?; group.write_frame(timestamp, frame)?; group.finish()?; @@ -6373,6 +6376,18 @@ mod test { assert_eq!(drain(&mut subscriber), vec![2, 3, 4]); } + /// A refused single-frame write leaves no group behind: an empty open group would hold a + /// subscriber waiting for a frame that never comes. + #[test] + fn a_mismatched_frame_appends_no_group() { + let mut producer = track_producer("test", None); + assert!(matches!( + producer.write_frame(None, bytes::Bytes::from_static(b"x")), + Err(Error::TimestampMismatch) + )); + assert_eq!(producer.append_group().unwrap().sequence, 0); + } + /// A track is all timed or all untimed, so a datagram that doesn't match it is refused. #[test] fn a_mismatched_datagram_is_refused() { From 1c239786d1ef64a54029863a653443c358c2648f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 13:42:49 -0700 Subject: [PATCH 4/4] fix(net): an untimed start stays under the subscription's end An unfloored SUBSCRIBE with an end on an untimed track started at the newest cached group even when it sat past the end, so nothing was delivered. Start at the newest servable group below the end, as js/net does (#4968). Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 71743b75d7..77a7465e5f 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -649,17 +649,20 @@ impl TrackState { } /// Where a new reader with no explicit start begins on an untimed track: its newest - /// servable group. + /// servable group below the exclusive `cap`. /// /// A drift budget resolves a timed track's start, but nothing is ever stale on an /// untimed track, so the budget would replay the whole cache. `None` on a timed track, /// or while nothing is servable yet. - fn untimed_start(&self) -> Option { + fn untimed_start(&self, cap: Option) -> Option { if self.info.as_ref()?.timescale.is_some() { return None; } self.lookup - .iter() + .range(( + std::ops::Bound::Unbounded, + cap.map_or(std::ops::Bound::Unbounded, std::ops::Bound::Excluded), + )) .rev() .find(|(_, slot)| slot.visible && !slot.group.is_aborted()) .map(|(sequence, _)| *sequence) @@ -3469,7 +3472,10 @@ impl Cursor { let preferences = subscription.read(); match preferences.start { Some(_) => floor_of(&preferences), - None => state.read().untimed_start().unwrap_or(0), + None => { + let cap = preferences.end.and_then(|end| Cap::from(end.group_end()).exclusive()); + state.read().untimed_start(cap).unwrap_or(0) + } } }; Self { @@ -6442,6 +6448,20 @@ mod test { assert_eq!(drain(&mut subscriber), vec![2, 3, 4]); } + /// An unfloored untimed subscription with an end starts at the latest group below it, + /// as the lite publisher serves a SUBSCRIBE with an end and no start. + #[test] + fn an_untimed_start_stays_under_the_end() { + let mut producer = untimed_producer(); + for _ in 0..5 { + append_untimed(&mut producer); + } + + let mut subscriber = producer.subscribe(Subscription::default().with_end(Position::group(3))); + subscriber.end_at(Position::group(3).group_end()); + assert_eq!(drain(&mut subscriber), vec![2]); + } + /// A refused single-frame write leaves no group behind: an empty open group would hold a /// subscriber waiting for a frame that never comes. #[test]