diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 9b1653add91..e3532b4e379 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -22124,7 +22124,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_datagram() != - 17412) { + 32344) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_group() != 40781) { @@ -22437,7 +22437,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_append_datagram() != - 8934) { + 38326) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_append_group() != 45225) { @@ -22459,10 +22459,10 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_insert_datagram() != - 29400) { + 30484) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackproducer_largest() != 3968) { + if (uniffi_moq_ffi_checksum_method_moqtrackproducer_largest() != 24089) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_requests() != 60252) { diff --git a/demo/web/src/stats.html b/demo/web/src/stats.html index 22f06bab7ae..8e41dc6d6ab 100644 --- a/demo/web/src/stats.html +++ b/demo/web/src/stats.html @@ -60,7 +60,7 @@

MoQ Relay Stats

0
-
Datagrams
+
Datagrams
0
diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts index 95f981a762f..c5c161a3825 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -64,8 +64,8 @@ interface Snapshot { bytes?: number; frames?: number; groups?: number; - // Single-frame groups carried over unreliable QUIC datagrams: a subset of `groups`, - // with their payload already in `frames` / `bytes`. + // Datagrams of datagram tracks: they belong to no group, so they count in neither + // `groups` nor `frames`, while their payload is in `bytes`. datagrams?: number; } type BroadcastFrame = Record; @@ -264,7 +264,7 @@ interface Sample { ingress: number; // cumulative ingress bytes announce: number; // cumulative announce-control bytes (both directions) fetches: number; // cumulative group fetches requested - datagrams: number; // cumulative single-frame groups sent as QUIC datagrams + datagrams: number; // cumulative datagrams of datagram tracks, apart from groups broadcasters: number; viewers: number; tracks: number; diff --git a/doc/.vitepress/drafts.test.ts b/doc/.vitepress/drafts.test.ts index bae6a5d20d7..32f7fc3500f 100644 --- a/doc/.vitepress/drafts.test.ts +++ b/doc/.vitepress/drafts.test.ts @@ -128,20 +128,30 @@ describe("tables render as tables", () => { const rendered = html(page as DraftPage); - // The Bidirectional Streams table: one header, eight body rows, each + // The Bidirectional Streams table: one header, nine body rows, each // separated by a kramdown rule in the source. Cells carry the source's // alignment, so match around the style attribute. const header = /]*>Stream<\/th>/.exec(rendered); expect(header).not.toBeNull(); - for (const stream of ["Announce", "Subscribe", "Fetch", "Probe", "Goaway", "Track", "Auth", "Tracks"]) { + for (const stream of [ + "Announce", + "Subscribe", + "Fetch", + "Probe", + "Goaway", + "Track", + "Auth", + "Tracks", + "Datagrams", + ]) { expect(rendered).toMatch(new RegExp(`]*>${stream}`)); } - // One header row plus eight body rows, in a single table. + // One header row plus nine body rows, in a single table. const start = rendered.lastIndexOf("")[0]; - expect(rows.match(//g) ?? []).toHaveLength(9); + expect(rows.match(//g) ?? []).toHaveLength(10); }); }); diff --git a/doc/concept/archives.md b/doc/concept/archives.md index 5f1b9259c8d..c9743103a7b 100644 --- a/doc/concept/archives.md +++ b/doc/concept/archives.md @@ -45,9 +45,9 @@ cannot negotiate moq-lite 07. Recording starts with content the subscription can still receive. A missing group is given up after the publisher's max age capped by the producer's max -delay, so a gap cannot stall later groups. Datagrams are uncached and can only -be recorded from when the subscription takes effect. An archive cannot recover -content already lost or evicted before it subscribed. The same max delay +delay, so a gap cannot stall later groups. Only stream tracks are recorded: a +datagram track is live-only, so an archive's playback never includes it. An +archive cannot recover content already lost or evicted before it subscribed. The same max delay (`producer::Config::max_delay`, 10 s by default and in `moq export archive`) bounds the backlog: content further behind the live edge is skipped, even when the publisher still caches it. @@ -74,7 +74,7 @@ segment. Within one broadcast prefix: Objects coalesce content by duration, using media time for timed tracks and wall time for untimed ones. A flush deadline also stores idle tracks, splitting long groups between frames when needed. Groups, frame positions, timestamps, -payloads, and the distinction between groups and datagrams survive storage. +and payloads survive storage. With DVR pruning enabled, old superseded objects are deleted after the retention window and grace; the newest object of each track stays. Pruning runs only while the recorder runs: once a recording completes or its recorder stops, objects not @@ -101,11 +101,10 @@ periodically listed for new tracks and objects. Once `.end` appears, refreshing stops and tracks end as recorded; without it, including when a recorder stopped early, the archive stays live. Empty or unrecognized prefixes are refused. -Serving history is not paced playback from the beginning. Datagrams remain -lossy datagrams, never fetchable groups. Time ranges currently resolve against -the served track's cache, not the archive's stored keys, so they cannot reliably -seek old stored content. There is no automatic switch from a live broadcast to -its archive at the original path. +Serving history is not paced playback from the beginning. Time ranges currently +resolve against the served track's cache, not the archive's stored keys, so they +cannot reliably seek old stored content. There is no automatic switch from a +live broadcast to its archive at the original path. The archived catalog is an ordinary track. [HLS](/bin/hls) can serve a recording whose renditions include their `hls` record tracks, just as it serves a live diff --git a/doc/concept/compression.md b/doc/concept/compression.md index 697653f7a0b..306e9e29c4c 100644 --- a/doc/concept/compression.md +++ b/doc/concept/compression.md @@ -43,7 +43,7 @@ ends list `moq-lite-07-wip`. Sessions on the default versions, and relay links among them, carry the inflated frames. Only Rust offers the moq-transport extension, so a JavaScript or third-party moq-transport peer reads the inflated copy. A received track's `compress` flag says which form arrived. A -datagram compresses alone, with a fresh window. +datagram track is never compressed. ## Cost on the relay diff --git a/doc/concept/index.md b/doc/concept/index.md index aaac09deba2..06cc676446e 100644 --- a/doc/concept/index.md +++ b/doc/concept/index.md @@ -20,10 +20,12 @@ what lets it forward media it can't parse. A **broadcast** is a named, discoverable collection of **tracks** from one publisher. A **track** is a live sequence of **groups**, and each group is a sequence of **frames** delivered reliably and in order over its own QUIC -stream (or, for a tiny single frame, as one unreliable datagram). Groups are independent: they can arrive out of order, be prioritized +stream. Groups are independent: they can arrive out of order, be prioritized against each other, and be dropped under congestion without corrupting the rest. For video a group is a GoP starting at a keyframe; for audio it's a second or so of samples; for data it's whatever unit can be skipped as a whole. +A track can instead be a [datagram track](/concept/moq-lite#datagrams): small +unreliable payloads with no groups. Subscribers pull. Nothing is transmitted until someone subscribes, duplicate subscriptions are merged at every relay on the way upstream, and a publisher diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 8e0703d5cdb..ff0385a16d0 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -27,7 +27,7 @@ carries [publisher instances](#publisher-instances). | **Track** | A live sequence of groups, delivered out of order until closed. | Track | | **Group** | A sequence of frames delivered reliably and in order, on its own QUIC stream. | Group | | **Frame** | A sized chunk of bytes. | Object | -| **Datagram** | One unreliable frame sent as a QUIC datagram instead of a group. | Datagram | +| **Datagram** | One unreliable payload of a datagram track, sent as a QUIC datagram. | Datagram | A broadcast is named by a path: non-empty segments joined by `/`, such as `room/alice`. A path never starts or ends with `/` or doubles one, and a `\` @@ -322,7 +322,7 @@ than the live and default starts. An untimed track refuses a time anchor. At the edge, a time end may let one group through before its first frame shows it starts past the end: the first group that has yet to start is handed out without waiting for that -frame, and the next one waits on it. A datagram is judged by its own timestamp. +frame, and the next one waits on it. Order only decides what goes first, never what is skipped, so catching up oldest first needs a max delay that covers the backlog. When a relay serves subscribers of @@ -430,22 +430,29 @@ keyframes do not use this mechanism. ## Datagrams -Since moq-lite 05, a publisher can send a tiny single-frame group as a QUIC -datagram: unreliable, unordered, under about 1200 bytes, and never -retransmitted. It suits real-time audio and sensor data. There is no stream -fallback, so a datagram that doesn't fit isn't delivered that way. - -A datagram shares its subscription's priority and group order with streams, -so a low-priority datagram waits behind higher-priority groups instead of -jumping the queue. From a native Rust publisher over QUIC or WebTransport, -a full send buffer drops the lowest-priority subscription's -oldest datagram. - -Nothing caches a datagram. A subscription gets the ones inside its group -range, and a new one may get the few still in the publisher's short send -buffer, but `FETCH` never returns one. A publisher that knows the sequence was -a datagram refuses with `NOT_FETCHABLE` (moq-lite-07), otherwise with -`NOT_FOUND`. Use a group for anything a late joiner needs. +A track is either a stream track, made of groups, or a datagram track, decided +when the publisher creates it. A datagram track is a series of QUIC datagrams: +unreliable, unordered, under about 1200 bytes, and never retransmitted. Each +has the datagram track's own sequence number and a timestamp, and no group. It +suits real-time audio and sensor data. There is no stream fallback, so a +datagram that doesn't fit isn't delivered. + +Datagram tracks have a namespace apart from stream tracks, so `audio` as +datagrams and `audio` as streams are two independent tracks. Nothing on the +wire says which kind a name is: the application knows from its catalog or by +convention, so a broadcast's track listing never includes a datagram track. In +Rust, `broadcast.create_datagrams(name, info)` publishes one and +`broadcast.datagrams(name)` reads it. + +A datagram subscription is live from when it starts: it has no ranges, order, +or max delay, only a priority, which a subscriber can change. A datagram still +waits behind higher-priority traffic instead of jumping the queue. Nothing +caches a datagram, and `FETCH` never returns one, so use a stream track for +anything a late joiner needs. + +Datagram tracks need a transport with datagrams, so not WebSocket or TCP. The +draft's [Datagrams](/draft/moq-lite#datagrams) section specifies them, and each +library's API docs say which versions carry them. ## What moq-lite leaves out diff --git a/doc/concept/standard.md b/doc/concept/standard.md index ac1775442c1..805f6d2a5d9 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -28,7 +28,7 @@ subset drops. What a peer actually observes against this implementation: - **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`. - **We list no tracks by `PUBLISH`.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. Our own peers list tracks over moq-lite instead. - **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end fails the listing, and readers list again. Our own moq-transport publishers refuse it, so a Rust listing of one fails. -- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Datagrams are never fetchable. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. +- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. - **A requester's FIN.** On draft 18 a requester finishing its stream cancels its request, except after `TRACK_STATUS`: nothing can follow it, so the FIN only ends the requester's input and the request is still answered. From draft 20 a FIN never cancels; a reset or `STOP_SENDING` does. - **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. - **Timing.** A track whose `SUBSCRIBE_OK` or `PUBLISH` declares no `TIMESCALE` is untimed. A `SUBSCRIBE` or `FETCH` with `INCLUDE_PROPERTIES=0` gets no `TIMESCALE` in its `SUBSCRIBE_OK` or `FETCH_OK`, so its objects go out unstamped. A Rust reader that never subscribed takes the units from `TRACK_STATUS`, and still asks for the properties whenever it subscribes to or fetches a timed track. @@ -36,7 +36,7 @@ subset drops. What a peer actually observes against this implementation: - **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, each distinct request token is verified once while any open request on that session names it, and its grant authorizes only requests naming it, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update on a token-authorized request moves it to the token it carries; the old token ends with its last naming request. A request is admitted by the session or by a token for good, so an update naming a token on one the session admitted is refused with `NOT_SUPPORTED`. A token-less `PUBLISH_NAMESPACE` our limit does not cover is refused `UNAUTHORIZED`, and one the limit stops covering ends; the publisher re-announces once it widens. Expiry or revocation ends every request naming that token. The relay reports the lease end to its auth server, and a later request naming the token verifies it again. A refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE`; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. Rust honors a token on any supported request; discovery reveals only paths it grants, and a joining `FETCH` keeps following its subscription. A request repeating a token is refused with `MALFORMED_AUTH_TOKEN`. The negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. - **Pause delivery.** `FORWARD=0` on a `SUBSCRIBE` or its update drops the subscription, closing its data streams and holding nothing upstream, while the request and its slot stay. `FORWARD=1` subscribes again as a new `SUBSCRIBE` with the same filter would, so nothing carries across the pause: its `REQUEST_OK` names the live edge it joined at. A paused update may change only priority and max age. - **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `SUBSCRIBE_TRACKS`, multi-group `FETCH`, and joining requests reaching before the joined group get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` from draft 20 because the advertised filter limit is zero. From draft 20, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts. Rust also carries range changes, keeps bounded subscriptions updatable, renews request tokens, and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). From draft 20, a wider or historical fill fails on its separate fetch stream while its subscription stays open; a largest-group fill hands its head to the live subgroup tail. Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. A namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. -- **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers without the extension see consecutive frames and only normal datagram payloads at Object ID 0; other datagram objects and statuses are dropped on those surfaces. JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none, as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped between draft 18 and later drafts, or refused when immutable, because draft 20 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. A status object carrying properties is refused. A `FETCH` has no status objects, so its response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. +- **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Plain Rust readers and moq-lite peers without the extension see consecutive frames. Rust drops every OBJECT\_DATAGRAM, since a moq-lite track takes either streams or datagrams and Rust does not yet map a [datagram track](/concept/moq-lite#datagrams) onto moq-transport; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none, as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped between draft 18 and later drafts, or refused when immutable, because draft 20 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. A status object carrying properties is refused. A `FETCH` has no status objects, so its response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. - **Dynamic groups.** A track advertises `DYNAMIC_GROUPS` when its publisher sets a refresh ratio, and a track received with `DYNAMIC_GROUPS` gets a ratio of 0. A `NEW_GROUP_REQUEST` counts as one more subscriber joining, which a `SUBSCRIBE` already is: it asks for a new group only when the newest group's bytes after its first frame exceed the ratio times the first frame. Joins coalesce until the asked-for group arrives. A Rust relay sends `NEW_GROUP_REQUEST` upstream, on the `SUBSCRIBE` or in a `REQUEST_UPDATE`, once a join wants a group past the newest it holds; a relay holding nothing yet asks for none. On a track without a ratio, an update carrying one ends the subscription with `UPDATE_FAILED`. A moq-lite-07 hop (`moq-lite-07-wip`, opt-in) carries the same demand natively: the ratio in `TRACK_INFO` and the asked-for group in `SUBSCRIBE_UPDATE`. Earlier moq-lite versions carry neither, so a track received over one never refreshes. - **Priority.** Higher is served first. This stack's priorities run from -128 to 127 with 0 unset, and go out on the IETF wire, which ranks lower first, as `127 - priority`: an unset priority is IETF 127. An omitted subscriber or publisher priority reads as the drafts' default of IETF 128, one step below unset, so a relay forwards it unchanged. A relay's upstream subscription carries the highest priority its subscribers ask for and updates it as they come and go; its group order stays as the `SUBSCRIBE` asked, since no draft lets an update change it. - **Size.** An object extension block larger than 64 KiB ends that subgroup stream. The session stays up. This cap is ours, not the draft's. diff --git a/doc/concept/stats.md b/doc/concept/stats.md index b2fc040f48f..3c54f1d5650 100644 --- a/doc/concept/stats.md +++ b/doc/concept/stats.md @@ -370,7 +370,7 @@ limit: the track then ends with `FRAME_TOO_LARGE` instead of going stale. "fetches": 0, "bytes": 1048576, "frames": 900, "groups": 30, "datagrams": 0, "inflated": 0, - "stale": { "bytes": 0, "frames": 0, "groups": 0, "datagrams": 0 }, + "stale": { "bytes": 0, "frames": 0, "groups": 0 }, "announced": 1, "announced_closed": 0, "broadcasts": 3, "broadcasts_closed": 1, "subscriptions": 6, "subscriptions_closed": 2 @@ -385,10 +385,10 @@ limit: the track then ends with `FRAME_TOO_LARGE` instead of going stale. | `broadcasts_started` / `broadcasts_ended` | On egress (`publisher`), a session's first subscription to the broadcast and its last one closing. Started minus ended is the viewer count. Ingress (`subscriber`) leaves both at zero: it does not count viewers. | | `subscriptions_started` / `subscriptions_ended` | Track subscriptions opened and closed. | | `fetches` | One-shot group fetches requested, including ones that found nothing. Their payload counts in `bytes`, `frames`, and `groups`. | -| `bytes` / `frames` / `groups` | Payload delivered. | -| `datagrams` | Groups delivered as an unreliable datagram. A subset of `groups`. | -| `inflated` | Bytes inflated from compressed groups for plain readers, counted once per group however many share the copy, plus received compressed datagrams, which inflate on arrival. Not part of `bytes`, which counts payload as stored or sent. | -| `stale` | Payload skipped because it aged past a subscriber's latency budget, with the same four fields. Disjoint from the top-level payload counters. | +| `bytes` / `frames` / `groups` | Payload delivered. `bytes` includes datagram payloads. | +| `datagrams` | Datagrams of datagram tracks delivered. A datagram belongs to no group, so it counts in neither `groups` nor `frames`. | +| `inflated` | Bytes inflated from compressed groups for plain readers, counted once per group however many share the copy. Not part of `bytes`, which counts payload as stored or sent. | +| `stale` | Payload skipped because it aged past a subscriber's latency budget, with `bytes`, `frames`, and `groups`. Disjoint from the top-level payload counters. | The last six fields are legacy spellings of the `*_started` and `*_ended` counters, still written so an older consumer reads a newer relay. A reader diff --git a/drafts/draft-lcurley-moq-e2ee.md b/drafts/draft-lcurley-moq-e2ee.md index aacf9cf94d5..94d5edf991e 100644 --- a/drafts/draft-lcurley-moq-e2ee.md +++ b/drafts/draft-lcurley-moq-e2ee.md @@ -74,7 +74,7 @@ It differs where the models diverge: - The nonce is the identity counter itself, not a salt XOR. The derived key is already unique per credential, epoch, physical name, and domain. - The payload is ciphertext concatenated with the 16-byte tag, with no inner length prefix, encrypted properties, or padding. - moq-lite frame indices are implied by position in the group ({{moql}} Section "Frame"), not an on-wire object ID. This profile still uses that index as the 32-bit nonce half, because it is the only canonical end-to-end frame identity on both moq-lite and MoQ Transport. -- moq-lite datagrams share the group sequence namespace of the same track ({{moql}} Section "Datagrams"). A grouped frame 0 and a datagram with that sequence would collide under one AES-GCM key, so datagrams use a separate key domain with frame ID zero. +- A datagram belongs to a datagram track, which numbers its datagrams with its own sequence and may share its name with a stream track ({{moql}} Section "Datagrams"). A grouped frame 0 and a datagram at that sequence would collide under one AES-GCM key, so datagrams use a separate key domain with frame ID zero. SFrame {{sframe}} is the cryptographic ancestor of {{secure}}. This profile is not SFrame: it has no SFrame header, no CTR field on the wire, and no per-object KID. @@ -224,6 +224,7 @@ key = HKDF-Expand(prk, key_info, 16) ~~~ `domain` is a single byte: `0x00` for grouped frames, `0x01` for datagrams. +A stream track and a datagram track of one semantic name share its physical name and differ only in `domain`. A given `(generation, semantic_name, domain)` tuple has one key and one physical name. A subscriber derives both from the semantic name it wants; a publisher derives them from the semantic name it creates or recovers from a request. @@ -240,8 +241,8 @@ Publishers supporting both transports MUST assign contiguous Object IDs from zer An Object ID above `2^32-1` MUST be refused as `identity` before encryption or decryption. ## Datagrams -A datagram uses `domain = 0x01`, its 64-bit sequence as `group`, and `frame = 0`. -On MoQ Transport, a datagram is an Object at ID 0 in an OBJECT_DATAGRAM whose Group ID is the sequence. +A datagram uses `domain = 0x01`, its datagram track's sequence as `group`, and `frame = 0`. +That sequence is the Sequence of a moq-lite-07 datagram, the Group Sequence of a moq-lite-05 or -06 Group Datagram, and the Group ID of a MoQ Transport OBJECT_DATAGRAM at Object ID 0. Shared vectors cover grouped tracks on both transports and datagrams on moq-lite only. ## Nonce @@ -264,7 +265,7 @@ AES-128-GCM encrypts the application bytes with `key`, `nonce`, and empty AAD. The bytes placed in the MoQ frame or datagram payload are ciphertext concatenated with the 16-byte tag, in the {{RFC5116}} convention. There is no inner header. -Within a generation a publisher MUST allocate group and datagram sequences monotonically and frame indices in write order, so an identity is encrypted at most once. +Within a generation a publisher MUST allocate each semantic name's group sequences, and separately its datagram sequences, monotonically, and frame indices in write order, so an identity is encrypted at most once. Encrypting at an identity the same instance already used is `reuse` and MUST be refused. Relays and caches forward ciphertext unchanged, so replay from a cache never re-encrypts. @@ -273,8 +274,8 @@ Protected payload length is plaintext length plus `Nt`. A publisher MUST refuse plaintext that would make the protected payload exceed the transport payload limit for that object (`oversize`), before it touches the network. The interoperable grouped-frame payload cap matching current moq-net implementations is 32 MiB, so grouped plaintext MUST be at most `32 MiB - 16` bytes. -moq-lite datagram bodies MUST remain at most 1200 bytes including Type, Subscribe ID, Group Sequence, and Timestamp ({{moql}} Section "Datagrams"). -The Type is one byte and each of those three varints at most 9 bytes, so datagram plaintext MUST be at most `1200 - 28 - 16 = 1156` bytes; a publisher cannot observe the Subscribe ID each hop will encode and MUST NOT budget for a smaller header. +moq-lite datagram bodies MUST remain at most 1200 bytes including Type, Subscribe ID, Sequence, and Timestamp ({{moql}} Section "Datagrams"). +The moq-lite-07 Type is one byte and each of those three varints at most 9 bytes, the widest header of any moq-lite version, so datagram plaintext MUST be at most `1200 - 28 - 16 = 1156` bytes; a publisher cannot observe the Subscribe ID each hop will encode and MUST NOT budget for a smaller header. ## Catalogs A catalog is a track like any other: published under the physical name derived from its semantic name, with each snapshot or delta protected at its own group and frame identity. @@ -392,6 +393,7 @@ This document requests no registrations. - Forbid applications from relying on group completeness, which is unauthenticated. - Derive physical track names by AES-128-GCM encryption of the padded semantic name under an HMAC-SHA-256 nonce, so a publisher recovers any requested name, and bind AEAD keys to the semantic name. - Drop a datagram that fails to open with its own `identity`, `oversize`, or `authentication` code; an `exhausted` open still fails the read. +- A datagram belongs to a datagram track with its own sequence, independent of a stream track of the same name; the datagram domain keeps the two apart. ## draft-lcurley-moq-e2ee-00 {:numbered="false"} diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index e5dd9fe4c81..027ac9ac54c 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -164,6 +164,10 @@ The subscriber and publisher both indicate their delivery preference: The combination of these preferences enables the most important content to arrive during network degradation while still respecting encoding dependencies. +A Track is instead a datagram Track when its publisher creates it as one: a series of unreliable QUIC datagrams with no Groups (see [Datagrams](#datagrams)). +Which kind a Track is never changes, and the wire does not say; the application knows it from its catalog or by convention. +Datagram Tracks have a namespace apart from other Tracks, so a datagram Track and a Track of Groups may share a name and are independent. + ## Group A Group is an ordered stream of Frames within a Track. @@ -174,8 +178,6 @@ This ensures that all Frames within a Group arrive reliably and in order. In contrast, Groups may arrive out of order due to network congestion and prioritization. The application SHOULD process or buffer groups out of order to avoid blocking on flow control. -A small single-frame Group MAY instead be transmitted as a QUIC datagram when reliability is not required (see [Datagrams](#datagrams)). - ## Frame A Frame is a payload of bytes within a Group. @@ -187,7 +189,7 @@ 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 of a timed Track carries a presentation timestamp expressed in the Track's `Timescale` (see [TRACK_INFO](#track-info)), used by the moq-lite layer for [expiration](#expiration) decisions. -A Track without a `Timescale` is untimed: its frames and datagrams carry no timestamp. +A Track without a `Timescale` is untimed: its frames carry no timestamp. A timestamp MUST be less than 2^61, so every version can carry it; a receiver treats a larger one as a malformed Track. Unless a negotiated extension specifies otherwise, a publisher MUST NOT start a timed Group's frame 0 earlier than that of any lower-sequence Group it publishes. A receiver MAY check a received Group against this rule, refusing a violating Group's stream (STOP_SENDING) with TIMESTAMP_MISMATCH. @@ -238,7 +240,7 @@ All five carry the same control and data streams defined elsewhere in this docum | 5 | Qmux over RTCDataChannel | `moq-lite-xx` (signaling and DataChannel protocol) | DataChannel message | For bindings 1 and 2, moq-lite uses the underlying QUIC/WebTransport stream APIs directly. -QUIC datagrams (see [Datagrams](#datagrams)) are supported by bindings 1 and 2 only; a publisher MUST NOT emit datagrams on bindings 3, 4, and 5. +QUIC datagrams (see [Datagrams](#datagrams)) are supported by bindings 1 and 2 only; a publisher MUST NOT emit datagrams on bindings 3, 4, and 5, and a subscriber SHOULD NOT open a [Datagrams Stream](#datagrams-stream) there. For binding 3, a client opens a TCP connection, performs a TLS handshake, and negotiates the moq-lite version as the ALPN token. Each direction of the TLS byte stream then carries Qmux Records as defined in [qmux]. @@ -349,8 +351,6 @@ Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (ST | ------- | ------------- | ----------- | | 0x39 | TIMESTAMP_MISMATCH | A frame's timestamp does not fit its track: it is present on an untimed track, absent on a timed one, or starts a Group before a lower-sequence one. | | ------- | ------------- | ----------- | -| 0x3A | NOT_FETCHABLE | The FETCH named a group delivered only as a datagram, which is never cached (see [Datagrams](#datagrams)). | -| ------- | ------------- | ----------- | | 0x3B | UNAUTHORIZED | The endpoint does not authorize this request, or no longer does, such as when the [scope](#auth-stream) does not cover it. The session stays up. | | ------- | ------------- | ----------- | @@ -385,6 +385,8 @@ There's a 1-byte STREAM_TYPE at the beginning of each stream. | ------- | ------------- | ----------- | | 0x8 | Tracks | Subscriber | | ------- | ------------- | ----------- | +| 0x9 | Datagrams | Subscriber | +| ------- | ------------- | ----------- | ### Announce A subscriber can open an Announce Stream to discover routes matching a prefix. @@ -557,7 +559,7 @@ The publisher replies with listing events, then FINs the stream once the Broadca - [TRACKS_FINISH](#tracks-finish): a listed Track no longer is because it finished. - [SYNCED](#synced): every Track this hop listed when the stream opened, and still lists, has been sent. -A Broadcast lists the Tracks its publisher produces. +A Broadcast lists the Tracks its publisher produces, except datagram Tracks, which are never listed: TRACK_INFO does not say which kind a Track is, so a subscriber could not tell whether to SUBSCRIBE or send DATAGRAMS. A Track the publisher serves only on request, such as any name a transcoder answers for, is never listed, and listing never creates one. A removal, either TRACKS_REMOVE or TRACKS_FINISH, promises only that the Track is no longer listed; a SUBSCRIBE for its name MAY still succeed. TRACKS_FINISH says the Track's publisher declared its end, so its Groups are all the Track will hold; TRACKS_REMOVE says it left unfinished, such as a publisher that went away or a route that was withdrawn, so it may go on elsewhere. @@ -577,6 +579,19 @@ A FIN before SYNCED means the Broadcast ended. A publisher MUST reset a Tracks Stream with NOT_FOUND when the route it would serve the path from covers it by a shorter prefix: a route claims capability, not inventory, so it names no Broadcast to list. A subscriber MAY skip the Track Stream for a Track it learned from TRACKS_ADD, since the properties are the same immutable ones. +### Datagrams {#datagrams-stream} +A subscriber opens a Datagrams Stream (0x9) to subscribe to a datagram Track (see [Datagrams](#datagrams)). + +The subscriber sends a [DATAGRAMS](#datagrams-message) message naming the broadcast path, Instance, and Track name, with its priority and a Subscribe ID. +The publisher replies with [DATAGRAMS_INFO](#datagrams-info) once it serves the Track, then sends the Track's datagrams, each carrying that Subscribe ID. +The subscription is live: it has no start, end, order, or age, and receives only datagrams the Track produces after the publisher answers. +The subscriber MAY send any number of [DATAGRAMS_UPDATE](#datagrams-update) messages, each replacing its priority. +A datagram still waits behind higher-priority traffic. + +A publisher with no datagram Track of that name answers as for any missing Track, resetting the stream with NOT_FOUND, and a rejection is a reset as for [SUBSCRIBE](#subscribe). +The publisher FINs the stream once the Track ends, and the subscriber then FINs its own side; a reset ends the Track with that error. +A subscriber MAY cancel by closing its side (FIN); the publisher then closes its side. + ### Probe A subscriber opens a Probe Stream (0x4) to measure, and optionally increase, the available bitrate of the connection. The publisher advertises its Probe level in SETUP (see [Probe Parameter](#probe-parameter)): None, Report (measure only), or Increase (measure and actively probe). @@ -642,7 +657,7 @@ The subscriber's `Group Order` determines which Group within the Track is transm A publisher SHOULD attempt to transmit streams based on these rules. This depends on the QUIC implementation and it may not be possible to get fine-grained control. -A publisher SHOULD order a [datagram](#datagrams) among the subscription's streams as if it were its Group's stream, ahead of that stream when both exist. +A [datagram](#datagrams) is ranked by its Datagrams Stream's priority like a Group Stream, the newest first among them. ### Priority The `Subscriber Priority` is scoped to the connection and MAY change over the life of the subscription via SUBSCRIBE_UPDATE. @@ -769,20 +784,15 @@ The subscriber MAY cache the error and potentially retry later. ## Datagrams QUIC datagrams provide unreliable, unordered delivery for latency-sensitive content that does not need retransmission. +They carry the datagrams of a datagram Track: each has the Track's own Sequence and a Timestamp, and belongs to no Group. +A datagram is never cached, stored, retransmitted, or returned by FETCH, and a subscriber receives one only through a [Datagrams Stream](#datagrams-stream). +A publisher SHOULD drop a datagram rather than block when the transport has no room for it. -Every datagram begins with a Type (i): 0x0 for a Group Datagram, or 0x1 for a Padding Datagram. +Every datagram begins with a Type (i): 0x0 for a Track Datagram, or 0x1 for a Padding Datagram. A receiver MUST discard a datagram with an unknown type. The total datagram body, including Type, MUST NOT exceed 1200 bytes; a sender MUST NOT send a larger one and a receiver MUST silently drop it. -### Group Datagram - -A publisher MAY transmit a Group consisting of exactly one Frame as a single QUIC datagram, in addition to (or instead of) opening a Group Stream, based on application hints, group size, and network conditions; a multi-frame Group is delivered via a Group Stream only. -A publisher sends a datagram to a subscription only when its Group Sequence is inside one of the subscription's Ranges, where a start Position past frame 0 excludes its group (see [Positions](#positions)). -A datagram is never cached or retransmitted, and FETCH never returns one. -A publisher MAY send a new subscription the datagrams still in a short send buffer, so a subscriber cannot assume a datagram was sent after it subscribed. -A publisher that knows a FETCH names a group delivered only as a datagram resets the stream with NOT_FETCHABLE; otherwise it answers as for a group that does not exist, with NOT_FOUND. -A publisher SHOULD drop a datagram rather than block when the transport has no room for it. -There is no separate subscription for datagram delivery: datagrams are routed to existing subscriptions via the Subscribe ID, and a subscriber receiving the same group via both a stream and a datagram MUST deduplicate by group sequence. +### Track Datagram Each datagram body has the following encoding (note: there is no message length prefix; the QUIC datagram boundary delimits the payload): @@ -790,31 +800,27 @@ Each datagram body has the following encoding (note: there is no message length DATAGRAM Body { Type (i) = 0x0 Subscribe ID (i) - Group Sequence (i) + Sequence (i) [Timestamp (i)] - [Inflated Size (i)] Payload (b) } ~~~ **Subscribe ID**: -The Subscribe ID of an active subscription on the same session. -A subscriber receiving a datagram with an unknown Subscribe ID MUST silently drop it. +The Subscribe ID of an active Datagrams Stream on the same session. +A subscriber MUST silently drop a datagram with an unknown Subscribe ID, or one that arrives before its stream's DATAGRAMS_INFO, since it cannot tell whether a Timestamp follows. -**Group Sequence**: -The absolute sequence number of the group carried by this datagram. +**Sequence**: +The datagram's sequence number within its datagram Track, which the publisher assigns in the order it produces them. +It is the Track's own, not a Group Sequence. **Timestamp**: -The absolute timestamp of the single frame in the group, expressed in the Track's `Timescale`. -Present only on a timed Track, which the receiver learns from the subscription named by `Subscribe ID`. - -**Inflated Size**: -The size of the payload once inflated. -Present only when the Track's Compression is not none; the datagram payload is then compressed alone, as a stream of its own. +The absolute presentation timestamp of the datagram, in the Track's `Timescale`. +Present only when DATAGRAMS_INFO's `Timescale` is not 0; on a Track with a `Timescale` of 0, the Sequence stands in for the datagram's time. **Payload**: -The frame payload, extending to the end of the datagram. -A group whose frame does not fit is simply not eligible for datagram delivery. +The payload, extending to the end of the datagram. +A payload that does not fit is not sent. ### Padding Datagram @@ -1416,7 +1422,7 @@ The unit is milliseconds, matching `Subscriber Max Age`. See the [Expiration](#expiration) section for more information. **Timescale**: -The number of timestamp units per second for frame timestamps on this Track, or `0` for an untimed Track, whose FRAME messages and datagrams carry no timestamp. +The number of timestamp units per second for frame timestamps on this Track, or `0` for an untimed Track, whose FRAME messages carry no timestamp. Common values include `1000` (milliseconds), `1000000` (microseconds), `48000` (audio sample rate), and `90000` (RTP video clock). **Refresh**: @@ -1429,7 +1435,7 @@ A relay forwards its joins' demand in SUBSCRIBE_UPDATE's `Wanted Group`. **Compression**: How each Group of this Track is compressed: none (0x0), or DEFLATE (0x1), each Group one DEFLATE stream as specified by {{moqflate}}. Any other value is a PROTOCOL_VIOLATION. -When Compression is not none, FRAME messages and datagrams carry an `Inflated Size`. +When Compression is not none, FRAME messages carry an `Inflated Size`. ## TRACKS {#tracks} TRACKS is sent by a subscriber to list a Broadcast's Tracks. @@ -1496,6 +1502,50 @@ TRACKS_FINISH Message { **Track Name**: The name a TRACKS_ADD on the stream listed. +## DATAGRAMS {#datagrams-message} +DATAGRAMS is sent by a subscriber to subscribe to a datagram Track. +It is the first message on a [Datagrams Stream](#datagrams-stream) (0x9). + +~~~ +DATAGRAMS Message { + Message Length (i) + Subscribe ID (i) + Broadcast Path (s) + Instance (b) + Track Name (s) + Subscriber Priority (8) +} +~~~ + +**Subscribe ID**: +A unique identifier chosen by the subscriber, from the same space as SUBSCRIBE's: it MUST NOT reuse the Subscribe ID of any SUBSCRIBE or DATAGRAMS within the same session. + +See [SUBSCRIBE](#subscribe) and [TRACK](#track) for the other fields. + +## DATAGRAMS_UPDATE {#datagrams-update} +A subscriber changes the priority of a Datagrams Stream with DATAGRAMS_UPDATE, each replacing the last. + +~~~ +DATAGRAMS_UPDATE Message { + Message Length (i) + Subscriber Priority (8) +} +~~~ + +## DATAGRAMS_INFO {#datagrams-info} +DATAGRAMS_INFO is the publisher's answer on a Datagrams Stream: the datagram Track's properties, which a subscriber needs to read its datagrams. + +~~~ +DATAGRAMS_INFO Message { + Message Length (i) + Publisher Priority (8) + Timescale (i) +} +~~~ + +Both fields are fixed for the lifetime of the Track, as [TRACK_INFO](#track-info)'s are, and mean what they do there. +A datagram Track has no compression: each payload is sent as written. + ## SUBSCRIBE_OK {#subscribe-ok} A SUBSCRIBE_OK message confirms a subscription and resolves where each Range starts. It is the first message the publisher sends on the Subscribe Stream, once those starts are known, except that SUBSCRIBE_FINISH MAY precede it. @@ -1574,11 +1624,10 @@ SUBSCRIBE_DROP Message { **Error Code** is a [stream error code](#error-codes) saying why the groups were given up, as a reset retaining the Group Stream header would: for example NOT_FOUND for a sequence the publisher never had, or OLD for one past its budget. A subscriber MUST end the readers of a dropped group it holds with that error, including a group whose stream is still arriving, rather than end it cleanly. -For every sequence in the subscription's range, the publisher MUST send a Group Stream, send a datagram, or name it in SUBSCRIBE_DROP. +For every sequence in the subscription's range, the publisher MUST send a Group Stream or name it in SUBSCRIBE_DROP. This includes sequences it skipped and streams reset before their headers were delivered. A reliably delivered reset retaining the Group Stream header accounts for that group instead. The publisher SHOULD send SUBSCRIBE_DROP as soon as it gives a sequence up, without waiting for SUBSCRIBE_FINISH. -A datagram counts as sent even if it is lost; a subscriber SHOULD bound its wait for unaccounted sequences, for example by Subscriber Max Age. ## FETCH FETCH is sent by a subscriber to request a single group from a track. @@ -1885,8 +1934,8 @@ The `Message Length` describes the payload size on the wire. - Named 128 the midpoint priority for an unset Subscriber or Publisher Priority. - Made TRACK_INFO Publisher Max Age optional, encoded as milliseconds plus one with zero meaning no limit. - Added TRACK_INFO `Refresh` and SUBSCRIBE_UPDATE `Wanted Group`, carrying ratio-based refresh demand through relays. -- Added `Compression` to TRACK_INFO: none (0x0) or DEFLATE (0x1), whose Groups are DEFLATE streams per draft-lcurley-moq-flate. FRAME and datagrams carry an `Inflated Size` when it is not none. -- An untimed Track declares a `Timescale` of 0 in TRACK_INFO, and its FRAME messages and datagrams omit the timestamp. +- Added `Compression` to TRACK_INFO: none (0x0) or DEFLATE (0x1), whose Groups are DEFLATE streams per draft-lcurley-moq-flate. FRAME carries an `Inflated Size` when it is not none. +- An untimed Track declares a `Timescale` of 0 in TRACK_INFO, and its FRAME messages omit the timestamp. - A publisher must not start a timed Group's frame 0 earlier than a lower-sequence Group's, and a receiver may refuse a Group that does. A relay serves every Track's timestamps on as received. - Bounded every timestamp below 2^61. A larger one is a malformed Track. @@ -1901,7 +1950,7 @@ The `Message Length` describes the payload size on the wire. - 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. - Renamed SUBSCRIBE_END to SUBSCRIBE_FINISH, matching TRACKS_FINISH: it ends the Track, not the subscription. - Added `Error Code` to SUBSCRIBE_FINISH: a track can end with an error while keeping the groups below its end. -- Restored SUBSCRIBE_DROP (type 0x2), replacing Stream Count on SUBSCRIBE_FINISH. Every sequence is accounted for by a Group Stream, a datagram, or a drop; a publisher SHOULD send a drop as soon as it gives a sequence up. +- Restored SUBSCRIBE_DROP (type 0x2), replacing Stream Count on SUBSCRIBE_FINISH. Every sequence is accounted for by a Group Stream or a drop; a publisher SHOULD send a drop as soon as it gives a sequence up. - SUBSCRIBE_DROP's Error Code is a stream error code, which ends the dropped groups' readers, including one still arriving. - Refuse a SUBSCRIBE_DROP whose Group Start is greater than Group End plus one on every version that carries it. - SUBSCRIBE_FINISH declares the final boundary while lower groups can still be in flight, and no longer closes the Subscribe Stream: the publisher keeps reading SUBSCRIBE_UPDATE, so an update can reach an ended Track's history. @@ -1911,12 +1960,11 @@ The `Message Length` describes the payload size on the wire. ### Delivery {#changelog-07-delivery} -- Added a leading Type to datagrams: Group (0x0) or Padding (0x1), which either endpoint discards without changing session or subscription state. +- Replaced group datagrams with datagram Tracks, a namespace apart from Tracks of Groups. A subscriber subscribes to one on the Datagrams Stream (0x9) with DATAGRAMS and DATAGRAMS_UPDATE, which carry only a priority, and the publisher answers with DATAGRAMS_INFO. A datagram carries the Track's own Sequence and a Timestamp, with no Group, Inflated Size, or Range. Datagrams no longer count toward SUBSCRIBE_DROP, and a subscriber no longer deduplicates them against Group Streams. +- Added a leading Type to datagrams: Track (0x0) or Padding (0x1), which either endpoint discards without changing session or subscription state. - Expiration also measures a group's arrival age, the time since its successor arrived at this hop, so a group of an untimed Track expires by arrival age alone. - A publisher resets the Group Stream of an expired, unfinished group with OLD rather than finishing it, even after sending every frame it holds. -- A subscription's range bounds datagrams like groups, and FETCH never returns a datagram. -- A publisher SHOULD order a datagram among the subscription's streams as if it were its Group's stream, ahead of that stream when both exist. It SHOULD drop a datagram rather than block when the transport has no room for it, where it previously sent one only if the congestion controller could transmit it immediately. -- Assigned 0x3A NOT_FETCHABLE in the stream error table: a FETCH for a group delivered only as a datagram. +- A publisher SHOULD drop a datagram rather than block when the transport has no room for it, where it previously sent one only if the congestion controller could transmit it immediately. - Added an optional `Subscriber Max Age` to FETCH, encoded as milliseconds plus one with zero meaning absent. It can tighten, but never relax, `Publisher Max Age`. ## moq-lite-06 @@ -2097,7 +2145,7 @@ Hop IDs (see [ANNOUNCE_OK](#announce-ok) and [ANNOUNCE_START](#announce-start)) A peer can open many streams (subscriptions, announcements, fetches), request large announce prefixes, or advertise broad routes. Implementations SHOULD bound the number of concurrent subscriptions, announce matches, and cached groups; an endpoint whose peer goes past its bound on subscriptions or announcements SHOULD close the session with TOO_MANY_REQUESTS, and SHOULD rely on QUIC flow control and stream limits to backpressure a misbehaving peer (see [ANNOUNCE_REQUEST](#announce-request)). Expiration (see [Expiration](#expiration)) bounds how long stale groups consume memory and flow control. A broad route invites a request for any covered path, each of which may start work: an advertiser SHOULD bound the work it starts, withdrawing its route before it runs out, and every refusal is terminal, so a flood of requests costs the mesh one round trip each rather than a search (see [Resolution](#resolution)). ## Datagram Injection -Datagrams are routed to a subscription solely by Subscribe ID and carry no per-group authentication beyond that of the QUIC connection. On an unmodified QUIC/WebTransport connection this is sufficient, since datagrams are protected by the transport. A subscriber MUST silently drop any datagram with an unknown Subscribe ID and MUST deduplicate against groups received on streams (see [Datagrams](#datagrams)). +Datagrams are routed to a subscription solely by Subscribe ID and carry no per-group authentication beyond that of the QUIC connection. On an unmodified QUIC/WebTransport connection this is sufficient, since datagrams are protected by the transport. A subscriber MUST silently drop any datagram with an unknown Subscribe ID (see [Datagrams](#datagrams)). ## Opaque Payloads The moq-lite layer treats Frame payloads as opaque and performs no validation of their contents. Confidentiality or integrity of the media itself (e.g. end-to-end encryption transparent to relays) is an application concern and out of scope for this draft. diff --git a/go/wrapper/README.md b/go/wrapper/README.md index 4ef501f19bc..a36c8f5725a 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -163,7 +163,7 @@ sends one `Frame` and returns its sequence number (`InsertDatagram` takes the ca own sequence instead), while `TrackConsumer.RecvDatagram` and `TrackConsumer.Datagrams` receive them in arrival order. Payloads are capped at 1200 bytes. Datagram delivery requires a datagram-capable transport and lite-05 or -newer moq-lite, or moq-transport; pre-lite-05, WebSocket, and TCP paths do not +newer moq-lite; moq-transport, pre-lite-05, WebSocket, and TCP paths do not deliver them, and there is no stream fallback. ## Versioning diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 73942d39b48..4ff1c20af46 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -551,10 +551,6 @@ func TestInsertDatagramAndLargest(t *testing.T) { if err != nil { t.Fatal(err) } - if largest, err := track.Largest(); err != nil || largest != nil { - t.Fatalf("empty track: largest %v, err %v", largest, err) - } - if err := track.InsertDatagram(7, moq.Frame{Payload: []byte("seven"), Timestamp: ts(0)}); err != nil { t.Fatal(err) } @@ -565,16 +561,13 @@ func TestInsertDatagramAndLargest(t *testing.T) { if datagram == nil || datagram.Sequence != 7 || string(datagram.Payload) != "seven" { t.Fatalf("got datagram %+v", datagram) } - largest, err := track.Largest() - if err != nil { - t.Fatal(err) - } - if largest == nil || *largest != (moq.Position{Group: 7, Frame: 0}) { - t.Fatalf("largest %+v", largest) - } if next, err := track.AppendDatagram(moq.Frame{Payload: []byte("next"), Timestamp: ts(0)}); err != nil || next != 8 { t.Fatalf("appended at %d, err %v", next, err) } + // Datagrams are numbered apart from groups, so they never move Largest. + if largest, err := track.Largest(); err != nil || largest != nil { + t.Fatalf("largest %v, err %v", largest, err) + } } func TestBroadcastConsumerClosed(t *testing.T) { diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 4ad723616a2..4a7d2fca318 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -222,7 +222,7 @@ func (t *TrackProducer) InsertDatagram(sequence uint64, frame Frame) error { return t.inner.InsertDatagram(sequence, frame) } -// Largest reports the newest group or datagram written, at its newest frame, or nil +// Largest reports the newest group written, at its newest frame, or nil // before any. func (t *TrackProducer) Largest() (*Position, error) { return t.inner.Largest() diff --git a/kt/README.md b/kt/README.md index 1fb202ee284..fe9ecd6fe69 100644 --- a/kt/README.md +++ b/kt/README.md @@ -62,7 +62,7 @@ The `dev.moq` package is intentionally thin: Kotlin has extension functions, so - **Fetched media**: `dev.moq.media.GroupConsumer.fetch(...).frames()` streams the decoded frames of one retained group, then completes. `dev.moq.media.GroupConsumer(group, container)` decodes a raw group instead, such as one from `recvGroup()` for arrival-order reads. - **Timestamp conversions** (`Timestamp.kt`): media timestamps are exact `Timestamp(value, scale)` records. `Duration.toTimestamp()` builds one exactly at nanosecond scale, and `Timestamp.toDuration()` reads one back as a `kotlin.time.Duration`, rounded down to a whole nanosecond. Durations such as `stats.rtt` are already `java.time.Duration`. - **`logLevel(...)`**: configures native Rust tracing without importing the raw bindings package. -- **Raw datagrams**: `TrackProducer.appendDatagram(Frame(payload, timestamp))` sends one best-effort frame and returns its sequence, and `insertDatagram(sequence, frame)` takes the caller's own; `TrackConsumer.recvDatagram()` and `datagrams()` receive them. Payloads are capped at 1200 bytes, require a datagram-capable transport plus lite-05 or newer moq-lite or moq-transport, and have no stream fallback. +- **Raw datagrams**: `TrackProducer.appendDatagram(Frame(payload, timestamp))` sends one best-effort frame and returns its sequence, and `insertDatagram(sequence, frame)` takes the caller's own; `TrackConsumer.recvDatagram()` and `datagrams()` receive them. Payloads are capped at 1200 bytes, require a datagram-capable transport plus lite-05 or newer moq-lite, and have no stream fallback. - **`MoqException.isShutdown`** (`Errors.kt`): true for the graceful `Cancelled`/`Closed` cases. ## Versioning diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index d9457e4456b..4630a685467 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -184,7 +184,7 @@ client = moq.Client( - `.abort(error_code)` terminates the track or group with an application error. - `.append_datagram(payload, timestamp=None) -> sequence` (`TrackProducer`) sends a best-effort datagram. Payloads are capped at 1200 bytes and there is no stream fallback. - `.insert_datagram(sequence, payload, timestamp=None)` (`TrackProducer`) sends one at the caller's own sequence. - - `.largest() -> Position | None` (`TrackProducer`) reports the newest group or datagram written, at its newest frame. + - `.largest() -> Position | None` (`TrackProducer`) reports the newest group written, at its newest frame. Datagrams are numbered apart from groups and never move it. ### Media @@ -274,7 +274,7 @@ Every handle whose cleanup is `cancel()` is an async context manager, so exiting - **`Timestamp`**. `.value: int` units at `.scale: int` units per second, exact at any scale such as 90 kHz. `Timestamp.from_timedelta(duration)` is exact at microsecond scale; `.to_timedelta()` rounds down to a whole microsecond. A write with a scale of 0 or past `2**62 - 1`, or a value past `2**61 - 1`, raises `Error.TimeOverflow`. - **`Frame`**. `.payload: bytes`, `.timestamp: Timestamp | None`. The unit of every write and every raw read; `None` means untimed when publishing or reading. - **`media.MediaFrame`**. `.payload: bytes`, `.timestamp: Timestamp`, `.keyframe: bool`. The frame variant of a media read. `keyframe` marks a group start or video keyframe; for audio it is true only at a group start. -- **`Datagram`**. `.sequence: int`, `.timestamp: Timestamp | None`, `.payload: bytes`. Delivered only on datagram-capable transports with lite-05 or newer moq-lite, or moq-transport. +- **`Datagram`**. `.sequence: int`, `.timestamp: Timestamp | None`, `.payload: bytes`. Delivered only on datagram-capable transports with lite-05 or newer moq-lite. - **`media.Audio`**. `.codec`, `.sample_rate`, `.channel_count`, `.bitrate`, `.enabled`, `.description`. - **`media.Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.enabled`, `.framerate`, `.description`. A false `.enabled` means no frames are coming, so don't select the rendition. - **`Subscription`**. Subscriber delivery preferences: priority, staleness, and optional group range. diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 6b1456b9c31..4b1482e1876 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -139,7 +139,7 @@ def insert_datagram(self, sequence: int, payload: bytes, timestamp: Timestamp | self._inner.insert_datagram(sequence, Frame(payload, timestamp)._ffi()) def largest(self) -> Position | None: - """The newest group or datagram written, at its newest frame; ``None`` before any.""" + """The newest group written, at its newest frame; ``None`` before any. Datagrams never move it.""" return self._inner.largest() def consume(self, subscription: Subscription | None = None) -> TrackConsumer: diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index bcf801290ca..770d8e0ede1 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -118,18 +118,18 @@ async def reject(): await task -async def test_insert_datagram_keeps_its_sequence_and_largest_follows(): +async def test_insert_datagram_keeps_its_sequence_apart_from_groups(): broadcast = moq.BroadcastProducer() track = broadcast.publish_track("events") consumer = track.consume() - assert track.largest() is None track.insert_datagram(7, b"seven") datagram = await asyncio.wait_for(consumer.recv_datagram(), 5) assert datagram is not None assert (datagram.sequence, datagram.payload) == (7, b"seven") - assert track.largest() == moq.Position(group=7, frame=0) assert track.append_datagram(b"next") == 8 + # Datagrams are numbered apart from groups, so they never move `largest`. + assert track.largest() is None async def test_broadcast_consumer_closed_resolves_on_close(): diff --git a/quest/m1/datagram-tracks/README.md b/quest/m1/datagram-tracks/README.md index f787a92fe2a..143c558c70e 100644 --- a/quest/m1/datagram-tracks/README.md +++ b/quest/m1/datagram-tracks/README.md @@ -61,26 +61,50 @@ Decided 2026-11-01 (maintainer, in `/quest-plan`): - Outbound, a datagram track sends only OBJECT_DATAGRAM with object ID 0. - **Archive.** ✅ moq-archive skips datagram tracks. A datagram track is live-only, and an archive's playback doesn't include it. +- **Old versions carry both kinds.** ✅ Decided 2026-11-03 (maintainer, on + [moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752)): "carry both + on old versions, like moq-transport. Ignore duplicates because in practice, + only one was being used anyway." + - A lite-05/06 SUBSCRIBE for a name carries its stream track's groups and + its datagram track's datagrams, as one moq-transport subscription does. A + name with only a datagram track is served from it alone. + - A subscriber keeps the kind it asked for. One upstream SUBSCRIBE serves + readers of both kinds: the stream track's subscription feeds the name's + datagram track, so a relay between old peers carries datagrams without a + second subscription. + - No dedup: a name using both kinds can deliver a datagram to an old + subscriber that also holds a group of the same sequence. + - Reason: lite-05/06 are the default connections, and moq-ffi/moq-c publish + every raw track's datagrams beside it, so serving a name only one kind + stopped their datagrams on the default path. - **Version.** ✅ lite-07, before it is finalized. [Finalize moq-lite-07](/quest/m1/lite07-finalize.md) requires this epic. lite-01..06 keep today's group datagrams. -Not settled by the maintainer, so the first child decides it and records why: -the datagram request's answer carries the track's timescale and -compression, as TRACK_INFO does for a stream track. +Decided by the Rust child ([moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752)), +not the maintainer, so open to revisiting: -This README owns the work no child owns: +- **The answer.** The lite-07 request (Datagrams Stream 0x9: DATAGRAMS, then + DATAGRAMS_UPDATE) is answered with DATAGRAMS_INFO, carrying the publisher + priority and the timescale, and no compression: a datagram track is never + compressed. Why: a subscriber needs the timescale to read a datagram (its + Timestamp is present only on a timed track), so the answer must carry it, + and a separate TRACK lookup would need a kind signal TRACK does not have. A + payload under 1200 bytes, compressed alone with a fresh window, saves little, + while compression would cost every datagram an Inflated Size and a decoder. +- **Timestamp on a `Timescale` 0 track.** The datagram carries none, and its + Sequence stands in for its time. Why: it mirrors the + [model](/quest/m1/time-addressing/model.md)'s rule that a scale-less frame's + timestamp is its group's key, since a datagram's address is its sequence. + Until that model lands, Rust's datagram writes take `Option` like + frame writes do (`None` on an untimed track), and the model quest converts + both. -- the Rust-JS interop test of a datagram track on lite-07, direct and - through a relay; -- rewriting the Datagrams section of `doc/concept/moq-lite.md`. - -The draft's own Datagrams section changes with the -[Rust child](/quest/m1/datagram-tracks/rust.md). +This README owns the work no child owns: the Rust-JS interop test of a +datagram track on lite-07, direct and through a relay. ## Required -- [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) - Rust model types, the lite-07 datagram request and body, the draft, the relay, and moq-archive skipping datagram tracks - [JS datagram tracks](/quest/m1/datagram-tracks/js.md) - `@moq/net` datagram types and the lite-07 datagram request - [moq-transport datagram tracks](/quest/m1/datagram-tracks/ietf.md) - Rust and JS map a datagram track to SUBSCRIBE and OBJECT_DATAGRAM with object ID 0, keeping the requested kind - [Bindings datagram tracks](/quest/m1/datagram-tracks/bindings.md) - moq-ffi, moq-c, and every wrapper expose the separate datagram types diff --git a/quest/m1/datagram-tracks/bindings.md b/quest/m1/datagram-tracks/bindings.md index 4373429a18c..5c65e5cd897 100644 --- a/quest/m1/datagram-tracks/bindings.md +++ b/quest/m1/datagram-tracks/bindings.md @@ -13,8 +13,13 @@ UniFFI outputs, update `go/wrapper` (`publish.go`, `subscribe.go`, `records.go`) and `dart/moq`, and record the migration in each package's `MIGRATE.md`. Update `doc/lib/*` where they show datagrams. -Public API: breaking for every binding's datagrams. Wire: none. - -## Required +Until this lands, moq-ffi and moq-c back the old API with the new types +([moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752)): every raw track +publishes a datagram track of the same name, and a reader subscribes to the +stream track before it opens the datagram track, so a datagram reader still +shows as the track's demand and reaches a dynamic publisher's request handler. +Remove that coupling here, and give the separate types their own demand (the +datagram producer's aggregate priority) and on-demand requests +(`Requests::datagrams()`), so a native datagram-only reader is visible too. -- [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) - the Rust types the bindings mirror +Public API: breaking for every binding's datagrams. Wire: none. diff --git a/quest/m1/datagram-tracks/ietf.md b/quest/m1/datagram-tracks/ietf.md index 498568af977..c0a7c874677 100644 --- a/quest/m1/datagram-tracks/ietf.md +++ b/quest/m1/datagram-tracks/ietf.md @@ -23,9 +23,21 @@ Decided 2026-11-01 (maintainer), in the Touches `rs/moq-net/src/ietf/datagram.rs` and the JS IETF adapter. Test against moxygen in the interop lanes where it sends datagrams. +Rust datagram tracks ([moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752)) +left moq-transport without datagrams in Rust until this lands: the IETF +subscriber drops every OBJECT_DATAGRAM, `datagrams()` on an IETF source is +NOT_FOUND, and a stream track never sends one. Restore what it removed: + +- the Rust-JS `datagram` tail lanes in `test/interop/interop.sh`, the + `datagram` mode of `test/interop/clients/js-native/tail.ts`, and their + README bullet; +- moq-e2ee's `datagrams_over_ietf` transport test; +- the moq-transport rows of moq-tokio's `datagram_delivery` bench and + moq-net's `session_delivery_datagram`; +- the OBJECT_DATAGRAM sentence in `doc/concept/standard.md`. + Public API: none beyond the model. Wire: no format change. ## Required -- [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) - the datagram types this maps - [JS datagram tracks](/quest/m1/datagram-tracks/js.md) - the JS datagram types this maps diff --git a/quest/m1/datagram-tracks/js.md b/quest/m1/datagram-tracks/js.md index 7dfb26190e4..851c6215870 100644 --- a/quest/m1/datagram-tracks/js.md +++ b/quest/m1/datagram-tracks/js.md @@ -10,8 +10,10 @@ the group counter. Older lite versions keep today's group datagrams. ## Plan Follow the [epic](/quest/m1/datagram-tracks/README.md)'s decisions and the -wire that [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) lands in -the draft. +lite-07 wire that `drafts/draft-lcurley-moq-lite.md` specifies (Datagrams +Stream 0x9, DATAGRAMS, DATAGRAMS_UPDATE, DATAGRAMS_INFO, and the Track +Datagram body), which Rust speaks since +[moq-dev/moq#7752](https://github.com/moq-dev/moq/pull/7752). - Remove from `js/net/src/track.ts` and the lite subscriber and publisher: - `appendDatagram` and the shared sequence counter (`track.test.ts` @@ -21,10 +23,8 @@ the draft. - the datagram cases in ranges. - Map lite-01..06 group datagrams the same way Rust does. - Update any first-party consumer of JS datagrams. +- Drop the sentence saying the JavaScript library still sends datagrams + within a stream track from the Datagrams section of `doc/concept/moq-lite.md`. Public API: breaking for `@moq/net` datagrams. Wire: lite-07-wip, matching Rust. - -## Required - -- [Rust datagram tracks](/quest/m1/datagram-tracks/rust.md) - lands the lite-07 wire this matches diff --git a/quest/m1/datagram-tracks/rust.md b/quest/m1/datagram-tracks/rust.md deleted file mode 100644 index 99e749617a9..00000000000 --- a/quest/m1/datagram-tracks/rust.md +++ /dev/null @@ -1,71 +0,0 @@ -# [L] Rust datagram tracks - -## Goal - -moq-net has separate datagram types: a broadcast creates a datagram -producer/consumer pair with its own sequence counter, and stream tracks -carry no datagram API. Lite-07 subscribes to a datagram track through its -own request and sends datagrams addressed by that sequence. The relay -forwards datagram tracks, and moq-archive skips them. lite-01..06 keep -today's group datagrams. - -## Plan - -The decisions are in the [epic](/quest/m1/datagram-tracks/README.md). - -**Model** -- Add the datagram producer and consumer types to `rs/moq-net/src/model/`. - Each has its own sequence counter and a tiny send/receive buffer, with no - cache and no ranges. -- Datagram tracks have their own namespace in a broadcast. -- Remove the datagram paths from stream tracks: - - `insert_datagram` and the stored datagrams in `model/datagram.rs`; - - `poll_recv_datagram` and its resume handling in `model/resume.rs`; - - datagram cases in fills, misses, and range accounting. - -**Wire (lite-07)** -- A datagram subscribe request carries only a priority, and an update - changes only the priority. -- A datagram carries its sequence, its timestamp, and the payload. -- Decide whether the request's answer carries the track's timescale and - compression as TRACK_INFO does, and record the reason in the epic. -- Every datagram write takes a timestamp, as every write does - ([the model](/quest/m1/time-addressing/model.md), maintainer 2026-11-03). - On a scale-less track (`Timescale` 0) a stream frame's timestamp is its - group's key, and a datagram has no group: decide what its timestamp is - there, and record the reason in the epic. -- Remove the lite-07 rules that mixing needed: - - stream/datagram dedup; - - ordering a datagram among its group's streams; - - NOT_FETCHABLE; - - datagrams in SUBSCRIBE_DROP coverage; - - the datagram grace in the subscriber's end (`a_lost_datagram_delays_the_end`). -- On lite-01..06, one subscription to a name carries both group streams and - group datagrams. Map it the way moq-transport does, keeping the requested - kind: - - Inbound, a datagram-track subscription keeps only group datagrams and - drops group streams. A stream-track subscription drops group datagrams. - - Outbound, an old-version SUBSCRIBE for a name is served from the stream - track of that name. It is served from the datagram track, as group - datagrams keyed by its sequence, only when no stream track has that name. - - This outbound rule is the plan's default. This quest may revisit it, and - records why if it does. - -**Elsewhere** -- The relay forwards each kind as it received it. -- moq-archive's producer and consumer drop their datagram form. -- moq-ffi's public datagram API (`append_datagram`, `recv_datagram`) is - unchanged in this quest, and is backed internally by the new types. The - [bindings](/quest/m1/datagram-tracks/bindings.md) quest then replaces it - with the separate types. -- Update `drafts/draft-lcurley-moq-lite.md`: the Datagrams section, the - request, the error table, and the lite-07 changelog. Then run - `just drafts check` and `just test interop --all`. -- The PR's Impact section says how to migrate. - -Public API: breaking. Stream tracks lose their datagram methods, and the -datagram types are new. Wire: lite-07-wip datagrams and a new request. - -## Related - -- [Lite-07 by start](/quest/m1/time-addressing/lite07.md) - stream groups address by start; datagrams keep a sequence diff --git a/quest/m1/subscribe-ranges/lite.md b/quest/m1/subscribe-ranges/lite.md index 185f31ebc87..5ed3658d37a 100644 --- a/quest/m1/subscribe-ranges/lite.md +++ b/quest/m1/subscribe-ranges/lite.md @@ -131,8 +131,8 @@ requirement landed. Reuse its design: If the demand leaves while fills are out, the subscription keeps the demand's ranges until they are answered. - A NotFound drop names the fill's groups dropped - (`Fill::drop_groups`), as a FETCH miss does; Old and NOT_FETCHABLE reject - them, and any other error refuses only that request. + (`Fill::drop_groups`), as a FETCH miss does; Old rejects them, and any + other error refuses only that request. Its integration tests (`range_fill.rs`: a miss subscribes and sends no FETCH, a miss rides the live subscription then retires its range, a group the @@ -157,19 +157,9 @@ whole) passed. These failed, and must pass on #7545's miss judging: Open questions the attempt raised, now decided (2026-11-03, above), kept for their detail: -- **Datagram sequences.** A range fill of a sequence served only as a datagram - never completes: datagrams resolve no SUBSCRIBE_START, so a FIN or end - overtaking them leaves the receiver no owed range to wait on - (`datagram::a_fetched_datagram_is_refused_per_version`). Recommended: a - lite-07 publisher sends SUBSCRIBE_START for a bounded past range, then - SUBSCRIBE_DROP with NOT_FETCHABLE for each sequence it holds only as a - datagram; the subscriber already maps that drop onto the fill. This is a - wire change. The alternative keeps FETCH for a fill the first answer shows is - a datagram, at a round trip and a second path. - Since settled in part: datagrams become their own track kind, which takes - no ranges (maintainer, 2026-11-01). Today a lite-07 publisher that served a - bounded range's only group as a datagram never answers it with SUBSCRIBE_OK - (`TrackRun::unservable`), so such a fill parks until its readers leave. +- **Datagram sequences.** Settled: datagrams are their own track kind, which + takes no ranges, and a stream track never holds one (maintainer, 2026-11-01; + NOT_FETCHABLE is gone). - **Fills from partway through a group.** Route recovery (`resume::Recover`) fills from frame N, which a whole-group range cannot say. Recommended: keep FETCH on lite-07 for a fill whose first group starts past frame 0, as diff --git a/quest/m1/subscribe-ranges/start-bookkeeping.md b/quest/m1/subscribe-ranges/start-bookkeeping.md index 1d7f173b5ce..fb9dbc8792c 100644 --- a/quest/m1/subscribe-ranges/start-bookkeeping.md +++ b/quest/m1/subscribe-ranges/start-bookkeeping.md @@ -29,10 +29,10 @@ generations of "start". DROP, since [Datagram tracks](/quest/m1/datagram-tracks/README.md) takes datagrams out of stream tracks (maintainer, 2026-11-01). The subscriber's end is "owed - range covered and no stream active", with no datagram grace; - `a_lost_datagram_delays_the_end` becomes a lite-05/06 case. Keep - `Settle`'s full grace for lite-05/06 and moq-transport behind one version - predicate. + range covered and no stream active", with no datagram grace. A stream + track's lite-05/06 subscription drops group datagrams, so a group an old + publisher sent only as one is a hole there. Keep `Settle`'s full grace for + lite-05/06 and moq-transport behind one version predicate. - Lite-07 is unpublished, so sending a fresh START per update can remove the `requested == start` gate (`lite/subscriber.rs`, about line 4898). diff --git a/quest/m2/net-wrap/e2ee.md b/quest/m2/net-wrap/e2ee.md index 12c9cc3684c..35046297d52 100644 --- a/quest/m2/net-wrap/e2ee.md +++ b/quest/m2/net-wrap/e2ee.md @@ -59,38 +59,14 @@ the reverse (decided 2026-10-20, maintainer): without changing moq-net's `track(name)`; propose the encoding in the PR. Record it in `draft-lcurley-moq-e2ee.md`, replacing the Catalogs section's `.z` sibling rule. -- Datagram header allowance (decided 2026-10-31, maintainer, a follow-up - of [moq-dev/moq#7435](https://github.com/moq-dev/moq/pull/7435)): - `MAX_DATAGRAM_HEADER` (`rs/moq-crypto/src/e2ee/limits.rs`, - `js/crypto/src/e2ee/limits.ts`; 28 after #7435, lite-07's Type plus three 9-byte - varints) also counts the optional lite datagram fields: the relayed fields - the lite `ietf` extension adds, and moq-net's Inflated Size header field, - which a compressed physical track's datagram carries outside the AEAD - (`rs/moq-net/src/lite/datagram.rs`). Without them a maximum-size protected - datagram can encode past the 1200-byte body and be dropped. That header - field is not the wrapper's own inflated size above, which sits inside the - authenticated payload and counts against the plaintext budget instead. - Today a caller's `track::Info` reaches the physical track unchanged, so it - can be compressed. Once the wrapper creates the physical track - uncompressed, the header field never appears and the allowance needs only - the `ietf` fields. Of those, the relay state, object ID, priority, and - status have a fixed worst case, but `encode_datagram` - (`rs/moq-net/src/lite/ietf.rs`) also writes the forwarded properties - block, which may run to `MAX_OBJECT_EXTENSIONS` (64 KiB, - `rs/moq-net/src/ietf/group.rs`), so no fixed allowance covers it, and the - lite publisher drops a datagram whose body exceeds 1200 bytes - (`rs/moq-net/src/lite/publisher.rs`). Choose during implementation between - capping the properties a hop forwards on a protected datagram (and - counting that cap) or documenting that relay properties are outside the - allowance, so such a datagram may be dropped, and record the choice in the - PR. Sealing and opening share - `MAX_DATAGRAM_PAYLOAD` today (`rs/moq-crypto/src/e2ee/track.rs`, - `js/crypto/src/e2ee/track.ts`), so a wider allowance would also make `open` - refuse a datagram an older sender sealed under the narrower one. Split - the seal and open limits, or pick the limit by wire version, so a - receiver still opens anything that fits the body. Update the e2ee - draft's plaintext bound and the `moq-e2ee-00` vectors with it, covering - both limits and a datagram with non-empty relay properties. +- Datagram header allowance: a moq-lite datagram carries only Type, + Subscribe ID, Sequence, and Timestamp, since datagram tracks dropped the + relayed `ietf` fields and the Inflated Size from it, so + `MAX_DATAGRAM_HEADER` (28, `rs/moq-crypto/src/e2ee/limits.rs` and + `js/crypto/src/e2ee/limits.ts`) covers every version. Re-check it if + moq-transport datagram tracks + ([moq-transport datagram tracks](/quest/m1/datagram-tracks/ietf.md)) add + header fields. - Ranges: every moq-net range starts at a group's first frame ([moq-dev/moq#6465](https://github.com/moq-dev/moq/pull/6465)), so the wrapper reads each physical group whole and inflates from frame 0. diff --git a/quest/m2/resume-redesign.md b/quest/m2/resume-redesign.md index 9674308b6c0..d7ebbd56226 100644 --- a/quest/m2/resume-redesign.md +++ b/quest/m2/resume-redesign.md @@ -61,8 +61,7 @@ paths diverged. 2. The upstream request is already the union of readers' resume points (`Producer::subscription`), so per-reader copy cursors are local overhead. 3. Readers become plain cursors: delete `Inner::Resume`, `Reader`, `Copy`, - `Retain`, `Candidate`, per-reader datagram dedupe (dedupe once on insert), - and per-reader `learn_end`. + `Retain`, `Candidate`, and per-reader `learn_end`. 4. `Recover` attaches to the slot, not a handed-out group: continuing a group whose copy failed is a [flight](/quest/m1/unbounded-groups/flights.md) from the reader's frame on diff --git a/rs/moq-archive/MIGRATE.md b/rs/moq-archive/MIGRATE.md index dfb4799fc65..cea967e0a91 100644 --- a/rs/moq-archive/MIGRATE.md +++ b/rs/moq-archive/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Recording format version 4 drops the datagram form ([#7752](https://github.com/moq-dev/moq/pull/7752)) + +An archive records stream tracks only, and its entries carry no `Form`. Versions 2 and 3 still read, but an entry of the datagram form refuses its object with `Error::Form`. +A version 4 producer refuses to resume a prefix holding a version 2 or 3 track, failing with `Error::Version` before it writes anything: start a new recording prefix, and read the older one with a reader. + ### Recording format version 3 replaces version 1 Release wrote version 1; the current codecs refuse it. Start a new recording prefix and retain an older reader for existing version 1 recordings. diff --git a/rs/moq-archive/src/consumer/mod.rs b/rs/moq-archive/src/consumer/mod.rs index 7fc6fff1ea9..92c86d92cac 100644 --- a/rs/moq-archive/src/consumer/mod.rs +++ b/rs/moq-archive/src/consumer/mod.rs @@ -4,8 +4,7 @@ //! stored track on a `moq_net` broadcast, the archived catalog included, with its stored //! timescale and priority and no max age: the store's retention bounds what it holds. Each //! track's live feed starts at its newest stored sequence, and every older group is served on -//! demand from the store through [`track::Requests`](moq_net::track::Requests) fills, each sequence in its recorded form: a -//! group as a group, a datagram as a datagram. +//! demand from the store through [`track::Requests`](moq_net::track::Requests) fills. //! //! A reader's time start older than the newest group is looked up from the object keys, each of //! which carries its first frame's timestamp, so the range starts at the stored group containing @@ -348,7 +347,7 @@ async fn refresh(shared: Arc>, mut tracks: Vec Frame { /// Group `sequence`'s frames `frames`. fn frames(timed: bool, sequence: u64, frames: std::ops::Range) -> Entry { - Entry::Group { + Entry { sequence, frames: frames.map(|index| frame(timed, sequence, index)).collect(), } } -fn datagram(timed: bool, sequence: u64) -> Entry { - Entry::Datagram { - sequence, - frame: frame(timed, sequence, 0), - } -} - /// What a reader sees of `frames(timed, sequence, indices)`. fn expected(timed: bool, sequence: u64, indices: std::ops::Range) -> Vec { indices.map(|index| describe(&frame(timed, sequence, index))).collect() @@ -185,7 +178,7 @@ async fn a_recording_round_trips() { .with_priority(3) .with_max_age(Duration::from_secs(1)); let video = source.create_track("video", timed).unwrap(); - let mut log = source + let log = source .create_track("log", track::Info::default().with_priority(9)) .unwrap(); let config = crate::producer::Config::default() @@ -210,8 +203,6 @@ async fn a_recording_round_trips() { group.write_frame(None, "a").unwrap(); group.finish().unwrap(); sleep(Duration::from_millis(10)).await; - log.insert_datagram(1, None, "b").unwrap(); - sleep(Duration::from_millis(10)).await; video.finish().unwrap(); log.finish().unwrap(); sleep(Duration::from_millis(10)).await; @@ -239,14 +230,9 @@ async fn a_recording_round_trips() { assert!(reader.next_group().await.unwrap().is_none()); assert_eq!(reader.finished().await.unwrap(), Position::group(6)); - // The untimed track keeps its datagram a datagram. let info = broadcast.consume().track("log").unwrap().info().await.unwrap(); assert_eq!((info.priority, info.timescale), (9, None)); assert_eq!(fetch(&broadcast, "log", 0).await.unwrap(), ["a"]); - assert!(matches!( - fetch(&broadcast, "log", 1).await, - Err(moq_net::Error::NotFetchable | moq_net::Error::NotFound) - )); } /// History older than the source's max age relative to the newest group is still served. @@ -604,55 +590,6 @@ async fn markers_end_a_followed_archive() { assert!(next(&mut reader).await.is_none()); } -/// Groups and datagrams sharing one sequence space replay in their recorded forms. -#[tokio::test(start_paused = true)] -async fn groups_and_datagrams_keep_their_forms() { - let archive = Archive::new(); - archive.track("mixed", 0, false).await; - archive - .object( - "mixed", - 0, - vec![frames(false, 0, 0..1), datagram(false, 1), frames(false, 2, 0..1)], - ) - .await; - - let (broadcast, _run) = serve(&archive.store).await; - let track = broadcast.consume().track("mixed").unwrap(); - let subscription = Subscription::default() - .with_range(0..) - .with_order(Order::Ascending) - .with_delay(Delay::default().with_max(None)); - let mut reader = track.subscribe(subscription).await.unwrap(); - - archive - .object("mixed", 0, vec![datagram(false, 3), frames(false, 4, 0..1)]) - .await; - let datagram = timeout(10 * REFRESH, reader.recv_datagram()) - .await - .expect("the datagram arrives") - .unwrap() - .unwrap(); - assert_eq!((datagram.sequence, &datagram.payload[..]), (3, &b"3/0"[..])); - - // A stored datagram is never fetched as a group. - assert!(fetch(&broadcast, "mixed", 1).await.is_err()); - assert_eq!(fetch(&broadcast, "mixed", 0).await.unwrap(), ["0/0"]); - assert_eq!(fetch(&broadcast, "mixed", 2).await.unwrap(), ["2/0"]); - - // The newest stored group arrives live as a group, still open. - let mut sequences = Vec::new(); - while sequences.last() != Some(&4) { - let group = timeout(10 * REFRESH, reader.recv_group()) - .await - .unwrap() - .unwrap() - .unwrap(); - sequences.push(group.sequence); - } - assert!(!sequences.contains(&1) && !sequences.contains(&3), "{sequences:?}"); -} - /// A track removed and re-added under its name replays both runs in sequence. #[tokio::test(start_paused = true)] async fn a_re_added_track_replays_both_runs() { @@ -902,7 +839,7 @@ async fn a_continuation_does_not_bound_a_group_start() { .object( "video", 0, - vec![Entry::Group { + vec![Entry { sequence: 0, frames: vec![frame(0, 0, 0)], }], @@ -913,11 +850,11 @@ async fn a_continuation_does_not_bound_a_group_start() { "video", 1, vec![ - Entry::Group { + Entry { sequence: 0, frames: vec![frame(0, 1, 1_000)], }, - Entry::Group { + Entry { sequence: 1, frames: vec![frame(1, 0, 500)], }, diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs index 5ca84109ef0..23c7ccdc160 100644 --- a/rs/moq-archive/src/consumer/track.rs +++ b/rs/moq-archive/src/consumer/track.rs @@ -49,66 +49,56 @@ impl Live { pub fn push(&mut self, objects: &[Arc], from: u64) -> Result<()> { for object in objects { for (index, entry) in object.entries.iter().enumerate() { - if entry.sequence() < from { + if entry.sequence < from { continue; } if self.next.is_none() { // Readers ask fills for everything older. - self.producer.start_at(entry.sequence())?; + self.producer.start_at(entry.sequence)?; } let first = match index { 0 => object.frame_start, _ => 0, }; self.entry(entry, first)?; - self.next = Some(entry.sequence() + 1); + self.next = Some(entry.sequence + 1); } } Ok(()) } fn entry(&mut self, entry: &Entry, first: u64) -> Result<()> { - match entry { - Entry::Datagram { sequence, frame } => { - // A later sequence completes the open group. - self.close()?; - let timestamp = timestamp(frame.timestamp, self.timescale)?; - self.producer - .insert_datagram(*sequence, timestamp, frame.payload.clone())?; - } - Entry::Group { sequence, frames } => { - if self.open.as_ref().is_some_and(|open| open.sequence != *sequence) { - self.close()?; + let sequence = entry.sequence; + if self.open.as_ref().is_some_and(|open| open.sequence != sequence) { + self.close()?; + } + // The group stays in `open` while it is written, so a failure aborts it with the track + // rather than dropping it. + let open = match &mut self.open { + Some(open) => { + if open.next != first { + return Err(Error::Sequence); } - // The group stays in `open` while it is written, so a failure aborts it with - // the track rather than dropping it. - let open = match &mut self.open { - Some(open) => { - if open.next != first { - return Err(Error::Sequence); - } - open - } - None => { - let group = self.producer.create_group(group::Info { sequence: *sequence })?; - let open = self.open.insert(Open { - group, - sequence: *sequence, - next: first, - }); - if first > 0 { - // Its head was pruned. - open.group.start_at(first)?; - } - open - } - }; - for frame in frames { - let timestamp = timestamp(frame.timestamp, self.timescale)?; - open.group.write_frame(timestamp, frame.payload.clone())?; - open.next += 1; + open + } + None => { + let group = self.producer.create_group(group::Info { sequence })?; + let open = self.open.insert(Open { + group, + sequence, + next: first, + }); + if first > 0 { + // Its head was pruned. + open.group.start_at(first)?; } + open } + }; + for frame in &entry.frames { + let timestamp = timestamp(frame.timestamp, self.timescale)?; + open.group.write_frame(timestamp, frame.payload.clone())?; + open.next += 1; } Ok(()) } @@ -203,17 +193,15 @@ async fn look_up(shared: &Shared, index: &Mutex, fill: .entries .iter() .enumerate() - .filter_map(|(position, entry)| match entry { - Entry::Group { sequence, frames } if position > 0 || object.frame_start == 0 => { - let first = frames.first()?; - first - .timestamp - .is_some_and(|at| u128::from(at) <= limit) - .then_some(*sequence) - } - _ => None, - }) - .last(); + .rev() + .filter(|(position, _)| *position > 0 || object.frame_start == 0) + .find_map(|(_, entry)| { + let first = entry.frames.first()?; + first + .timestamp + .is_some_and(|at| u128::from(at) <= limit) + .then_some(entry.sequence) + }); if let Some(sequence) = found { return serve_group(shared, index, fill, sequence).await; } @@ -230,14 +218,8 @@ async fn look_up(shared: &Shared, index: &Mutex, fill: .entries .iter() .enumerate() - .find_map(|(position, entry)| match entry { - Entry::Group { sequence, frames } - if !frames.is_empty() && (position > 0 || object.frame_start == 0) => - { - Some(*sequence) - } - _ => None, - }); + .find(|(position, entry)| !entry.frames.is_empty() && (*position > 0 || object.frame_start == 0)) + .map(|(_, entry)| entry.sequence); if let Some(sequence) = found { return serve_group(shared, index, fill, sequence).await; } @@ -319,32 +301,22 @@ async fn serve_group(shared: &Shared, index: &Mutex, f fill.drop_groups(0..next); return; }; - let Some(position) = first.entries.iter().position(|entry| entry.sequence() >= sequence) else { + let Some(position) = first.entries.iter().position(|entry| entry.sequence >= sequence) else { // A gap after the object's last entry. - let last = first.entries.last().map_or(0, |entry| entry.sequence() + 1); + let last = first.entries.last().map_or(0, |entry| entry.sequence + 1); fill.drop_groups(last..next); return; }; - let frames = match &first.entries[position] { - Entry::Group { - sequence: found, - frames, - } if *found == sequence => frames, - // A datagram stays lossy and uncached, never a fetched group. - Entry::Datagram { sequence: found, .. } if *found == sequence => { - fill.drop_groups(sequence..sequence + 1); - return; - } + let found = &first.entries[position]; + if found.sequence != sequence { // A gap between two entries. - other => { - let gap = match position { - 0 => sequence, - _ => first.entries[position - 1].sequence() + 1, - }; - fill.drop_groups(gap..other.sequence()); - return; - } - }; + let gap = match position { + 0 => sequence, + _ => first.entries[position - 1].sequence + 1, + }; + fill.drop_groups(gap..found.sequence); + return; + } let start = match position { 0 => first.frame_start, _ => 0, @@ -356,15 +328,12 @@ async fn serve_group(shared: &Shared, index: &Mutex, f } // The group's frames, with their indices, across every object holding it. - let mut stored: Vec<(u64, &Frame)> = (start..).zip(frames).collect(); + let mut stored: Vec<(u64, &Frame)> = (start..).zip(&found.frames).collect(); for object in &objects[1..] { let next = start + stored.len() as u64; match object.entries.first() { - Some(Entry::Group { - sequence: found, - frames, - }) if *found == sequence && object.frame_start == next => { - stored.extend((next..).zip(frames)); + Some(entry) if entry.sequence == sequence && object.frame_start == next => { + stored.extend((next..).zip(&entry.frames)); } _ => { // A continuation went missing, so the group can't be served whole. diff --git a/rs/moq-archive/src/error.rs b/rs/moq-archive/src/error.rs index 0d733ba7df5..5a7dc8ae988 100644 --- a/rs/moq-archive/src/error.rs +++ b/rs/moq-archive/src/error.rs @@ -44,6 +44,11 @@ pub enum Error { #[error("empty object")] Empty, + /// A version 2 or 3 object entry is not a group: the datagram form, which an archive + /// does not replay, or an unknown one. + #[error("unsupported entry form {0}")] + Form(u64), + /// Group sequences are not strictly ascending. #[error("group sequences are not strictly ascending")] Sequence, diff --git a/rs/moq-archive/src/info.rs b/rs/moq-archive/src/info.rs index 286f0a80582..80d3ee0c4ca 100644 --- a/rs/moq-archive/src/info.rs +++ b/rs/moq-archive/src/info.rs @@ -8,8 +8,8 @@ use crate::{Error, Result, VERSION, VERSION_MIN}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "Stored")] pub struct Info { - /// Recording format version: [`VERSION`], or 2 for a recording read from before - /// priorities were signed, which only a reader accepts. + /// Recording format version: [`VERSION`], or 2 or 3 for an older recording, which only a + /// reader accepts. pub version: u64, /// The publisher's track priority, higher first. pub priority: i8, @@ -98,7 +98,7 @@ mod tests { fn roundtrip() { let info = Info::new(0, Some(1_000_000)).unwrap(); let bytes = info.encode().unwrap(); - assert_eq!(bytes.as_ref(), br#"{"version":3,"priority":0,"timescale":1000000}"#); + assert_eq!(bytes.as_ref(), br#"{"version":4,"priority":0,"timescale":1000000}"#); assert_eq!(Info::decode(&bytes).unwrap(), info); } @@ -106,16 +106,16 @@ mod tests { fn an_untimed_track_omits_its_timescale() { let info = Info::new(3, None).unwrap(); let bytes = info.encode().unwrap(); - assert_eq!(bytes.as_ref(), br#"{"version":3,"priority":3}"#); + assert_eq!(bytes.as_ref(), br#"{"version":4,"priority":3}"#); assert_eq!(Info::decode(&bytes).unwrap(), info); } #[test] fn whitespace_and_member_order_do_not_affect_equality() { - let info = Info::decode(br#"{"timescale":1000,"priority":7,"version":3}"#).unwrap(); + let info = Info::decode(br#"{"timescale":1000,"priority":7,"version":4}"#).unwrap(); assert_eq!( info, - Info::decode(br#"{ "version": 3, "priority": 7, "timescale": 1000 }"#).unwrap() + Info::decode(br#"{ "version": 4, "priority": 7, "timescale": 1000 }"#).unwrap() ); assert_eq!(info.priority, 7); assert_eq!(info.timescale, Some(1000)); @@ -125,12 +125,12 @@ mod tests { fn priority_is_stored_signed() { for priority in [i8::MIN, -1, 0, i8::MAX] { let info = Info::new(priority, None).unwrap(); - let json = format!(r#"{{"version":3,"priority":{priority}}}"#); + let json = format!(r#"{{"version":4,"priority":{priority}}}"#); assert_eq!(info.encode().unwrap().as_ref(), json.as_bytes()); assert_eq!(Info::decode(json.as_bytes()).unwrap(), info); } for priority in [-129, 128] { - let json = format!(r#"{{"version":3,"priority":{priority}}}"#); + let json = format!(r#"{{"version":4,"priority":{priority}}}"#); assert!( matches!(Info::decode(json.as_bytes()), Err(Error::Json(_))), "{priority}" @@ -154,6 +154,13 @@ mod tests { } } + #[test] + fn a_version_3_info_reads_its_signed_priority() { + let info = Info::decode(br#"{"version":3,"priority":-1,"timescale":1000}"#).unwrap(); + assert_eq!((info.version, info.priority, info.timescale), (3, -1, Some(1000))); + assert!(matches!(info.encode(), Err(Error::Version(3)))); + } + #[test] fn unknown_version_is_refused() { for version in [1, VERSION + 1] { @@ -177,11 +184,11 @@ mod tests { Err(Error::Json(_)) )); assert!(matches!( - Info::decode(br#"{"version":3,"priority":0,"timescale":1,"extra":true}"#), + Info::decode(br#"{"version":4,"priority":0,"timescale":1,"extra":true}"#), Err(Error::Json(_)) )); assert!(matches!( - Info::decode(br#"{"version":3,"timescale":1}"#), + Info::decode(br#"{"version":4,"timescale":1}"#), Err(Error::Json(_)) )); } diff --git a/rs/moq-archive/src/layout.rs b/rs/moq-archive/src/layout.rs index 12edbf93491..e4a5e3f864c 100644 --- a/rs/moq-archive/src/layout.rs +++ b/rs/moq-archive/src/layout.rs @@ -152,81 +152,56 @@ pub(crate) struct Frame { pub payload: Bytes, } -/// One stored sequence, in the form it was forwarded in. +/// One stored group: all of its frames, or the run of them this object holds. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum Entry { - /// A group's frames: all of them, or the run of them this object holds. - Group { sequence: u64, frames: Vec }, - /// A datagram: one payload and timestamp, never split. - Datagram { sequence: u64, frame: Frame }, -} - -impl Entry { - pub(crate) fn sequence(&self) -> u64 { - match self { - Self::Group { sequence, .. } | Self::Datagram { sequence, .. } => *sequence, - } - } - - fn first(&self) -> &Frame { - match self { - Self::Group { frames, .. } => &frames[0], - Self::Datagram { frame, .. } => frame, - } - } +pub(crate) struct Entry { + pub sequence: u64, + pub frames: Vec, } /// One object: entries in ascending sequence order, the first starting at `frame_start`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct Object { - /// The index of the first group's first stored frame; zero when the first entry is a datagram. + /// The index of the first entry's first stored frame. pub frame_start: u64, pub entries: Vec, } -const GROUP: u64 = 0; -const DATAGRAM: u64 = 1; +/// The form field version 2 and 3 entries carry: 0 for a group, 1 for a datagram. +const LEGACY_GROUP: u64 = 0; impl Object { /// The key this object is stored under. pub(crate) fn start(&self) -> Result { let first = self.entries.first().ok_or(Error::Empty)?; Ok(Start { - group: first.sequence(), + group: first.sequence, frame: self.frame_start, - pts: first.first().timestamp, + pts: first.frames.first().ok_or(Error::Empty)?.timestamp, }) } /// Its payload bytes plus a small per-frame overhead, for a byte-bounded cache. pub(crate) fn weight(&self) -> u64 { - let frames = self.entries.iter().flat_map(|entry| match entry { - Entry::Group { frames, .. } => frames.as_slice(), - Entry::Datagram { frame, .. } => std::slice::from_ref(frame), - }); + let frames = self.entries.iter().flat_map(|entry| &entry.frames); frames.map(|frame| frame.payload.len() as u64 + 32).sum::() + 64 } - /// The position just past the last stored frame or datagram. + /// The position just past the last stored frame. pub(crate) fn end(&self) -> Result { let last = self.entries.last().ok_or(Error::Empty)?; - Ok(match last { - Entry::Datagram { sequence, .. } => Position::group(sequence.checked_add(1).ok_or(Error::Overflow)?), - Entry::Group { sequence, frames } => { - let first = match self.entries.len() { - 1 => self.frame_start, - _ => 0, - }; - Position { - group: *sequence, - frame: first.checked_add(frames.len() as u64).ok_or(Error::Overflow)?, - } - } + let first = match self.entries.len() { + 1 => self.frame_start, + _ => 0, + }; + Ok(Position { + group: last.sequence, + frame: first.checked_add(last.frames.len() as u64).ok_or(Error::Overflow)?, }) } /// The version, the first frame index, then each entry's sequence (the first absolute, the - /// rest `current - previous - 1`), form, and frame table, followed by every payload in order. + /// rest `current - previous - 1`) and frame table, followed by every payload in order. pub(crate) fn encode(&self, timed: bool) -> Result { self.validate(timed)?; @@ -236,36 +211,21 @@ impl Object { write(&mut table, self.entries.len() as u64)?; let mut payload = BytesMut::new(); - let mut frame = |table: &mut BytesMut, frame: &Frame| -> Result<()> { - if let Some(timestamp) = frame.timestamp { - write(table, timestamp)?; - } - write(table, frame.payload.len() as u64)?; - payload.extend_from_slice(&frame.payload); - Ok(()) - }; - let mut prev: Option = None; for entry in &self.entries { - let sequence = entry.sequence(); let delta = match prev { - None => sequence, - Some(prev) => sequence - prev - 1, + None => entry.sequence, + Some(prev) => entry.sequence - prev - 1, }; - prev = Some(sequence); + prev = Some(entry.sequence); write(&mut table, delta)?; - match entry { - Entry::Group { frames, .. } => { - write(&mut table, GROUP)?; - write(&mut table, frames.len() as u64)?; - for f in frames { - frame(&mut table, f)?; - } - } - Entry::Datagram { frame: f, .. } => { - write(&mut table, DATAGRAM)?; - frame(&mut table, f)?; + write(&mut table, entry.frames.len() as u64)?; + for frame in &entry.frames { + if let Some(timestamp) = frame.timestamp { + write(&mut table, timestamp)?; } + write(&mut table, frame.payload.len() as u64)?; + payload.extend_from_slice(&frame.payload); } } @@ -274,32 +234,21 @@ impl Object { } /// Decode a whole table before slicing any payload; `timed` is whether the track's `.info` - /// declares a timescale, which says whether each frame stores a timestamp. Version 2 objects - /// are identical to the current ones. + /// declares a timescale, which says whether each frame stores a timestamp. A version 2 or 3 + /// object's entries each carry a form, and one holding a datagram is refused: an archive + /// replays groups only, and skipping the entry would read as a gap the recording never had. pub(crate) fn decode(mut buf: impl Buf, timed: bool) -> Result { let version = read(&mut buf)?; if !(VERSION_MIN..=VERSION).contains(&version) { return Err(Error::Version(version)); } + let legacy = version < 4; let frame_start = read(&mut buf)?; - // Each entry is at least a sequence, a form, and a frame count or length. - let count = read_count(&mut buf, 3)?; + // Each entry is at least a sequence and a frame count, plus a form when legacy. + let count = read_count(&mut buf, if legacy { 3 } else { 2 })?; let per_frame = if timed { 2 } else { 1 }; let mut lengths = Vec::new(); - let mut read_frame = |buf: &mut dyn Buf| -> Result> { - let timestamp = match timed { - true => Some(check_id(read(buf)?)?), - false => None, - }; - lengths.push(read(buf)?); - Ok(timestamp) - }; - - enum Raw { - Group(u64, Vec>), - Datagram(u64, Option), - } let mut raw = Vec::with_capacity(count); let mut prev: Option = None; for _ in 0..count { @@ -313,18 +262,22 @@ impl Object { }; check_id(sequence)?; prev = Some(sequence); - match read(&mut buf)? { - GROUP => { - let frames = read_count(&mut buf, per_frame)?; - let mut timestamps = Vec::with_capacity(frames); - for _ in 0..frames { - timestamps.push(read_frame(&mut buf)?); - } - raw.push(Raw::Group(sequence, timestamps)); + if legacy { + let form = read(&mut buf)?; + if form != LEGACY_GROUP { + return Err(Error::Form(form)); } - DATAGRAM => raw.push(Raw::Datagram(sequence, read_frame(&mut buf)?)), - _ => return Err(Error::Table), } + let frames = read_count(&mut buf, per_frame)?; + let mut timestamps = Vec::with_capacity(frames); + for _ in 0..frames { + timestamps.push(match timed { + true => Some(check_id(read(&mut buf)?)?), + false => None, + }); + lengths.push(read(&mut buf)?); + } + raw.push((sequence, timestamps)); } let mut payload = buf.copy_to_bytes(buf.remaining()); @@ -337,24 +290,20 @@ impl Object { } let mut lengths = lengths.into_iter(); - let mut take = |timestamp: Option| -> Frame { - let len = lengths.next().expect("one length per frame") as usize; - Frame { - timestamp, - payload: payload.split_to(len), - } - }; let entries = raw .into_iter() - .map(|raw| match raw { - Raw::Group(sequence, timestamps) => Entry::Group { - sequence, - frames: timestamps.into_iter().map(&mut take).collect(), - }, - Raw::Datagram(sequence, timestamp) => Entry::Datagram { - sequence, - frame: take(timestamp), - }, + .map(|(sequence, timestamps)| Entry { + sequence, + frames: timestamps + .into_iter() + .map(|timestamp| { + let len = lengths.next().expect("one length per frame") as usize; + Frame { + timestamp, + payload: payload.split_to(len), + } + }) + .collect(), }) .collect(); @@ -364,23 +313,20 @@ impl Object { } fn validate(&self, timed: bool) -> Result<()> { - let first = self.entries.first().ok_or(Error::Empty)?; - if matches!(first, Entry::Datagram { .. }) && self.frame_start != 0 { - return Err(Error::Table); + if self.entries.is_empty() { + return Err(Error::Empty); } let mut prev = None; for entry in &self.entries { - let sequence = check_id(entry.sequence())?; + let sequence = check_id(entry.sequence)?; if prev.is_some_and(|prev| sequence <= prev) { return Err(Error::Sequence); } prev = Some(sequence); - let frames = match entry { - Entry::Group { frames, .. } if frames.is_empty() => return Err(Error::Empty), - Entry::Group { frames, .. } => frames.as_slice(), - Entry::Datagram { frame, .. } => std::slice::from_ref(frame), - }; - for frame in frames { + if entry.frames.is_empty() { + return Err(Error::Empty); + } + for frame in &entry.frames { match frame.timestamp { Some(timestamp) if timed => { check_id(timestamp)?; @@ -497,19 +443,15 @@ mod tests { } #[test] - fn groups_and_datagrams_keep_their_form() { + fn groups_roundtrip() { let object = Object { frame_start: 4, entries: vec![ - Entry::Group { + Entry { sequence: 3, frames: vec![frame(Some(10), b"a"), frame(Some(9), b"bb")], }, - Entry::Datagram { - sequence: 4, - frame: frame(Some(11), b"d"), - }, - Entry::Group { + Entry { sequence: 7, frames: vec![frame(Some(12), b"ccc")], }, @@ -533,32 +475,41 @@ mod tests { entries: vec![object.entries[0].clone()], }; assert_eq!(split.end().unwrap(), Position { group: 3, frame: 6 }); - - let datagram = Object { - frame_start: 0, - entries: vec![object.entries[1].clone()], - }; - assert_eq!(datagram.end().unwrap(), Position::group(5)); } #[test] fn an_untimed_object_stores_no_timestamps() { let object = Object { frame_start: 0, - entries: vec![Entry::Group { + entries: vec![Entry { sequence: 2, frames: vec![frame(None, b"x"), frame(None, b"yz")], }], }; let bytes = object.encode(false).unwrap(); - // version, frame start, one entry: sequence 2, a group of two frames, then the payloads. - assert_eq!(bytes.as_ref(), &[VERSION as u8, 0, 1, 2, 0, 2, 1, 2, b'x', b'y', b'z']); + // version, frame start, one entry: sequence 2 with two frames, then the payloads. + assert_eq!(bytes.as_ref(), &[VERSION as u8, 0, 1, 2, 2, 1, 2, b'x', b'y', b'z']); assert_eq!(Object::decode(bytes.clone(), false).unwrap(), object); assert!(matches!(object.encode(true), Err(Error::Timing))); + } - let mut v2 = bytes.to_vec(); - v2[0] = 2; - assert_eq!(Object::decode(&v2[..], false).unwrap(), object); + /// Version 2 and 3 entries carry a form: a group reads as today's entry, and a datagram + /// refuses the whole object rather than reading as a gap. + #[test] + fn older_objects_read_groups_and_refuse_datagrams() { + let object = Object { + frame_start: 0, + entries: vec![Entry { + sequence: 2, + frames: vec![frame(None, b"x"), frame(None, b"yz")], + }], + }; + for version in [2, 3] { + let group = [version, 0, 1, 2, 0, 2, 1, 2, b'x', b'y', b'z']; + assert_eq!(Object::decode(&group[..], false).unwrap(), object); + let datagram = [version, 0, 1, 2, 1, 1, b'x']; + assert!(matches!(Object::decode(&datagram[..], false), Err(Error::Form(1)))); + } } #[test] @@ -569,44 +520,41 @@ mod tests { let descending = Object { frame_start: 0, entries: vec![ - Entry::Datagram { + Entry { sequence: 5, - frame: frame(None, b"a"), + frames: vec![frame(None, b"a")], }, - Entry::Datagram { + Entry { sequence: 5, - frame: frame(None, b"b"), + frames: vec![frame(None, b"b")], }, ], }; assert!(matches!(descending.encode(false), Err(Error::Sequence))); - let datagram_mid_group = Object { - frame_start: 1, - entries: vec![Entry::Datagram { + let no_frames = Object { + frame_start: 0, + entries: vec![Entry { sequence: 5, - frame: frame(None, b"a"), + frames: Vec::new(), }], }; - assert!(matches!(datagram_mid_group.encode(false), Err(Error::Table))); + assert!(matches!(no_frames.encode(false), Err(Error::Empty))); let good = Object { frame_start: 0, - entries: vec![Entry::Group { + entries: vec![Entry { sequence: 1, frames: vec![frame(None, b"abc")], }], } .encode(false) .unwrap(); - // Truncated payload, trailing bytes, an unknown form, and an absurd count. + // Truncated payload, trailing bytes, and an absurd count. assert!(Object::decode(&good[..good.len() - 1], false).is_err()); let mut trailing = good.to_vec(); trailing.push(0); assert!(Object::decode(&trailing[..], false).is_err()); - let mut form = good.to_vec(); - form[4] = 2; - assert!(matches!(Object::decode(&form[..], false), Err(Error::Table))); assert!(matches!( Object::decode(&[VERSION as u8, 0, 0x7f][..], false), Err(Error::Table) diff --git a/rs/moq-archive/src/lib.rs b/rs/moq-archive/src/lib.rs index f50a4a1426e..f329f8742a8 100644 --- a/rs/moq-archive/src/lib.rs +++ b/rs/moq-archive/src/lib.rs @@ -8,7 +8,7 @@ //! //! A [`Producer`] writes, under its prefix: //! -//! - `/.info`: the track's properties as JSON, `{"version":3,"priority":0,"timescale":1000}`, +//! - `/.info`: the track's properties as JSON, `{"version":4,"priority":0,"timescale":1000}`, //! written before its first object. `timescale` is absent on an untimed track. Properties never //! change for a track name. The source's max age is not stored: it bounds the live cache, not //! the archive. @@ -32,8 +32,8 @@ //! //! ```text //! Object { -//! Version (i) = 3, -//! Frame Start (i), // the first group's first frame index; 0 when it starts with a datagram +//! Version (i) = 4, +//! Frame Start (i), // the first group's first frame index //! Entry Count (i), //! Entry (..) ..., //! Payload (..), // every frame's bytes, in table order @@ -41,9 +41,8 @@ //! //! Entry { //! Sequence (i), // absolute for the first entry, then current - previous - 1 -//! Form (i), // 0 for a group, 1 for a datagram -//! [Frame Count (i)], // a group only, at least 1 -//! Frame (..) ..., // a datagram has exactly one +//! Frame Count (i), // at least 1 +//! Frame (..) ..., //! } //! //! Frame { @@ -52,8 +51,8 @@ //! } //! ``` //! -//! A track's groups and datagrams share one sequence space, so each entry keeps its form, and a -//! datagram replays as a datagram, never as a cached one-frame group. +//! An archive records stream tracks only. A datagram track is live-only, so a broadcast's +//! archive holds none of its datagram tracks. #![warn(missing_docs)] @@ -78,10 +77,11 @@ pub use producer::Producer; pub use store::Store; /// Recording format version written into `.info` and the binary envelope. -pub const VERSION: u64 = 3; +pub const VERSION: u64 = 4; /// The oldest recording format version a [`Consumer`] accepts. Version 2 stored `.info`'s -/// priority as its moq-lite byte and is otherwise identical. +/// priority as its moq-lite byte, and versions 2 and 3 give each object entry a form, which +/// a reader accepts only as a group. pub(crate) const VERSION_MIN: u64 = 2; /// Largest sequence, frame index, or timestamp value the format allows (`2^53 - 1`). diff --git a/rs/moq-archive/src/producer/backends.rs b/rs/moq-archive/src/producer/backends.rs index 9e6674709a1..06eeb2b0934 100644 --- a/rs/moq-archive/src/producer/backends.rs +++ b/rs/moq-archive/src/producer/backends.rs @@ -133,15 +133,10 @@ fn decode(objects: &BTreeMap, key: &str, timed: bool) -> Vec<(u64 object .entries .iter() - .map(|entry| match entry { - crate::layout::Entry::Group { sequence, frames } => ( - *sequence, - frames - .iter() - .map(|frame| String::from_utf8(frame.payload.to_vec()).unwrap()) - .collect(), - ), - crate::layout::Entry::Datagram { .. } => panic!("no datagrams were sent"), + .map(|entry| { + let frames = entry.frames.iter(); + let frames = frames.map(|frame| String::from_utf8(frame.payload.to_vec()).unwrap()); + (entry.sequence, frames.collect()) }) .collect() } diff --git a/rs/moq-archive/src/producer/mod.rs b/rs/moq-archive/src/producer/mod.rs index 4e137b33894..a8e37e18cf3 100644 --- a/rs/moq-archive/src/producer/mod.rs +++ b/rs/moq-archive/src/producer/mod.rs @@ -3,8 +3,8 @@ //! A [`Producer`] lists the broadcast's tracks (TRACKS, [`broadcast::Consumer::tracks`]) and //! records each through its own ascending SUBSCRIBE, so it holds what the publisher's cache //! stops serving: a group still missing once the subscription's max delay passes is a gap, never -//! a stall. Datagrams are never cached, so a track's are recorded from when its subscription -//! takes effect. The host picks the key prefix, per broadcast path and instance say, and runs one +//! a stall. TRACKS lists stream tracks only, so a datagram track, which is live-only, is never +//! recorded. The host picks the key prefix, per broadcast path and instance say, and runs one //! producer per broadcast; the producer stores no instance. See the [crate docs](crate#layout) for //! what it writes. //! diff --git a/rs/moq-archive/src/producer/ordered.rs b/rs/moq-archive/src/producer/ordered.rs index 2838ce3a7fc..a58a5735d19 100644 --- a/rs/moq-archive/src/producer/ordered.rs +++ b/rs/moq-archive/src/producer/ordered.rs @@ -1,7 +1,7 @@ -//! A track's groups and datagrams in sequence order, waiting a bounded time for a late one. +//! A track's groups in sequence order, waiting a bounded time for a late one. //! -//! `moq_net::track::Ordered` seeks past a missing group at once and orders groups alone, so it -//! fits a player, not an archive that must keep every group and datagram it can in key order. +//! `moq_net::track::Ordered` seeks past a missing group at once, so it fits a player, not an +//! archive that must keep every group it can in key order. use std::collections::{BTreeMap, VecDeque}; use std::pin::Pin; @@ -9,36 +9,29 @@ use std::task::{Poll, ready}; use std::time::Duration; use kio::clock::Instant; +use moq_net::group; use moq_net::track::{Position, Subscriber}; -use moq_net::{Datagram, group}; - -/// One sequence, in the form it was forwarded in. -pub(super) enum Item { - // Boxed, since a group consumer dwarfs a datagram and waits in `Ordered::pending`. - Group(Box), - Datagram(Datagram), -} /// Hands out each sequence once every lower one was handed out or given up: at once when it is /// the next, otherwise once `max_delay` passes from the oldest arrival still waiting, which is /// when the gap below it was first seen. While no start is known, anything but 0 waits the same -/// way, since arrival is not sequence order. Once both channels end, everything received is -/// handed out without waiting. +/// way, since arrival is not sequence order. Once the groups end, everything received is handed +/// out without waiting. pub(super) struct Ordered { subscriber: Subscriber, max_delay: Duration, /// The next sequence to hand out; `None` until a start is known or the first wait ends. next: Option, /// Received sequences waiting on a lower one. - pending: BTreeMap, + /// Boxed, since a group consumer is large and moves as the map rebalances. + pending: BTreeMap>, /// Each pending sequence with when it arrived, oldest first. The front is always pending, /// and the ones handed out behind it go once it does, so this holds at most what arrived /// within a max delay. The oldest dates the gap, so a later, lower arrival never extends it. arrivals: VecDeque<(u64, Instant)>, timer: Option>>, groups_done: bool, - datagrams_done: bool, - /// The first error a channel ended with: an end declared with an error, or an abort. + /// The error the groups ended with: an end declared with an error, or an abort. error: Option, } @@ -55,7 +48,6 @@ impl Ordered { arrivals: VecDeque::new(), timer: None, groups_done: false, - datagrams_done: false, error: None, } } @@ -65,7 +57,7 @@ impl Ordered { let now = kio::clock::now(); while !self.groups_done { match self.subscriber.poll_recv_group(waiter) { - Poll::Ready(Ok(Some(group))) => self.arrive(group.sequence, Item::Group(Box::new(group)), now), + Poll::Ready(Ok(Some(group))) => self.arrive(Box::new(group), now), // How the track ended is read from `poll_finished`. Poll::Ready(Ok(None)) => self.groups_done = true, Poll::Ready(Err(err)) => { @@ -75,33 +67,23 @@ impl Ordered { Poll::Pending => break, } } - while !self.datagrams_done { - match self.subscriber.poll_recv_datagram(waiter) { - Poll::Ready(Ok(Some(datagram))) => self.arrive(datagram.sequence, Item::Datagram(datagram), now), - Poll::Ready(Ok(None)) => self.datagrams_done = true, - Poll::Ready(Err(err)) => { - self.error.get_or_insert(err); - self.datagrams_done = true; - } - Poll::Pending => break, - } - } } - fn arrive(&mut self, sequence: u64, item: Item, now: Instant) { + fn arrive(&mut self, group: Box, now: Instant) { + let sequence = group.sequence; // A sequence names one group, so a copy of one already here or handed out is dropped. if self.next.is_none_or(|next| sequence >= next) && let std::collections::btree_map::Entry::Vacant(entry) = self.pending.entry(sequence) { - entry.insert(item); + entry.insert(group); self.arrivals.push_back((sequence, now)); } } - /// The next sequence in order, or `None` once both channels ended and nothing is left. - pub(super) fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { + /// The next group in order, or `None` once the groups ended and nothing is left. + pub(super) fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { self.poll_arrivals(waiter); - let draining = self.groups_done && self.datagrams_done; + let draining = self.groups_done; loop { let Some(entry) = self.pending.first_entry() else { return match draining { @@ -134,10 +116,10 @@ impl Ordered { } } } - let item = self.pending.remove(&sequence).expect("the lowest pending sequence"); + let group = self.pending.remove(&sequence).expect("the lowest pending sequence"); self.trim(); self.next = Some(sequence.saturating_add(1)); - return Poll::Ready(Some((sequence, item))); + return Poll::Ready(Some(group)); } } @@ -152,7 +134,7 @@ impl Ordered { } } - /// How the track ended, once it has: the error a channel ended with, which an end declared + /// How the track ended, once it has: the error the groups ended with, which an end declared /// with an error reads as while keeping its history, or else its declared end. pub(super) fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll> { let end = ready!(self.subscriber.poll_finished(waiter))?; @@ -180,7 +162,7 @@ mod tests { fn next(ordered: &mut Ordered) -> Option { match ordered.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Some((sequence, _))) => Some(sequence), + Poll::Ready(Some(group)) => Some(group.sequence), _ => None, } } @@ -200,12 +182,12 @@ mod tests { Ordered::new(subscriber, MAX_DELAY, next) } - /// The next sequence is handed out at once, a datagram in turn with the groups, and one past a - /// missing sequence only once the max delay from its arrival passes. + /// The next sequence is handed out at once, and one past a missing sequence only once the max + /// delay from its arrival passes. #[tokio::test(start_paused = true)] async fn only_a_missing_sequence_waits_the_max_delay() { let broadcast = broadcast::Info::new().produce(); - let mut track = broadcast.create_track("t", track::Info::default()).unwrap(); + let track = broadcast.create_track("t", track::Info::default()).unwrap(); let mut ordered = ordered(&broadcast, None).await; group(&track, 0); @@ -213,7 +195,7 @@ mod tests { group(&track, 2); assert_eq!(next(&mut ordered), None); - track.insert_datagram(1, None, "d").unwrap(); + group(&track, 1); assert_eq!(next(&mut ordered), Some(1)); assert_eq!(next(&mut ordered), Some(2)); diff --git a/rs/moq-archive/src/producer/tests.rs b/rs/moq-archive/src/producer/tests.rs index c560de61c1d..6353719c33e 100644 --- a/rs/moq-archive/src/producer/tests.rs +++ b/rs/moq-archive/src/producer/tests.rs @@ -10,7 +10,7 @@ use tokio::task::JoinHandle; use super::*; use crate::Info; -use crate::layout::{At, Entry, Object}; +use crate::layout::{At, Object}; use crate::mock::Mock; const SECOND: Duration = Duration::from_secs(1); @@ -109,8 +109,8 @@ async fn objects(store: &Store, track: &str, timed: bool) -> BTreeMap) -> Vec)>> { +/// Each object's entries as `(sequence, frames)`, where a frame is `pts/payload`. +fn contents(objects: &BTreeMap) -> Vec)>> { objects .values() .map(|object| { @@ -118,11 +118,8 @@ fn contents(objects: &BTreeMap) -> Vec ("group", frames.as_slice()), - Entry::Datagram { frame, .. } => ("datagram", std::slice::from_ref(frame)), - }; - let frames = frames + let frames = entry + .frames .iter() .map(|frame| { let payload = String::from_utf8(frame.payload.to_vec()).unwrap(); @@ -132,7 +129,7 @@ fn contents(objects: &BTreeMap) -> Vec>(), [key(1, 0, 1000)]); assert_eq!( contents(&stored)[0], - [ - (1, "group", vec!["1000/b".to_string()]), - (2, "group", vec!["2000/c".to_string()]), - ] + [(1, vec!["1000/b".to_string()]), (2, vec!["2000/c".to_string()]),] ); // Group 0 arriving after that is too late. @@ -303,7 +294,7 @@ async fn an_oversized_timed_group_splits_into_ordered_unique_keys() { let stored: Vec = contents(&objects) .into_iter() .flatten() - .flat_map(|(_, _, frames)| frames) + .flat_map(|(_, frames)| frames) .collect(); let expected: Vec = frames.iter().map(|(pts, payload)| format!("{pts}/{payload}")).collect(); assert_eq!(stored, expected); @@ -335,10 +326,7 @@ async fn an_open_untimed_group_is_cut_at_each_flush() { assert_eq!(stored.keys().cloned().collect::>(), [head, untimed(0, 1)]); assert_eq!( contents(&stored), - [ - [(0, "group", vec!["a".to_string()])], - [(0, "group", vec!["b".to_string()])] - ] + [[(0, vec!["a".to_string()])], [(0, vec!["b".to_string()])]] ); open.finish().unwrap(); @@ -386,9 +374,9 @@ async fn an_untimed_group_splits_by_wall_clock() { assert_eq!( contents(&stored), [ - vec![(0, "group", strings(&["a", "b", "c", "d"]))], - vec![(0, "group", strings(&["e"]))], - vec![(1, "group", strings(&["f"]))], + vec![(0, strings(&["a", "b", "c", "d"]))], + vec![(0, strings(&["e"]))], + vec![(1, strings(&["f"]))], ] ); } @@ -470,7 +458,7 @@ async fn a_restart_mid_group_loses_and_duplicates_nothing() { let frames: Vec = contents(&stored) .into_iter() .flatten() - .flat_map(|(_, _, frames)| frames) + .flat_map(|(_, frames)| frames) .collect(); assert_eq!(frames, ["0/a", "100/b", "200/c", "300/d"]); @@ -483,7 +471,7 @@ async fn a_restart_mid_group_loses_and_duplicates_nothing() { let frames: Vec = contents(&stored) .into_iter() .flatten() - .flat_map(|(_, _, frames)| frames) + .flat_map(|(_, frames)| frames) .collect(); assert_eq!(frames, ["a", "b", "c", "d"]); assert!(complete(&store).await); @@ -526,7 +514,7 @@ async fn a_partial_group_resumes_after_its_last_stored_frame() { let frames: Vec = contents(&stored) .into_iter() .flatten() - .flat_map(|(_, _, frames)| frames) + .flat_map(|(_, frames)| frames) .collect(); assert_eq!(frames, ["0/a", "500/c", "600/d"]); assert_eq!(end(&store, "video").await, Some(End::Finish(At { group: 2, frame: 0 }))); @@ -683,36 +671,31 @@ async fn a_track_added_mid_recording_is_recorded() { assert!(complete(&store).await); } +/// An archive records stream tracks only: a datagram track of the same name leaves no trace. #[tokio::test(start_paused = true)] -async fn groups_and_datagrams_keep_their_form_through_an_abrupt_end() { +async fn a_datagram_track_is_not_recorded() { let store = store(); let broadcast = broadcast::Info::new().produce(); - let mut track = broadcast.create_track("mixed", timed()).unwrap(); + let video = broadcast.create_track("video", timed()).unwrap(); + let mut datagrams = broadcast + .create_datagrams( + "video", + moq_net::datagram::Info::default().with_timescale(Timescale::MILLI), + ) + .unwrap(); let task = spawn(&store, &broadcast, config()); - subscribed(&track).await; - - group(&track, 0, &[(0, "g0")]); - track.insert_datagram(1, ms(100), "d1").unwrap(); - group(&track, 2, &[(200, "g2")]); - track.insert_datagram(3, ms(300), "d3").unwrap(); - track.insert_datagram(4, ms(400), "d4").unwrap(); - // Ends at once: every received datagram is stored before the end. - track.finish().unwrap(); + subscribed(&video).await; + datagrams.append(ms(0), "datagram").unwrap(); + group(&video, 0, &[(0, "a")]); + settle().await; + datagrams.finish(); + video.finish().unwrap(); broadcast.close(); finished(task).await; - let stored = objects(&store, "mixed", true).await; - assert_eq!( - contents(&stored), - [[ - (0, "group", vec!["0/g0".to_string()]), - (1, "datagram", vec!["100/d1".to_string()]), - (2, "group", vec!["200/g2".to_string()]), - (3, "datagram", vec!["300/d3".to_string()]), - (4, "datagram", vec!["400/d4".to_string()]), - ]] - ); - assert_eq!(end(&store, "mixed").await, Some(End::Finish(At { group: 5, frame: 0 }))); + // The stream track's lone frame, and nothing of the datagram's. + let video = objects(&store, "video", true).await; + assert_eq!(contents(&video), [vec![(0, vec!["0/a".to_string()])]]); } #[tokio::test(start_paused = true)] @@ -770,7 +753,7 @@ async fn an_errored_end_records_its_error() { let frames: Vec = contents(&objects(&store, "video", true).await) .into_iter() .flatten() - .flat_map(|(_, _, frames)| frames) + .flat_map(|(_, frames)| frames) .collect(); assert_eq!(frames, ["0/a", "1000/b"]); assert_eq!(end(&store, "video").await, Some(End::Error(64 + 7))); @@ -802,7 +785,7 @@ async fn a_re_added_track_continues_and_its_marker_records_the_last_run() { let frames: Vec = contents(&objects(&store, "video", true).await) .into_iter() .flatten() - .flat_map(|(_, _, frames)| frames) + .flat_map(|(_, frames)| frames) .collect(); assert_eq!(frames, ["0/a", "1000/b", "2000/c"]); assert_eq!(end(&store, "video").await, Some(End::Error(64 + 3))); diff --git a/rs/moq-archive/src/producer/track.rs b/rs/moq-archive/src/producer/track.rs index 6e4675f4d07..a2720df5530 100644 --- a/rs/moq-archive/src/producer/track.rs +++ b/rs/moq-archive/src/producer/track.rs @@ -14,7 +14,7 @@ use object_store::path::Path; use object_store::{ObjectStore, ObjectStoreExt, PutMode, PutPayload}; use super::Shared; -use super::ordered::{Item, Ordered}; +use super::ordered::Ordered; use super::resume::Stored; use crate::layout::{self, End, Entry, Frame, Object}; use crate::{Error, Info, Result}; @@ -71,7 +71,7 @@ struct Building { enum Event { Frame(u64, moq_net::Result>), - Next(Option<(u64, Item)>), + Next(Option>), Timer, } @@ -156,7 +156,7 @@ impl Track { Ok(()) } - /// Record one subscription until both its groups and its datagrams end, returning how. + /// Record one subscription until its groups end, returning how. async fn run(&mut self, info: &moq_net::track::Info) -> Result { let shared = self.shared.clone(); let config = &shared.config; @@ -227,15 +227,8 @@ impl Track { let now = kio::clock::now(); match event { Event::Frame(index, read) => self.frame(&mut current, index, read, now)?, - Event::Next(Some((sequence, Item::Datagram(datagram)))) => { - let frame = Frame { - timestamp: self.stamp(datagram.timestamp)?, - payload: datagram.payload, - }; - self.push(sequence, 0, frame, true, now)?; - self.next = Some(Position::group(sequence + 1)); - } - Event::Next(Some((sequence, Item::Group(group)))) => { + Event::Next(Some(group)) => { + let sequence = group.sequence; // A resumed group continues after its stored frames. The clamp also starts a // group whose head is held elsewhere at its first frame, rather than lagging // behind it. @@ -280,7 +273,7 @@ impl Track { timestamp: self.stamp(frame.timestamp)?, payload: frame.payload, }; - self.push(sequence, index, frame, false, now)?; + self.push(sequence, index, frame, now)?; self.next = Some(Position { group: sequence, frame: index + 1, @@ -308,11 +301,11 @@ impl Track { } } - /// Append one frame of `sequence`, or a datagram, cutting the object before it when due. - fn push(&mut self, sequence: u64, index: u64, frame: Frame, datagram: bool, now: Instant) -> Result<()> { + /// Append one frame of `sequence`, cutting the object before it when due. + fn push(&mut self, sequence: u64, index: u64, frame: Frame, now: Instant) -> Result<()> { let config = &self.shared.config; if let Some(building) = &self.object { - let last = building.object.entries.last().map(Entry::sequence); + let last = building.object.entries.last().map(|entry| entry.sequence); let boundary = last != Some(sequence); // Only an object's first entry carries a frame start, so a group starting past frame 0 // opens its own object. @@ -352,16 +345,13 @@ impl Track { }); let pts = frame.timestamp.unwrap_or(0); match building.object.entries.last_mut() { - Some(Entry::Group { sequence: last, frames }) if *last == sequence && !datagram => frames.push(frame), + Some(last) if last.sequence == sequence => last.frames.push(frame), _ => { building.run_pts = pts; building.run_at = now; - building.object.entries.push(match datagram { - true => Entry::Datagram { sequence, frame }, - false => Entry::Group { - sequence, - frames: vec![frame], - }, + building.object.entries.push(Entry { + sequence, + frames: vec![frame], }); } } diff --git a/rs/moq-archive/src/store.rs b/rs/moq-archive/src/store.rs index 8a9196ac40d..81ad5b2d62e 100644 --- a/rs/moq-archive/src/store.rs +++ b/rs/moq-archive/src/store.rs @@ -105,7 +105,7 @@ mod tests { .inner() .put( &path, - br#"{ "timescale": 1000, "priority": 1, "version": 3 }"#.as_ref().into(), + br#"{ "timescale": 1000, "priority": 1, "version": 4 }"#.as_ref().into(), ) .await .unwrap(); @@ -116,7 +116,7 @@ mod tests { .bytes() .await .unwrap(); - assert_eq!(&kept[..], br#"{ "timescale": 1000, "priority": 1, "version": 3 }"#); + assert_eq!(&kept[..], br#"{ "timescale": 1000, "priority": 1, "version": 4 }"#); } #[tokio::test] @@ -133,7 +133,7 @@ mod tests { matches!(err, Error::Version(2)) }), ("junk", b"not json", |err| matches!(err, Error::Json(_))), - ("zero", br#"{"version":3,"priority":0,"timescale":0}"#, |err| { + ("zero", br#"{"version":4,"priority":0,"timescale":0}"#, |err| { matches!(err, Error::Timescale(0)) }), ] { diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index d524ff1358a..b75c742e14a 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,14 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Datagrams are a track of their own + +A raw track's datagrams travel on a separate datagram track of the same name, so the C ABI is unchanged but some results differ: + +- `moq_publish_track_datagram` numbers datagrams on their own, apart from groups, and `moq_datagram.sequence` is that number. Don't compare it with group sequences. +- `moq_consume_datagrams` receives only the datagrams sent once its subscription is answered. Every raw track carries a datagram track, but a name with none, such as a media or JSON track, ends the callback with a not-found error. +- moq-transport carries no datagrams. + ### JSON writes refuse a NULL timestamp ([#7691](https://github.com/moq-dev/moq/pull/7691)) `moq_publish_json_snapshot_update`, `moq_publish_json_stream_append`, `moq_publish_json_window_push`, and `moq_publish_json_window_pop` fail with `MOQ_ERROR_INVALID_POINTER` and publish nothing on a NULL `timestamp`, instead of stamping the broadcast clock's current time. diff --git a/rs/moq-c/c-tests/timestamps.c b/rs/moq-c/c-tests/timestamps.c index 88954ac9292..0c7aa3e6d8e 100644 --- a/rs/moq-c/c-tests/timestamps.c +++ b/rs/moq-c/c-tests/timestamps.c @@ -120,8 +120,21 @@ int main(void) { WAIT_FOR(&c, c.frame_count == 1); CHECK(moq_publish_track_frame((uint32_t)track, payload, sizeof(payload), &tick) == 0); WAIT_FOR(&c, c.frame_count == 2); - CHECK(moq_publish_track_datagram((uint32_t)track, payload, sizeof(payload), &tick, NULL) == 0); - WAIT_FOR(&c, c.datagram); + // The datagram reader joins once its subscription is answered, and a datagram sent + // before then is gone, so resend until one lands. + for (int sent = 0;; sent++) { + CHECK(sent < 1000); + CHECK(moq_publish_track_datagram((uint32_t)track, payload, sizeof(payload), &tick, NULL) == 0); + struct timespec deadline; + CHECK(clock_gettime(CLOCK_REALTIME, &deadline) == 0); + deadline.tv_nsec += 10 * 1000 * 1000; + if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; } + pthread_mutex_lock(&c.mutex); + while (!c.datagram && pthread_cond_timedwait(&c.changed, &c.mutex, &deadline) == 0) {} + int landed = c.datagram != 0; + pthread_mutex_unlock(&c.mutex); + if (landed) break; + } // JSON tracks moq-c creates count milliseconds. const moq_timestamp stamped = {1500, 1000}; CHECK(moq_publish_json_stream_append((uint32_t)json, doc, strlen(doc), &stamped) == 0); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index c207a408075..bb5e426ecb4 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -636,7 +636,7 @@ pub struct moq_datagram { /// long as `payload`. pub timestamp: *const moq_timestamp, - /// Per-track sequence number, drawn from the same namespace as groups. + /// The datagram's sequence number, counted apart from the track's groups. pub sequence: u64, } @@ -3173,10 +3173,11 @@ pub unsafe extern "C" fn moq_publish_track_frame( /// Send a best-effort datagram on a raw track created by [moq_publish_track]. /// /// Takes `payload` then `timestamp`, matching [moq_publish_track_frame]. The payload must -/// be at most 1200 bytes. On success the datagram's per-track sequence number (shared with the -/// group namespace) is written to `out_sequence` when it is non-NULL. Datagrams are -/// delivered only on transports and wire versions with a datagram channel; there is no -/// group fallback. +/// be at most 1200 bytes. On success the datagram's sequence number, counted apart from the +/// track's groups, is written to `out_sequence` when it is non-NULL. A subscriber receives the +/// datagrams sent once its [moq_consume_datagrams] subscription is answered, and counts toward +/// the track's demand. Datagrams are delivered only on +/// transports and wire versions with a datagram channel; there is no group fallback. /// /// Returns a zero on success, or a negative code on failure. /// @@ -4301,14 +4302,15 @@ pub extern "C" fn moq_consume_track_cancel(track: u32) -> i32 { /// Subscribe to a raw track's best-effort datagrams by name. /// -/// The datagram counterpart to [moq_consume_track], on its own subscription. `on_datagram` +/// The datagram counterpart to [moq_consume_track], on its own subscription, delivering the +/// datagrams sent once the publisher answered it. `on_datagram` /// is called with a positive datagram ID for each datagram in arrival order, then exactly /// once more with a terminal code: `0` (closed cleanly) or a negative error. After the /// terminal (`<= 0`) callback, `on_datagram` is never called again and `user_data` is never /// touched again, so release `user_data` there. The terminal callback fires even after /// [moq_consume_datagrams_cancel]. Read each datagram with [moq_consume_datagram] and release /// it with [moq_consume_datagram_free]. Datagrams arrive only over datagram-capable -/// transports on moq-transport or lite-05 and newer moq-lite; there is no stream fallback. +/// transports on lite-05 and newer moq-lite; there is no stream fallback. /// /// Returns a non-zero handle to the subscription 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 2d628601c1d..649b8e988cc 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -844,7 +844,11 @@ impl Consume { let broadcast = context.broadcast.clone(); let name = name.to_string(); - // `subscribe` blocks on SUBSCRIBE_OK, so run it inside the task. + // The datagrams ride a datagram track of the track's name. Subscribing to the track + // first keeps what a datagram reader always was to its publisher: demand on the track, + // and a request a dynamic publisher creates both from. A name with no stream track, + // whose publisher made only a datagram track, is read without one. `subscribe` blocks + // on the publisher's answer, so run it inside the task. self.datagram_task.spawn( on_datagram, |state| &mut state.consume.datagram_task, @@ -852,20 +856,46 @@ impl Consume { driver .watch(async move { let Some(track) = - Self::until_closed(&mut close, Self::subscribe(&broadcast, &name, None)).await? + Self::until_closed(&mut close, Self::subscribe_demand(&broadcast, &name)).await? else { return Ok(()); }; - Self::run_datagrams(on_datagram, track, close).await + let datagrams = broadcast.datagrams(&name)?; + Self::run_datagrams(on_datagram, datagrams, track, close).await }) .await }, ) } + /// Subscribe to `name`'s stream track for its demand, or `None` when the name has none. + async fn subscribe_demand( + broadcast: &moq_net::broadcast::Consumer, + name: &str, + ) -> Result, Error> { + let missing = |err: &moq_net::Error| { + matches!( + err, + moq_net::Error::NotFound | moq_net::Error::Stream(moq_net::StreamError::NotFound) + ) + }; + let track = match broadcast.track(name) { + Ok(track) => track, + Err(err) if missing(&err) => return Ok(None), + Err(err) => return Err(err.into()), + }; + match track.subscribe(None).await { + Ok(subscriber) => Ok(Some(subscriber)), + Err(err) if missing(&err) => Ok(None), + Err(err) => Err(err.into()), + } + } + async fn run_datagrams( callback: OnStatus, - mut track: moq_net::track::Subscriber, + mut track: moq_net::datagram::Consumer, + // Held for its demand only; its groups go unread. + _subscriber: Option, mut close: oneshot::Receiver<()>, ) -> Result<(), Error> { loop { @@ -873,7 +903,7 @@ impl Consume { let datagram = tokio::select! { biased; _ = &mut close => return Ok(()), - datagram = track.recv_datagram() => match datagram? { + datagram = track.recv() => match datagram? { Some(datagram) => datagram, None => return Ok(()), }, diff --git a/rs/moq-c/src/error.rs b/rs/moq-c/src/error.rs index f9a59d5ea19..8e08a9109c7 100644 --- a/rs/moq-c/src/error.rs +++ b/rs/moq-c/src/error.rs @@ -472,8 +472,7 @@ fn stream_kind(err: &moq_net::StreamError) -> moq_protocol_kind { moq_net::StreamError::GoingAway => MOQ_PROTOCOL_KIND_GOING_AWAY, moq_net::StreamError::TooFarBehind => MOQ_PROTOCOL_KIND_TOO_FAR_BEHIND, moq_net::StreamError::MalformedTrack => MOQ_PROTOCOL_KIND_MALFORMED_TRACK, - // A datagram reached by a FETCH is, to a binding, a miss like any other. - moq_net::StreamError::NotFound | moq_net::StreamError::NotFetchable => MOQ_PROTOCOL_KIND_NOT_FOUND, + moq_net::StreamError::NotFound => MOQ_PROTOCOL_KIND_NOT_FOUND, moq_net::StreamError::Unroutable => MOQ_PROTOCOL_KIND_UNROUTABLE, moq_net::StreamError::Old => MOQ_PROTOCOL_KIND_OLD, moq_net::StreamError::Evicted => MOQ_PROTOCOL_KIND_EVICTED, diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 407c88fbcf3..522132adce0 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -90,6 +90,8 @@ enum Family { open: HashSet, /// Finished producers, kept so a late subscriber still finds their tracks. kept: Vec>, + /// The broadcast, which a raw track creates its datagram track on. + broadcast: moq_net::broadcast::Producer, }, /// The broadcast closed: its children ended and new ones are refused. Closed, @@ -105,16 +107,23 @@ enum Family { #[derive(Clone)] pub struct Children(Arc>); +/// The family of a standalone broadcast of its own. +#[cfg(test)] impl Default for Children { fn default() -> Self { + Self::new(moq_net::broadcast::Info::new().produce()) + } +} + +impl Children { + fn new(broadcast: moq_net::broadcast::Producer) -> Self { Self(Arc::new(std::sync::Mutex::new(Family::Open { open: HashSet::new(), kept: Vec::new(), + broadcast, }))) } -} -impl Children { fn lock(&self) -> std::sync::MutexGuard<'_, Family> { self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } @@ -144,12 +153,23 @@ impl Children { /// Keep `child`'s finished producer until the broadcast ends, or drop it now if it already has. pub(crate) fn keep(&self, child: Child, producer: impl Send + 'static) { let producer: Box = Box::new(producer); - if let Family::Open { open, kept } = &mut *self.lock() { + if let Family::Open { open, kept, .. } = &mut *self.lock() { open.remove(&child); kept.push(producer); } } + /// Create the datagram track carrying the datagrams of the raw track `name` with `info`. + fn datagrams(&self, name: &str, info: &moq_net::track::Info) -> Result { + let Family::Open { broadcast, .. } = &*self.lock() else { + return Err(Error::BroadcastNotFound); + }; + let info = moq_net::datagram::Info::default() + .with_timescale(info.timescale) + .with_priority(info.priority); + Ok(broadcast.create_datagrams(name, info)?) + } + /// Stop tracking `child`, which ended on its own. pub(crate) fn forget(&self, child: Child) { if let Family::Open { open, .. } = &mut *self.lock() { @@ -161,10 +181,7 @@ impl Children { fn close(&self) -> HashSet { let family = std::mem::replace(&mut *self.lock(), Family::Closed); match family { - Family::Open { open, kept } => { - drop(kept); - open - } + Family::Open { open, .. } => open, Family::Closed => HashSet::new(), } } @@ -196,12 +213,22 @@ impl Abort for Kept> { } } -impl Abort for Kept { +/// A raw track and the datagram track of the same name that carries its datagrams. +struct Raw { + track: moq_net::track::Producer, + datagrams: moq_net::datagram::Producer, +} + +impl Abort for Kept { fn abort(self, err: moq_net::Error) { + let Raw { track, datagrams } = self.inner; // A track with a declared end ends there, as when its last producer drops. - if self.inner.finished().is_none() { - let _ = self.inner.abort(err); + if track.finished().is_some() { + datagrams.finish(); + return; } + datagrams.abort(err.clone()); + let _ = track.abort(err); } } @@ -288,7 +315,7 @@ pub struct Publish { containers: NonZeroSlab, /// Raw track producers (no media/container/catalog framing). - tracks: NonZeroSlab>, + tracks: NonZeroSlab>, /// Raw group producers, created from a raw track producer. groups: NonZeroSlab, @@ -354,11 +381,11 @@ impl Publish { let id = self.broadcasts.insert(Broadcast { driver, + children: Children::new(broadcast.clone()), producer: broadcast, catalog, video: BTreeMap::new(), audio: BTreeMap::new(), - children: Children::default(), ended: false, })?; Ok(id) @@ -789,12 +816,18 @@ impl Publish { /// 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).map(|track| &mut track.inner) + self.tracks.get_mut(track).map(|raw| &mut raw.inner.track) + } + + /// A raw track's datagram producer, for tests that send a datagram once a reader listens. + #[cfg(test)] + pub(crate) fn datagram_producer(&self, track: Id) -> Option { + self.tracks.get(track).map(|raw| raw.inner.datagrams.clone()) } /// 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()) + Ok(self.tracks.get(track).ok_or(Error::TrackNotFound)?.track.demand()) } /// A watch-only handle to a media importer's subscriber demand. @@ -891,7 +924,7 @@ impl Publish { /// Serve fetches of uncached groups on a raw track, delivering each as a group-request /// handle via `on_group`. pub fn track_dynamic(&mut self, track: Id, on_group: OnStatus) -> Result { - let dynamic = self.tracks.get(track).ok_or(Error::TrackNotFound)?.requests(); + let dynamic = self.tracks.get(track).ok_or(Error::TrackNotFound)?.track.requests(); self.spawn_dynamic(Dynamic::Track(dynamic), on_group, Default::default()) } @@ -1020,11 +1053,25 @@ impl Publish { /// Accept a track request as a raw track, returning a track handle like [`Self::track`]. pub fn track_request_accept(&mut self, request: Id, info: moq_net::track::Info) -> Result { let TrackRequest { children, request, .. } = self.track_request.remove(request).ok_or(Error::NotFound)?; - let track = Kept { - inner: request.accept(info), + let datagrams = match children.datagrams(request.name(), &info) { + Ok(datagrams) => datagrams, + Err(err) => { + // A closed broadcast ends its subscribers the way a publisher leaving does. + request.reject(match &err { + Error::Moq(err) => err.clone(), + _ => moq_net::Error::Unroutable, + }); + return Err(err); + } + }; + let raw = Kept { + inner: Raw { + track: request.accept(info), + datagrams, + }, children: children.clone(), }; - children.insert(&mut self.tracks, track, Child::Track) + children.insert(&mut self.tracks, raw, Child::Track) } /// Accept a track request as an audio track, returning a media handle like [`Self::audio`]. @@ -1117,25 +1164,33 @@ impl Publish { pub fn track(&mut self, broadcast: Id, name: &str, info: Option) -> Result { let children = self.children(broadcast)?; let broadcast = self.producer(broadcast)?; - let track = broadcast.create_track(name, info)?; - let track = Kept { - inner: track, + let info = info.unwrap_or_default(); + let datagrams = children.datagrams(name, &info)?; + let track = match broadcast.create_track(name, info) { + Ok(track) => track, + Err(err) => { + datagrams.abort(err.clone()); + return Err(err.into()); + } + }; + let raw = Kept { + inner: Raw { track, datagrams }, children: children.clone(), }; - children.insert(&mut self.tracks, track, Child::Track) + children.insert(&mut self.tracks, raw, Child::Track) } /// Append a new group to a raw track, returning a group producer. pub fn track_group(&mut self, track: Id) -> Result { - let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; - let group = track.append_group()?; + let raw = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; + let group = raw.track.append_group()?; self.groups.insert(group) } /// Create a raw group with an explicit sequence number. pub fn track_group_at(&mut self, track: Id, sequence: u64) -> Result { - let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; - let group = track.create_group(moq_net::group::Info { sequence })?; + let raw = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; + let group = raw.track.create_group(moq_net::group::Info { sequence })?; self.groups.insert(group) } @@ -1146,24 +1201,26 @@ impl Publish { timestamp: Option, payload: &[u8], ) -> Result<(), Error> { - let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; - track.write_frame(timestamp, bytes::Bytes::copy_from_slice(payload))?; + let raw = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; + raw.track + .write_frame(timestamp, bytes::Bytes::copy_from_slice(payload))?; Ok(()) } - /// Send a best-effort datagram on a raw track, returning its per-track sequence. + /// Send a best-effort datagram on a raw track's datagram track, returning its sequence. /// - /// The payload must be at most [`moq_net::MAX_DATAGRAM_PAYLOAD`] bytes. Datagrams are - /// delivered only on transports and wire versions with a datagram channel; there is no - /// group fallback. + /// Datagrams are delivered only on transports and wire versions with a datagram channel; + /// there is no group fallback. pub fn track_datagram( &mut self, track: Id, timestamp: Option, payload: &[u8], ) -> Result { - let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; - Ok(track.append_datagram(timestamp, bytes::Bytes::copy_from_slice(payload))?) + let raw = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; + Ok(raw + .datagrams + .append(timestamp, bytes::Bytes::copy_from_slice(payload))?) } /// Finish a raw track. No more groups or frames can be written. @@ -1172,27 +1229,34 @@ impl Publish { /// boundary and only releases the handle; the broadcast keeps the track. pub fn track_finish(&mut self, track: Id) -> Result<(), Error> { let id = track; - let track = self.tracks.remove(id).ok_or(Error::TrackNotFound)?; + let Kept { + inner: Raw { track, datagrams }, + children, + } = self.tracks.remove(id).ok_or(Error::TrackNotFound)?; if track.finished().is_none() { track.finish()?; } - track.keep(Child::Track(id)); + datagrams.finish(); + Kept { inner: track, children }.keep(Child::Track(id)); Ok(()) } /// Declare a raw track's exclusive final group sequence. pub fn track_finish_at(&mut self, track: Id, final_sequence: u64) -> Result<(), Error> { - let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; - track.finish_at(moq_net::track::Position::group(final_sequence), None)?; + let raw = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; + raw.track + .finish_at(moq_net::track::Position::group(final_sequence), None)?; Ok(()) } /// Abort a raw track with an application error code. pub fn track_abort(&mut self, track: Id, error_code: u16) -> Result<(), Error> { let id = track; - let track = self.tracks.remove(id).ok_or(Error::TrackNotFound)?; - track.children.forget(Child::Track(id)); - track.inner.abort(moq_net::Error::App(error_code))?; + let raw = self.tracks.remove(id).ok_or(Error::TrackNotFound)?; + raw.children.forget(Child::Track(id)); + let err = moq_net::Error::App(error_code); + raw.inner.datagrams.abort(err.clone()); + raw.inner.track.abort(err)?; Ok(()) } @@ -1612,14 +1676,14 @@ mod children_tests { async fn a_child_of_a_closed_broadcast_is_refused() { let children = Children::default(); children.close(); - let track = moq_net::broadcast::Info::new() - .produce() - .create_track("late", None) - .unwrap(); + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast.create_track("late", None).unwrap(); + let datagrams = broadcast.create_datagrams("late", Default::default()).unwrap(); let mut reader = track.subscribe(None); + let mut datagram_reader = datagrams.consume(); let mut tracks = NonZeroSlab::default(); let kept = Kept { - inner: track, + inner: Raw { track, datagrams }, children: children.clone(), }; assert!(matches!( @@ -1627,6 +1691,7 @@ mod children_tests { Err(Error::BroadcastNotFound) )); assert!(matches!(reader.recv_group().await, Err(moq_net::Error::Unroutable))); + assert!(matches!(datagram_reader.recv().await, Err(moq_net::Error::Unroutable))); } } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index dc80f7a93db..2ab9e2a0dbc 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1861,7 +1861,8 @@ fn timestamps_round_trip_exactly() { assert_eq!(read_timestamp(frame.timestamp), written, "{name} frame"); assert_eq!(moq_consume_track_frame_free(frame_id), 0); - // The datagram subscription is live once the frame arrived on the same track. + // The datagram consumer is live once its subscription is answered. + wait_datagram_reader(track); assert_eq!( unsafe { moq_publish_track_datagram(track, payload.as_ptr(), payload.len(), timestamp, std::ptr::null_mut()) @@ -2053,6 +2054,135 @@ fn raw_track_publish_consume() { assert_eq!(moq_origin_close(origin), 0); } +/// Wait until a raw track's datagram track has a reader, which a datagram consumer becomes +/// once its subscription is answered. +fn wait_datagram_reader(track: u32) { + let datagrams = State::lock() + .publish + .datagram_producer(Id::try_from(track).unwrap()) + .expect("a raw track"); + let reader = kio::wait(|waiter| match datagrams.poll_priority(waiter, None) { + std::task::Poll::Ready(Some(_)) => std::task::Poll::Ready(()), + _ => std::task::Poll::Pending, + }); + crate::ffi::RUNTIME + .block_on(async { kio::clock::timeout(TIMEOUT, reader).await }) + .expect("no datagram reader arrived"); +} + +/// A datagram reader subscribes to its track, as before datagrams had a track of their own: +/// the publisher sees it as demand, and a dynamic publisher learns of the track from it. +#[test] +fn a_datagram_reader_is_demand_and_requests_its_track() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"datagram-demand"); + let request_cb = Callback::new(); + let dynamic = id(unsafe { moq_publish_requests(broadcast, Some(channel_callback), request_cb.ptr) }); + let consume = consume_local(broadcast); + + // Nothing publishes the track yet: the reader's request reaches the handler. + let name = b"events"; + let dg_cb = Callback::new(); + let consumer = id(unsafe { + moq_consume_datagrams( + consume, + name.as_ptr() as *const c_char, + name.len(), + Some(channel_callback), + dg_cb.ptr, + ) + }); + let request = id(request_cb.recv()); + let track = id(unsafe { moq_track_request_accept(request, std::ptr::null()) }); + + let demand_cb = Callback::new(); + let _watcher = id(unsafe { moq_publish_track_demand(track, Some(channel_callback), demand_cb.ptr) }); + assert_eq!(demand_cb.recv(), moq_demand::MOQ_DEMAND_WANTED as i32); + + wait_datagram_reader(track); + let payload = b"on demand"; + assert_eq!( + unsafe { moq_publish_track_datagram(track, payload.as_ptr(), payload.len(), µs(7), std::ptr::null_mut()) }, + 0 + ); + let dg_id = id(dg_cb.recv()); + let mut datagram = moq_datagram { + payload: std::ptr::null(), + payload_size: 0, + timestamp: std::ptr::null(), + sequence: u64::MAX, + }; + assert_eq!(unsafe { moq_consume_datagram(dg_id, &mut datagram) }, 0); + assert_eq!( + unsafe { std::slice::from_raw_parts(datagram.payload, datagram.payload_size) }, + payload + ); + assert_eq!(datagram.sequence, 0); + assert_eq!(moq_consume_datagram_free(dg_id), 0); + + // The reader leaving is the demand leaving. + assert_eq!(moq_consume_datagrams_cancel(consumer), 0); + assert_eq!(dg_cb.recv_terminal(), 0); + assert_eq!(demand_cb.recv(), moq_demand::MOQ_DEMAND_UNWANTED as i32); + + assert_eq!(moq_publish_requests_cancel(dynamic), 0); + assert_eq!(request_cb.recv_terminal(), 0); + assert_eq!(moq_publish_track_finish(track), 0); + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(demand_cb.recv_terminal(), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// A name with only a datagram track, as a moq-net publisher makes one, is read without a +/// stream track. +#[test] +fn a_datagram_only_track_is_read() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let mut datagrams = broadcast + .create_datagrams("events", moq_net::datagram::Info::default()) + .unwrap(); + let consume = i32::from(State::lock().consume.start(broadcast.consume(), None).unwrap()) as u32; + + let name = b"events"; + let dg_cb = Callback::new(); + let consumer = id(unsafe { + moq_consume_datagrams( + consume, + name.as_ptr() as *const c_char, + name.len(), + Some(channel_callback), + dg_cb.ptr, + ) + }); + let reader = kio::wait(|waiter| match datagrams.poll_priority(waiter, None) { + std::task::Poll::Ready(Some(_)) => std::task::Poll::Ready(()), + _ => std::task::Poll::Pending, + }); + crate::ffi::RUNTIME + .block_on(async { kio::clock::timeout(TIMEOUT, reader).await }) + .expect("no datagram reader arrived"); + + assert_eq!(datagrams.append(None, &b"only"[..]).unwrap(), 0); + let dg_id = id(dg_cb.recv()); + let mut datagram = moq_datagram { + payload: std::ptr::null(), + payload_size: 0, + timestamp: std::ptr::null(), + sequence: u64::MAX, + }; + assert_eq!(unsafe { moq_consume_datagram(dg_id, &mut datagram) }, 0); + assert_eq!( + unsafe { std::slice::from_raw_parts(datagram.payload, datagram.payload_size) }, + b"only" + ); + assert_eq!(moq_consume_datagram_free(dg_id), 0); + + assert_eq!(moq_consume_datagrams_cancel(consumer), 0); + assert_eq!(dg_cb.recv_terminal(), 0); + assert_eq!(moq_consume_close(consume), 0); +} + #[test] fn raw_track_datagram_publish_consume() { let origin = id(moq_origin_create()); @@ -2082,6 +2212,7 @@ fn raw_track_datagram_publish_consume() { ) }); + wait_datagram_reader(track); let payload = b"hello datagram"; let mut sequence: u64 = u64::MAX; assert_eq!( @@ -3030,31 +3161,31 @@ type NamedConsumer = ( const NAMED_CONSUMERS: [NamedConsumer; 5] = [ ( - b"raw", + b"datagrams", |consume, name, user_data| unsafe { - moq_consume_track( + moq_consume_datagrams( consume, name.as_ptr() as *const c_char, name.len(), - std::ptr::null(), Some(channel_callback), user_data, ) }, - moq_consume_track_cancel, + moq_consume_datagrams_cancel, ), ( - b"datagrams", + b"raw", |consume, name, user_data| unsafe { - moq_consume_datagrams( + moq_consume_track( consume, name.as_ptr() as *const c_char, name.len(), + std::ptr::null(), Some(channel_callback), user_data, ) }, - moq_consume_datagrams_cancel, + moq_consume_track_cancel, ), ( b"snapshot", diff --git a/rs/moq-crypto/MIGRATE.md b/rs/moq-crypto/MIGRATE.md index 78c1ee2d257..765efc831b3 100644 --- a/rs/moq-crypto/MIGRATE.md +++ b/rs/moq-crypto/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Datagrams are their own track kind ([#7752](https://github.com/moq-dev/moq/pull/7752)) + +`e2ee::track::Producer::{append_datagram, insert_datagram}` and `e2ee::track::Consumer::{poll_recv_datagram, recv_datagram}` are removed. Create a datagram track with `e2ee::broadcast::Producer::create_datagrams(name, info)` and read it with `e2ee::broadcast::Consumer::datagrams(name)`, which return `datagram::Producer` (`append`, `insert`) and `datagram::Consumer` (`recv`, yielding the same `datagram::Event`). Its sequence is its own, not the stream track's group counter, and its key domain stays separate. + ### Features gate signing and encryption The `sign` and `e2ee` modules sit behind default features of the same names. diff --git a/rs/moq-crypto/benches/protect.rs b/rs/moq-crypto/benches/protect.rs index 286c30cc30e..5a1f7ed8334 100644 --- a/rs/moq-crypto/benches/protect.rs +++ b/rs/moq-crypto/benches/protect.rs @@ -20,14 +20,8 @@ fn credential() -> Credential { } /// Each producer is its own publisher instance, so it gets its own epoch. -fn produce(credential: &Credential, semantic: &str) -> moq_crypto::e2ee::track::Producer { - // Advancing timestamps plus a zero max age keep only the newest group cached. - let info = moq_net::track::Info::default() - .with_timescale(moq_net::Timescale::MILLI) - .with_max_age(Duration::ZERO); +fn broadcast(credential: &Credential) -> moq_crypto::e2ee::broadcast::Producer { moq_crypto::e2ee::broadcast::Producer::new(moq_net::broadcast::Info::new().produce(), credential.produce()) - .create_track(semantic, info) - .unwrap() } fn bench_protect(c: &mut Criterion) { @@ -38,7 +32,11 @@ fn bench_protect(c: &mut Criterion) { let mut group = c.benchmark_group("protect"); group.throughput(Throughput::Bytes(grouped.len() as u64)); group.bench_function("grouped_1k", |b| { - let mut producer = produce(&credential, "video"); + // Advancing timestamps plus a zero max age keep only the newest group cached. + let info = moq_net::track::Info::default() + .with_timescale(moq_net::Timescale::MILLI) + .with_max_age(Duration::ZERO); + let mut producer = broadcast(&credential).create_track("video", info).unwrap(); let mut ms = 0u64; b.iter(|| { ms += 1; @@ -51,12 +49,13 @@ fn bench_protect(c: &mut Criterion) { }); group.throughput(Throughput::Bytes(opus.len() as u64)); group.bench_function("datagram_opus160", |b| { - let mut producer = produce(&credential, "audio"); + let info = moq_net::datagram::Info::default().with_timescale(moq_net::Timescale::MILLI); + let mut producer = broadcast(&credential).create_datagrams("audio", info).unwrap(); let mut ms = 0u64; b.iter(|| { ms += 1; producer - .append_datagram(Timestamp::from_millis(ms).unwrap(), black_box(&opus)) + .append(Timestamp::from_millis(ms).unwrap(), black_box(&opus)) .unwrap() }); }); diff --git a/rs/moq-crypto/src/e2ee/broadcast.rs b/rs/moq-crypto/src/e2ee/broadcast.rs index 42abf367a62..106f1ea80c8 100644 --- a/rs/moq-crypto/src/e2ee/broadcast.rs +++ b/rs/moq-crypto/src/e2ee/broadcast.rs @@ -2,12 +2,15 @@ //! //! The application names tracks the way it would in plaintext (`catalog.json`, `video`). These //! wrappers derive the physical name of each, so only physical names reach `moq-net` and the -//! relays behind it. +//! relays behind it. Stream and datagram tracks are separate namespaces, as in `moq-net`: one +//! semantic name may be both, under one physical name and separate keys. use std::task::{Poll, ready}; use crate::e2ee::error::{Error, Result}; -use crate::e2ee::{Name, generation, track}; +use crate::e2ee::generation::Domain; +use crate::e2ee::key::Claim; +use crate::e2ee::{Name, datagram, generation, track}; /// The publishing side of a protected broadcast. /// @@ -62,11 +65,11 @@ impl Producer { pub fn reserve_track(&self, semantic: &str) -> Result { let name = self.generation.name(semantic)?; let request = self.inner.reserve_track(name.as_str())?; - match self.generation.claim(semantic) { - Ok(keys) => Ok(track::Request::new( + match self.generation.claim(semantic, Domain::Group) { + Ok(claim) => Ok(track::Request::new( request, self.generation.clone(), - keys, + claim, semantic.to_string(), )), Err(err) => { @@ -76,6 +79,22 @@ impl Producer { } } + /// Create the datagram track for `semantic` under its physical name. + /// + /// A name this generation already produced datagrams for, even on another broadcast, + /// resumes above every datagram sequence it allocated. A stream track of the same name is + /// independent. + /// + /// # Errors + /// + /// [`Error::Identity`] if `semantic` exceeds 65535 bytes, or a net error if the broadcast + /// already holds a live datagram track of that name. + pub fn create_datagrams(&self, semantic: &str, info: moq_net::datagram::Info) -> Result { + let name = self.generation.name(semantic)?; + let track = self.inner.create_datagrams(name.as_str(), info)?; + self.generation.produce_datagrams(track, semantic) + } + /// Create a handler for requests to tracks nobody created or reserved, named by their /// semantic names. pub fn requests(&self) -> Requests { @@ -113,25 +132,16 @@ impl Requests { pub fn poll_requested_track(&mut self, waiter: &kio::Waiter) -> Poll> { loop { let request = ready!(self.inner.poll_requested_track(waiter))?; - match self.open(request.name()) { - Ok((keys, semantic)) => { + match open(&self.generation, request.name(), Domain::Group) { + Ok((claim, semantic)) => { let generation = self.generation.clone(); - return Poll::Ready(Ok(track::Request::new(request, generation, keys, semantic))); + return Poll::Ready(Ok(track::Request::new(request, generation, claim, semantic))); } Err(_) => request.reject(moq_net::Error::NotFound), } } } - /// Open a requested physical name to its semantic name and keys, claiming nothing: the - /// request claims the name only once the handler accepts it. - fn open(&self, name: &str) -> Result<(track::Keys, String)> { - // Syntax first, so a name that cannot be one costs no open. - let name: Name = name.parse()?; - let semantic = self.generation.open(&name)?; - Ok((self.generation.peek(&semantic)?, semantic)) - } - /// Block until a consumer requests a track, returning a [`track::Request`] to serve. /// /// # Errors @@ -141,6 +151,15 @@ impl Requests { kio::wait(|waiter| self.poll_requested_track(waiter)).await } + /// Create a handler for requests to datagram tracks nobody created, named by their semantic + /// names. See [`moq_net::broadcast::Requests::datagrams`]. + pub fn datagrams(&self) -> DatagramRequests { + DatagramRequests { + inner: self.inner.datagrams(), + generation: self.generation.clone(), + } + } + /// Create a consumer that can subscribe to tracks in this broadcast. pub fn consume(&self) -> Consumer { Consumer::new(self.inner.consume(), self.generation.consumer().clone()) @@ -175,6 +194,55 @@ impl Requests { } } +/// Open a requested physical name to its semantic name and `domain` key, claiming nothing: the +/// request claims the name only once the handler accepts it. +fn open(generation: &generation::Producer, name: &str, domain: Domain) -> Result<(Claim, String)> { + // Syntax first, so a name that cannot be one costs no open. + let name: Name = name.parse()?; + let semantic = generation.open(&name)?; + Ok((generation.peek(&semantic, domain)?, semantic)) +} + +/// Handles on-demand datagram tracks for a protected broadcast, as [`Requests`] does stream +/// tracks. +/// +/// Created by [`Requests::datagrams`]. A requested name this generation did not issue is +/// answered as [`moq_net::Error::NotFound`] after at most one open, and keeps no state. +#[derive(Clone)] +pub struct DatagramRequests { + inner: moq_net::broadcast::DatagramRequests, + generation: generation::Producer, +} + +impl DatagramRequests { + /// Poll for the next datagram track a subscriber asks for, to accept or reject. + /// + /// # Errors + /// + /// [`moq_net::Error::Closed`] once the broadcast has ended. + pub fn poll_requested(&mut self, waiter: &kio::Waiter) -> Poll> { + loop { + let request = ready!(self.inner.poll_requested(waiter))?; + match open(&self.generation, request.name(), Domain::Datagram) { + Ok((claim, semantic)) => { + let generation = self.generation.clone(); + return Poll::Ready(Ok(datagram::Request::new(request, generation, claim, semantic))); + } + Err(_) => request.reject(moq_net::Error::NotFound), + } + } + } + + /// Block until a subscriber asks for a datagram track, returning a [`datagram::Request`] to serve. + /// + /// # Errors + /// + /// Same as [`Self::poll_requested`]. + pub async fn requested(&mut self) -> Result { + kio::wait(|waiter| self.poll_requested(waiter)).await + } +} + /// A handler's answer to a protected broadcast's listings, taking semantic names and listing /// their physical names. See [`moq_net::broadcast::Lister`]. pub struct Lister { @@ -294,6 +362,18 @@ impl Consumer { semantic.to_string(), ))) } + + /// Read the datagram track for `semantic` under its physical name, live from now. + /// + /// # Errors + /// + /// [`Error::Identity`] if `semantic` exceeds 65535 bytes, or a net error if the broadcast + /// has ended or serves no such datagram track. + pub fn datagrams(&self, semantic: &str) -> Result { + let name = self.generation.name(semantic)?; + let track = self.inner.datagrams(name.as_str())?; + self.generation.consume_datagrams(track, semantic) + } } impl std::fmt::Debug for Producer { @@ -312,6 +392,14 @@ impl std::fmt::Debug for Requests { } } +impl std::fmt::Debug for DatagramRequests { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("broadcast::DatagramRequests") + .field("generation", &self.generation) + .finish() + } +} + impl std::fmt::Debug for Lister { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("broadcast::Lister") diff --git a/rs/moq-crypto/src/e2ee/datagram.rs b/rs/moq-crypto/src/e2ee/datagram.rs index ee86dfc31ff..faf1c5ef69f 100644 --- a/rs/moq-crypto/src/e2ee/datagram.rs +++ b/rs/moq-crypto/src/e2ee/datagram.rs @@ -1,11 +1,25 @@ -//! Decrypted datagrams and the events a protected datagram read yields. +//! Protected datagram tracks: writers, readers, and the events a read yields. +//! +//! A datagram track is a `moq-net` [datagram track](moq_net::datagram) under the physical name of +//! its semantic name, independent of any stream track of that name. Each datagram is sealed under +//! the name's datagram key at its own sequence, with frame 0. + +use std::fmt; +use std::sync::{Arc, Mutex}; +use std::task::{Poll, ready}; use bytes::Bytes; +use crate::e2ee::error::{Error, Result}; +use crate::e2ee::generation::Domain; +use crate::e2ee::key::{Claim, TrackKey}; +use crate::e2ee::limits::MAX_DATAGRAM_PAYLOAD; +use crate::e2ee::window::DatagramWindow; + /// A decrypted datagram. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Datagram { - /// Per-track sequence, shared with the group namespace. + /// The datagram track's own sequence. pub sequence: u64, /// Presentation timestamp, or `None` when untimed. pub timestamp: Option, @@ -39,3 +53,243 @@ pub enum Event { sequence: u64, }, } + +/// Exclusive producer for one physical datagram track. +/// +/// Uses the datagram key and the datagram sequences of its semantic name, shared with every +/// datagram producer of that name under the generation and independent of its stream track. +/// Sequences are allocated monotonically, so a re-created or re-served name resumes above +/// every sequence allocated before; reuse and exhaustion are refused before encryption. +pub struct Producer { + inner: moq_net::datagram::Producer, + claim: Claim, +} + +impl Producer { + pub(crate) fn new(inner: moq_net::datagram::Producer, claim: Claim) -> Self { + Self { inner, claim } + } + + /// The physical track name. + pub fn name(&self) -> &str { + self.inner.name() + } + + /// Encrypt `plaintext` at the next sequence and send it, returning that sequence. + /// + /// # Errors + /// + /// Same as [`Self::insert`], except [`Error::Reuse`]. + pub fn append(&mut self, timestamp: impl Into>, plaintext: &[u8]) -> Result { + self.write(None, timestamp.into(), plaintext) + } + + /// Encrypt `plaintext` at an explicit sequence, which must be above every sequence this + /// name's datagram tracks allocated under the generation, and send it. + /// + /// Plaintext is capped at [`MAX_DATAGRAM_PLAINTEXT`](crate::e2ee::MAX_DATAGRAM_PLAINTEXT). + /// A predictable failure is refused before the sequence is allocated; once + /// encrypted, the identity is spent even if the net write fails. + /// + /// # Errors + /// + /// [`Error::Reuse`] if `sequence` was already allocated, [`Error::Identity`], + /// [`Error::Exhausted`], [`Error::Oversize`], or a net write error. + pub fn insert( + &mut self, + sequence: u64, + timestamp: impl Into>, + plaintext: &[u8], + ) -> Result<()> { + self.write(Some(sequence), timestamp.into(), plaintext)?; + Ok(()) + } + + fn write(&mut self, sequence: Option, timestamp: Option, plaintext: &[u8]) -> Result { + // Checked before the sequence is spent. + let timestamp = self.inner.info().rescale(timestamp)?; + let (sequence, payload) = self.claim.allocate(sequence, |sequence| { + self.claim + .key + .lock() + .expect("track key") + .protect(sequence, 0, plaintext, MAX_DATAGRAM_PAYLOAD) + })?; + self.inner.insert(moq_net::Datagram { + sequence, + timestamp, + payload, + })?; + Ok(sequence) + } + + /// The highest priority among the track's subscribers, or `None` when it has none, so a + /// publisher can skip encrypting datagrams nobody reads. + pub fn priority(&self) -> Option { + self.inner.priority() + } + + /// Resolves to [`Self::priority`] once it differs from `last`. + pub fn poll_priority(&self, waiter: &kio::Waiter, last: Option) -> Poll> { + self.inner.poll_priority(waiter, last) + } + + /// Finish the track. + pub fn finish(self) { + self.inner.finish(); + } + + /// Abort the track, consuming the handle. + pub fn abort(self) { + self.inner.abort(moq_net::Error::Cancel); + } + + #[cfg(test)] + pub(crate) fn invocations(&self) -> u64 { + self.claim.key.lock().expect("track key").invocations() + } +} + +impl fmt::Debug for Producer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("datagram::Producer") + .field("name", &self.name()) + .field("next", &*self.claim.next.lock().expect("claim sequence")) + .finish() + } +} + +/// A datagram track a subscriber asked for that the handler has yet to serve. +/// +/// Yielded by [`broadcast::DatagramRequests::poll_requested`](crate::e2ee::broadcast::DatagramRequests::poll_requested) +/// under its semantic name. The name is claimed only once accepted. Dropping it without +/// [`accept`](Self::accept) rejects the waiting subscribers. +pub struct Request { + inner: moq_net::datagram::Request, + generation: crate::e2ee::generation::Producer, + // From `generation::Producer::peek`; claimed by `accept`. + claim: Claim, + semantic: String, +} + +impl Request { + pub(crate) fn new( + inner: moq_net::datagram::Request, + generation: crate::e2ee::generation::Producer, + claim: Claim, + semantic: String, + ) -> Self { + Self { + inner, + generation, + claim, + semantic, + } + } + + /// The semantic track name. + pub fn name(&self) -> &str { + &self.semantic + } + + /// Serve the track, claiming its name and resolving every waiting subscriber. + pub fn accept(self, info: moq_net::datagram::Info) -> Producer { + let claim = self.generation.adopt(&self.semantic, Domain::Datagram, self.claim); + Producer::new(self.inner.accept(info), claim) + } + + /// Fail every waiting subscriber with `err`. This claims nothing, and releases no earlier claim. + pub fn reject(self, err: moq_net::Error) { + self.inner.reject(err); + } +} + +impl fmt::Debug for Request { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("datagram::Request").field("name", &self.name()).finish() + } +} + +/// Subscriber for one physical datagram track, live from when it was created. +/// +/// A datagram that fails to open is dropped as an [`Event::Identity`], [`Event::Oversize`], +/// or [`Event::Authentication`], and one already opened as an [`Event::Duplicate`]; the track +/// continues until its key is exhausted. +pub struct Consumer { + inner: moq_net::datagram::Consumer, + // Shared with every other subscription to this name's datagrams under the generation. + key: Arc>, + window: DatagramWindow, +} + +impl Consumer { + pub(crate) fn new(inner: moq_net::datagram::Consumer, key: Arc>) -> Self { + Self { + inner, + key, + window: DatagramWindow::default(), + } + } + + /// The physical track name. + pub fn name(&self) -> &str { + self.inner.name() + } + + /// Set this subscriber's priority, higher first. + pub fn set_priority(&mut self, priority: i8) { + self.inner.set_priority(priority); + } + + /// Poll for the next datagram event, or `None` once the track finished. + /// + /// # Errors + /// + /// [`Error::Exhausted`] once the datagram key is spent, or a net error. + pub fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll>> { + let Some(datagram) = ready!(self.inner.poll_recv(waiter)?) else { + return Poll::Ready(Ok(None)); + }; + let sequence = datagram.sequence; + if self.window.is_duplicate(sequence) { + return Poll::Ready(Ok(Some(Event::Duplicate { sequence }))); + } + let opened = self + .key + .lock() + .expect("track key") + .open(sequence, 0, &datagram.payload, MAX_DATAGRAM_PAYLOAD); + let event = match opened { + Ok(plaintext) => { + self.window.mark(sequence); + Event::Datagram(Datagram { + sequence, + timestamp: datagram.timestamp, + plaintext, + }) + } + Err(Error::Authentication) => Event::Authentication { sequence }, + Err(Error::Identity) => Event::Identity { sequence }, + Err(Error::Oversize) => Event::Oversize { sequence }, + Err(err) => return Poll::Ready(Err(err)), + }; + Poll::Ready(Ok(Some(event))) + } + + /// Receive the next datagram event. + /// + /// # Errors + /// + /// Same as [`Self::poll_recv`]. + pub async fn recv(&mut self) -> Result> { + kio::wait(|waiter| self.poll_recv(waiter)).await + } +} + +impl fmt::Debug for Consumer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("datagram::Consumer") + .field("name", &self.name()) + .finish() + } +} diff --git a/rs/moq-crypto/src/e2ee/generation.rs b/rs/moq-crypto/src/e2ee/generation.rs index fd6b57a90c8..2256a54639d 100644 --- a/rs/moq-crypto/src/e2ee/generation.rs +++ b/rs/moq-crypto/src/e2ee/generation.rs @@ -7,13 +7,17 @@ use std::sync::{Arc, Mutex}; use crate::e2ee::Epoch; use crate::e2ee::credential::{Credential, append_bytes}; use crate::e2ee::error::Result; -use crate::e2ee::key::TrackKey; +use crate::e2ee::key::{Claim, TrackKey}; use crate::e2ee::limits::{KEY_LABEL, NAME_KEY_LABEL, NAME_MAC_LABEL}; use crate::e2ee::name::{self, Name}; -use crate::e2ee::track; +use crate::e2ee::{datagram, track}; use rind::{aead, hmac}; /// Grouped-frame versus datagram key domain. +/// +/// A stream track and a datagram track of one semantic name share a physical name but allocate +/// sequences independently, so the domain alone keeps a group's frame 0 and the datagram at +/// that sequence from sharing a key and nonce. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Domain { Group = 0x00, @@ -24,10 +28,20 @@ struct Inner { credential: Credential, epoch: Epoch, names: name::Keys, - // Keyed by semantic name. Never evicted: re-deriving a key would reset its budget and, for a - // producer, its sequences. Bounded by the semantic names the application produces, serves, or - // subscribes to, for as long as it holds the generation. - keys: Mutex>, + // One map per domain, keyed by semantic name. Never evicted: re-deriving a key would reset its + // budget and, for a producer, its sequences. Bounded by the semantic names the application + // produces, serves, or subscribes to, for as long as it holds the generation. + groups: Mutex>, + datagrams: Mutex>, +} + +impl Inner { + fn claims(&self, domain: Domain) -> &Mutex> { + match domain { + Domain::Group => &self.groups, + Domain::Datagram => &self.datagrams, + } + } } /// A consume-only generation bound to a discovered epoch. @@ -65,7 +79,8 @@ impl Consumer { credential, epoch, names, - keys: Mutex::default(), + groups: Mutex::default(), + datagrams: Mutex::default(), })) } @@ -94,25 +109,33 @@ impl Consumer { /// /// [`Error::Identity`](crate::e2ee::Error::Identity) if `semantic` exceeds 65535 bytes. pub(crate) fn consume(&self, track: moq_net::track::Subscriber, semantic: &str) -> Result { - Ok(track::Consumer::new(track, self.keys(semantic)?)) + Ok(track::Consumer::new(track, self.keys(semantic, Domain::Group)?.key)) } - /// The keys for `semantic`, shared with every earlier subscription to or claim of it. - pub(crate) fn keys(&self, semantic: &str) -> Result { - let mut keys = self.0.keys.lock().expect("generation keys"); - if let Some(keys) = keys.get(semantic) { - return Ok(keys.clone()); - } - let derived = self.derive(semantic)?; - Ok(keys.entry(semantic.to_string()).or_insert(derived).clone()) + /// Decrypt a net datagram track named by the physical name of `semantic`, whose datagram key it binds. + /// + /// # Errors + /// + /// [`Error::Identity`](crate::e2ee::Error::Identity) if `semantic` exceeds 65535 bytes. + pub(crate) fn consume_datagrams( + &self, + track: moq_net::datagram::Consumer, + semantic: &str, + ) -> Result { + Ok(datagram::Consumer::new( + track, + self.keys(semantic, Domain::Datagram)?.key, + )) } - /// Derive fresh keys for `semantic`, with their own accounting. - fn derive(&self, semantic: &str) -> Result { - Ok(track::Keys::new( - self.key(semantic, Domain::Group)?, - self.key(semantic, Domain::Datagram)?, - )) + /// The `domain` key for `semantic`, shared with every earlier subscription to or claim of it. + pub(crate) fn keys(&self, semantic: &str, domain: Domain) -> Result { + let mut claims = self.0.claims(domain).lock().expect("generation keys"); + if let Some(claim) = claims.get(semantic) { + return Ok(claim.clone()); + } + let derived = Claim::new(self.key(semantic, domain)?); + Ok(claims.entry(semantic.to_string()).or_insert(derived).clone()) } pub(crate) fn key(&self, semantic: &str, domain: Domain) -> Result { @@ -154,9 +177,9 @@ fn scope(info: &mut Vec, credential: &Credential, epoch: &Epoch) { /// A publisher-minted generation whose clones share permanent track claims. /// -/// A claim holds a semantic name's keys and its sequences' high-water mark, so a track -/// created, reserved, or served again under the name resumes above every sequence it allocated -/// before, on any broadcast of the generation. +/// A claim holds a semantic name's key for stream or datagram tracks and that kind's sequence +/// high-water mark, so a track created, reserved, or served again under the name resumes above +/// every sequence its kind allocated before, on any broadcast of the generation. #[derive(Clone, Debug)] pub struct Producer { // Its keys are the claims: never released, since reopening a track would restart its nonce counters. @@ -205,8 +228,8 @@ impl Producer { /// /// [`Error::Identity`](crate::e2ee::Error::Identity) if `semantic` exceeds 65535 bytes, and then `track` is aborted. pub(crate) fn produce(&self, track: moq_net::track::Producer, semantic: &str) -> Result { - match self.claim(semantic) { - Ok(keys) => Ok(track::Producer::new(track, keys)), + match self.claim(semantic, Domain::Group) { + Ok(claim) => Ok(track::Producer::new(track, claim)), // Its subscribers should see the refusal, not the track vanish. Err(err) => { let _ = track.abort(moq_net::Error::Cancel); @@ -215,38 +238,70 @@ impl Producer { } } - /// Claim a semantic name, and so its physical name, for good, returning its keys and - /// sequences. Claiming it again resumes the earlier claim. + /// Protect a net datagram track named by the physical name of `semantic`, claiming it for + /// this generation. + /// + /// # Errors + /// + /// [`Error::Identity`](crate::e2ee::Error::Identity) if `semantic` exceeds 65535 bytes, and then `track` is aborted. + pub(crate) fn produce_datagrams( + &self, + track: moq_net::datagram::Producer, + semantic: &str, + ) -> Result { + match self.claim(semantic, Domain::Datagram) { + Ok(claim) => Ok(datagram::Producer::new(track, claim)), + Err(err) => { + track.abort(moq_net::Error::Cancel); + Err(err) + } + } + } + + /// Claim a semantic name in `domain`, and so its physical name, for good, returning its key + /// and sequences. Claiming it again resumes the earlier claim. /// /// # Errors /// /// [`Error::Identity`](crate::e2ee::Error::Identity) if `semantic` exceeds 65535 bytes. - pub(crate) fn claim(&self, semantic: &str) -> Result { - self.consumer.keys(semantic) + pub(crate) fn claim(&self, semantic: &str, domain: Domain) -> Result { + self.consumer.keys(semantic, domain) } - /// The keys for `semantic` without claiming it: its claim's if it has one, otherwise fresh - /// keys that hold nothing until [`Self::adopt`]. + /// The `domain` key for `semantic` without claiming it: its claim's if it has one, otherwise + /// a fresh one that holds nothing until [`Self::adopt`]. /// /// # Errors /// /// [`Error::Identity`](crate::e2ee::Error::Identity) if `semantic` exceeds 65535 bytes. - pub(crate) fn peek(&self, semantic: &str) -> Result { - if let Some(keys) = self.consumer.0.keys.lock().expect("generation keys").get(semantic) { - return Ok(keys.clone()); + pub(crate) fn peek(&self, semantic: &str, domain: Domain) -> Result { + if let Some(claim) = self + .consumer + .0 + .claims(domain) + .lock() + .expect("generation keys") + .get(semantic) + { + return Ok(claim.clone()); } - self.consumer.derive(semantic) + Ok(Claim::new(self.consumer.key(semantic, domain)?)) } - /// Claim `semantic` with keys from [`Self::peek`], resuming any claim made since instead. - pub(crate) fn adopt(&self, semantic: &str, keys: track::Keys) -> track::Keys { - let mut claims = self.consumer.0.keys.lock().expect("generation keys"); - claims.entry(semantic.to_string()).or_insert(keys).clone() + /// Claim `semantic` in `domain` with a key from [`Self::peek`], resuming any claim made + /// since instead. + pub(crate) fn adopt(&self, semantic: &str, domain: Domain, claim: Claim) -> Claim { + let mut claims = self.consumer.0.claims(domain).lock().expect("generation keys"); + claims.entry(semantic.to_string()).or_insert(claim).clone() } - /// How many semantic names this generation holds keys for. + /// How many semantic names this generation holds keys for, across both domains. #[cfg(test)] pub(crate) fn claims(&self) -> usize { - self.consumer.0.keys.lock().expect("generation keys").len() + let inner = &self.consumer.0; + [Domain::Group, Domain::Datagram] + .into_iter() + .map(|domain| inner.claims(domain).lock().expect("generation keys").len()) + .sum() } } diff --git a/rs/moq-crypto/src/e2ee/key.rs b/rs/moq-crypto/src/e2ee/key.rs index 5eb308aa72f..d851c37dc28 100644 --- a/rs/moq-crypto/src/e2ee/key.rs +++ b/rs/moq-crypto/src/e2ee/key.rs @@ -1,12 +1,47 @@ use std::fmt; +use std::sync::{Arc, Mutex}; use bytes::Bytes; use rind::aead; use crate::e2ee::error::{Error, Result}; -use crate::e2ee::limits::{MAX_INVOCATIONS, MAX_PLAINTEXT_BYTES, TAG_LEN}; +use crate::e2ee::limits::{MAX_INVOCATIONS, MAX_PLAINTEXT_BYTES, TAG_LEN, check_u53}; use crate::e2ee::protect::{nonce, open, protect}; +/// A semantic name's key in one domain and its next unallocated sequence; clones share their +/// accounting. +#[derive(Clone, Debug)] +pub(crate) struct Claim { + pub key: Arc>, + // The high-water mark plus one, kept for the generation's life so no sequence repeats. + // Locked before the key. + pub next: Arc>, +} + +impl Claim { + pub(crate) fn new(key: TrackKey) -> Self { + Self { + key: Arc::new(Mutex::new(key)), + next: Arc::default(), + } + } + + /// Allocate `sequence`, or the next one if `None`, refusing one already allocated or one + /// that cannot be represented. Locked across `body`, so a concurrent producer of this name + /// cannot take the sequence, and committed only if `body` succeeds. + pub(crate) fn allocate(&self, sequence: Option, body: impl FnOnce(u64) -> Result) -> Result<(u64, T)> { + let mut next = self.next.lock().expect("claim sequence"); + let sequence = sequence.unwrap_or(*next); + if sequence < *next { + return Err(Error::Reuse); + } + check_u53(sequence)?; + let value = body(sequence)?; + *next = sequence + 1; + Ok((sequence, value)) + } +} + /// Per-track, per-domain AES-128-GCM key with invocation and byte accounting. /// /// Not `Clone`: cloning would duplicate the exhaustion counters. diff --git a/rs/moq-crypto/src/e2ee/limits.rs b/rs/moq-crypto/src/e2ee/limits.rs index 448114db925..782cc2d961c 100644 --- a/rs/moq-crypto/src/e2ee/limits.rs +++ b/rs/moq-crypto/src/e2ee/limits.rs @@ -43,7 +43,7 @@ pub(crate) const MAX_GROUPED_PAYLOAD: usize = 32 * 1024 * 1024; /// Largest plaintext a grouped frame can carry: 32 MiB minus the tag. pub const MAX_GROUPED_PLAINTEXT: usize = MAX_GROUPED_PAYLOAD - TAG_LEN; -/// moq-lite datagram body cap, including Type, Subscribe ID, Group Sequence, and Timestamp. +/// moq-lite datagram body cap, including Type, Subscribe ID, Sequence, and Timestamp. pub(crate) const MAX_DATAGRAM_BODY: usize = 1200; /// Widest moq-lite datagram header: lite-07's one-byte Type and three 9-byte leading-ones varints. diff --git a/rs/moq-crypto/src/e2ee/tests.rs b/rs/moq-crypto/src/e2ee/tests.rs index 77891114bea..02bd7ab1576 100644 --- a/rs/moq-crypto/src/e2ee/tests.rs +++ b/rs/moq-crypto/src/e2ee/tests.rs @@ -21,13 +21,18 @@ use crate::e2ee::limits::{ }; use crate::e2ee::name::{Name, pad}; use crate::e2ee::protect::{nonce, protect}; -use crate::e2ee::{group, track}; +use crate::e2ee::{datagram, group, track}; /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) } +/// A millisecond datagram track. +fn timed_datagrams() -> moq_net::datagram::Info { + moq_net::datagram::Info::default().with_timescale(moq_net::Timescale::MILLI) +} + /// Raw lengths of the derived AES-128-GCM and HMAC-SHA-256 keys, which the vectors pin. const KEY_LEN: usize = 16; const NAME_MAC_LEN: usize = 32; @@ -371,6 +376,42 @@ fn pair(semantic: &str) -> Pair { } } +struct DatagramPair { + semantic: String, + generation: Consumer, + net: moq_net::datagram::Producer, + producer: datagram::Producer, + consumer: datagram::Consumer, +} + +fn datagram_pair(semantic: &str) -> DatagramPair { + let generation = test_generation(); + let net = timed_datagrams().produce(generation.name(semantic).unwrap().as_str()); + let producer = generation.produce_datagrams(net.clone(), semantic).unwrap(); + let consuming = consume(&generation); + let consumer = consuming.consume_datagrams(net.consume(), semantic).unwrap(); + DatagramPair { + semantic: semantic.to_string(), + generation: consuming, + net, + producer, + consumer, + } +} + +impl DatagramPair { + /// Send `payload` raw, the way a relay could. + fn inject(&mut self, sequence: u64, timestamp: moq_net::Timestamp, payload: impl Into) { + self.net + .insert(moq_net::Datagram { + sequence, + timestamp: Some(timestamp), + payload: payload.into(), + }) + .unwrap(); + } +} + fn ms(value: u64) -> moq_net::Timestamp { moq_net::Timestamp::from_millis(value).unwrap() } @@ -389,8 +430,8 @@ fn read_frame(consumer: &mut group::Consumer) -> group::Frame { } } -fn recv_datagram(consumer: &mut track::Consumer) -> Event { - match consumer.poll_recv_datagram(&kio::Waiter::noop()) { +fn recv_datagram(consumer: &mut datagram::Consumer) -> Event { + match consumer.poll_recv(&kio::Waiter::noop()) { Poll::Ready(Ok(Some(event))) => event, other => panic!("expected datagram event, got {other:?}"), } @@ -430,9 +471,9 @@ fn grouped_roundtrip() { #[test] fn datagram_roundtrip() { - let mut pair = pair("audio"); - assert_eq!(pair.producer.append_datagram(ms(5), b"opus").unwrap(), 0); - assert_eq!(pair.producer.datagram_invocations(), 1); + let mut pair = datagram_pair("audio"); + assert_eq!(pair.producer.append(ms(5), b"opus").unwrap(), 0); + assert_eq!(pair.producer.invocations(), 1); match recv_datagram(&mut pair.consumer) { Event::Datagram(d) => { assert_eq!(d.sequence, 0); @@ -448,25 +489,17 @@ fn sequences_allocate_monotonically() { let mut pair = pair("video"); assert_eq!(pair.producer.append_group().unwrap().sequence(), 0); assert_eq!(pair.producer.create_group(5).unwrap().sequence(), 5); - assert_eq!(pair.producer.append_datagram(ms(1), b"x").unwrap(), 6); - assert_eq!(pair.producer.append_group().unwrap().sequence(), 7); - assert_eq!( - pair.producer.create_group(7).map(|_| ()).unwrap_err().to_string(), - "reuse" - ); - assert_eq!( - pair.producer.create_group(3).map(|_| ()).unwrap_err().to_string(), - "reuse" - ); - assert_eq!( - pair.producer.insert_datagram(6, ms(1), b"x").unwrap_err().to_string(), - "reuse" - ); - assert_eq!( - pair.producer.datagram_invocations(), - 1, - "reuse is refused before encryption" - ); + assert_eq!(pair.producer.append_group().unwrap().sequence(), 6); + for sequence in [6, 3] { + assert_eq!( + pair.producer + .create_group(sequence) + .map(|_| ()) + .unwrap_err() + .to_string(), + "reuse" + ); + } assert_eq!( pair.producer .create_group(MAX_U53 + 1) @@ -478,29 +511,107 @@ fn sequences_allocate_monotonically() { } #[test] -fn oversize_datagram_keeps_the_sequence() { - let mut pair = pair("audio"); - let big = vec![0u8; MAX_DATAGRAM_PLAINTEXT + 1]; +fn datagram_sequences_allocate_monotonically() { + let mut pair = datagram_pair("audio"); + assert_eq!(pair.producer.append(ms(1), b"x").unwrap(), 0); + pair.producer.insert(5, ms(1), b"x").unwrap(); + assert_eq!(pair.producer.append(ms(1), b"x").unwrap(), 6); + for sequence in [6, 3] { + assert_eq!( + pair.producer.insert(sequence, ms(1), b"x").unwrap_err().to_string(), + "reuse" + ); + } assert_eq!( - pair.producer.append_datagram(ms(1), &big).unwrap_err().to_string(), - "oversize" + pair.producer.insert(MAX_U53 + 1, ms(1), b"x").unwrap_err().to_string(), + "identity" ); - assert_eq!(pair.producer.datagram_invocations(), 0); + assert_eq!(pair.producer.invocations(), 3, "refused before encryption"); +} + +#[test] +fn oversize_datagram_keeps_the_sequence() { + let mut pair = datagram_pair("audio"); + let big = vec![0u8; MAX_DATAGRAM_PLAINTEXT + 1]; + assert_eq!(pair.producer.append(ms(1), &big).unwrap_err().to_string(), "oversize"); + assert_eq!(pair.producer.invocations(), 0); let max = vec![0u8; MAX_DATAGRAM_PLAINTEXT]; - assert_eq!(pair.producer.append_datagram(ms(1), &max).unwrap(), 0); + assert_eq!(pair.producer.append(ms(1), &max).unwrap(), 0); } #[test] fn refused_datagram_timestamp_keeps_the_sequence() { - let mut pair = pair("audio"); + let mut pair = datagram_pair("audio"); // (2^61-1) seconds overflows conversion to the milli-scale track. let huge = moq_net::Timestamp::from_secs((1 << 61) - 1).unwrap(); for timestamp in [None, Some(huge)] { - let err = pair.producer.append_datagram(timestamp, b"x").unwrap_err(); + let err = pair.producer.append(timestamp, b"x").unwrap_err(); assert!(matches!(err, Error::Net(moq_net::Error::TimestampMismatch)), "{err:?}"); } - assert_eq!(pair.producer.datagram_invocations(), 0); - assert_eq!(pair.producer.append_datagram(ms(1), b"x").unwrap(), 0); + assert_eq!(pair.producer.invocations(), 0); + assert_eq!(pair.producer.append(ms(1), b"x").unwrap(), 0); +} + +#[test] +fn one_name_keys_its_stream_and_datagram_tracks_apart() { + let generation = test_generation(); + let name = generation.name("audio").unwrap(); + let broadcast = moq_net::broadcast::Info::new().produce(); + let net = broadcast.create_track(name.as_str(), timed()).unwrap(); + let net_datagrams = broadcast.create_datagrams(name.as_str(), timed_datagrams()).unwrap(); + let mut track = generation.produce(net.clone(), "audio").unwrap(); + let mut datagrams = generation.produce_datagrams(net_datagrams.clone(), "audio").unwrap(); + assert_eq!(track.name(), datagrams.name()); + + let consuming = consume(&generation); + let mut track_reader = consuming.consume(net.subscribe(subscribe_all()), "audio").unwrap(); + let mut datagram_reader = consuming.consume_datagrams(net_datagrams.consume(), "audio").unwrap(); + + // The same plaintext at the same identity in each kind: group 0 frame 0, and datagram 0. + let mut group = track.append_group().unwrap(); + group.write_frame(ms(1), b"same").unwrap(); + group.finish().unwrap(); + assert_eq!(datagrams.append(ms(1), b"same").unwrap(), 0); + assert_eq!(read_frame(&mut recv_group(&mut track_reader)).plaintext, &b"same"[..]); + match recv_datagram(&mut datagram_reader) { + Event::Datagram(d) => { + assert_eq!(d.sequence, 0); + assert_eq!(&d.plaintext[..], b"same"); + } + other => panic!("{other:?}"), + } + + // Only the domain separates the two keys, and neither ciphertext opens as the other. + let frame = consuming + .key("audio", Domain::Group) + .unwrap() + .protect(0, 0, b"same", MAX_GROUPED_PAYLOAD) + .unwrap(); + let sealed = datagram_ciphertext(&consuming, "audio", 0, b"same"); + assert_ne!(frame, sealed); + + let forged = net_track(&name); + let mut reader = consuming.consume(forged.subscribe(subscribe_all()), "audio").unwrap(); + let mut group = forged.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); + group.write_frame(ms(1), sealed).unwrap(); + group.finish().unwrap(); + assert_eq!( + recv_group(&mut reader) + .poll_read_frame(&kio::Waiter::noop()) + .map(|r| r.unwrap_err().to_string()), + Poll::Ready("authentication".to_string()) + ); + + let mut forged = timed_datagrams().produce(name.as_str()); + let mut reader = consuming.consume_datagrams(forged.consume(), "audio").unwrap(); + forged + .insert(moq_net::Datagram { + sequence: 0, + timestamp: Some(ms(1)), + payload: frame, + }) + .unwrap(); + assert_eq!(recv_datagram(&mut reader), Event::Authentication { sequence: 0 }); } #[test] @@ -511,26 +622,34 @@ fn reproduced_track_resumes_above_its_marks() { let mut producer = generation.produce(net.clone(), "video").unwrap(); assert_eq!(producer.name(), name.as_str()); producer.create_group(4).unwrap(); - assert_eq!(producer.append_datagram(ms(1), b"x").unwrap(), 5); drop(producer); // A clone of the generation reopening the name, on any output, resumes above its marks. let mut again = generation.clone().produce(net_track(&name), "video").unwrap(); - for sequence in [0, 5] { + for sequence in [0, 4] { assert_eq!( again.create_group(sequence).map(|_| ()).unwrap_err().to_string(), "reuse" ); - assert_eq!( - again.insert_datagram(sequence, ms(1), b"x").unwrap_err().to_string(), - "reuse" - ); } - assert_eq!(again.append_group().unwrap().sequence(), 6); + assert_eq!(again.append_group().unwrap().sequence(), 5); // Two live producers of the name share one sequence namespace. let mut other = generation.produce(net_track(&name), "video").unwrap(); - assert_eq!(other.append_datagram(ms(1), b"x").unwrap(), 7); - assert_eq!(again.append_group().unwrap().sequence(), 8); + assert_eq!(other.append_group().unwrap().sequence(), 6); + assert_eq!(again.append_group().unwrap().sequence(), 7); + + // A datagram track of the name has sequences of its own, and resumes above them too. + let datagrams = || { + generation + .produce_datagrams(timed_datagrams().produce(name.as_str()), "video") + .unwrap() + }; + let mut first = datagrams(); + assert_eq!(first.append(ms(1), b"x").unwrap(), 0); + drop(first); + let mut second = datagrams(); + assert_eq!(second.insert(0, ms(1), b"x").unwrap_err().to_string(), "reuse"); + assert_eq!(second.append(ms(1), b"x").unwrap(), 1); // A new epoch is a new publisher instance. let fresh = generation.credential().produce(); @@ -542,7 +661,7 @@ fn reproduced_track_resumes_above_its_marks() { consume(&generation) .consume(net.subscribe(subscribe_all()), "video") .unwrap(); - assert_eq!(generation.claims(), 1); + assert_eq!(generation.claims(), 2); } #[test] @@ -625,11 +744,9 @@ fn reserved_track_serves_on_demand() { let mut audio = request.accept(timed()); let mut reader = resolve(&subscribing).unwrap(); - audio.append_datagram(ms(1), b"opus").unwrap(); - match recv_datagram(&mut reader) { - Event::Datagram(d) => assert_eq!(&d.plaintext[..], b"opus"), - other => panic!("{other:?}"), - } + let mut group = audio.append_group().unwrap(); + group.write_frame(ms(1), b"opus").unwrap(); + assert_eq!(read_frame(&mut recv_group(&mut reader)).plaintext, &b"opus"[..]); } #[test] @@ -639,15 +756,19 @@ fn broadcast_claims_span_outputs() { let mut video = first.create_track("video", timed()).unwrap(); video.create_group(2).unwrap(); let mut audio = first.reserve_track("audio").unwrap().accept(timed()); - audio.append_datagram(ms(1), b"opus").unwrap(); + audio.append_group().unwrap(); + let mut voice = first.create_datagrams("voice", timed_datagrams()).unwrap(); + voice.append(ms(1), b"opus").unwrap(); - // A clone of the generation on another output resumes either name above its marks. + // A clone of the generation on another output resumes each name above its marks. let second = crate::e2ee::broadcast::Producer::new(moq_net::broadcast::Info::new().produce(), generation); let mut video = second.create_track("video", timed()).unwrap(); assert_eq!(video.append_group().unwrap().sequence(), 3); let request = second.reserve_track("audio").unwrap(); assert_eq!(request.name(), "audio"); - assert_eq!(request.accept(timed()).append_datagram(ms(1), b"opus").unwrap(), 1); + assert_eq!(request.accept(timed()).append_group().unwrap().sequence(), 1); + let mut voice = second.create_datagrams("voice", timed_datagrams()).unwrap(); + assert_eq!(voice.append(ms(1), b"opus").unwrap(), 1); } #[test] @@ -807,6 +928,42 @@ fn requests_serve_a_dropped_name_above_its_marks() { assert_eq!(recv_group(&mut reader).sequence(), 4); } +#[test] +fn datagram_requests_serve_issued_names() { + let protected = protected(); + // Without a handler, a datagram track nobody created is missing. + assert!(matches!( + protected.consumer.datagrams("presence"), + Err(Error::Net(moq_net::Error::NotFound)) + )); + let requests = protected.producer.requests(); + let mut datagrams = requests.datagrams(); + let waiter = kio::Waiter::noop(); + let generation = protected.producer.generation(); + + // A name the generation did not issue is answered as missing without reaching the handler. + let mut forged = protected.raw.datagrams("presence").unwrap(); + assert!(datagrams.poll_requested(&waiter).is_pending()); + assert!(matches!( + forged.poll_recv(&waiter), + Poll::Ready(Err(moq_net::Error::NotFound)) + )); + + let mut reader = protected.consumer.datagrams("presence").unwrap(); + let Poll::Ready(Ok(request)) = datagrams.poll_requested(&waiter) else { + panic!("expected a request"); + }; + assert_eq!(request.name(), "presence"); + assert_eq!(generation.claims(), 0); + let mut presence = request.accept(timed_datagrams()); + assert_eq!(generation.claims(), 1); + assert_eq!(presence.append(ms(1), b"here").unwrap(), 0); + match recv_datagram(&mut reader) { + Event::Datagram(d) => assert_eq!(&d.plaintext[..], b"here"), + other => panic!("{other:?}"), + } +} + #[test] fn lister_lists_physical_names() { let protected = protected(); @@ -960,12 +1117,6 @@ fn grouped_authentication_ends_track() { .map(|r| r.unwrap_err().to_string()), Poll::Ready("authentication".to_string()) ); - assert_eq!( - pair.consumer - .poll_recv_datagram(&waiter) - .map(|r| r.unwrap_err().to_string()), - Poll::Ready("authentication".to_string()) - ); } /// Wakes when a parked read is notified. One flag per parked task, so a sibling @@ -1031,8 +1182,8 @@ fn grouped_failure_wakes_pending_reads_and_releases_demand() { let mut forged = recv_group(&mut pair.consumer); assert!(bad_demand.wanted()); - // Sibling frame, next group, and datagram reads are already parked. Nothing - // further is published. + // Sibling frame and next group reads are already parked. Nothing further is + // published. let mut frame = Parked::new(); assert!(matches!( frame.poll(|waiter| good.poll_read_frame(waiter)), @@ -1043,10 +1194,6 @@ fn grouped_failure_wakes_pending_reads_and_releases_demand() { track.poll(|waiter| pair.consumer.poll_recv_group(waiter)), Poll::Pending )); - assert!(matches!( - track.poll(|waiter| pair.consumer.poll_recv_datagram(waiter)), - Poll::Pending - )); assert_eq!( forged @@ -1056,7 +1203,7 @@ fn grouped_failure_wakes_pending_reads_and_releases_demand() { ); assert!(frame.woken(), "parked frame read was not woken"); - assert!(track.woken(), "parked group and datagram reads were not woken"); + assert!(track.woken(), "parked group read was not woken"); assert!(!bad_demand.wanted(), "failed group demand stayed live"); assert!(!pair.net.is_closed(), "publisher must stay open"); @@ -1081,12 +1228,6 @@ fn grouped_failure_wakes_pending_reads_and_releases_demand() { .map(|r| r.unwrap_err().to_string()), Poll::Ready("authentication".to_string()) ); - assert_eq!( - track - .poll(|waiter| pair.consumer.poll_recv_datagram(waiter)) - .map(|r| r.unwrap_err().to_string()), - Poll::Ready("authentication".to_string()) - ); // The handles that observed the failure are still held. drop(good); @@ -1114,51 +1255,15 @@ fn a_held_group_keeps_demand_after_the_track_drops() { assert!(demand.wanted().is_none(), "the last reader leaving releases the track"); } -#[test] -fn bad_datagram_stays_an_event_and_keeps_demand() { - let mut pair = pair("audio"); - let demand = pair.net.demand(); - let mut open = pair.producer.append_group().unwrap(); - open.write_frame(ms(1), b"ok").unwrap(); - - let semantic = pair.semantic.as_str(); - let mut forged = datagram_ciphertext(&pair.generation, semantic, 0, b"nope").to_vec(); - forged[0] ^= 1; - pair.net.insert_datagram(0, ms(2), forged).unwrap(); - - let mut good = recv_group(&mut pair.consumer); - assert_eq!(read_frame(&mut good).plaintext, &b"ok"[..]); - let mut frame = Parked::new(); - assert!(matches!( - frame.poll(|waiter| good.poll_read_frame(waiter)), - Poll::Pending - )); - - assert_eq!(recv_datagram(&mut pair.consumer), Event::Authentication { sequence: 0 }); - assert!(!frame.woken(), "a bad datagram must not wake grouped reads"); - assert!(matches!( - frame.poll(|waiter| good.poll_read_frame(waiter)), - Poll::Pending - )); - assert!(demand.wanted().is_some()); - - pair.producer.insert_datagram(1, ms(3), b"next").unwrap(); - match recv_datagram(&mut pair.consumer) { - Event::Datagram(d) => assert_eq!(&d.plaintext[..], b"next"), - other => panic!("{other:?}"), - } - assert!(demand.wanted().is_some()); -} - #[test] fn bad_datagram_is_event() { - let mut pair = pair("audio"); - let semantic = pair.semantic.as_str(); - pair.producer.append_datagram(ms(1), b"one").unwrap(); - let mut forged = datagram_ciphertext(&pair.generation, semantic, 1, b"two").to_vec(); + let mut pair = datagram_pair("audio"); + let semantic = pair.semantic.clone(); + pair.producer.append(ms(1), b"one").unwrap(); + let mut forged = datagram_ciphertext(&pair.generation, &semantic, 1, b"two").to_vec(); forged[0] ^= 1; - pair.net.insert_datagram(1, ms(2), forged).unwrap(); - pair.producer.insert_datagram(2, ms(3), b"three").unwrap(); + pair.inject(1, ms(2), forged); + pair.producer.insert(2, ms(3), b"three").unwrap(); assert!(matches!(recv_datagram(&mut pair.consumer), Event::Datagram(_))); assert_eq!(recv_datagram(&mut pair.consumer), Event::Authentication { sequence: 1 }); @@ -1170,16 +1275,14 @@ fn bad_datagram_is_event() { #[test] fn unsealable_datagram_is_dropped_with_its_code() { - let mut pair = pair("audio"); - let semantic = pair.semantic.as_str(); - let sealed = datagram_ciphertext(&pair.generation, semantic, 0, b"x"); - pair.net - .insert_datagram(1, ms(1), vec![0u8; MAX_DATAGRAM_PAYLOAD + 1]) - .unwrap(); - pair.net.insert_datagram(2, ms(2), sealed.slice(..3)).unwrap(); - pair.net.insert_datagram(MAX_U53 + 1, ms(3), sealed.clone()).unwrap(); - pair.net.insert_datagram(MAX_U53 + 2, ms(3), sealed.slice(..3)).unwrap(); - pair.producer.insert_datagram(MAX_U53, ms(4), b"last").unwrap(); + let mut pair = datagram_pair("audio"); + let semantic = pair.semantic.clone(); + let sealed = datagram_ciphertext(&pair.generation, &semantic, 0, b"x"); + pair.inject(1, ms(1), vec![0u8; MAX_DATAGRAM_PAYLOAD + 1]); + pair.inject(2, ms(2), sealed.slice(..3)); + pair.inject(MAX_U53 + 1, ms(3), sealed.clone()); + pair.inject(MAX_U53 + 2, ms(3), sealed.slice(..3)); + pair.producer.insert(MAX_U53, ms(4), b"last").unwrap(); assert_eq!(recv_datagram(&mut pair.consumer), Event::Oversize { sequence: 1 }); assert_eq!(recv_datagram(&mut pair.consumer), Event::Oversize { sequence: 2 }); @@ -1200,35 +1303,32 @@ fn unsealable_datagram_is_dropped_with_its_code() { #[test] fn resubscribing_shares_a_keys_budget() { - let mut pair = pair("audio"); + let mut pair = datagram_pair("audio"); let semantic = pair.semantic.as_str(); - pair.producer.append_datagram(ms(1), b"one").unwrap(); + pair.producer.append(ms(1), b"one").unwrap(); assert!(matches!(recv_datagram(&mut pair.consumer), Event::Datagram(_))); drop(pair.consumer); - let keys = pair.generation.keys(semantic).unwrap(); - assert_eq!(keys.datagram.lock().unwrap().invocations(), 1); - keys.datagram.lock().unwrap().set_usage(MAX_INVOCATIONS, 0); + let claim = pair.generation.keys(semantic, Domain::Datagram).unwrap(); + assert_eq!(claim.key.lock().unwrap().invocations(), 1); + claim.key.lock().unwrap().set_usage(MAX_INVOCATIONS, 0); - let mut again = pair - .generation - .consume(pair.net.subscribe(subscribe_all()), semantic) - .unwrap(); - pair.producer.append_datagram(ms(2), b"two").unwrap(); + let mut again = pair.generation.consume_datagrams(pair.net.consume(), semantic).unwrap(); + pair.producer.append(ms(2), b"two").unwrap(); assert!(matches!( - again.poll_recv_datagram(&kio::Waiter::noop()), + again.poll_recv(&kio::Waiter::noop()), Poll::Ready(Err(Error::Exhausted)) )); } #[test] fn forged_datagram_does_not_burn_identity() { - let mut pair = pair("audio"); - let semantic = pair.semantic.as_str(); - pair.producer.append_datagram(ms(1), b"one").unwrap(); - let mut forged = datagram_ciphertext(&pair.generation, semantic, 1, b"two").to_vec(); + let mut pair = datagram_pair("audio"); + let semantic = pair.semantic.clone(); + pair.producer.append(ms(1), b"one").unwrap(); + let mut forged = datagram_ciphertext(&pair.generation, &semantic, 1, b"two").to_vec(); forged[0] ^= 1; - pair.net.insert_datagram(1, ms(2), forged).unwrap(); - pair.producer.insert_datagram(1, ms(2), b"two").unwrap(); + pair.inject(1, ms(2), forged); + pair.producer.insert(1, ms(2), b"two").unwrap(); assert!(matches!(recv_datagram(&mut pair.consumer), Event::Datagram(_))); assert_eq!(recv_datagram(&mut pair.consumer), Event::Authentication { sequence: 1 }); @@ -1243,26 +1343,21 @@ fn forged_datagram_does_not_burn_identity() { #[test] fn duplicate_datagram_is_bounded() { - let mut pair = pair("audio"); - let semantic = pair.semantic.as_str(); - let replay = datagram_ciphertext(&pair.generation, semantic, 0, b"once"); - pair.net.insert_datagram(0, ms(1), replay.clone()).unwrap(); - pair.net.insert_datagram(0, ms(1), replay.clone()).unwrap(); + let mut pair = datagram_pair("audio"); + let semantic = pair.semantic.clone(); + let replay = datagram_ciphertext(&pair.generation, &semantic, 0, b"once"); + pair.inject(0, ms(1), replay.clone()); + pair.inject(0, ms(1), replay.clone()); assert!(matches!(recv_datagram(&mut pair.consumer), Event::Datagram(_))); assert_eq!(recv_datagram(&mut pair.consumer), Event::Duplicate { sequence: 0 }); // Once the window slides past it, the same ciphertext opens again. for sequence in 1..=DATAGRAM_WINDOW { - pair.net - .insert_datagram( - sequence, - ms(sequence), - datagram_ciphertext(&pair.generation, semantic, sequence, b"x"), - ) - .unwrap(); + let sealed = datagram_ciphertext(&pair.generation, &semantic, sequence, b"x"); + pair.inject(sequence, ms(sequence), sealed); assert!(matches!(recv_datagram(&mut pair.consumer), Event::Datagram(_))); } - pair.net.insert_datagram(0, ms(1), replay).unwrap(); + pair.inject(0, ms(1), replay); match recv_datagram(&mut pair.consumer) { Event::Datagram(d) => assert_eq!(d.sequence, 0), other => panic!("{other:?}"), diff --git a/rs/moq-crypto/src/e2ee/track.rs b/rs/moq-crypto/src/e2ee/track.rs index 60994a7ef3d..d5da6ead31f 100644 --- a/rs/moq-crypto/src/e2ee/track.rs +++ b/rs/moq-crypto/src/e2ee/track.rs @@ -1,52 +1,29 @@ -//! Grouped-frame and datagram writers and readers for one physical track. +//! Grouped-frame writers and readers for one physical stream track. use std::fmt; use std::sync::{Arc, Mutex}; use std::task::{Poll, ready}; -use crate::e2ee::datagram::{Datagram, Event}; -use crate::e2ee::error::{Error, Result}; +use crate::e2ee::error::Result; +use crate::e2ee::generation::Domain; use crate::e2ee::group; -use crate::e2ee::key::TrackKey; -use crate::e2ee::limits::{MAX_DATAGRAM_PAYLOAD, check_u53}; +use crate::e2ee::key::{Claim, TrackKey}; use crate::e2ee::terminal::Terminal; -use crate::e2ee::window::DatagramWindow; -/// Exclusive producer for one physical track. +/// Exclusive producer for one physical stream track. /// -/// Uses the grouped-frame and datagram keys and the sequence namespace of its semantic name, -/// shared with every producer of that name under the generation. Sequences are allocated -/// monotonically, so a re-created or re-served name resumes above every sequence allocated -/// before; reuse and exhaustion are refused before encryption. +/// Uses the grouped-frame key and the group sequences of its semantic name, shared with every +/// producer of that name under the generation. Sequences are allocated monotonically, so a +/// re-created or re-served name resumes above every sequence allocated before; reuse and +/// exhaustion are refused before encryption. pub struct Producer { inner: moq_net::track::Producer, - keys: Keys, -} - -/// A semantic name's grouped-frame and datagram keys and its next unallocated sequence; -/// clones share their accounting. -#[derive(Clone, Debug)] -pub(crate) struct Keys { - pub group: Arc>, - pub datagram: Arc>, - // The high-water mark plus one, kept for the generation's life so no sequence repeats. - // Locked before either key. - pub next: Arc>, -} - -impl Keys { - pub(crate) fn new(group: TrackKey, datagram: TrackKey) -> Self { - Self { - group: Arc::new(Mutex::new(group)), - datagram: Arc::new(Mutex::new(datagram)), - next: Arc::default(), - } - } + claim: Claim, } impl Producer { - pub(crate) fn new(inner: moq_net::track::Producer, keys: Keys) -> Self { - Self { inner, keys } + pub(crate) fn new(inner: moq_net::track::Producer, claim: Claim) -> Self { + Self { inner, claim } } /// The physical track name. @@ -58,7 +35,7 @@ impl Producer { /// /// # Errors /// - /// [`Error::Identity`] if the next sequence exceeds `2^53-1`, or a net error. + /// [`Error::Identity`](crate::e2ee::Error::Identity) if the next sequence exceeds `2^53-1`, or a net error. pub fn append_group(&mut self) -> Result { self.start_group(None) } @@ -68,85 +45,22 @@ impl Producer { /// /// # Errors /// - /// [`Error::Reuse`] if `sequence` was already allocated, [`Error::Identity`] if + /// [`Error::Reuse`](crate::e2ee::Error::Reuse) if `sequence` was already allocated, [`Error::Identity`](crate::e2ee::Error::Identity) if /// it exceeds `2^53-1`, or a net error. pub fn create_group(&mut self, sequence: u64) -> Result { self.start_group(Some(sequence)) } fn start_group(&mut self, sequence: Option) -> Result { - let sequence = { - let mut next = self.keys.next.lock().expect("track sequence"); - let sequence = reserve(*next, sequence)?; - *next = sequence + 1; - sequence - }; + let (sequence, ()) = self.claim.allocate(sequence, |_| Ok(()))?; let inner = self.inner.create_group(moq_net::group::Info { sequence })?; Ok(group::Producer::new( inner, self.inner.info().clone(), - Arc::clone(&self.keys.group), + Arc::clone(&self.claim.key), )) } - /// Encrypt `plaintext` at the next sequence and insert the datagram, returning that sequence. - /// - /// # Errors - /// - /// [`Error::Identity`], [`Error::Exhausted`], [`Error::Oversize`], or a net write error. - pub fn append_datagram( - &mut self, - timestamp: impl Into>, - plaintext: &[u8], - ) -> Result { - self.write_datagram(None, timestamp.into(), plaintext) - } - - /// Encrypt `plaintext` at an explicit sequence and insert the datagram. - /// - /// Plaintext is capped at [`MAX_DATAGRAM_PLAINTEXT`](crate::e2ee::MAX_DATAGRAM_PLAINTEXT). - /// A predictable failure is refused before the sequence is allocated; once - /// encrypted, the identity is spent even if the net write fails. - /// - /// # Errors - /// - /// [`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: impl Into>, - plaintext: &[u8], - ) -> Result<()> { - self.write_datagram(Some(sequence), timestamp.into(), plaintext)?; - Ok(()) - } - - fn write_datagram( - &mut self, - sequence: Option, - timestamp: Option, - plaintext: &[u8], - ) -> Result { - // Checked before the sequence is spent. - let timestamp = self.inner.info().rescale(timestamp)?; - // Held across encryption, so another producer of this name cannot take the sequence. - let (sequence, payload) = { - let mut next = self.keys.next.lock().expect("track sequence"); - let sequence = reserve(*next, sequence)?; - let payload = - self.keys - .datagram - .lock() - .expect("track key") - .protect(sequence, 0, plaintext, MAX_DATAGRAM_PAYLOAD)?; - *next = sequence + 1; - (sequence, payload) - }; - self.inner.insert_datagram(sequence, timestamp, payload)?; - Ok(sequence) - } - /// Finish the track after the last allocated sequence. /// /// # Errors @@ -166,33 +80,17 @@ impl Producer { self.inner.abort(moq_net::Error::Cancel)?; Ok(()) } - - #[cfg(test)] - pub(crate) fn datagram_invocations(&self) -> u64 { - self.keys.datagram.lock().expect("track key").invocations() - } } impl fmt::Debug for Producer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("track::Producer") .field("name", &self.name()) - .field("next", &*self.keys.next.lock().expect("track sequence")) + .field("next", &*self.claim.next.lock().expect("claim sequence")) .finish() } } -/// The sequence to allocate, `next` unless one is given, refusing one already allocated or -/// one that cannot be represented. -fn reserve(next: u64, sequence: Option) -> Result { - let sequence = sequence.unwrap_or(next); - if sequence < next { - return Err(Error::Reuse); - } - check_u53(sequence)?; - Ok(sequence) -} - /// A requested or reserved track its subscribers wait on until it is accepted. /// /// Yielded by [`broadcast::Requests::poll_requested_track`](crate::e2ee::broadcast::Requests::poll_requested_track) @@ -205,7 +103,7 @@ pub struct Request { inner: moq_net::track::Request, generation: crate::e2ee::generation::Producer, // From `generation::Producer::peek` or `claim`; claimed by `accept`. - keys: Keys, + claim: Claim, semantic: String, } @@ -213,13 +111,13 @@ impl Request { pub(crate) fn new( inner: moq_net::track::Request, generation: crate::e2ee::generation::Producer, - keys: Keys, + claim: Claim, semantic: String, ) -> Self { Self { inner, generation, - keys, + claim, semantic, } } @@ -236,8 +134,8 @@ impl Request { /// Serve the track, claiming its name and resolving every waiting subscriber. pub fn accept(self, info: impl Into>) -> Producer { - let keys = self.generation.adopt(&self.semantic, self.keys); - Producer::new(self.inner.accept(info), keys) + let claim = self.generation.adopt(&self.semantic, Domain::Group, self.claim); + Producer::new(self.inner.accept(info), claim) } /// Fail every waiting subscriber with `err`. This claims nothing, and releases no earlier claim. @@ -302,26 +200,23 @@ impl kio::Task for Subscribing { } } -/// Subscriber for one physical track. +/// Subscriber for one physical stream track. /// /// Grouped authentication failure ends the track and wakes pending reads with -/// [`Error::Authentication`]. A datagram that fails to open is dropped as an -/// [`Event::Identity`], [`Event::Oversize`], or [`Event::Authentication`] until that key is exhausted. +/// [`Error::Authentication`](crate::e2ee::Error::Authentication). pub struct Consumer { name: String, // Shared with every other subscription to this name under the generation. - keys: Keys, - window: DatagramWindow, + key: Arc>, terminal: Arc, } impl Consumer { - pub(crate) fn new(inner: moq_net::track::Subscriber, keys: Keys) -> Self { + pub(crate) fn new(inner: moq_net::track::Subscriber, key: Arc>) -> Self { let name = inner.name().to_string(); Self { name, - keys, - window: DatagramWindow::default(), + key, terminal: Terminal::new(Some(inner)), } } @@ -335,17 +230,17 @@ impl Consumer { /// /// # Errors /// - /// [`Error::Authentication`] once a grouped frame has failed to open, or a net error. + /// [`Error::Authentication`](crate::e2ee::Error::Authentication) once a grouped frame has failed to open, or a net error. pub fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { let terminal = Arc::clone(&self.terminal); - let group_key = Arc::clone(&self.keys.group); + let key = Arc::clone(&self.key); terminal.poll_subscription(waiter, |inner, waiter| { let Some(inner) = ready!(inner.poll_recv_group(waiter)?) else { return Poll::Ready(Ok(None)); }; Poll::Ready(Ok(Some(group::Consumer::new( inner, - Arc::clone(&group_key), + Arc::clone(&key), Arc::clone(&terminal), )))) }) @@ -359,66 +254,6 @@ impl Consumer { pub async fn recv_group(&mut self) -> Result> { kio::wait(|waiter| self.poll_recv_group(waiter)).await } - - /// Poll for the next datagram event. - /// - /// A datagram that fails to open (a sequence out of range, a bad size, or a forged tag) and - /// a duplicate are events, not track-ending errors. - /// - /// # Errors - /// - /// [`Error::Authentication`] only if a grouped frame already failed, - /// [`Error::Exhausted`] from opening, or a net error. - pub fn poll_recv_datagram(&mut self, waiter: &kio::Waiter) -> Poll>> { - let terminal = Arc::clone(&self.terminal); - terminal.poll_subscription(waiter, |inner, waiter| self.open_datagram(inner, waiter)) - } - - /// Open one datagram. A bad datagram stays an event; exhausting the datagram key does not. - fn open_datagram( - &mut self, - inner: &mut moq_net::track::Subscriber, - waiter: &kio::Waiter, - ) -> Poll>> { - let Some(datagram) = ready!(inner.poll_recv_datagram(waiter)?) else { - return Poll::Ready(Ok(None)); - }; - let sequence = datagram.sequence; - if self.window.is_duplicate(sequence) { - return Poll::Ready(Ok(Some(Event::Duplicate { sequence }))); - } - let opened = - self.keys - .datagram - .lock() - .expect("track key") - .open(sequence, 0, &datagram.payload, MAX_DATAGRAM_PAYLOAD); - match opened { - Ok(plaintext) => { - self.window.mark(sequence); - Poll::Ready(Ok(Some(Event::Datagram(Datagram { - sequence, - timestamp: datagram.timestamp, - plaintext, - })))) - } - // A bad datagram is not grouped failure: the track, its demand, and any - // parked grouped read stay up. `exhausted` is this key's own limit. - Err(Error::Authentication) => Poll::Ready(Ok(Some(Event::Authentication { sequence }))), - Err(Error::Identity) => Poll::Ready(Ok(Some(Event::Identity { sequence }))), - Err(Error::Oversize) => Poll::Ready(Ok(Some(Event::Oversize { sequence }))), - Err(err) => Poll::Ready(Err(err)), - } - } - - /// Receive the next datagram event. - /// - /// # Errors - /// - /// Same as [`Self::poll_recv_datagram`]. - pub async fn recv_datagram(&mut self) -> Result> { - kio::wait(|waiter| self.poll_recv_datagram(waiter)).await - } } // Groups share the terminal, so without this a held group would keep the diff --git a/rs/moq-crypto/tests/transport.rs b/rs/moq-crypto/tests/transport.rs index a43973ed8cb..4923266be46 100644 --- a/rs/moq-crypto/tests/transport.rs +++ b/rs/moq-crypto/tests/transport.rs @@ -1,4 +1,4 @@ -//! Grouped tracks and datagrams on moq-lite and MoQ Transport. +//! Grouped tracks on moq-lite and MoQ Transport, and datagram tracks on moq-lite. mod support; @@ -22,16 +22,22 @@ fn produce_origin(hop: u64) -> moq_net::origin::Producer { producer } +/// A millisecond datagram track. +fn timed_datagrams() -> moq_net::datagram::Info { + moq_net::datagram::Info::default().with_timescale(moq_net::Timescale::MILLI) +} + +/// A protected broadcast and its subscriber's view across one session. struct Fixture { - producer: moq_crypto::e2ee::track::Producer, - consumer: moq_crypto::e2ee::track::Consumer, + producer: moq_crypto::e2ee::broadcast::Producer, + consumer: moq_crypto::e2ee::broadcast::Consumer, _broadcast: moq_net::origin::Publication, _pair: MockPair, } -async fn connect_protected(version: Version, track: &str) -> Fixture { +async fn connect_protected(version: Version, label: &str) -> Fixture { let cred = Credential::new(Config { - context: format!("transport-{version}-{track}").into(), + context: format!("transport-{version}-{label}").into(), kid: 7, secret: *b"moq-e2ee-00 test secret!!!!!!!!!", }) @@ -43,8 +49,7 @@ async fn connect_protected(version: Version, track: &str) -> Fixture { let consumer_origin = produce_origin(2); let broadcast = publisher.create_broadcast(&path).unwrap(); - let protected = moq_crypto::e2ee::broadcast::Producer::new(broadcast.broadcast().clone(), generation.clone()); - let producer = protected.create_track(track, timed()).unwrap(); + let producer = moq_crypto::e2ee::broadcast::Producer::new(broadcast.broadcast().clone(), generation.clone()); broadcast .announce(moq_net::origin::Route::default().with_instance(generation.epoch().instance(), 0)) .unwrap(); @@ -66,15 +71,7 @@ async fn connect_protected(version: Version, track: &str) -> Fixture { ); } let epoch = moq_crypto::e2ee::Epoch::of(&remote).unwrap_or_else(|| generation.epoch().clone()); - let consumer = moq_crypto::e2ee::broadcast::Consumer::new(remote, cred.consume(epoch)) - .subscribe( - track, - moq_net::track::Subscription::default() - .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))), - ) - .unwrap() - .await - .unwrap(); + let consumer = moq_crypto::e2ee::broadcast::Consumer::new(remote, cred.consume(epoch)); Fixture { producer, @@ -84,51 +81,58 @@ async fn connect_protected(version: Version, track: &str) -> Fixture { } } -#[tokio::test] -async fn grouped_over_lite() { +async fn grouped_roundtrip(version: &str, payload: &[u8]) { kio::clock::timeout(TEST_TIMEOUT, async { - let mut fixture = connect_protected("moq-lite-07-wip".parse().unwrap(), "video").await; - let mut group = fixture.producer.append_group().unwrap(); - group - .write_frame(Timestamp::from_millis(1).unwrap(), b"lite-frame") + let fixture = connect_protected(version.parse().unwrap(), "video").await; + let mut producer = fixture.producer.create_track("video", timed()).unwrap(); + let mut consumer = fixture + .consumer + .subscribe( + "video", + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))), + ) + .unwrap() + .await .unwrap(); + let mut group = producer.append_group().unwrap(); + group.write_frame(Timestamp::from_millis(1).unwrap(), payload).unwrap(); group.finish().unwrap(); - let mut group = fixture.consumer.recv_group().await.unwrap().unwrap(); + let mut group = consumer.recv_group().await.unwrap().unwrap(); let frame = group.read_frame().await.unwrap().unwrap(); - assert_eq!(&frame.plaintext[..], b"lite-frame"); + assert_eq!(&frame.plaintext[..], payload); }) .await .expect("timed out"); } +#[tokio::test] +async fn grouped_over_lite() { + grouped_roundtrip("moq-lite-07-wip", b"lite-frame").await; +} + #[tokio::test] async fn grouped_over_ietf() { - kio::clock::timeout(TEST_TIMEOUT, async { - let mut fixture = connect_protected("moq-transport-22".parse().unwrap(), "video").await; - let mut group = fixture.producer.append_group().unwrap(); - group - .write_frame(Timestamp::from_millis(1).unwrap(), b"ietf-frame") - .unwrap(); - group.finish().unwrap(); - let mut group = fixture.consumer.recv_group().await.unwrap().unwrap(); - let frame = group.read_frame().await.unwrap().unwrap(); - assert_eq!(&frame.plaintext[..], b"ietf-frame"); - }) - .await - .expect("timed out"); + grouped_roundtrip("moq-transport-22", b"ietf-frame").await; } async fn datagram_roundtrip(version: &str, plaintext: &[u8]) { kio::clock::timeout(TEST_TIMEOUT, async { - let mut fixture = connect_protected(version.parse().unwrap(), "audio").await; - fixture - .producer - .append_datagram(Timestamp::from_millis(9).unwrap(), plaintext) - .unwrap(); - match fixture.consumer.recv_datagram().await.unwrap() { + let fixture = connect_protected(version.parse().unwrap(), "audio").await; + let mut producer = fixture.producer.create_datagrams("audio", timed_datagrams()).unwrap(); + let mut consumer = fixture.consumer.datagrams("audio").unwrap(); + // A remote subscriber is live once its request reaches the publisher, which sees its priority. + consumer.set_priority(1); + let mut priority = producer.priority(); + while priority != Some(1) { + priority = kio::wait(|waiter| producer.poll_priority(waiter, priority)).await; + } + + let sequence = producer.append(Timestamp::from_millis(9).unwrap(), plaintext).unwrap(); + match consumer.recv().await.unwrap() { Some(moq_crypto::e2ee::datagram::Event::Datagram(d)) => { assert_eq!(&d.plaintext[..], plaintext); - assert_eq!(d.sequence, 0); + assert_eq!(d.sequence, sequence); } other => panic!("{other:?}"), } @@ -138,17 +142,12 @@ async fn datagram_roundtrip(version: &str, plaintext: &[u8]) { } #[tokio::test] -async fn datagrams_over_lite() { +async fn datagrams_over_lite_05() { datagram_roundtrip("moq-lite-05", b"opus").await; } -/// The largest plaintext still fits once lite-07 prefixes the datagram with its Type. +/// The largest plaintext still fits lite-07's datagram body at its widest header. #[tokio::test] async fn max_datagram_over_lite_07() { datagram_roundtrip("moq-lite-07-wip", &[7; moq_crypto::e2ee::MAX_DATAGRAM_PLAINTEXT]).await; } - -#[tokio::test] -async fn datagrams_over_ietf() { - datagram_roundtrip("moq-transport-22", b"opus").await; -} diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index 371588cddad..b19af36d87c 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -32,6 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 takes the next item as it arrives, and sequence or arrival order is unchanged. A call the binding cancelled but has not freed yet no longer holds up the next one, and `cancel()` on these handles releases their state before it returns. +- A raw track's datagrams travel on their own datagram track: they are numbered apart + from groups, never move `largest`, and reach a subscription from its first + `recv_datagram` on. See `MIGRATE.md`. ## [0.4.12](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.11...moq-ffi-v0.4.12) - 2026-10-06 diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 16c160d0c05..125bc6c280b 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,16 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Datagrams are a track of their own + +A raw track's datagrams travel on a separate datagram track of the same name, so the API is unchanged but some results differ: + +- `append_datagram` numbers datagrams on their own, apart from groups, and `MoqDatagram.sequence` is that number. Don't compare it with group sequences. +- `MoqTrackProducer::largest` reports groups only: a datagram never moves it. +- `insert_datagram` ignores a `finish_at` boundary. +- A `MoqTrackConsumer` from `subscribe_track` subscribes to datagrams on its first `recv_datagram` and receives only those sent from then on, so start reading before the datagrams you need are sent. One from `MoqTrackProducer::consume` receives those sent after it was created. +- `recv_datagram` fails with `NotFound` on a track with no datagram track, such as a media or JSON track, and over moq-transport, which carries no datagrams. Every raw track has one. + ### `MoqVideoInit.lower` takes either producer kind ([#7699](https://github.com/moq-dev/moq/pull/7699)) `MoqVideoInit.lower` is `Option` instead of `Option`. Wrap a frame producer as `MoqMediaProducer::Track { producer }`; a `MoqMediaTrackStreamProducer` goes in as `MoqMediaProducer::Stream { producer }`. diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 3d254df1e6b..ef266870504 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -362,7 +362,8 @@ impl MoqBroadcastConsumer { .watch(async { let subscription = subscription.map(moq_net::track::Subscription::try_from).transpose()?; let track = self.inner.track(&name)?.subscribe(subscription).await?; - Ok(Arc::new(MoqTrackConsumer::new(track, self.driver.clone()))) + let datagrams = Datagrams::Remote(self.inner.clone()); + Ok(Arc::new(MoqTrackConsumer::new(track, datagrams, self.driver.clone()))) }) .await } @@ -391,7 +392,7 @@ impl MoqBroadcastConsumer { pub(crate) fn map_fetch_error(err: moq_net::Error) -> MoqError { match err { - moq_net::Error::NotFound | moq_net::Error::NotFetchable => MoqError::NotFound, + moq_net::Error::NotFound => MoqError::NotFound, moq_net::Error::Unsupported | moq_net::Error::Version => MoqError::Unsupported, err => err.into(), } @@ -405,7 +406,7 @@ pub(crate) fn map_fetch_error(err: moq_net::Error) -> MoqError { /// binding can't express in its type system, so the handle commits on the first group /// read instead: whichever group cursor a caller reaches for first is the one this /// track keeps, and reaching for the other afterwards is [`MoqError::AlreadyCommitted`]. -/// Datagrams are a separate cursor on either handle, so reading them never commits. +/// Datagrams are a separate track of the same name, so reading them never commits. enum Cursor { /// No group read has happened yet; the track can still commit either way. Uncommitted(moq_net::track::Subscriber), @@ -429,6 +430,17 @@ struct OrderedCursor { struct TrackInner { track: Cursor, + datagrams: Datagrams, +} + +/// The datagram track of a track's name. A subscribed track opens it on the first datagram +/// read, so a reader that never asks for datagrams never subscribes to them. +pub(crate) enum Datagrams { + /// Not opened yet; opened from the broadcast the track was subscribed through. + Remote(moq_net::broadcast::Consumer), + Open(moq_net::datagram::Consumer), + /// Read from a track that had already finished, so it has no datagrams to come. + Ended, } impl TrackInner { @@ -526,20 +538,74 @@ impl TrackInner { } } - fn poll_recv_datagram(&mut self, waiter: &kio::Waiter) -> Poll, MoqError>> { - // Datagrams are unordered on either handle; reading them never commits the - // group cursor. - let poll = match &mut self.track { - Cursor::Uncommitted(track) | Cursor::Arrival(track) => track.poll_recv_datagram(waiter), - Cursor::Ordered(ordered) => ordered.track.poll_recv_datagram(waiter), - // Poisoned mid-conversion; see `arrival`. Datagrams never commit a cursor, - // so this is the only way they can fail on a live handle. - Cursor::Converting => return Poll::Ready(Err(MoqError::Closed)), + /// The datagram consumer, opened now if this is the first datagram read, or `None` once + /// there are none to come. + fn datagrams( + &mut self, + control: &moq_net::track::Control, + ) -> Result, MoqError> { + let name = match &self.track { + Cursor::Uncommitted(track) | Cursor::Arrival(track) => track.name(), + Cursor::Ordered(ordered) => ordered.track.name(), + // Poisoned mid-conversion; see `arrival`. + Cursor::Converting => return Err(MoqError::Closed), + }; + if let Datagrams::Remote(broadcast) = &self.datagrams { + match broadcast.datagrams(name) { + Ok(mut opened) => { + opened.set_priority(control.subscription().priority); + self.datagrams = Datagrams::Open(opened); + } + Err(err) => self.datagrams = self.missing(err)?, + } + } + match &mut self.datagrams { + Datagrams::Open(datagrams) => Ok(Some(datagrams)), + Datagrams::Ended => Ok(None), + Datagrams::Remote(_) => unreachable!("opened above"), + } + } + + /// The datagrams after looking them up failed with `err`: none to come when the track + /// declared its end, since its datagram track finished with it and a finished one is no + /// longer found, or else `err`. + fn missing(&mut self, err: moq_net::Error) -> Result { + let missing = matches!( + err, + moq_net::Error::NotFound | moq_net::Error::Stream(moq_net::StreamError::NotFound) + ); + let waiter = kio::Waiter::noop(); + let finished = match &mut self.track { + Cursor::Uncommitted(track) | Cursor::Arrival(track) => track.poll_finished(&waiter), + Cursor::Ordered(ordered) => waiter.poll_future(std::pin::pin!(ordered.track.finished())), + Cursor::Converting => Poll::Pending, }; - let datagram = match ready!(poll) { + match (missing, finished) { + (true, Poll::Ready(Ok(_))) => Ok(Datagrams::Ended), + _ => Err(err.into()), + } + } + + fn poll_recv_datagram( + &mut self, + waiter: &kio::Waiter, + control: &moq_net::track::Control, + ) -> Poll, MoqError>> { + let datagrams = match self.datagrams(control) { + Ok(Some(datagrams)) => datagrams, + Ok(None) => return Poll::Ready(Ok(None)), + Err(err) => return Poll::Ready(Err(err)), + }; + let datagram = match ready!(datagrams.poll_recv(waiter)) { Ok(Some(datagram)) => datagram, Ok(None) => return Poll::Ready(Ok(None)), - Err(err) => return Poll::Ready(Err(err.into())), + Err(err) => { + self.datagrams = match self.missing(err) { + Ok(ended) => ended, + Err(err) => return Poll::Ready(Err(err)), + }; + return Poll::Ready(Ok(None)); + } }; Poll::Ready(Ok(Some(MoqDatagram { sequence: datagram.sequence, @@ -562,12 +628,13 @@ pub struct MoqTrackConsumer { } impl MoqTrackConsumer { - pub(crate) fn new(track: moq_net::track::Subscriber, driver: Background) -> Self { + pub(crate) fn new(track: moq_net::track::Subscriber, datagrams: Datagrams, driver: Background) -> Self { let control = track.control(); let info = track.info().clone(); Self { reads: Polled::new(TrackInner { track: Cursor::Uncommitted(track), + datagrams, }) .supervised(driver.clone()), control, @@ -649,13 +716,19 @@ impl MoqTrackConsumer { /// Receive the next best-effort datagram in arrival order. /// - /// Returns `None` when the track ends. Datagram delivery is unavailable over - /// pre-lite-05 moq-lite and stream-only transports. - /// Datagrams are a separate cursor from groups, so this works alongside either - /// group order, never commits the track to one, and progresses while a group + /// A consumer from [`MoqTrackProducer::consume`](crate::producer::MoqTrackProducer::consume) + /// receives the datagrams sent after it was created. One from a subscription subscribes to + /// the track's datagrams on its first call, receiving those sent from then on, so start + /// reading before the ones you want are sent. Returns `None` when the track ends. + /// Datagram delivery is unavailable over pre-lite-05 moq-lite, moq-transport, and + /// stream-only transports. Datagrams are separate from groups, so this works alongside + /// either group order, never commits the track to one, and progresses while a group /// read is pending. pub async fn recv_datagram(&self) -> Result, MoqError> { - self.reads.wait(|inner, waiter| inner.poll_recv_datagram(waiter)).await + let control = &self.control; + self.reads + .wait(|inner, waiter| inner.poll_recv_datagram(waiter, control)) + .await } /// Return the publisher-side track properties learned during subscription. @@ -669,10 +742,18 @@ impl MoqTrackConsumer { /// that point. Errors on a time anchor the track can't serve, leaving the /// preferences as they were. pub fn update(&self, subscription: MoqSubscription) -> Result<(), MoqError> { - match self.control.update(subscription.try_into()?) { - Ok(()) | Err(moq_net::Error::Closed) => Ok(()), - Err(err) => Err(err.into()), + let subscription: moq_net::track::Subscription = subscription.try_into()?; + let priority = subscription.priority; + match self.control.update(subscription) { + Ok(()) | Err(moq_net::Error::Closed) => {} + Err(err) => return Err(err.into()), } + self.reads.peek_mut(|inner| { + if let Datagrams::Open(datagrams) = &mut inner.datagrams { + datagrams.set_priority(priority); + } + }); + Ok(()) } /// Cancel all current and future reads. diff --git a/rs/moq-ffi/src/error.rs b/rs/moq-ffi/src/error.rs index 096ac68e331..77f64f97005 100644 --- a/rs/moq-ffi/src/error.rs +++ b/rs/moq-ffi/src/error.rs @@ -163,8 +163,7 @@ fn stream_kind(err: &moq_net::StreamError) -> MoqProtocolKind { moq_net::StreamError::GoingAway => MoqProtocolKind::GoingAway, moq_net::StreamError::TooFarBehind => MoqProtocolKind::TooFarBehind, moq_net::StreamError::MalformedTrack => MoqProtocolKind::MalformedTrack, - // A datagram reached by a FETCH is, to a binding, a miss like any other. - moq_net::StreamError::NotFound | moq_net::StreamError::NotFetchable => MoqProtocolKind::NotFound, + moq_net::StreamError::NotFound => MoqProtocolKind::NotFound, moq_net::StreamError::Unroutable => MoqProtocolKind::Unroutable, moq_net::StreamError::Old => MoqProtocolKind::Old, moq_net::StreamError::Evicted => MoqProtocolKind::Evicted, @@ -417,7 +416,7 @@ impl From for MoqError { moq_net::Error::IdleTimeout => Self::Transport { reason: "transport idle timeout".into(), }, - moq_net::Error::NotFound | moq_net::Error::NotFetchable => Self::NotFound, + moq_net::Error::NotFound => Self::NotFound, // A track whose own session closed aborts its open groups with this; a remote // SESSION_CLOSED decodes as `Internal`, so only the local close lands here. moq_net::Error::Closed diff --git a/rs/moq-ffi/src/ffi.rs b/rs/moq-ffi/src/ffi.rs index 373a72fdb39..755d026f19a 100644 --- a/rs/moq-ffi/src/ffi.rs +++ b/rs/moq-ffi/src/ffi.rs @@ -725,6 +725,11 @@ impl Polled { self.state.lock().as_ref().map(f) } + /// Modify the state synchronously, between polls, or `None` once it is gone. + pub fn peek_mut(&self, f: impl FnOnce(&mut T) -> R) -> Option { + self.state.lock().as_mut().map(f) + } + /// Whether [Self::cancel] or [Self::take] has run. pub fn is_cancelled(&self) -> bool { self.state.lock().is_none() diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index c279aa5e878..d0d18777e1d 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -260,7 +260,7 @@ pub struct MoqMediaFrame { /// takes a [`MoqFrame`] and assigns the sequence number for you. #[derive(uniffi::Record)] pub struct MoqDatagram { - /// Per-track sequence number, shared with groups. + /// The datagram's sequence number, counted apart from the track's groups. #[uniffi(default = 0)] pub sequence: u64, /// Presentation timestamp, or null on an untimed track, on both publish and receive. diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 96904e3699f..4228694b7fe 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -5,7 +5,7 @@ use std::task::{Poll, ready}; use moq_mux::catalog::hang::Extra; -use crate::consumer::{MoqBroadcastConsumer, MoqGroupConsumer, MoqOrder, MoqSubscription, MoqTrackConsumer}; +use crate::consumer::{Datagrams, MoqBroadcastConsumer, MoqGroupConsumer, MoqOrder, MoqSubscription, MoqTrackConsumer}; use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::{Background, Polled}; @@ -84,6 +84,13 @@ fn raw_track_info(info: Option) -> Result moq_net::datagram::Info { + moq_net::datagram::Info::default() + .with_timescale(info.timescale) + .with_priority(info.priority) +} + impl From<&moq_net::track::Info> for MoqTrackInfo { fn from(info: &moq_net::track::Info) -> Self { Self { @@ -135,6 +142,8 @@ enum Family { open: Vec>, /// Finished producers, kept so a late subscriber still finds their tracks. kept: Vec>, + /// What [`Children::broadcast`] reaches while the broadcast handle is open. + _broadcast: Arc, }, /// The broadcast handle was dropped without a close: each child ends on its own. Released, @@ -150,22 +159,35 @@ enum Family { /// go of its producer when it finishes, so the broadcast keeps finished producers here instead: /// a subscriber arriving after the end still finds the track until the broadcast closes. #[derive(Clone)] -pub(crate) struct Children(Arc>); - -impl Default for Children { - fn default() -> Self { - Self(Arc::new(std::sync::Mutex::new(Family::Open { - open: Vec::new(), - kept: Vec::new(), - }))) - } +pub(crate) struct Children { + family: Arc>, + /// Reachable while a handle keeping the broadcast open holds it: the broadcast handle, or a + /// request handler. Never kept alive from here, so a child never holds its broadcast open. + broadcast: std::sync::Weak, } impl Children { + fn new(broadcast: moq_net::broadcast::Producer) -> Self { + let broadcast = Arc::new(broadcast); + Self { + broadcast: Arc::downgrade(&broadcast), + family: Arc::new(std::sync::Mutex::new(Family::Open { + open: Vec::new(), + kept: Vec::new(), + _broadcast: broadcast, + })), + } + } + + /// The broadcast, while a handle keeping it open still holds it. + fn broadcast(&self) -> Option> { + self.broadcast.upgrade() + } + /// Register a new child so a close ends it. Once the broadcast closed, the child is aborted /// and refused with `Closed`. pub(crate) fn adopt(&self, child: Arc) -> Result, MoqError> { - let mut family = self.0.lock().unwrap(); + let mut family = self.family.lock().unwrap(); match &mut *family { Family::Open { open, .. } => { open.retain(|child| child.strong_count() > 0); @@ -184,21 +206,21 @@ impl Children { /// Whether the broadcast closed, ending its children. fn is_closed(&self) -> bool { - matches!(*self.0.lock().unwrap(), Family::Closed) + matches!(*self.family.lock().unwrap(), Family::Closed) } /// Keep a finished producer until the broadcast ends, or drop it now if it already has. pub(crate) fn keep(&self, producer: impl Send + 'static) { let producer: Box = Box::new(producer); - if let Family::Open { kept, .. } = &mut *self.0.lock().unwrap() { + if let Family::Open { kept, .. } = &mut *self.family.lock().unwrap() { kept.push(producer); } } /// End every open child with `Unroutable`, then let go of the finished ones. fn close(&self) { - let family = std::mem::replace(&mut *self.0.lock().unwrap(), Family::Closed); - if let Family::Open { open, kept } = family { + let family = std::mem::replace(&mut *self.family.lock().unwrap(), Family::Closed); + if let Family::Open { open, kept, .. } = family { // Outside the lock: a child's own finish keeps into it while holding the child's lock. for child in open.iter().filter_map(std::sync::Weak::upgrade) { child.abort(moq_net::Error::Unroutable); @@ -209,7 +231,7 @@ impl Children { /// Let go of the finished producers, leaving open children to end on their own. fn release(&self) { - let mut family = self.0.lock().unwrap(); + let mut family = self.family.lock().unwrap(); if matches!(*family, Family::Open { .. }) { let released = std::mem::replace(&mut *family, Family::Released); drop(family); @@ -227,10 +249,17 @@ pub struct MoqBroadcastProducer { /// A handler for requests for tracks in a broadcast. #[derive(uniffi::Object)] pub struct MoqBroadcastRequests { - reads: Polled, + reads: Polled, children: Children, } +struct RequestsInner { + requests: moq_net::broadcast::Requests, + /// Holds the broadcast for [`Children::broadcast`] as long as `requests` keeps it open, so a + /// request accepted after the broadcast handle dropped still creates its datagram track. + _broadcast: Arc, +} + /// Serves on-demand fetches of uncached groups for one track. #[derive(uniffi::Object)] pub struct MoqTrackRequests { @@ -257,11 +286,11 @@ impl MoqBroadcastProducer { let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config)?; Ok(Self { state: std::sync::Mutex::new(Some(BroadcastProducer { + children: Children::new(broadcast.clone()), broadcast, catalog, timebases: Default::default(), driver, - children: Default::default(), ended: false, })), }) @@ -309,8 +338,12 @@ impl MoqBroadcastProducer { pub fn requests(&self) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.with_state(|state| { + let inner = RequestsInner { + requests: state.broadcast.requests(), + _broadcast: state.children.broadcast().ok_or(MoqError::Closed)?, + }; Ok(Arc::new(MoqBroadcastRequests { - reads: Polled::new(state.broadcast.requests()).supervised(state.driver.clone()), + reads: Polled::new(inner).supervised(state.driver.clone()), children: state.children.clone(), })) }) @@ -338,10 +371,15 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::enter(); self.with_state(|state| { let info = raw_track_info(info)?; - // Clone the broadcast handle (shared Arc internally) to get &mut access. - let broadcast = state.broadcast.clone(); - let producer = broadcast.create_track(name, Some(info))?; - MoqTrackProducer::child(producer, &state.children) + let datagrams = state.broadcast.create_datagrams(name.as_str(), datagram_info(&info))?; + let track = match state.broadcast.create_track(name, Some(info)) { + Ok(track) => track, + Err(err) => { + datagrams.abort(err.clone()); + return Err(err.into()); + } + }; + MoqTrackProducer::child(Raw::new(track, datagrams), &state.children) }) } @@ -388,7 +426,7 @@ impl MoqBroadcastRequests { // The subscriber's subscribe stays pending until then. let request = self .reads - .wait(|requests, waiter| Poll::Ready(Ok(ready!(requests.poll_requested_track(waiter))?))) + .wait(|inner, waiter| Poll::Ready(Ok(ready!(inner.requests.poll_requested_track(waiter))?))) .await?; Ok(Arc::new(MoqTrackRequest::new(request, self.children.clone()))) } @@ -594,7 +632,22 @@ impl MoqTrackRequest { let _guard = crate::ffi::enter(); let info = raw_track_info(info)?; let request = self.take()?; - MoqTrackProducer::child(request.accept(Some(info)), &self.children) + // Without a handle keeping it open, the broadcast has ended. + let Some(broadcast) = self.children.broadcast() else { + request.reject(moq_net::Error::Unroutable); + return Err(MoqError::Closed); + }; + // The datagram track first: accepting wakes the track's subscribers, and one that asks + // for its datagrams, or a lite-05/06 subscription that carries them, must find it. + let datagrams = match broadcast.create_datagrams(request.name(), datagram_info(&info)) { + Ok(datagrams) => datagrams, + Err(err) => { + request.reject(err.clone()); + return Err(err.into()); + } + }; + let track = request.accept(Some(info)); + MoqTrackProducer::child(Raw::new(track, datagrams), &self.children) } /// Reject the request with an application error code, failing the waiting subscriber. @@ -614,18 +667,48 @@ impl MoqTrackRequest { /// `Unroutable`, which a moq-transport reader sees as `Internal`: its wire has no such code. #[derive(uniffi::Object)] pub struct MoqTrackProducer { - inner: std::sync::Mutex>, + inner: std::sync::Mutex>, // Taken by the first finish, so repeating it keeps the track only once. keep: std::sync::Mutex>, } +/// A raw track and the datagram track of the same name that carries its datagrams. +struct Raw { + track: moq_net::track::Producer, + /// Ended with the track, so `None` once it finished. + datagrams: Option, +} + +impl Raw { + fn new(track: moq_net::track::Producer, datagrams: moq_net::datagram::Producer) -> Self { + Self { + track, + datagrams: Some(datagrams), + } + } + + /// End both tracks with `err`. A track with a declared end ends there instead, as when its + /// handle drops, and its datagrams finish with it. + fn abort(self, err: moq_net::Error) { + let finished = self.track.finished().is_some(); + if let Some(datagrams) = self.datagrams { + match finished { + true => datagrams.finish(), + false => datagrams.abort(err.clone()), + } + } + if !finished { + let _ = self.track.abort(err); + } + } +} + impl Child for MoqTrackProducer { fn abort(&self, err: moq_net::Error) { let _guard = crate::ffi::enter(); - // A finished track ends at its declared end, as when its handle drops. - let track = self.inner.lock().unwrap().take(); - if let Some(track) = track.filter(|track| track.finished().is_none()) { - let _ = track.abort(err); + let raw = self.inner.lock().unwrap().take(); + if let Some(raw) = raw { + raw.abort(err); } } } @@ -634,26 +717,25 @@ impl Drop for MoqTrackProducer { fn drop(&mut self) { let _guard = crate::ffi::enter(); // A finished track lives on in its broadcast, and may still take groups below its end. - let track = self.inner.get_mut().unwrap().take(); - if let Some(track) = track.filter(|track| track.finished().is_none()) { - let _ = track.abort(moq_net::Error::Unroutable); + if let Some(raw) = self.inner.get_mut().unwrap().take() { + raw.abort(moq_net::Error::Unroutable); } } } impl MoqTrackProducer { - /// Wrap `track` as a child of the broadcast `children` belongs to. - fn child(track: moq_net::track::Producer, children: &Children) -> Result, MoqError> { + /// Wrap `raw` as a child of the broadcast `children` belongs to. + fn child(raw: Raw, children: &Children) -> Result, MoqError> { children.adopt(Arc::new(Self { - inner: std::sync::Mutex::new(Some(track)), + inner: std::sync::Mutex::new(Some(raw)), keep: std::sync::Mutex::new(Some(children.clone())), })) } pub(crate) fn track_demand(&self) -> Result { let guard = self.inner.lock().unwrap(); - let track = guard.as_ref().ok_or(MoqError::Closed)?; - Ok(track.demand()) + let raw = guard.as_ref().ok_or(MoqError::Closed)?; + Ok(raw.track.demand()) } /// Hand the track to a typed writer that `f` builds (a JSON producer, say), closing this @@ -665,11 +747,11 @@ impl MoqTrackProducer { ) -> Result, MoqError> { // Taken for the whole construction, so a racing adopt finds the handle closed. `f` runs // outside the lock: it may take the broadcast's, which a close holds while it takes this one. - let track = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; - let writer = match f(track.clone()) { + let raw = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + let writer = match f(raw.track.clone()) { Ok(writer) => Arc::new(writer), Err(err) => { - *self.inner.lock().unwrap() = Some(track); + *self.inner.lock().unwrap() = Some(raw); // A close during `f` found nothing to end, so the restored track ends here. let children = self.keep.lock().unwrap().clone(); if children.is_some_and(|children| children.is_closed()) { @@ -678,6 +760,10 @@ impl MoqTrackProducer { return Err(err); } }; + // The writer publishes groups only. + if let Some(datagrams) = raw.datagrams { + datagrams.finish(); + } // A finished track has no family left to join, and ends at its declared end. let children = self.keep.lock().unwrap().clone(); match children { @@ -696,8 +782,8 @@ impl MoqTrackProducer { pub fn requests(&self) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.inner.lock().unwrap(); - let track = guard.as_ref().ok_or(MoqError::Closed)?; - Ok(Arc::new(MoqTrackRequests::new(track.requests()))) + let raw = guard.as_ref().ok_or(MoqError::Closed)?; + Ok(Arc::new(MoqTrackRequests::new(raw.track.requests()))) } /// A watch-only handle to this track's name and whether it has subscribers. @@ -712,20 +798,27 @@ impl MoqTrackProducer { pub fn consume(&self, subscription: Option) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.inner.lock().unwrap(); - let track = guard.as_ref().ok_or(MoqError::Closed)?; + let raw = guard.as_ref().ok_or(MoqError::Closed)?; let subscription = subscription.map(moq_net::track::Subscription::try_from).transpose()?; - Ok(Arc::new(MoqTrackConsumer::new( - track.subscribe(subscription), - Background::default(), - ))) + let track = raw.track.subscribe(subscription); + // Opened now: a local reader costs no subscription, and reads what is sent from here on. + let datagrams = match &raw.datagrams { + Some(datagrams) => { + let mut datagrams = datagrams.consume(); + datagrams.set_priority(track.control().subscription().priority); + Datagrams::Open(datagrams) + } + None => Datagrams::Ended, + }; + Ok(Arc::new(MoqTrackConsumer::new(track, datagrams, Background::default()))) } /// Append a new group to the track, returning a producer for writing frames into it. pub fn append_group(&self) -> Result, MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); - let track = guard.as_mut().ok_or(MoqError::Closed)?; - let group = track.append_group()?; + let raw = guard.as_mut().ok_or(MoqError::Closed)?; + let group = raw.track.append_group()?; Ok(Arc::new(MoqGroupProducer { sequence: group.sequence, inner: std::sync::Mutex::new(Some(group)), @@ -739,8 +832,8 @@ impl MoqTrackProducer { pub fn create_group(&self, sequence: u64) -> Result, MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); - let track = guard.as_mut().ok_or(MoqError::Closed)?; - let group = track.create_group(moq_net::group::Info { sequence })?; + let raw = guard.as_mut().ok_or(MoqError::Closed)?; + let group = raw.track.create_group(moq_net::group::Info { sequence })?; Ok(Arc::new(MoqGroupProducer { sequence: group.sequence, inner: std::sync::Mutex::new(Some(group)), @@ -756,56 +849,66 @@ impl MoqTrackProducer { let _guard = crate::ffi::enter(); let timestamp = frame.timestamp.map(TryInto::try_into).transpose()?; let mut guard = self.inner.lock().unwrap(); - let track = guard.as_mut().ok_or(MoqError::Closed)?; - track.write_frame(timestamp, frame.payload)?; + let raw = guard.as_mut().ok_or(MoqError::Closed)?; + raw.track.write_frame(timestamp, frame.payload)?; Ok(()) } /// 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. Like a frame, - /// its timestamp must match the track's timedness. + /// Datagrams are numbered on their own, apart from groups. A reader receives only those + /// sent after it subscribed to datagrams; see + /// [`recv_datagram`](crate::consumer::MoqTrackConsumer::recv_datagram). 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. Like a frame, its + /// timestamp must match the track's timedness. pub fn append_datagram(&self, frame: MoqFrame) -> Result { let _guard = crate::ffi::enter(); let timestamp = frame.timestamp.map(TryInto::try_into).transpose()?; let mut guard = self.inner.lock().unwrap(); - let track = guard.as_mut().ok_or(MoqError::Closed)?; - Ok(track.append_datagram(timestamp, frame.payload)?) + let datagrams = guard.as_mut().and_then(|raw| raw.datagrams.as_mut()); + Ok(datagrams.ok_or(MoqError::Closed)?.append(timestamp, frame.payload)?) } /// Send `frame` as a best-effort datagram at an explicit `sequence`. /// - /// [`append_datagram`](Self::append_datagram) assigns the next sequence; this is for a - /// caller that allocates its own, such as an encryption layer binding the sequence into - /// its nonce. The same payload, transport, and timedness rules apply, and a sequence at or - /// past a [`finish_at`](Self::finish_at) boundary is refused with `Closed`. + /// [`append_datagram`](Self::append_datagram) continues past the highest sequence sent; this + /// is for a caller that allocates its own, such as an encryption layer binding the sequence + /// into its nonce. The same payload, transport, and timedness rules apply. pub fn insert_datagram(&self, sequence: u64, frame: MoqFrame) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let timestamp = frame.timestamp.map(TryInto::try_into).transpose()?; let mut guard = self.inner.lock().unwrap(); - let track = guard.as_mut().ok_or(MoqError::Closed)?; - Ok(track.insert_datagram(sequence, timestamp, frame.payload)?) + let datagrams = guard.as_mut().and_then(|raw| raw.datagrams.as_mut()); + Ok(datagrams.ok_or(MoqError::Closed)?.insert(moq_net::Datagram { + sequence, + timestamp, + payload: frame.payload.into(), + })?) } - /// The newest content this track holds: its newest group or datagram, at that group's - /// newest frame (0 while it is empty), or null before anything was written. + /// The newest content this track holds: its newest group, at that group's newest frame + /// (0 while it is empty), or null before any group was written. /// /// A publisher compares it with [`MoqTrackDemand`]'s wanted group to tell whether a join /// is already served by a group it wrote. pub fn largest(&self) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.inner.lock().unwrap(); - let track = guard.as_ref().ok_or(MoqError::Closed)?; - Ok(track.largest().map(MoqPosition::from)) + let raw = guard.as_ref().ok_or(MoqError::Closed)?; + Ok(raw.track.largest().map(MoqPosition::from)) } /// Abort this track with an application error code. pub fn abort(&self, error_code: u16) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); - let track = guard.take().ok_or(MoqError::Closed)?; - track.abort(moq_net::Error::App(error_code))?; + let raw = guard.take().ok_or(MoqError::Closed)?; + let err = moq_net::Error::App(error_code); + if let Some(datagrams) = raw.datagrams { + datagrams.abort(err.clone()); + } + raw.track.abort(err)?; Ok(()) } @@ -817,12 +920,15 @@ impl MoqTrackProducer { pub fn finish(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); - let track = guard.as_mut().ok_or(MoqError::Closed)?; - if track.finished().is_none() { - track.finish()?; + let raw = guard.as_mut().ok_or(MoqError::Closed)?; + if raw.track.finished().is_none() { + raw.track.finish()?; + } + if let Some(datagrams) = raw.datagrams.take() { + datagrams.finish(); } if let Some(children) = self.keep.lock().unwrap().take() { - children.keep(track.clone()); + children.keep(raw.track.clone()); } Ok(()) } @@ -835,8 +941,9 @@ impl MoqTrackProducer { pub fn finish_at(&self, final_sequence: u64) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); - let track = guard.as_mut().ok_or(MoqError::Closed)?; - track.finish_at(moq_net::track::Position::group(final_sequence), None)?; + let raw = guard.as_mut().ok_or(MoqError::Closed)?; + raw.track + .finish_at(moq_net::track::Position::group(final_sequence), None)?; Ok(()) } } @@ -937,7 +1044,7 @@ mod metadata_tests { track.finish().unwrap(); } let state = broadcast.state.lock().unwrap(); - let kept = match &*state.as_ref().unwrap().children.0.lock().unwrap() { + let kept = match &*state.as_ref().unwrap().children.family.lock().unwrap() { Family::Open { kept, .. } => kept.len(), _ => unreachable!("the broadcast is still open"), }; diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index f4fc78b0371..1d0cc805c44 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -96,6 +96,23 @@ where handle } +/// The datagram `send` writes, read by a `recv_datagram` started first: a subscription +/// receives only datagrams sent after its first read. +async fn datagram_sent_by(consumer: &Arc, send: impl FnOnce() -> T) -> (T, MoqDatagram) { + let read = { + let consumer = consumer.clone(); + spawn_parked(async move { consumer.recv_datagram().await }).await + }; + let sent = send(); + let datagram = kio::clock::timeout(TIMEOUT, read) + .await + .expect("timed out waiting for a datagram") + .expect("datagram read panicked") + .unwrap() + .expect("expected a datagram"); + (sent, datagram) +} + /// Trust any certificate, for dialing a server with a generated one. fn insecure_tls() -> MoqClientTls { MoqClientTls { @@ -1244,6 +1261,7 @@ async fn raw_track_datagram_roundtrip() { let consumer = track.consume(None).unwrap(); let payload = b"hello datagram".to_vec(); + // A local reader receives what is sent after it was created, before its first read. let sequence = track .append_datagram(MoqFrame { payload: payload.clone(), @@ -2006,6 +2024,49 @@ async fn track_request_demand_survives_accept() { ); } +/// A requested track carries datagrams, even accepted once only its request handler keeps +/// the broadcast open. +#[tokio::test] +async fn requested_track_carries_datagrams() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let dynamic = broadcast.requests().unwrap(); + let consumer = broadcast.consume().unwrap(); + drop(broadcast); + let subscribe = tokio::spawn(async move { consumer.subscribe_track("events".into(), None).await }); + let request = kio::clock::timeout(TIMEOUT, dynamic.requested_track()) + .await + .expect("timed out waiting for the request") + .unwrap(); + let track = request.accept(Some(timed_info())).unwrap(); + let consumer = subscribe.await.unwrap().unwrap(); + let (sequence, datagram) = + datagram_sent_by(&consumer, || track.append_datagram(datagram_frame(b"beep")).unwrap()).await; + assert_eq!((datagram.sequence, datagram.payload), (sequence, b"beep".to_vec())); +} + +/// A track that finished before its first datagram read has none to come: its datagram +/// track finished with it and is no longer found, which reads as the end, not an error. +#[tokio::test] +async fn a_finished_track_reads_no_datagrams() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("events".into(), Some(timed_info())).unwrap(); + let consumer = broadcast + .consume() + .unwrap() + .subscribe_track("events".into(), None) + .await + .unwrap(); + track.finish().unwrap(); + let read = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) + .await + .expect("timed out waiting for the end"); + assert!( + matches!(read, Ok(None)), + "{:?}", + read.map(|datagram| datagram.map(|d| d.sequence)) + ); +} + #[tokio::test] async fn track_request_demand_ends_on_rejection() { let broadcast = MoqBroadcastProducer::new().unwrap(); @@ -2066,7 +2127,7 @@ async fn dynamic_track_request() { track.finish().unwrap(); } -#[tokio::test(start_paused = true)] +#[tokio::test] async fn raw_untimed_frames_and_datagrams_roundtrip() { let broadcast = MoqBroadcastProducer::new().unwrap(); let track = broadcast.publish_track("untimed".into(), None).unwrap(); @@ -2093,16 +2154,21 @@ async fn raw_untimed_frames_and_datagrams_roundtrip() { let frame = reader.read_frame().await.unwrap().unwrap(); assert_eq!(frame.payload, b"group"); assert_eq!(frame.timestamp, None); - track - .append_datagram(MoqFrame { - payload: b"datagram".to_vec(), - timestamp: None, - }) - .unwrap(); - let datagram = consumer.recv_datagram().await.unwrap().unwrap(); + let (_, datagram) = datagram_sent_by(&consumer, || { + track + .append_datagram(MoqFrame { + payload: b"datagram".to_vec(), + timestamp: None, + }) + .unwrap() + }) + .await; assert_eq!(datagram.payload, b"datagram"); assert_eq!(datagram.timestamp, None); track.finish().unwrap(); + // The datagrams end with the track, for readers from before and after the finish. + assert!(consumer.recv_datagram().await.unwrap().is_none()); + assert!(track.consume(None).unwrap().recv_datagram().await.unwrap().is_none()); } /// An explicitly timed raw track refuses frames and datagrams without a timestamp. @@ -2208,20 +2274,18 @@ async fn timestamps_are_exact() { value: 1, scale: 90_000, }; - track - .insert_datagram( - 7, - MoqFrame { - payload: b"datagram".to_vec(), - timestamp: Some(tick), - }, - ) - .unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) - .await - .unwrap() - .unwrap() - .unwrap(); + let (_, datagram) = datagram_sent_by(&consumer, || { + track + .insert_datagram( + 7, + MoqFrame { + payload: b"datagram".to_vec(), + timestamp: Some(tick), + }, + ) + .unwrap() + }) + .await; assert_eq!(datagram.sequence, 7); assert_eq!(datagram.timestamp, Some(tick)); @@ -5257,18 +5321,24 @@ async fn raw_track_group_order_commits_on_first_read() { .expect("expected a group"); assert_eq!(group.sequence(), 1); - // Datagrams never commit and keep working after a commitment. - let sequence = track + // A subscription subscribes to datagrams on its first read, so one sent before is not + // delivered. + track .append_datagram(MoqFrame { - payload: b"beep".to_vec(), + payload: b"early".to_vec(), timestamp: Some(us(0)), }) .unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) - .await - .expect("timed out waiting for a datagram") - .unwrap() - .expect("expected a datagram"); + // Datagrams never commit and keep working after a commitment. + let (sequence, datagram) = datagram_sent_by(&consumer, || { + track + .append_datagram(MoqFrame { + payload: b"beep".to_vec(), + timestamp: Some(us(0)), + }) + .unwrap() + }) + .await; assert_eq!(datagram.sequence, sequence); assert_eq!(datagram.payload, b"beep".to_vec()); } @@ -5313,12 +5383,8 @@ where }; assert!(!group_read.is_finished(), "group read should still be pending"); - let sequence = track.append_datagram(datagram_frame(b"beep")).unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) - .await - .expect("timed out waiting for datagram behind a pending group read") - .unwrap() - .expect("expected a datagram"); + let (sequence, datagram) = + datagram_sent_by(&consumer, || track.append_datagram(datagram_frame(b"beep")).unwrap()).await; assert_eq!(datagram.sequence, sequence); assert_eq!(datagram.payload, b"beep".to_vec()); assert!(!group_read.is_finished(), "group read should stay pending"); @@ -5369,12 +5435,10 @@ where async fn raw_track_insert_datagram_keeps_its_sequence() { let (_broadcast, track, consumer) = raw_track(); - track.insert_datagram(7, datagram_frame(b"seven")).unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) - .await - .expect("timed out waiting for the inserted datagram") - .unwrap() - .expect("expected a datagram"); + let ((), datagram) = datagram_sent_by(&consumer, || { + track.insert_datagram(7, datagram_frame(b"seven")).unwrap() + }) + .await; assert_eq!(datagram.sequence, 7); assert_eq!(datagram.payload, b"seven".to_vec()); assert_eq!(datagram.timestamp.map(micros), Some(1)); @@ -5473,12 +5537,10 @@ async fn raw_track_update_during_pending_group_still_reads_datagram() { }) .unwrap(); - let sequence = track.append_datagram(datagram_frame(b"after-update")).unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) - .await - .expect("timed out waiting for datagram after update") - .unwrap() - .expect("expected a datagram"); + let (sequence, datagram) = datagram_sent_by(&consumer, || { + track.append_datagram(datagram_frame(b"after-update")).unwrap() + }) + .await; assert_eq!(datagram.sequence, sequence); assert_eq!(datagram.payload, b"after-update".to_vec()); @@ -5506,12 +5568,10 @@ async fn raw_track_dropping_a_pending_group_read_does_not_cancel_datagrams() { Ok(_) => panic!("aborting the call should cancel only that future"), } - let sequence = track.append_datagram(datagram_frame(b"still-open")).unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) - .await - .expect("timed out waiting for datagram after a cancelled group read") - .unwrap() - .expect("expected a datagram"); + let (sequence, datagram) = datagram_sent_by(&consumer, || { + track.append_datagram(datagram_frame(b"still-open")).unwrap() + }) + .await; assert_eq!(datagram.sequence, sequence); track.write_frame(group_frame(b"group")).unwrap(); @@ -5677,7 +5737,11 @@ async fn raw_read_frame_skips_a_group_reset_as_old() { Some(moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI)), ) .unwrap(); - let consumer = MoqTrackConsumer::new(track.subscribe(None), crate::ffi::Background::default()); + let consumer = MoqTrackConsumer::new( + track.subscribe(None), + crate::consumer::Datagrams::Remote(broadcast.consume()), + crate::ffi::Background::default(), + ); let expired = track.append_group().unwrap(); // The read holds the group when the reset lands, as one arriving over the wire does. @@ -6252,6 +6316,115 @@ async fn roundtrip(media: &MoqMediaTrackProducer, track: &MoqMediaTrackConsumer, } } +/// A server publishing `path` and a client following it over default versions, which +/// negotiate lite-06: the server's broadcast, the client's view of it, the client's session, +/// and what keeps the server side alive. +async fn default_connection( + path: &str, +) -> ( + Arc, + Arc, + Arc, + impl Sized, +) { + let server_origin = MoqOriginProducer::new(MoqOriginConfig::default()); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + publish: Some(server_origin.clone()), + ..Default::default() + }) + .unwrap(); + let addr = kio::clock::timeout(TIMEOUT, server.listen()) + .await + .expect("listen timed out") + .expect("listen failed"); + let accepting = server.clone(); + let accept = tokio::spawn(async move { + let request = accepting.accept().await.unwrap().expect("a session"); + request.accept(None, None).await.expect("handshake failed") + }); + let broadcast = create_announced(&server_origin, path); + + let viewer = MoqOriginProducer::new(MoqOriginConfig::default()); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + consume: Some(viewer.clone()), + ..Default::default() + }) + .unwrap(); + let session = kio::clock::timeout(TIMEOUT, client.connect(format!("https://{addr}"))) + .await + .expect("connect timed out") + .expect("connect failed"); + let server_session = kio::clock::timeout(TIMEOUT, accept).await.unwrap().unwrap(); + let followed = viewer.consume().follow(path.into(), None).unwrap(); + let remote = kio::clock::timeout(TIMEOUT, followed.available()) + .await + .expect("timed out waiting for the broadcast") + .unwrap(); + (broadcast, remote, session, (server, server_origin, server_session)) +} + +/// Read one datagram from `consumer`, resending `track`'s until one arrives: the reader joins +/// the datagrams on its first read, which lands a moment later, and one sent before is gone. +async fn resend_until_read(track: &MoqTrackProducer, consumer: &Arc) -> MoqDatagram { + let read = { + let consumer = consumer.clone(); + spawn_parked(async move { consumer.recv_datagram().await }).await + }; + let send = async { + loop { + track + .append_datagram(MoqFrame { + payload: b"reading".to_vec(), + timestamp: Some(us(5_000)), + }) + .unwrap(); + kio::clock::sleep(Duration::from_millis(10)).await; + } + }; + tokio::select! { + read = kio::clock::timeout(TIMEOUT, read) => read.expect("timed out waiting for a datagram"), + _ = send => unreachable!(), + } + .expect("datagram read panicked") + .unwrap() + .expect("expected a datagram") +} + +/// A raw track's datagrams reach a remote reader over a default connection, which negotiates +/// lite-06: its subscription to the track carries the track's datagrams too. +#[tokio::test] +async fn raw_track_datagrams_cross_a_default_connection() { + let (broadcast, remote, session, _server) = default_connection("sensor").await; + let track = broadcast.publish_track("events".into(), Some(timed_info())).unwrap(); + let consumer = remote.subscribe_track("events".into(), None).await.unwrap(); + let datagram = resend_until_read(&track, &consumer).await; + assert_eq!(datagram.payload, b"reading"); + assert_eq!(datagram.timestamp.map(micros), Some(5_000)); + assert_eq!(session.state().version.as_deref(), Some("moq-lite-06")); +} + +/// So do a requested track's: its datagram track exists before the accept answers the +/// subscription that carries them. +#[tokio::test] +async fn requested_track_datagrams_cross_a_default_connection() { + let (broadcast, remote, session, _server) = default_connection("sensor").await; + let dynamic = broadcast.requests().unwrap(); + let subscribe = tokio::spawn(async move { remote.subscribe_track("events".into(), None).await }); + let request = kio::clock::timeout(TIMEOUT, dynamic.requested_track()) + .await + .expect("timed out waiting for the request") + .unwrap(); + let track = request.accept(Some(timed_info())).unwrap(); + let consumer = kio::clock::timeout(TIMEOUT, subscribe).await.unwrap().unwrap().unwrap(); + let datagram = resend_until_read(&track, &consumer).await; + assert_eq!(datagram.payload, b"reading"); + assert_eq!(session.state().version.as_deref(), Some("moq-lite-06")); +} + /// A replacement session from the same publisher re-announces every route while the old one /// is still open, as a GOAWAY drain does. Under another instance that is another instance: the /// next `available()` hands it out, and reading it carries on past the old session's close. diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 7485e37db75..2b831500649 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -1703,14 +1703,10 @@ fn is_cache_miss(err: &moq_net::Error) -> bool { matches!( err, moq_net::Error::NotFound - | moq_net::Error::NotFetchable | moq_net::Error::Old | moq_net::Error::Evicted | moq_net::Error::Stream( - moq_net::StreamError::NotFound - | moq_net::StreamError::NotFetchable - | moq_net::StreamError::Old - | moq_net::StreamError::Evicted + moq_net::StreamError::NotFound | moq_net::StreamError::Old | moq_net::StreamError::Evicted ) ) } @@ -1870,12 +1866,7 @@ mod tests { #[test] fn a_cache_miss_that_crossed_a_session_is_still_a_cache_miss() { - for local in [ - moq_net::Error::NotFound, - moq_net::Error::NotFetchable, - moq_net::Error::Old, - moq_net::Error::Evicted, - ] { + for local in [moq_net::Error::NotFound, moq_net::Error::Old, moq_net::Error::Evicted] { assert!(is_cache_miss(&local), "{local:?} locally"); let lite = over_lite(&local); assert!(is_cache_miss(&lite), "{local:?} over lite ({lite:?})"); diff --git a/rs/moq-json/src/snapshot/consumer.rs b/rs/moq-json/src/snapshot/consumer.rs index a59fc8deb74..d8f974dec2a 100644 --- a/rs/moq-json/src/snapshot/consumer.rs +++ b/rs/moq-json/src/snapshot/consumer.rs @@ -92,8 +92,8 @@ impl Consumer { fn poll_read(&mut self, waiter: &kio::Waiter, skip: bool) -> Poll>>> { if skip { // Jump to the newest group the reader already has; a newer one supersedes the rest. - // Taking groups, not the track's largest position, ignores datagrams (which share - // the sequence space) and stays inside any cap the caller set on the reader. + // Taking groups, not the track's largest position, stays inside any cap the caller + // set on the reader. while let Poll::Ready(Some(group)) = self.track.poll_next_group(waiter)? { self.group = Some(group); self.frames_read = 0; @@ -299,27 +299,4 @@ mod timestamps { assert_eq!(value.value["n"], 4, "latest crossed the cap"); assert!(consumer.poll_next(&waiter).is_pending()); } - - #[test] - fn latest_ignores_datagrams() { - let track = moq_net::broadcast::Info::new() - .produce() - .create_track("data", moq_net::track::Info::default()) - .unwrap(); - let mut raw = track.clone(); - let subscriber = track.subscribe(None); - let mut producer = crate::snapshot::Producer::new(track, crate::snapshot::Config::default()); - let mut consumer = Consumer::::new(subscriber, Default::default()); - let waiter = kio::Waiter::noop(); - for n in 0..2 { - producer.update(&serde_json::json!({ "n": n })).unwrap(); - producer.cut().unwrap(); - } - // A datagram shares the sequence space, so it outnumbers every group. - raw.append_datagram(None, &b"noise"[..]).unwrap(); - let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) else { - panic!("a datagram hid the latest state") - }; - assert_eq!(value.value["n"], 1); - } } diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 99eedc5a567..3ece960bddd 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,25 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Datagrams are their own track kind ([#7752](https://github.com/moq-dev/moq/pull/7752)) + +A stream track no longer carries datagrams. `track::Producer::{append_datagram, insert_datagram}` and `track::Subscriber/Ordered::{poll_recv_datagram, recv_datagram}` are removed; a datagram track is created and read through the broadcast, in a namespace apart from stream tracks, with its own sequence counter. + +```rust +// Before +let mut track = broadcast.create_track("audio", info)?; +track.append_datagram(timestamp, payload)?; +let mut sub = consumer.track("audio")?.subscribe(None).await?; +let datagram = sub.recv_datagram().await?; +// After +let mut audio = broadcast.create_datagrams("audio", datagram::Info::default().with_timescale(scale))?; +audio.append(timestamp, payload)?; +let mut reader = consumer.datagrams("audio")?; +let datagram = reader.recv().await?; +``` + +A `datagram::Consumer` is live from when it is created, has a priority (`set_priority`) and no ranges, and ends with `Ok(None)` on the producer's `finish`. A relay forwarding datagrams keeps their sequence with `datagram::Producer::insert`. A `broadcast::Requests` handler serves requested datagram tracks through `Requests::datagrams()`. `Error::NotFetchable` and `StreamError::NotFetchable` are removed, since no group is ever a datagram. Stats count a datagram in `datagrams` and `bytes` only, not as a group, and `stats::Content` has no `datagrams`. + ### `group::on_track` is `track::Info::rescale` ([#7702](https://github.com/moq-dev/moq/pull/7702)) The free function `moq_net::group::on_track(timestamp, timescale)` is removed. diff --git a/rs/moq-net/benches/ranges.rs b/rs/moq-net/benches/ranges.rs index 799f6987719..28392ab8189 100644 --- a/rs/moq-net/benches/ranges.rs +++ b/rs/moq-net/benches/ranges.rs @@ -19,11 +19,8 @@ //! - `ranges_time_gaps`: time ranges each ending between two groups of a sparsely numbered //! history, so each end waits on the missing sequences of a gap of its own, beside one time //! end past the live edge whose cap moves twice per group, as in `ranges_time_cap`. -//! - `ranges_datagram`: timed ranges none of which admits the datagram that lands, so the -//! reader judges it against all of them. //! - `ranges_frames`: a lite-07 publisher's frame bounds, each range starting and ending -//! partway through its own group, looked up for one group as a served group or datagram -//! does. +//! partway through its own group, looked up for one group as a served group does. //! //! Run with `cargo bench -p moq-net --features fuzz --bench ranges`. @@ -300,52 +297,6 @@ impl TimeGaps { } } -/// One reader judging datagrams no range admits; see `ranges_datagram`. -pub(crate) struct Datagrams { - _broadcast: broadcast::Producer, - track: track::Producer, - reader: track::Subscriber, - at: u64, -} - -impl Datagrams { - pub(crate) fn new(ranges: u64) -> Self { - let broadcast = broadcast::Info::default().produce(); - let mut track = broadcast.create_track("bench", timed()).unwrap(); - // Each range presents from a time of its own through group 0, which every datagram - // after the first passes. - let subscription = track::Subscription::default() - .with_delay(track::Delay::default().with_max(BUDGET)) - .with_ranges((0..ranges).map(|i| track::Range { - start: track::Anchor::Time(millis(i)), - end: Some(track::Anchor::Absolute(1)), - })); - let mut reader = track.subscribe(subscription); - // Group 0 is in every range, so only the first datagram is admitted. - track.append_datagram(millis(LIVE), Bytes::from_static(b"x")).unwrap(); - let waiter = kio::Waiter::noop(); - assert!(matches!(reader.poll_recv_datagram(&waiter), Poll::Ready(Ok(Some(_))))); - Self { - _broadcast: broadcast, - track, - reader, - at: LIVE + 1, - } - } - - pub(crate) fn step(&mut self) { - self.track - .append_datagram(millis(self.at), Bytes::from_static(b"x")) - .unwrap(); - self.at += 1; - let waiter = kio::Waiter::noop(); - assert!( - self.reader.poll_recv_datagram(&waiter).is_pending(), - "no range admits the datagram" - ); - } -} - /// A lite-07 publisher's frame bounds; see `ranges_frames`. pub(crate) struct Frames { bounds: moq_net::fuzz::FrameBounds, @@ -401,10 +352,6 @@ fn bench_time_gaps(c: &mut Criterion) { sweep(c, "ranges_time_gaps", TimeGaps::new, TimeGaps::step); } -fn bench_datagram(c: &mut Criterion) { - sweep(c, "ranges_datagram", Datagrams::new, Datagrams::step); -} - fn bench_frames(c: &mut Criterion) { sweep(c, "ranges_frames", Frames::new, Frames::step); } @@ -416,7 +363,6 @@ criterion_group!( bench_fills_out, bench_sparse, bench_time_gaps, - bench_datagram, bench_frames ); criterion_main!(benches); diff --git a/rs/moq-net/benches/session/mod.rs b/rs/moq-net/benches/session/mod.rs index d8554982cdc..73897e7463d 100644 --- a/rs/moq-net/benches/session/mod.rs +++ b/rs/moq-net/benches/session/mod.rs @@ -29,9 +29,10 @@ //! `session_delivery_prefixes` holds silent broadcasts open on one viewer session beside the //! one it watches, against a fixed route table. Each is an announce request the session //! keeps, so a per-group cost that grows with them shows as a slope over `follow`. -//! `session_delivery_datagram` writes datagrams instead of groups: one per track per round -//! across publisher and viewer axes, then larger payloads and cadences. Throughput counts -//! delivered datagrams, each checked for its sequence and payload, so a lost one fails. +//! `session_delivery_datagram` writes to a datagram track per broadcast instead of groups: +//! one datagram per track per round across publisher and viewer axes, then larger payloads +//! and cadences, on the lite versions (moq-transport carries no datagram tracks). Throughput +//! counts delivered datagrams, each checked for its sequence and payload, so a lost one fails. //! `SESSION_EXPIRY_ONLY=1` uses the standalone 30s-expiry pool and reports groups //! written plus charged cache bytes every 10s during long profiling runs. Each round //! advances the cache clock by a second, so the expiry window spans 30 rounds. @@ -90,7 +91,7 @@ use criterion::{BenchmarkId, Criterion, Throughput}; pub(crate) use harness::MockConnectOptions; use harness::{MockPair, connect_mock}; use mock::{MockSession, MockStats}; -use moq_net::{Hop, Timestamp, Version, broadcast, cache, group, origin, track}; +use moq_net::{Hop, Timestamp, Version, broadcast, cache, datagram, group, origin, track}; /// A millisecond track: the frames written here carry timestamps. fn timed() -> track::Info { @@ -109,6 +110,10 @@ const DATAGRAM_PAYLOADS: usize = 16; const TRACK: &str = "video"; +/// The datagram track's name. Lite-05/06 carry a name's stream and datagram tracks on one +/// subscription, so a name of its own keeps the stream rounds from carrying datagrams. +const DATAGRAMS: &str = "audio"; + /// Lite 06 is what the dash session and cluster peers negotiate today. const DASH_VERSIONS: [&str; 2] = ["moq-lite-06", "moq-transport-22"]; @@ -157,8 +162,8 @@ struct Shape { compress: bool, /// Read each group via a fresh FETCH rather than a live subscription. fetch: bool, - /// Deliver each round as `burst` datagrams of `frame` bytes per watched track instead of - /// groups. + /// Deliver each round as `burst` datagrams of `frame` bytes per watched broadcast's + /// datagram track instead of groups. datagrams: bool, } @@ -201,6 +206,11 @@ impl Shape { self.viewers * self.watch * self.burst } + /// Whether `version` carries this shape: moq-transport has no datagram tracks. + fn runs_on(&self, version: &str) -> bool { + !self.datagrams || version.parse::().unwrap().is_lite() + } + /// The broadcasts viewer `viewer` watches: a contiguous window starting at /// `viewer * watch`, so viewers spread over the broadcasts before any doubles up. fn watching(&self, viewer: usize) -> impl Iterator { @@ -577,6 +587,8 @@ struct Cluster { relays: Vec, /// One track per broadcast, indexed like [`path`]. tracks: Vec, + /// One datagram track per broadcast, indexed like [`path`], for datagram shapes only. + datagrams: Vec, _broadcasts: Vec, _origins: Vec, _pairs: Vec, @@ -595,6 +607,7 @@ impl Cluster { discovers_all: shape.follow == 0, relays: Vec::new(), tracks: Vec::new(), + datagrams: Vec::new(), _broadcasts: Vec::new(), _origins: Vec::new(), _pairs: Vec::new(), @@ -640,6 +653,11 @@ impl Cluster { .create_track(TRACK, timed().with_compress(shape.compress)) .unwrap(), ); + if shape.datagrams { + let info = datagram::Info::default().with_timescale(moq_net::Timescale::MILLI); + this.datagrams + .push(broadcast.create_datagrams(DATAGRAMS, info).unwrap()); + } this._broadcasts.push(broadcast); } @@ -677,6 +695,7 @@ impl Cluster { Viewer { following: Vec::new(), subscribers: Vec::new(), + datagrams: Vec::new(), fetches: Vec::new(), blocked: 0, _pair: pair, @@ -690,6 +709,8 @@ struct Viewer { /// Announcements watched without a subscription, one announce request each. following: Vec, subscribers: Vec, + /// Datagram track consumers, in the order of the broadcasts watched. + datagrams: Vec, fetches: Vec<(usize, track::Consumer)>, blocked: usize, _pair: Pair, @@ -711,6 +732,21 @@ impl Viewer { self.following.push(announced); } + /// Resolve `path` and consume its datagram track, once its publisher answers. + async fn watch_datagrams(&mut self, path: &str) { + let broadcast = self + .origin + .consume() + .follow(path, None) + .unwrap() + .available() + .await + .unwrap(); + let consumer = broadcast.datagrams(DATAGRAMS).unwrap(); + consumer.info().await.unwrap(); + self.datagrams.push(consumer); + } + /// Resolve `path` and subscribe to each of its `tracks`. async fn watch(&mut self, path: &str, tracks: &[impl AsRef], subscription: Option) { let broadcast = self @@ -782,7 +818,9 @@ impl Room { // publisher it watches whenever there is more than one. let mut viewer = cluster.join((viewer_index + 1) % shape.relays, false).await; for broadcast in broadcasts { - if shape.fetch { + if shape.datagrams { + viewer.watch_datagrams(&path(broadcast)).await; + } else if shape.fetch { let source = viewer .origin .consume() @@ -847,9 +885,7 @@ impl Room { .map(|viewer| viewer._pair.server_transport.blocked_writes()) .sum(); if shape.datagrams { - // A group first: moq-transport binds a subscription's alias with it and drops a - // datagram that lands before then. - room.group_round().await; + room.sync_datagrams().await; } // Warm every path so the timed rounds skip first-group setup. for _ in 0..shape.group_frames / shape.frames() { @@ -983,27 +1019,78 @@ impl Room { } } + /// Write the next datagram into every watched track. + fn write_datagrams(&mut self) { + for &broadcast in &self.watched { + let sequence = self.next[broadcast]; + let payload = self.payloads[sequence as usize % DATAGRAM_PAYLOADS].clone(); + let track = &mut self.cluster.datagrams[broadcast]; + assert_eq!(track.append(Timestamp::ZERO, payload).unwrap(), sequence); + self.next[broadcast] = sequence + 1; + } + } + + /// Line every viewer up on the same next sequence before the strict rounds. + /// + /// A consumer is live once its track's info arrives, but on lite-05/06 the SUBSCRIBE + /// that carries the datagrams follows that answer, so a datagram may still miss it. Write + /// until every viewer has read one, then a last one each viewer reads through: the memory + /// transport keeps a subscription's datagrams in order, so nothing earlier arrives after. + async fn sync_datagrams(&mut self) { + let waiter = kio::Waiter::noop(); + let mut reached = vec![false; self.shape.viewers * self.shape.watch]; + for attempt in 0.. { + if !reached.contains(&false) { + break; + } + // Fail rather than spin: a viewer the datagrams never reach is a broken path. + assert!(attempt < 1024, "datagrams never reached every viewer"); + self.write_datagrams(); + tokio::task::yield_now().await; + let consumers = self.viewers.iter_mut().flat_map(|viewer| &mut viewer.datagrams); + for (consumer, reached) in consumers.zip(&mut reached) { + while let std::task::Poll::Ready(Ok(Some(_))) = consumer.poll_recv(&waiter) { + *reached = true; + } + } + } + self.write_datagrams(); + for (index, viewer) in self.viewers.iter_mut().enumerate() { + for (consumer, broadcast) in viewer.datagrams.iter_mut().zip(self.shape.watching(index)) { + let last = self.next[broadcast] - 1; + // Bounded as above: a last datagram that never arrives fails the run. + let mut attempts = 0; + loop { + match consumer.poll_recv(&waiter) { + std::task::Poll::Ready(Ok(Some(datagram))) if datagram.sequence == last => break, + std::task::Poll::Ready(Ok(Some(_))) => continue, + std::task::Poll::Ready(end) => panic!("the datagram track ended: {end:?}"), + std::task::Poll::Pending => { + attempts += 1; + assert!(attempts < 1024, "the last datagram never reached a viewer"); + tokio::task::yield_now().await; + } + } + } + } + } + } + /// `burst` datagrams into every watched track, then every one out to each viewer /// watching it. The memory transport loses none below its queue bound, so each must /// arrive in order with its payload: a dropped datagram fails rather than looking /// cheaper. async fn datagram_round(&mut self) { - for &broadcast in &self.watched { - let track = &mut self.cluster.tracks[broadcast]; - for _ in 0..self.shape.burst { - let sequence = self.next[broadcast]; - let payload = self.payloads[sequence as usize % DATAGRAM_PAYLOADS].clone(); - assert_eq!(track.append_datagram(Timestamp::ZERO, payload).unwrap(), sequence); - self.next[broadcast] = sequence + 1; - } + for _ in 0..self.shape.burst { + self.write_datagrams(); } let burst = self.shape.burst as u64; let mut delivered = 0; for (index, viewer) in self.viewers.iter_mut().enumerate() { - for (subscriber, broadcast) in viewer.subscribers.iter_mut().zip(self.shape.watching(index)) { + for (consumer, broadcast) in viewer.datagrams.iter_mut().zip(self.shape.watching(index)) { let first = self.next[broadcast] - burst; for sequence in first..first + burst { - let datagram = subscriber.recv_datagram().await.unwrap().expect("track ended"); + let datagram = consumer.recv().await.unwrap().expect("track ended"); assert_eq!(datagram.sequence, sequence, "datagram lost or reordered"); assert_eq!( datagram.payload, @@ -1098,7 +1185,7 @@ fn delivery(c: &mut Criterion, name: &str, shapes: impl IntoIterator }; let mut group = c.benchmark_group(format!("{}session_delivery_{name}{cpu}", B::PREFIX)); for version in VERSIONS { - for shape in &shapes { + for shape in shapes.iter().filter(|shape| shape.runs_on(version)) { // Built on first call: Criterion only calls a routine its filter // selects, and calls it again for every sample. let mut room = None; @@ -1139,7 +1226,7 @@ pub(crate) fn group_cost(c: &mut Criterion) { /// Datagram delivery over independent publisher and viewer axes at a small payload and one /// datagram per track per round, then payload size and cadence at 16 publishers and 16 -/// viewers. 64 per round fills a track's datagram send buffer exactly. +/// viewers. 64 per round fills what a datagram track holds for an unread consumer exactly. fn datagram_shapes() -> impl Iterator { let base = Shape { datagrams: true, @@ -1503,7 +1590,7 @@ fn allocations() { .flat_map(|shape| [false, true].map(|paced| Shape { paced, ..shape })); let shapes: Vec<_> = groups.chain(datagram_shapes()).collect(); for version in VERSIONS { - for &shape in &shapes { + for &shape in shapes.iter().filter(|shape| shape.runs_on(version)) { let mut room = rt.block_on(Room::::new(version, shape)); room.instructions = Instructions::open(); ALLOCATIONS.store(0, Ordering::Relaxed); diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index df2c7dd5604..c9daf63bf15 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -93,11 +93,6 @@ //! cached, and the runs it skipped: a widening costs the runs it re-offers, so a scan of the //! cache or the audience shows up as a slope. //! -//! `track_datagram_pressure` appends datagrams across tracks sharing a bounded pool, each -//! holding a backlog for a reader that never reads, with small plain payloads and with -//! compressed zeros that inflate far past their wire size: the pool bills both forms, so the -//! retained bytes stay near capacity and the append cost stays flat over tracks and backlog. -//! //! `track_time_end` lands groups one at a time behind a sequence that never arrives, each //! drained by readers whose range ends at a time no group reaches, swept over the groups and //! the readers: an unsettled time end walks on from where it stopped, so a rescan of the @@ -1085,100 +1080,6 @@ fn bench_range_serving(c: &mut Criterion) { group.finish(); } -/// Late joins scan a bounded datagram ring; sweep both the backlog and the joining audience. -fn bench_datagram_subscriber_join(c: &mut Criterion) { - let mut group = c.benchmark_group("track_datagram_subscriber_join"); - for buffered in [0, 1, 8, 64] { - for join in [1, 8, 64] { - group.throughput(Throughput::Elements(join as u64)); - group.bench_function(BenchmarkId::new(format!("{buffered}d"), join), |b| { - b.iter_batched( - || { - let broadcast = broadcast::Info::default().produce(); - let mut track = broadcast.create_track("bench", timed()).unwrap(); - for millis in 0..buffered { - track - .append_datagram(Timestamp::from_millis(millis).unwrap(), b"x".as_slice()) - .unwrap(); - } - (broadcast, track) - }, - |(broadcast, track)| { - let preferences = track::Subscription::default() - .with_delay(track::Delay::default().with_max(Duration::from_millis(5))); - let viewers: Vec<_> = (0..join).map(|_| track.subscribe(preferences.clone())).collect(); - (broadcast, track, viewers) - }, - BatchSize::SmallInput, - ); - }); - } - } - group.finish(); -} - -/// Datagram payloads for `track_datagram_pressure`: a small plain one, and the largest -/// payload of zeros on a compressed track, which inflates far past its DEFLATE form. -const DATAGRAM_PAYLOADS: [(&str, usize, bool); 2] = [("plain", PAYLOAD, false), ("zeros", u16::MAX as usize, true)]; - -/// Tracks sharing one bounded pool, each holding `queued` datagrams for a reader that never -/// reads, while one datagram is appended to the next track and read by its fast reader. -/// -/// The pool bills every buffered datagram, so what the slow readers retain stays near the -/// pool's capacity however many tracks or queued datagrams there are (printed per case), and -/// appending must cost the same: a walk over the other tracks or the backlog shows as a slope. -fn bench_datagram_pressure(c: &mut Criterion) { - let mut group = c.benchmark_group("track_datagram_pressure"); - group.throughput(Throughput::Elements(1)); - for (name, size, compress) in DATAGRAM_PAYLOADS { - let payload = Bytes::from(vec![0u8; size]); - for tracks in [1, 16, 64] { - for queued in [1, 8, 64] { - let mut info = broadcast::Info::default(); - info.pool = cache::Pool::new(cache::Config::default().with_capacity(CACHE_CAPACITY)); - let pool = info.pool.clone(); - let broadcast = broadcast::Producer::new(info); - let mut held: Vec<_> = (0..tracks) - .map(|index| { - let mut track = broadcast - .create_track(format!("bench{index}").as_str(), timed().with_compress(compress)) - .unwrap(); - let slow = track.subscribe(None); - let fast = track.subscribe(None); - for _ in 0..queued { - track.append_datagram(Timestamp::ZERO, payload.clone()).unwrap(); - } - (track, slow, fast) - }) - .collect(); - let waiter = kio::Waiter::noop(); - for (_, _, fast) in &mut held { - while let Poll::Ready(Ok(Some(_))) = fast.poll_recv_datagram(&waiter) {} - } - eprintln!( - "track_datagram_pressure/{name}/{tracks}t/{queued}q: {} bytes retained", - pool.used() - ); - - let mut next = 0; - group.bench_function(BenchmarkId::new(format!("{name}/{tracks}t"), queued), |b| { - b.iter(|| { - let (track, _, fast) = &mut held[next % tracks]; - next += 1; - track.append_datagram(Timestamp::ZERO, payload.clone()).unwrap(); - let Poll::Ready(Ok(Some(datagram))) = fast.poll_recv_datagram(&waiter) else { - panic!("the fast reader takes each datagram"); - }; - black_box(datagram); - }); - }); - drop((held, broadcast)); - } - } - } - group.finish(); -} - /// Queues a parked read as woken, so the bench visits only what an append wakes. struct Woken { read: usize, @@ -2208,8 +2109,6 @@ criterion_group!( bench_subscription_ranges, bench_range_serving, bench_subscriber_preferences, - bench_datagram_subscriber_join, - bench_datagram_pressure, bench_parked_read, bench_sparse_read, bench_sparse_poll, diff --git a/rs/moq-net/src/coding/codes.rs b/rs/moq-net/src/coding/codes.rs index 6f2495098dd..59f74bf43dd 100644 --- a/rs/moq-net/src/coding/codes.rs +++ b/rs/moq-net/src/coding/codes.rs @@ -51,17 +51,11 @@ impl StreamCodes for lite::Version { } fn encode_stream_code(&self, err: &StreamError) -> u32 { - match err { - StreamError::NotFetchable if !self.has_not_fetchable() => StreamError::NotFound.to_code(), - err => err.to_code(), - } + err.to_code() } fn decode_stream_code(&self, code: u32) -> StreamError { - match StreamError::from_code(code) { - StreamError::NotFetchable if !self.has_not_fetchable() => StreamError::Unknown(code), - err => err, - } + StreamError::from_code(code) } } @@ -124,32 +118,6 @@ mod tests { assert_eq!(ietf.decode_stream_code(0x4), StreamError::GoingAway); } - /// NOT_FETCHABLE is lite-07's: an earlier peer is told NOT_FOUND, the miss it would - /// have been told before, and is not credited with a code its version never assigned. - #[test] - fn not_fetchable_is_lite_07s() { - for version in [lite::Version::Lite05, lite::Version::Lite06] { - assert_eq!( - version.encode_stream_code(&StreamError::NotFetchable), - StreamError::NotFound.to_code(), - "{version}" - ); - assert_eq!( - version.decode_stream_code(0x3a), - StreamError::Unknown(0x3a), - "{version}" - ); - } - - let version = lite::Version::Lite07; - assert_eq!(version.encode_stream_code(&StreamError::NotFetchable), 0x3a); - assert_eq!(version.decode_stream_code(0x3a), StreamError::NotFetchable); - assert_eq!( - version.decode_stream_code(StreamError::NotFound.to_code()), - StreamError::NotFound - ); - } - /// A dropped [`Writer`](super::Writer) resets with a cancellation, and `Drop` cannot /// carry the bound that would reach the negotiated registry. It does not need one only /// while every registry we speak agrees on the value, so pin that. diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index 0f3e1cd2117..ca72e858301 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -210,11 +210,6 @@ pub enum StreamError { #[error("not found")] NotFound, - /// A FETCH reached a datagram, which is never cached. Sent on moq-lite-07 - /// and later; an earlier version sends [`NotFound`](Self::NotFound) instead. - #[error("not fetchable")] - NotFetchable, - /// The broadcast is neither announced nor served, so there is no route to it. #[error("unroutable")] Unroutable, @@ -283,7 +278,6 @@ impl StreamError { Self::WrongSize => 0x37, Self::FrameTooLarge => 0x38, Self::TimestampMismatch => 0x39, - Self::NotFetchable => 0x3a, Self::Unauthorized => 0x3b, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, @@ -316,7 +310,6 @@ impl StreamError { 0x37 => Self::WrongSize, 0x38 => Self::FrameTooLarge, 0x39 => Self::TimestampMismatch, - 0x3a => Self::NotFetchable, 0x3b => Self::Unauthorized, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), @@ -418,10 +411,6 @@ pub enum Error { #[error("not found")] NotFound, - /// A FETCH reached a datagram, which is never cached. - #[error("not fetchable")] - NotFetchable, - /// A joining FETCH named a request that is not an active subscription. #[error("invalid joining request ID")] InvalidJoiningRequestId, @@ -678,7 +667,6 @@ impl From<&Error> for SessionError { | Error::Evicted | Error::Lagged | Error::NotFound - | Error::NotFetchable | Error::InvalidRange | Error::InvalidJoiningRequestId | Error::Unroutable @@ -713,7 +701,6 @@ impl From<&Error> for StreamError { Error::Evicted => Self::Evicted, Error::Lagged => Self::TooFarBehind, Error::NotFound => Self::NotFound, - Error::NotFetchable => Self::NotFetchable, Error::Unroutable => Self::Unroutable, Error::WrongSize => Self::WrongSize, Error::FrameTooLarge => Self::FrameTooLarge, @@ -856,7 +843,6 @@ mod tests { (StreamError::WrongSize, 0x37), (StreamError::FrameTooLarge, 0x38), (StreamError::TimestampMismatch, 0x39), - (StreamError::NotFetchable, 0x3a), (StreamError::Unauthorized, 0x3b), (StreamError::App(7), 0x40 + 7), ] { @@ -893,12 +879,9 @@ mod tests { let relayed = StreamError::from(&Error::from(StreamError::from_code(0x3))); assert_eq!(relayed.to_code(), 0x3); - // A datagram reached by a FETCH keeps its own code rather than reading as a plain miss. - assert_eq!(StreamError::from(&Error::NotFetchable), StreamError::NotFetchable); - // Registered stream codes survive the hop unchanged. for code in [ - 0x0, 0x1, 0x2, 0x4, 0x5, 0x12, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, + 0x0, 0x1, 0x2, 0x4, 0x5, 0x12, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3b, ] { let relayed = StreamError::from(&Error::from(StreamError::from_code(code))); assert_eq!(relayed.to_code(), code, "stream {code:#x} changed across a relay"); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 8872219ed52..23c74b81e5a 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -103,7 +103,7 @@ impl FrameBounds { Self(crate::lite::FramesBench::new(ranges)) } - /// The first and last frame served of `group`, as a served group or datagram looks + /// The first and last frame served of `group`, as a served group looks /// them up. pub fn serve(&self, group: u64) -> (u64, Option) { self.0.serve(group) @@ -137,7 +137,7 @@ const LITE_VERSIONS: &[lite::Version] = &[ const IETF_VERSIONS: &[ietf::Version] = &[ietf::Version::Draft18, ietf::Version::Draft20, ietf::Version::Draft22]; /// How many types [`lite_wire`] dispatches over. -const LITE_KINDS: u8 = 30; +const LITE_KINDS: u8 = 33; /// How many types [`ietf_wire`] dispatches over. const IETF_KINDS: u8 = 27; @@ -238,49 +238,23 @@ fn ietf_paths(data: &[u8], version: ietf::Version) { /// [`roundtrip`] for a datagram body, which runs to the end of its buffer. fn datagram(data: &[u8], version: lite::Version) -> bool { - // The body doesn't say whether it carries a timestamp or an inflated size, whether the - // session negotiated the `ietf` extension, or which draft its track came from, so try - // every reading. - let negotiated: &[bool] = match version.has_ietf() { - true => &[false, true], - false => &[false], - }; + // The body doesn't say whether it carries a timestamp, so try both readings. let mut any = false; - for &negotiated in negotiated { - for timescale in [Some(crate::Timescale::MILLI), None] { - for ietf in [ - None, - Some(ietf::Version::Draft18), - Some(ietf::Version::Draft20), - Some(ietf::Version::Draft22), - ] { - for compressed in [false, true] { - let framing = lite::Framing { - timescale, - ietf, - compressed, - }; - let Ok(decoded) = - lite::Datagram::decode(Bytes::copy_from_slice(data), version, negotiated, |_| framing) - else { - continue; - }; - any = true; - let Some(decoded) = decoded else { - continue; - }; - - let Ok(first) = decoded.encode_bytes(version) else { - continue; - }; - - let echo = lite::Datagram::decode(first, version, negotiated, |_| framing) - .expect("could not decode our own encoding") - .expect("encoded group decoded as padding"); - assert_eq!(echo, decoded, "datagram did not survive a round trip"); - } - } - } + for timed in [true, false] { + let Ok(decoded) = lite::Datagram::decode(Bytes::copy_from_slice(data), version, |_| timed) else { + continue; + }; + any = true; + let Some(decoded) = decoded else { + continue; + }; + let Ok(first) = decoded.encode_bytes(version) else { + continue; + }; + let echo = lite::Datagram::decode(first, version, |_| timed) + .expect("could not decode our own encoding") + .expect("encoded datagram decoded as padding"); + assert_eq!(echo, decoded, "datagram did not survive a round trip"); } any } @@ -328,6 +302,9 @@ pub fn lite_wire(data: &[u8]) -> bool { 27 => roundtrip::(rest, &mut Vec::new(), version), 28 => roundtrip::(rest, &mut Vec::new(), crate::Version::Lite(version)), 29 => roundtrip::(rest, &mut Vec::new(), crate::Version::Lite(version)), + 30 => roundtrip::, _>(rest, &mut Vec::new(), version), + 31 => roundtrip::(rest, &mut Vec::new(), version), + 32 => roundtrip::(rest, &mut Vec::new(), version), _ => unreachable!("kind is taken modulo LITE_KINDS"), } } @@ -655,9 +632,7 @@ impl LiteSample { subscribe: 3, sequence: 1_234, timestamp: Some(1_234_567_890), - inflated: None, payload: bytes::Bytes::new(), - ietf: None, } .encode(w, version) .unwrap(), @@ -689,15 +664,9 @@ impl LiteSample { } Self::Group => lite::Group::decode(r, version).map(|_| 1).unwrap(), Self::Subscribe => lite::Subscribe::decode(r, version).map(|_| 1).unwrap(), - Self::Datagram => { - lite::Datagram::decode(bytes::Bytes::copy_from_slice(data), version, false, |_| lite::Framing { - timescale: Some(crate::Timescale::MILLI), - ietf: None, - compressed: false, - }) + Self::Datagram => lite::Datagram::decode(bytes::Bytes::copy_from_slice(data), version, |_| true) .map(|_| 1) - .unwrap() - } + .unwrap(), Self::Setup => lite::Setup::decode(r, version).map(|_| 1).unwrap(), } } diff --git a/rs/moq-net/src/ietf/datagram.rs b/rs/moq-net/src/ietf/datagram.rs index 024534d69cc..92cca7711ba 100644 --- a/rs/moq-net/src/ietf/datagram.rs +++ b/rs/moq-net/src/ietf/datagram.rs @@ -73,29 +73,6 @@ impl ObjectDatagram { } } -impl crate::model::datagram::Wire { - /// Whether a datagram of this shape is defined, as ingress checks one. Egress resolves - /// the Object ID and priority each draft needs. - pub(crate) fn defined(&self) -> bool { - let shape = ObjectDatagram { - track_alias: 0, - group_id: 0, - object_id: self.object_id, - publisher_priority: self.priority, - end_of_group: self.end_of_group, - properties: self.properties.block.is_some().then(Vec::new), - body: match self.status { - Some(status) => DatagramBody::Status(status), - None => DatagramBody::Payload(Bytes::new()), - }, - }; - ObjectDatagram::valid(shape.kind()) - && self.status.is_none_or(|status| { - super::object::status_defined(status) && (self.properties.block.is_none() || status == 0) - }) - } -} - impl Encode for ObjectDatagram { fn encode(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { let kind = self.kind(); diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index add358719d2..fbf3ecbd519 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -219,12 +219,7 @@ pub(crate) mod request { | Error::Session(SessionError::Version | SessionError::Unsupported) => { return NOT_SUPPORTED; } - // moq-transport has no code for a FETCH that reached a datagram, which is never - // cached, so it is a group that is not there. NOT_SUPPORTED would claim the peer - // does not implement FETCH at all. - Error::NotFound - | Error::NotFetchable - | Error::Stream(StreamError::NotFound | StreamError::NotFetchable) => return DOES_NOT_EXIST, + Error::NotFound | Error::Stream(StreamError::NotFound) => return DOES_NOT_EXIST, Error::InvalidRange => invalid_range(kind), Error::InvalidJoiningRequestId => invalid_joining_request_id(kind, version), // A path with no route is one we will not carry. A subscriber that asked for it @@ -292,7 +287,7 @@ pub(crate) mod request { /// Every error a rejection distinguishes, plus one it does not, so the checks below cover /// the whole registry rather than the variants someone remembered. A new arm in /// [`to_code`] belongs here. - const EVERY_ERROR: [Error; 15] = [ + const EVERY_ERROR: [Error; 14] = [ Error::Duplicate, Error::Unauthorized, Error::Expired, @@ -300,7 +295,6 @@ pub(crate) mod request { Error::Unsupported, Error::Session(SessionError::Unsupported), Error::NotFound, - Error::NotFetchable, Error::InvalidRange, Error::InvalidJoiningRequestId, Error::Unroutable, @@ -339,15 +333,6 @@ pub(crate) mod request { // A broadcast with no route is, to the peer that asked for it, not here either. assert_eq!(to_code(&Error::Unroutable, Kind::Subscribe, Version::Draft22), 0x10); - - // So is a datagram group: never cached, so a FETCH reaching one finds nothing, and - // NOT_SUPPORTED would say the endpoint has no FETCH at all. - assert_eq!(to_code(&Error::NotFetchable, Kind::Fetch, Version::Draft22), 0x10); - // Including a lite-07 upstream's NOT_FETCHABLE, bridged by a relay. - assert_eq!( - to_code(&Error::Stream(StreamError::NotFetchable), Kind::Fetch, Version::Draft22), - 0x10 - ); } /// A refused route says UNINTERESTED to the requests that offer content. @@ -560,7 +545,6 @@ mod tests { fn unregistered_conditions_are_internal() { for err in [ StreamError::NotFound, - StreamError::NotFetchable, StreamError::Unroutable, StreamError::Old, StreamError::Evicted, @@ -609,7 +593,7 @@ mod tests { /// Every stream error this crate can hold, so the conformance check below covers the /// whole space rather than the variants someone remembered. A new variant belongs here. - const EVERY_ERROR: [StreamError; 20] = [ + const EVERY_ERROR: [StreamError; 19] = [ StreamError::Session(SessionError::Cancel), StreamError::Internal, StreamError::Cancel, @@ -619,7 +603,6 @@ mod tests { StreamError::TooFarBehind, StreamError::MalformedTrack, StreamError::NotFound, - StreamError::NotFetchable, StreamError::Unroutable, StreamError::Old, StreamError::Evicted, diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 6b5de430b70..6d21a3fea5b 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -18,7 +18,7 @@ use crate::transport::poll::{GroupOf, SendOptions}; use crate::{ DecodeError, Error, Timescale, Timestamp, - coding::{Encode as _, Encoder, Stream, Writer}, + coding::{Encoder, Stream, Writer}, ietf::{self, Control, EndLocation, FetchHeader, FetchType, Filter, Location, RequestId}, priority::{Priority, PriorityHandle, PriorityQueue}, track::{Order, Subscription}, @@ -4871,9 +4871,6 @@ struct TrackServe { /// The track's exclusive end, once its groups ran out because it finished. end: Option, terminal: Arc, - /// Serve the track's datagrams too, as OBJECT_DATAGRAMs. Off when the transport has no - /// datagrams; there is no stream fallback. - datagrams: bool, compression: bool, /// A track that always has another group ready still lets the session's other tasks run. budget: kio::coop::Budget, @@ -4897,7 +4894,6 @@ impl TrackServe { }; let end = range.end.map_or(Bound::Unbounded, |end| Bound::Included(end.group)); let track = track.with_range((Bound::Included(floor), end)); - let datagrams = session.max_datagram_size() > 0; let dynamic = track.info().refresh.is_some(); let priority = kio::Producer::new(track.subscription().priority); let publisher = track.info().priority; @@ -4913,7 +4909,6 @@ impl TrackServe { }; Self { - datagrams, compression: false, session, track, @@ -5172,7 +5167,7 @@ impl TrackServe { let _ = self.children.poll(waiter); loop { - // Still serves the datagrams and the groups already started below. + // Still serves the groups already started below. if self.budget.poll_yield(waiter).is_pending() { break; } @@ -5233,8 +5228,6 @@ impl TrackServe { let _ = self.children.poll(waiter); return Poll::Pending; } - // Datagrams written before the track finished still go out. - self.poll_datagrams(waiter)?; self.draining = true; if let Poll::Ready(Ok(end)) = self.track.poll_finished(waiter) { self.end = Some(end); @@ -5245,138 +5238,10 @@ impl TrackServe { Poll::Pending => break, } } - // Groups first, so a burst of datagrams cannot starve them. - self.poll_datagrams(waiter)?; // Newly created group machines start now rather than on the next wake. let _ = self.children.poll(waiter); Poll::Pending } - - /// Send every buffered datagram as an OBJECT_DATAGRAM, best-effort, like moq-lite. - /// - /// Public datagrams are Object 0 and end their group; relayed ones retain their fields. - /// The track's end or failure surfaces through its groups, so this only stops. - fn poll_datagrams(&mut self, waiter: &kio::Waiter) -> Result<(), Error> { - if !self.datagrams { - return Ok(()); - } - while let Poll::Ready(Ok(Some(datagram))) = self.track.poll_recv_wire_datagram(waiter) { - let sequence = datagram.sequence; - let id = datagram.wire.as_ref().and_then(|wire| wire.object_id).unwrap_or(0); - if self - .range - .start - .is_some_and(|start| start.group == sequence && id < start.object) - || self - .range - .end - .is_some_and(|end| end.group == sequence && end.object.is_some_and(|end| id > end)) - { - continue; - } - let (object_id, priority, end_of_group, status, properties) = match &datagram.wire { - Some(wire) => { - // A datagram this draft cannot carry is dropped like an oversized one: it - // is best-effort, and the track's groups do not depend on it. - if wire.status.is_some_and(|status| !super::object::status_defined(status)) { - tracing::debug!(sequence, "dropping a datagram status this draft cannot carry"); - continue; - } - // A status keeps its meaning without the properties this draft forbids - // beside it, as a stream's status object does. - let stripped = Default::default(); - let forwarded = match wire.status.is_some_and(|status| status != 0) { - true => &stripped, - false => &wire.properties, - }; - let properties = match super::object::write( - forwarded, - self.track.info().ietf(), - datagram.datagram.timestamp, - self.version, - self.timescale, - ) { - Ok(properties) => properties, - Err(err) => { - tracing::debug!(%err, sequence, "dropping a datagram whose properties cannot be forwarded"); - continue; - } - }; - let object = wire.object_id; - // An omitted priority inherited the source track's default, which this - // response may not carry (or carry the same), so resolve it here. - let priority = wire - .priority - .or_else(|| Some(super::priority::to_wire(self.track.info().priority))); - ( - object, - priority, - wire.end_of_group, - wire.status, - (!properties.is_empty()).then_some(properties), - ) - } - None => { - let mut properties = Vec::new(); - if let Some(timescale) = self.timescale { - let timestamp = datagram.timestamp.ok_or(Error::TimestampMismatch)?; - ietf::encode_object_time( - &mut Encoder::new(&mut properties, self.version.into()), - timestamp, - timescale, - )?; - } - ( - None, - Some(super::priority::to_wire(self.track.info().priority)), - true, - None, - (!properties.is_empty()).then_some(properties), - ) - } - }; - let (payload, inflated) = datagram.sent(self.compression); - let properties = super::object::compression(properties.unwrap_or_default(), self.version, inflated)?; - let properties = (!properties.is_empty()).then_some(properties); - let body = ietf::ObjectDatagram { - track_alias: self.request_id.0, - group_id: sequence, - object_id, - publisher_priority: priority, - end_of_group, - properties, - body: match status { - Some(status) => ietf::DatagramBody::Status(status), - None => ietf::DatagramBody::Payload(payload.clone()), - }, - }; - let body = match body.encode_bytes(self.version) { - Ok(body) => body, - Err(err) => { - tracing::debug!(%err, sequence, "dropping a datagram this draft cannot encode"); - continue; - } - }; - let max = self.session.max_datagram_size(); - if body.len() > max { - tracing::debug!( - sequence, - size = body.len(), - max, - "dropping datagram larger than the transport limit" - ); - continue; - } - // Ranked like a stream carrying the group from its start, so it merges into the - // subscription's group order and wins a tie with that group's stream. - let options = SendOptions { - group: self.ranking.group.as_ref(), - priority: crate::priority::in_group(sequence, 0, self.ranking.order), - }; - let _ = self.session.send_datagram(body, options); - } - Ok(()) - } } /// Where one request's streams are ranked: the session's queue, keyed by the request's id @@ -6763,49 +6628,6 @@ mod group_priority_test { assert_eq!(log.orders().len(), 2, "the streams keep their place in the group"); } - /// An OBJECT_DATAGRAM goes in its subscription's send group, ranked like the stream - /// carrying its group from the start, so the transport merges it into the group order. - #[test] - fn datagrams_rank_within_their_subscription() { - let log = crate::lite::test_transport::Log::default(); - let session = SinkSession::new(log.clone()) - .with_groups() - .with_datagrams(1200) - .with_unacked_fin(); - let mut track = track::Producer::new( - std::sync::Arc::new(crate::broadcast::Info::default()), - "test", - crate::track::Info::timed(), - ); - let subscriber = track.subscribe( - track::Subscription::default() - .with_priority(3) - .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(3600))), - ); - let mut serve = TrackServe::new( - crate::driver::Transport::new(session.clone()), - subscriber, - RequestId(0), - Version::Draft18, - ServeRange::default(), - None, - ); - let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); - group - .write_frame(crate::Timestamp::from_millis(0).unwrap(), b"x".as_slice()) - .unwrap(); - group.finish().unwrap(); - let sequence = track - .append_datagram(crate::Timestamp::from_millis(1).unwrap(), b"d".as_slice()) - .unwrap(); - - let waiter = kio::Waiter::noop(); - assert!(serve.poll(&waiter).is_pending()); - let order = crate::priority::in_group(sequence, 0, Order::Descending); - assert_eq!(log.datagram_ranks(), [(Some(0), order)]); - assert_eq!(log.orders(), [(0, 1)], "the stream for group 0 ranks below"); - } - /// A group with more objects ready than one poll's budget drains over several polls. #[kio::test] async fn a_long_group_drains_within_the_budget() { @@ -7318,7 +7140,7 @@ mod subscribe_cursor_test { #[cfg(test)] mod serve_tests { use super::*; - use crate::coding::{Decode, Decoder}; + use crate::coding::{Decode, Decoder, Encode as _}; use crate::ietf::GroupOrder; use crate::lite::test_transport::{Close, Log, ScriptedSession, Sent, SinkSession}; use crate::model::ProduceTest; @@ -12532,8 +12354,6 @@ mod serve_tests { IdleEnded, /// An idle copy whose track ends after its newest group: that group's last object. IdleFinished, - /// A live feed whose newest group is a datagram the cache cannot read: unknown. - Datagram, /// A live feed whose newest group was aborted mid-write: unknown. Aborted, } @@ -12561,10 +12381,6 @@ mod serve_tests { h.track.finish().unwrap(); None } - Largest::Datagram => { - h.track.append_datagram(timestamp(), b"d".as_slice()).unwrap(); - None - } Largest::Aborted => { let mut newer = h.track.create_group(group::Info { sequence: 5 }).unwrap(); newer.write_frame(timestamp(), b"5-0".to_vec()).unwrap(); @@ -12586,7 +12402,6 @@ mod serve_tests { Largest::Idle, Largest::IdleEnded, Largest::IdleFinished, - Largest::Datagram, Largest::Aborted, ] { for start in [2, 3] { @@ -12657,7 +12472,6 @@ mod serve_tests { (4, Largest::Finished, 1), (4, Largest::Idle, 7), (4, Largest::IdleEnded, 7), - (4, Largest::Datagram, 7), (4, Largest::Aborted, 7), ] { let mut h = serve(version); @@ -13911,34 +13725,6 @@ mod serve_tests { } } - /// A datagram group is never fetchable: a FETCH for one is refused like a group that - /// does not exist, newest or not, and opens no fetch stream for its payload. - #[kio::test] - async fn a_standalone_fetch_of_a_datagram_group_is_refused() { - for version in FETCH_DRAFTS { - for newest in [true, false] { - let mut h = serve(version); - publish_pairs(&mut h, 2, None); - let sequence = h.track.append_datagram(timestamp(), b"d".as_slice()).unwrap(); - if !newest { - h.track.append_group().unwrap().finish().unwrap(); - } - settle().await; - - let location = Location { - group: sequence, - object: 0, - }; - let buf = standalone_fetch(&h, location, location, GroupOrder::Ascending).await; - assert_eq!( - fetch_refusal(buf, version), - DOES_NOT_EXIST, - "{version}: newest={newest}" - ); - } - } - } - /// A joining FETCH reaching back before the subscription's group is refused. #[kio::test] async fn a_joining_fetch_spanning_several_groups_is_refused() { @@ -19208,6 +18994,7 @@ mod fill_range_tests { #[cfg(test)] mod compression_tests { use super::*; + use crate::coding::Encode as _; #[test] fn sealed_compression_refuses_incompatible_egress() { for version in [Version::Draft18, Version::Draft20, Version::Draft22] { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index c259e5adc4d..3d1432c1b3b 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -516,7 +516,7 @@ impl<'a> RangeWriter<'a> { /// Finish the current group and open group `sequence`, returning the first Object ID it /// starts at. The sequences between them do not exist. A group cached since it was asked - /// is read past, as is a datagram, which no request ever gets. + /// is read past, as is a datagram, which a stream track never holds. fn open(&mut self, sequence: u64, datagram: bool) -> Result { if sequence < self.next || sequence > self.fetch.last { tracing::warn!( @@ -536,8 +536,8 @@ impl<'a> RangeWriter<'a> { }; let producer = match datagram { true => { - tracing::debug!(sequence, "a datagram group is not fetchable"); - fill.reject_groups(sequence..sequence + 1, Error::NotFetchable); + tracing::debug!(sequence, "dropping a datagram group from a fetch"); + fill.drop_groups(sequence..sequence + 1); None } false => match fill.accept(sequence, None) { @@ -4831,76 +4831,21 @@ impl Subscriber { Ok(()) } - /// Deliver an OBJECT_DATAGRAM with private relay metadata on its subscription. + /// Drop an OBJECT_DATAGRAM: a subscription is a stream track's, which takes no datagrams. /// - /// A malformed datagram is a protocol error. An unbound alias is dropped, as the - /// draft permits instead of buffering; public readers receive only Normal payloads. + /// A malformed datagram is still a protocol error. pub fn recv_datagram(&self, payload: bytes::Bytes) -> Result<(), Error> { let (datagram, _) = ietf::ObjectDatagram::decode_slice(&payload, self.version)?; - let (alias, sequence) = (datagram.track_alias, datagram.group_id); - - let (payload, status) = match datagram.body { - ietf::DatagramBody::Payload(payload) => (payload, None), - ietf::DatagramBody::Status(status) if super::object::status_defined(status) => { - (bytes::Bytes::new(), Some(status)) - } - ietf::DatagramBody::Status(_) => return Err(Error::Session(SessionError::ProtocolViolation)), - }; - - let mut state = self.state.lock(); - let request_id = match state.aliases.read().map.get(&alias) { - Some(Alias::Active(request_id)) => *request_id, - _ => { - tracing::debug!(alias, sequence, "dropping a datagram for an unbound alias"); - return Ok(()); - } - }; - let Some(track) = state.subscribes.get_mut(&request_id) else { - return Ok(()); - }; - - // 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. - let properties = bytes::Bytes::from(datagram.properties.unwrap_or_default()); - // A status object has no frame to hold its time, so its block keeps it. - let (timestamp, properties, inflated) = match status.is_some_and(|status| status != 0) { - true => ( - None, - super::object::unframed(&properties, self.version, track.timescale)?, - None, - ), - false => { - let decoded = super::object::read(&properties, self.version, track.timescale)?; - super::object::check_frame(&decoded.properties, track.timescale)?; - (decoded.timestamp, decoded.properties, decoded.inflated) - } - }; - let Some(producer) = track.producer.as_mut() else { - return Ok(()); - }; - let inflated = match status.is_some_and(|s| s != 0) { - true => None, - false => super::object::inflated(inflated, producer.info().compress)?, - }; - let stored = crate::model::datagram::Stored { - datagram: crate::Datagram { - sequence, - timestamp, - payload, - }, - wire: Some(std::sync::Arc::new(crate::model::datagram::Wire { - object_id: datagram.object_id, - priority: datagram.publisher_priority, - end_of_group: datagram.end_of_group, - status, - properties, - })), - deflated: None, - }; - match inflated { - Some(size) => producer.insert_deflated_datagram(stored, size)?, - None => producer.insert_wire_datagram(stored)?, + if let ietf::DatagramBody::Status(status) = datagram.body + && !super::object::status_defined(status) + { + return Err(Error::Session(SessionError::ProtocolViolation)); } + tracing::debug!( + alias = datagram.track_alias, + group = datagram.group_id, + "dropping a datagram on a stream track's subscription" + ); Ok(()) } } @@ -5438,10 +5383,10 @@ impl Subscriber { prior_group = Some(sequence); } - // A datagram cannot be reconstructed from a FETCH stream. + // A datagram is never a stream track's, so a FETCH stream has no group to rebuild. if object.datagram { tracing::debug!(?group, "a datagram group is not fetchable"); - return Err(Error::NotFetchable); + return Err(Error::NotFound); } let raw = bytes::Bytes::from(object.properties.unwrap_or_default()); @@ -7378,126 +7323,48 @@ mod tests { ); } - /// Relays keep datagram Object IDs, while plain readers see only normal object 0. - /// An unbound alias is dropped, and a malformed datagram is the peer's violation. + /// An OBJECT_DATAGRAM is dropped, since a subscription is a stream track's and a stream + /// track takes no datagrams, but a malformed one is still the peer's violation. #[kio::test] - async fn an_object_datagram_is_a_datagram_group() { + async fn an_object_datagram_is_dropped() { use crate::coding::Encode as _; use futures::FutureExt as _; let subscriber = subscriber_with_tracks(&[(RequestId(11), "cam", "audio")]); subscriber.register_alias(RequestId(11), 7).unwrap(); - let (mut consumer, mut relay) = { + let mut consumer = { let mut state = subscriber.state.lock(); let track = state.subscribes.get_mut(&RequestId(11)).unwrap(); track.timescale = Some(Timescale::MILLI); - let producer = track.producer.as_ref().unwrap(); - (producer.subscribe(None), producer.subscribe(None)) - }; - - let timestamp = crate::Timestamp::new(96_000, Timescale::MILLI).unwrap(); - let datagram = |alias: u64, group_id: u64, object_id: Option, body: ietf::DatagramBody| { - let mut properties = Vec::new(); - ietf::encode_object_time( - &mut crate::coding::Encoder::new(&mut properties, Version::Draft18.into()), - timestamp, - Timescale::MILLI, - ) - .unwrap(); - ietf::ObjectDatagram { - track_alias: alias, - group_id, - object_id, - publisher_priority: None, - // Only a Normal Object may carry Properties, and a status cannot end the group. - end_of_group: matches!(body, ietf::DatagramBody::Payload(_)), - properties: matches!(body, ietf::DatagramBody::Payload(_)).then_some(properties), - body, - } - .encode_bytes(Version::Draft18) - .unwrap() + track.producer.as_ref().unwrap().subscribe(None) }; - let payload = |bytes: &'static [u8]| ietf::DatagramBody::Payload(bytes::Bytes::from_static(bytes)); - // Nonzero objects survive for relays; plain readers skip them, unbound aliases, and statuses. - subscriber - .recv_datagram(datagram(7, 4, Some(1), payload(b"no"))) - .unwrap(); - subscriber.recv_datagram(datagram(8, 4, None, payload(b"no"))).unwrap(); - subscriber - .recv_datagram(datagram(7, 9, Some(0), payload(b"yes"))) - .unwrap(); - subscriber - .recv_datagram(datagram(7, 10, None, ietf::DatagramBody::Status(END_OF_TRACK))) - .unwrap(); - let Poll::Ready(Ok(Some(first))) = relay.poll_recv_stored_datagram(&kio::Waiter::noop()) else { - panic!("the relay did not receive the object"); - }; - assert_eq!(first.sequence, 4); - assert_eq!(first.wire.as_ref().unwrap().object_id, Some(1)); - assert_eq!(&first.payload[..], b"no"); - 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, Some(timestamp)); - assert_eq!(&received.payload[..], b"yes"); - assert!( - consumer.recv_datagram().now_or_never().is_none(), - "only the normal object 0 payload got through" - ); + let mut properties = Vec::new(); + ietf::encode_object_time( + &mut crate::coding::Encoder::new(&mut properties, Version::Draft18.into()), + crate::Timestamp::new(96_000, Timescale::MILLI).unwrap(), + Timescale::MILLI, + ) + .unwrap(); + let datagram = ietf::ObjectDatagram { + track_alias: 7, + group_id: 4, + object_id: None, + publisher_priority: None, + end_of_group: true, + properties: Some(properties), + body: ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"d")), + } + .encode_bytes(Version::Draft18) + .unwrap(); + subscriber.recv_datagram(datagram).unwrap(); + assert!(consumer.recv_group().now_or_never().is_none(), "no group arrives"); // A status datagram cannot end the group. let malformed = bytes::Bytes::from_static(&[0x22, 0x07, 0x04, 0x00]); assert!(is_protocol_violation(&subscriber.recv_datagram(malformed).unwrap_err())); } - /// A datagram that lands before SUBSCRIBE_OK binds its alias is dropped, not held for the - /// subscription to replay once it is bound. The reader was already subscribed, so only the - /// missing alias can have dropped it. - #[kio::test] - async fn a_datagram_before_its_alias_is_dropped() { - use crate::coding::Encode as _; - use futures::FutureExt as _; - - let subscriber = subscriber_with_tracks(&[(RequestId(11), "cam", "audio")]); - let mut consumer = { - let mut state = subscriber.state.lock(); - let track = state.subscribes.get_mut(&RequestId(11)).unwrap(); - track.timescale = Some(Timescale::MILLI); - track.producer.as_ref().unwrap().subscribe(None) - }; - let datagram = |group_id: u64| { - let mut properties = Vec::new(); - ietf::encode_object_time( - &mut crate::coding::Encoder::new(&mut properties, Version::Draft18.into()), - crate::Timestamp::new(96_000, Timescale::MILLI).unwrap(), - Timescale::MILLI, - ) - .unwrap(); - ietf::ObjectDatagram { - track_alias: 7, - group_id, - object_id: None, - publisher_priority: None, - end_of_group: true, - properties: Some(properties), - body: ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"d")), - } - .encode_bytes(Version::Draft18) - .unwrap() - }; - - subscriber.recv_datagram(datagram(4)).unwrap(); - subscriber.register_alias(RequestId(11), 7).unwrap(); - subscriber.recv_datagram(datagram(5)).unwrap(); - - let received = consumer.recv_datagram().now_or_never().unwrap().unwrap().unwrap(); - assert_eq!(received.sequence, 5); - assert!( - consumer.recv_datagram().now_or_never().is_none(), - "group 4 never arrives" - ); - } - /// One alias naming two different tracks is the collision section 11.1 makes fatal. #[kio::test] async fn an_alias_reused_for_another_track_is_fatal() { @@ -13740,130 +13607,6 @@ mod stitch_tests { } } - #[kio::test] - async fn passthrough_preserves_datagram_objects_through_dispatch() { - // Also through a second relay behind a moq-lite hop with the `ietf` extension. - for (version, ranged, hop) in [Version::Draft22] - .into_iter() - .flat_map(|version| [(version, false), (version, true)]) - .filter(|(version, ranged)| !*ranged || *version == Version::Draft22) - .flat_map(|(version, ranged)| [(version, ranged, false), (version, ranged, true)]) - { - let origin = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); - let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); - let track = broadcast - .create_track("video", accepted_info(&Default::default(), version)) - .unwrap(); - let mut h = Harness::with_timescale(Fill::Done, vec![], None); - h.subscriber.version = version; - h.subscriber.state.lock().subscribes.get_mut(&REQUEST).unwrap().producer = Some(track.clone()); - let properties = vec![2, 7, 3, 1, 42]; - let mut original = Vec::new(); - for id in [6, 7, 8] { - let datagram = ietf::ObjectDatagram { - track_alias: ALIAS, - group_id: SEQUENCE, - object_id: Some(id), - // An omitted priority inherits the track default, which the - // downstream response need not carry. - publisher_priority: (id != 6).then_some(200), - end_of_group: false, - properties: (id != 8).then(|| properties.clone()), - body: if id == 8 { - ietf::DatagramBody::Status(END_OF_GROUP) - } else { - ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object")) - }, - }; - h.subscriber - .recv_datagram(datagram.encode_bytes(version).unwrap()) - .unwrap(); - original.push(datagram); - } - track.finish().unwrap(); - let (served, _sessions, _copy) = match hop { - true => { - let (served, sessions) = crate::lite::ietf::test_hop(&origin, "room", [true, true]).await; - // The hop learns the track's info first, and holds the copy for the SUBSCRIBE - // below: a SUBSCRIBE sent alongside the TRACK has the cache replayed as - // datagrams before the TRACK_INFO they are read with, and they are dropped. - let copy = served - .consume() - .request("room", None) - .await - .unwrap() - .track("video") - .unwrap(); - copy.info().await.unwrap(); - (served, Some(sessions), Some(copy)) - } - false => (origin, None, None), - }; - let downstream = ScriptedSession::per_stream(vec![vec![]]).with_datagrams(65535); - let peer = peer::PeerSetup::default(); - peer.set(peer::Peer::default()); - let publisher = crate::ietf::Publisher::new( - crate::time::sim(), - crate::driver::Transport::new(downstream.clone()), - served.consume(), - Control::new(false), - None, - peer, - version, - ); - let subscribe = ietf::Subscribe { - max_age: None, - new_group: None, - request_id: RequestId(3), - track_namespace: Path::from_static("room"), - track_name: "video".into(), - subscriber_priority: 128, - group_order: ietf::GroupOrder::Ascending, - filter: if ranged { - Filter::Absolute { - start: ietf::Location { - group: SEQUENCE, - object: 7, - }, - end: Some(ietf::EndLocation { - group: SEQUENCE, - object: Some(7), - }), - } - } else { - Filter::Unfiltered - }, - fill: None, - properties_wanted: true, - forward: true, - rendezvous: false, - range_filters: false, - authorization_token: None, - }; - let mut body = Vec::new(); - subscribe - .encode_msg(&mut Encoder::new(&mut body, version.into()), version) - .unwrap(); - let stream = Stream::open(&mut crate::driver::Transport::new(downstream.clone()), version) - .await - .unwrap(); - publisher - .handle_stream(ietf::Subscribe::ID, ietf::Body(body.into()), stream) - .unwrap() - .await; - let sent = downstream.log.datagrams.lock().unwrap(); - original.retain(|datagram| !ranged || datagram.object_id == Some(7)); - assert_eq!(sent.len(), original.len(), "{version} ranged={ranged} hop={hop}"); - let inherited = super::super::priority::to_wire(track.publisher_priority()); - for (bytes, mut expected) in sent.iter().zip(original) { - expected.track_alias = 3; - expected.publisher_priority = expected.publisher_priority.or(Some(inherited)); - let (actual, _) = ietf::ObjectDatagram::decode_slice(bytes, version).unwrap(); - assert_eq!(actual, expected); - } - } - } - #[kio::test] async fn passthrough_translates_mutable_property_encodings() { for ingress in [Version::Draft22] { @@ -13918,45 +13661,6 @@ mod stitch_tests { } } - /// Relays retain a datagram's Object ID; a plain reader skips it until object 0 arrives. - #[kio::test] - async fn passthrough_accepts_datagrams_past_object_zero() { - let version = Version::Draft22; - let mut h = Harness::with_timescale(Fill::Done, vec![], None); - h.subscriber.version = version; - let mut consumer = h.track.subscribe(None); - let mut plain = h.track.subscribe(None); - let mut datagram = ietf::ObjectDatagram { - track_alias: ALIAS, - group_id: SEQUENCE, - object_id: Some(6), - publisher_priority: Some(128), - end_of_group: false, - properties: None, - body: ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object-six")), - }; - h.subscriber - .recv_datagram(datagram.encode_bytes(version).unwrap()) - .unwrap(); - let received = consumer.poll_recv_stored_datagram(&kio::Waiter::noop()); - assert!( - matches!(&received, Poll::Ready(Ok(Some(data))) - if data.payload == b"object-six"[..] && data.wire.as_ref().unwrap().object_id == Some(6)), - "{received:?}" - ); - assert!(plain.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - datagram.object_id = Some(0); - datagram.body = ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object-zero")); - h.subscriber - .recv_datagram(datagram.encode_bytes(version).unwrap()) - .unwrap(); - let received = plain.poll_recv_datagram(&kio::Waiter::noop()); - assert!( - matches!(&received, Poll::Ready(Ok(Some(data))) if data.payload == b"object-zero"[..]), - "{received:?}" - ); - } - #[kio::test] async fn an_overflowing_object_id_reaches_session_teardown() { let mut bytes = tail_stream(SEQUENCE, 0, &[b"head"]); @@ -15262,8 +14966,8 @@ mod stitch_tests { buf } - /// A datagram group is never fetchable, so a group fetch answered with one names it - /// dropped as not fetchable, caching nothing, and reads on past its payload. + /// A stream track takes no datagrams, so a group fetch answered with a datagram Object + /// drops its group, caching nothing, and reads on past its payload. #[kio::test] async fn a_group_fetch_drops_a_datagram_group() { let mut run = GroupFetchRun::new(VERSION, datagram_object(SEQUENCE)).await; @@ -15271,7 +14975,7 @@ mod stitch_tests { let res = run.subscriber.recv_group_fetch(&mut run.stream, slot).await; assert!(res.is_ok(), "{res:?}"); - assert!(matches!(fetch.await, Err(Error::NotFetchable))); + assert!(matches!(fetch.await, Err(Error::NotFound))); assert!(run.track.consume().peek_group(SEQUENCE).is_none(), "nothing is cached"); } @@ -15424,7 +15128,7 @@ mod stitch_tests { assert!(matches!( h.subscriber.clone().recv_fill(&mut fill).await, - Err(Error::NotFetchable) + Err(Error::NotFound) )); h.subscriber .clone() diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 99e7de3f1b1..96c10f71c14 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -66,8 +66,8 @@ //! [`origin::Producer::new`] also returns a producer and driver. Its driver runs //! route changes, serving, linger, teardown, and the origin's cache expiration. //! Standalone caches expose [`cache::Pool::poll`]. Frame read/write methods -//! clear their expiration timestamp for the next cleanup pass. Datagrams use a bounded -//! FIFO; model read/write APIs take no time argument, though a frame write stamps +//! clear their expiration timestamp for the next cleanup pass. Datagram tracks keep a +//! short buffer and no cache; model read/write APIs take no time argument, though a frame write stamps //! its arrival from the ambient clock ([`frame::Frame::arrived`]). //! //! Both drivers implement [`time::Driver`]. `moq-uring` drives thread-local diff --git a/rs/moq-net/src/lite/datagram.rs b/rs/moq-net/src/lite/datagram.rs index d96208ab884..d5fd1bb0e71 100644 --- a/rs/moq-net/src/lite/datagram.rs +++ b/rs/moq-net/src/lite/datagram.rs @@ -1,37 +1,39 @@ -//! Wire-level QUIC datagram bodies for moq-lite. +//! moq-lite datagrams: QUIC datagram bodies, and lite-07's request for a datagram track. //! -//! One datagram carries a single-frame group routed over an existing subscription. The body is -//! `subscribe (i) | sequence (i) | [timestamp (i)] | [inflated size (i)] | payload (b)`, with the +//! A body is `subscribe (i) | sequence (i) | [timestamp (i)] | payload (b)`, with the //! timestamp present only when the subscription's track carries one (see -//! [`Version::wire_timescale`]) and the inflated size only when it is compressed (lite-07). -//! Lite-07 prefixes group datagrams with type 0x0; type 0x1 is discarded padding. The -//! payload runs to the datagram boundary, so unlike a [`super::Message`] there is no inner length -//! prefix. The model counterpart is [`crate::Datagram`]. +//! [`Version::wire_timescale`]). Lite-07 prefixes it with type 0x0; type 0x1 is discarded +//! padding. The payload runs to the datagram boundary, so unlike a [`super::Message`] there +//! is no inner length prefix. +//! +//! On lite-05/06 a body is a single-frame group on a SUBSCRIBE subscription, its sequence +//! the group's. On lite-07 it is a datagram of a datagram track, subscribed through a +//! Datagrams Stream ([`Datagrams`]), its sequence the datagram track's own. The model +//! counterpart is [`crate::Datagram`]. + +use std::borrow::Cow; use bytes::Bytes; -use crate::coding::{DecodeError, Decoder, Encode, EncodeError, Encoder}; +use crate::{ + Path, Timescale, + coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}, +}; -use super::Version; +use super::{Message, Version}; /// A decoded QUIC datagram body. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Datagram { /// Subscribe ID this datagram is delivered on. pub subscribe: u64, - /// Group sequence number (shared with the track's group namespace). + /// The datagram's sequence: its group's on lite-05/06, the datagram track's on lite-07. pub sequence: u64, /// Absolute presentation timestamp in the track's wire timescale, or `None` on a track /// that carries no timestamps. pub timestamp: Option, - /// On a compressed track, the payload's inflated size; the payload is then its own - /// DEFLATE stream. - pub inflated: Option, - /// The frame payload, delimited by the datagram boundary. + /// The payload, delimited by the datagram boundary. pub payload: Bytes, - /// With the `ietf` extension negotiated, the moq-transport state a relay preserves, or - /// `Some(None)` for a plain datagram. `None` without the extension. - pub ietf: Option>, } impl Encode for Datagram { @@ -45,35 +47,14 @@ impl Encode for Datagram { return Err(EncodeError::Version); } - if self.inflated.is_some() && !version.has_compression() { - return Err(EncodeError::Version); - } - if version.has_padding() { w.varint(0x0)?; } w.varint(self.subscribe)?; w.varint(self.sequence)?; - if let Some(wire) = &self.ietf { - if !version.has_ietf() { - return Err(EncodeError::Version); - } - // Any status object carries no payload, which moq-transport could not send beside - // it, and a non-zero one carries no object and so no time either. - let status = wire.as_ref().and_then(|wire| wire.status); - if status.is_some() && (!self.payload.is_empty() || self.inflated.is_some()) - || status.is_some_and(|status| status != 0) && self.timestamp.is_some() - { - return Err(EncodeError::InvalidState); - } - super::ietf::encode_datagram(w, wire.as_ref())?; - } if let Some(timestamp) = self.timestamp { w.varint(timestamp)?; } - if let Some(inflated) = self.inflated { - w.varint(inflated)?; - } // Payload runs to the datagram boundary: written raw, no length prefix. w.slice(&self.payload); @@ -85,17 +66,16 @@ impl Datagram { /// The largest encoded datagram body a lite hop carries. pub(super) const MAX_SIZE: usize = 1200; - /// Decode a group datagram, or discard padding with `None`, without copying its payload. + /// Decode a datagram, or discard padding with `None`, without copying its payload. /// - /// `ietf` says whether the session negotiated the extension, and `framing` describes - /// the track of the given subscribe ID, which the body itself does not. + /// `timed` says whether the track of the given subscribe ID carries timestamps, which + /// the body itself does not. pub(crate) fn decode( buf: Bytes, version: Version, - ietf: bool, - framing: impl FnOnce(u64) -> super::Framing, + timed: impl FnOnce(u64) -> bool, ) -> Result, DecodeError> { - if !version.has_datagrams() || (ietf && !version.has_ietf()) { + if !version.has_datagrams() { return Err(DecodeError::Version); } @@ -109,130 +89,197 @@ impl Datagram { } let subscribe = r.varint()?; let sequence = r.varint()?; - let framing = framing(subscribe); - let timescale = framing.timescale; - let ietf = ietf - .then(|| super::ietf::decode_datagram(&mut r, framing)) - .transpose()?; - // Any status object carries no payload, which moq-transport could not send beside - // it, and a non-zero one carries no object and so no time either. - let status = ietf - .as_ref() - .and_then(|wire| wire.as_ref()) - .and_then(|wire| wire.status); - let timestamp = if timescale.is_some() && status.is_none_or(|status| status == 0) { - Some(r.varint()?) - } else { - None - }; - // A status has no payload to inflate. - let inflated = match framing.compressed && version.has_compression() && status.is_none() { + let timestamp = match timed(subscribe) { true => Some(r.varint()?), false => None, }; // Everything remaining is the payload (the datagram boundary delimits it). let payload = buf.slice(buf.len() - r.remaining()..); - if status.is_some() && !payload.is_empty() { - return Err(DecodeError::InvalidValue); - } Ok(Some(Self { subscribe, sequence, timestamp, - inflated, payload, - ietf, })) } } -#[cfg(test)] -mod test { - use super::*; - use crate::Timescale; +/// Sent by the subscriber on a Datagrams Stream (0x9) to subscribe to a datagram track, +/// live from now. +/// +/// Lite07+ only. +#[derive(Clone, Debug)] +pub struct Datagrams<'a> { + /// The Subscribe ID its datagrams carry, from the same space as SUBSCRIBE's. + pub id: u64, + pub broadcast: Path, + /// The instance the subscriber expects; see [`crate::origin::Route::instance`]. + pub instance: Option, + pub track: Cow<'a, str>, + /// The subscriber's priority. + pub priority: i8, +} - #[test] - fn padding_needs_no_subscription_framing() { - for mut body in [vec![1], vec![1; Datagram::MAX_SIZE], vec![0x80, 1, 0xff]] { - // Padding bytes need not be valid varints or any other structured encoding. - if body.len() == Datagram::MAX_SIZE { - body[1..].fill(0xff); - } - for ietf in [false, true] { - assert!( - Datagram::decode(body.clone().into(), Version::Lite07, ietf, |_| { - panic!("padding must not look up a subscription") - }) - .unwrap() - .is_none() - ); - } +impl Datagrams<'_> { + /// Copy the borrowed track name, so the request outlives the bytes it came from. + pub fn into_owned(self) -> Datagrams<'static> { + Datagrams { + id: self.id, + broadcast: self.broadcast, + instance: self.instance, + track: Cow::Owned(self.track.into_owned()), + priority: self.priority, } } +} - #[test] - fn invalid_types_never_reach_a_subscription() { - for body in [&[][..], &[0x80][..], &[2, 1, 2, 3][..], &[0xff; 9][..]] { - assert!( - Datagram::decode(Bytes::copy_from_slice(body), Version::Lite07, false, |_| { - panic!("invalid type must not look up a subscription") - }) - .is_err() - ); +impl<'a> Message<'a> for Datagrams<'a> { + const CONTROL: Option = Some(crate::stats::Message::Subscribe); + + fn decode_msg(r: &mut Decoder<'a>, version: Version) -> Result { + if !version.has_datagram_tracks() { + return Err(DecodeError::Version); } + let id = r.varint()?; + let broadcast = Path::decode(r, version)?; + let instance = super::instance::decode_pin(r, version)?; + let track = Cow::Borrowed(r.str()?); + let priority = super::priority::from_wire(r.u8()?); + Ok(Self { + id, + broadcast, + instance, + track, + priority, + }) } - /// A millisecond track a moq-lite publisher published. - fn timed() -> super::super::Framing { - super::super::Framing { - timescale: Some(Timescale::MILLI), - ietf: None, - compressed: false, + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + if !version.has_datagram_tracks() { + return Err(EncodeError::Version); } + w.varint(self.id)?; + self.broadcast.encode(w, version)?; + super::instance::encode_pin(w, version, self.instance.as_ref())?; + w.string(&self.track)?; + w.u8(super::priority::to_wire(self.priority)); + Ok(()) } +} + +/// Sent by the subscriber on a Datagrams Stream to change its priority. +/// +/// Lite07+ only. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DatagramsUpdate { + pub priority: i8, +} + +impl<'a> Message<'a> for DatagramsUpdate { + const CONTROL: Option = Some(crate::stats::Message::Subscribe); - /// A millisecond track a draft-22 moq-transport publisher published. - fn relayed() -> super::super::Framing { - super::super::Framing { - ietf: Some(crate::ietf::Version::Draft22), - ..timed() + fn decode_msg(r: &mut Decoder<'a>, version: Version) -> Result { + if !version.has_datagram_tracks() { + return Err(DecodeError::Version); } + let priority = super::priority::from_wire(r.u8()?); + Ok(Self { priority }) } - /// A millisecond compressed track a moq-lite publisher published. - fn compressed() -> super::super::Framing { - super::super::Framing { - compressed: true, - ..timed() + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + if !version.has_datagram_tracks() { + return Err(EncodeError::Version); } + w.u8(super::priority::to_wire(self.priority)); + Ok(()) } +} - /// A compressed track's inflated size sits between the timestamp and the payload. - #[test] - fn compressed_carries_the_inflated_size() { - let dg = Datagram { - subscribe: 1, - sequence: 2, - timestamp: Some(3), - inflated: Some(40), - payload: Bytes::from_static(b"slice"), - ietf: None, +/// The publisher's answer on a Datagrams Stream: the datagram track's properties, which a +/// subscriber needs to read its datagrams. +/// +/// Lite07+ only. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DatagramsInfo { + /// The publisher's tie-break priority for this track. + pub priority: i8, + /// Units per second of each datagram's timestamp, or `None` when datagrams carry none. + pub timescale: Option, +} + +impl From for DatagramsInfo { + fn from(info: crate::datagram::Info) -> Self { + Self { + priority: info.priority, + timescale: info.timescale, + } + } +} + +impl From for crate::datagram::Info { + fn from(info: DatagramsInfo) -> Self { + Self::default() + .with_priority(info.priority) + .with_timescale(info.timescale) + } +} + +impl<'a> Message<'a> for DatagramsInfo { + const CONTROL: Option = Some(crate::stats::Message::TrackInfo); + + fn decode_msg(r: &mut Decoder<'a>, version: Version) -> Result { + if !version.has_datagram_tracks() { + return Err(DecodeError::Version); + } + let priority = super::priority::from_wire(r.u8()?); + let timescale = match r.varint()? { + 0 => None, + scale => Some(Timescale::new(scale).map_err(|_| DecodeError::InvalidValue)?), }; - let buf = dg.encode_bytes(Version::Lite07).unwrap(); - assert_eq!(&buf[..5], [0, 1, 2, 3, 40]); - assert_eq!( - Datagram::decode(buf, Version::Lite07, false, |_| compressed()) + Ok(Self { priority, timescale }) + } + + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + if !version.has_datagram_tracks() { + return Err(EncodeError::Version); + } + w.u8(super::priority::to_wire(self.priority)); + w.varint(self.timescale.map_or(0, u64::from))?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn padding_needs_no_subscription() { + for mut body in [vec![1], vec![1; Datagram::MAX_SIZE], vec![0x80, 1, 0xff]] { + // Padding bytes need not be valid varints or any other structured encoding. + if body.len() == Datagram::MAX_SIZE { + body[1..].fill(0xff); + } + assert!( + Datagram::decode(body.into(), Version::Lite07, |_| panic!( + "padding must not look up a subscription" + )) .unwrap() - .unwrap(), - dg - ); + .is_none() + ); + } + } - for version in [Version::Lite05, Version::Lite06] { + #[test] + fn invalid_types_never_reach_a_subscription() { + for body in [&[][..], &[0x80][..], &[2, 1, 2, 3][..], &[0xff; 9][..]] { assert!( - matches!(dg.encode_bytes(version), Err(EncodeError::Version)), - "{version}" + Datagram::decode(Bytes::copy_from_slice(body), Version::Lite07, |_| panic!( + "invalid type must not look up a subscription" + )) + .is_err() ); } } @@ -243,34 +290,15 @@ mod test { subscribe: 1, sequence: 42, timestamp: Some(1_000), - inflated: None, payload: Bytes::from_static(b"hello"), - ietf: None, }; for version in [Version::Lite05, Version::Lite06, Version::Lite07] { let buf = original.encode_bytes(version).unwrap(); - let decoded = Datagram::decode(buf, version, false, |_| timed()).unwrap().unwrap(); - assert_eq!(decoded, original, "payload has no trailing length prefix"); + let decoded = Datagram::decode(buf, version, |_| true).unwrap().unwrap(); + assert_eq!(decoded, original); } } - #[test] - fn empty_payload() { - let original = Datagram { - subscribe: 0, - sequence: 0, - timestamp: Some(0), - inflated: None, - payload: Bytes::new(), - ietf: None, - }; - let buf = original.encode_bytes(Version::Lite05).unwrap(); - let decoded = Datagram::decode(buf, Version::Lite05, false, |_| timed()) - .unwrap() - .unwrap(); - assert_eq!(decoded, original); - } - #[test] fn no_inner_length_prefix() { // The payload is boundary-delimited, so the encoding is exactly the three @@ -279,12 +307,9 @@ mod test { subscribe: 1, sequence: 2, timestamp: Some(3), - inflated: None, payload: Bytes::from_static(b"world"), - ietf: None, }; let buf = dg.encode_bytes(Version::Lite05).unwrap(); - // 1 + 1 + 1 (single-byte varints) + 5 payload = 8 bytes, no length byte. assert_eq!(buf.len(), 8); assert_eq!(&buf[3..], b"world"); } @@ -295,32 +320,19 @@ mod test { subscribe: 1, sequence: 2, timestamp: None, - inflated: None, payload: Bytes::from_static(b"world"), - ietf: None, }; let buf = dg.encode_bytes(Version::Lite07).unwrap(); - assert_eq!(buf.len(), 8); - assert_eq!(&buf[3..], b"world"); - let decoded = Datagram::decode(buf, Version::Lite07, false, |id| { + assert_eq!(&buf[..], b"\x00\x01\x02world"); + let decoded = Datagram::decode(buf, Version::Lite07, |id| { assert_eq!(id, 1); - Default::default() + false }) .unwrap() .unwrap(); assert_eq!(decoded, dg); - } - #[test] - fn untimed_needs_lite07() { - let dg = Datagram { - subscribe: 1, - sequence: 2, - timestamp: None, - inflated: None, - payload: Bytes::from_static(b"world"), - ietf: None, - }; + // Lite-05/06 have no untimed body. for version in [Version::Lite05, Version::Lite06] { assert!( matches!(dg.encode_bytes(version), Err(EncodeError::Version)), @@ -329,115 +341,49 @@ mod test { } } - /// A timed datagram's Timestamp is its only time: its relayed properties cannot hold a - /// second, mutable one that could disagree. - #[test] - fn relayed_properties_hold_no_timestamp_on_a_timed_track() { - let dg = |id: u64| Datagram { - subscribe: 1, - sequence: 2, - timestamp: Some(500), - inflated: None, - payload: Bytes::from_static(b"x"), - ietf: Some(Some(crate::model::datagram::Wire { - object_id: None, - priority: None, - end_of_group: false, - status: None, - properties: crate::model::object::Properties { - block: crate::ietf::object::block( - &[crate::model::object::Property { - id, - value: crate::model::object::Value::Int(7), - }], - crate::ietf::Version::Draft22, - ) - .unwrap(), - ..Default::default() - }, - })), - }; - let decode = |dg: Datagram| { - let buf = dg.encode_bytes(Version::Lite07).unwrap(); - Datagram::decode(buf, Version::Lite07, true, |_| relayed()) - }; - assert!(decode(dg(0x20)).is_ok()); - assert!(matches!(decode(dg(0x10)), Err(DecodeError::InvalidValue))); - } - - /// A Normal status (0) is an empty object, which moq-transport sends as a status with - /// no payload, so a STATUS field beside a payload, or its inflated size, is refused - /// whatever its value. - #[test] - fn a_status_carries_no_payload() { - for status in [0, 3] { - let dg = |payload: &'static [u8]| Datagram { - subscribe: 1, - sequence: 2, - timestamp: (status == 0).then_some(500), - inflated: None, - payload: Bytes::from_static(payload), - ietf: Some(Some(crate::model::datagram::Wire { - object_id: None, - priority: None, - end_of_group: false, - status: Some(status), - properties: Default::default(), - })), - }; - assert!( - matches!(dg(b"x").encode_bytes(Version::Lite07), Err(EncodeError::InvalidState)), - "{status}" - ); - let empty = dg(b""); - let sized = Datagram { - inflated: Some(0), - ..empty.clone() - }; - assert!( - matches!(sized.encode_bytes(Version::Lite07), Err(EncodeError::InvalidState)), - "{status}" - ); - let mut buf = empty.encode_bytes(Version::Lite07).unwrap().to_vec(); - for framing in [ - relayed(), - super::super::Framing { - compressed: true, - ..relayed() - }, - ] { - let decoded = Datagram::decode(Bytes::from(buf.clone()), Version::Lite07, true, |_| framing) - .unwrap() - .unwrap(); - assert_eq!(decoded, empty, "{status}"); - } - buf.push(b'x'); - assert!( - matches!( - Datagram::decode(Bytes::from(buf), Version::Lite07, true, |_| relayed()), - Err(DecodeError::InvalidValue) - ), - "{status}" - ); - } - } - #[test] fn rejects_old_versions() { let dg = Datagram { subscribe: 1, sequence: 2, timestamp: Some(3), - inflated: None, payload: Bytes::from_static(b"x"), - ietf: None, }; assert!(matches!(dg.encode_bytes(Version::Lite04), Err(EncodeError::Version))); - assert!(matches!( - Datagram::decode(Bytes::from_static(b"\x01\x02\x03x"), Version::Lite04, false, |_| timed( - )), + Datagram::decode(Bytes::from_static(b"\x01\x02\x03x"), Version::Lite04, |_| true), Err(DecodeError::Version) )); } + + #[test] + fn the_request_roundtrips_on_lite07_only() { + let request = Datagrams { + id: 7, + broadcast: Path::from_static("room/alice"), + instance: None, + track: "audio".into(), + priority: -3, + }; + let buf = request.encode_bytes(Version::Lite07).unwrap(); + let (decoded, _) = Datagrams::decode_slice(&buf, Version::Lite07).unwrap(); + assert_eq!(decoded.id, 7); + assert_eq!(decoded.broadcast, request.broadcast); + assert_eq!(decoded.track, "audio"); + assert_eq!(decoded.priority, -3); + assert!(matches!( + request.encode_bytes(Version::Lite06), + Err(EncodeError::Version) + )); + + let update = DatagramsUpdate { priority: 9 }; + let buf = update.encode_bytes(Version::Lite07).unwrap(); + assert_eq!(DatagramsUpdate::decode_slice(&buf, Version::Lite07).unwrap().0, update); + + for timescale in [None, Some(Timescale::MILLI)] { + let info = DatagramsInfo { priority: 2, timescale }; + let buf = info.encode_bytes(Version::Lite07).unwrap(); + assert_eq!(DatagramsInfo::decode_slice(&buf, Version::Lite07).unwrap().0, info); + } + } } diff --git a/rs/moq-net/src/lite/ietf.rs b/rs/moq-net/src/lite/ietf.rs index 64d97ad40f5..b6ca86f1d6e 100644 --- a/rs/moq-net/src/lite/ietf.rs +++ b/rs/moq-net/src/lite/ietf.rs @@ -27,12 +27,6 @@ const FIRST_OBJECT: u64 = 0x2; const EXTENSIONS: u64 = 0x4; const END_OF_GROUP: u64 = 0x8; -// Datagram State bits, after RELAYED. -const OBJECT_ID: u64 = 0x2; -const PRIORITY: u64 = 0x4; -const DATAGRAM_END: u64 = 0x8; -const STATUS: u64 = 0x10; - /// Immutable Properties, whose keys TRACK_PROPERTIES derives its sealed list from. const IMMUTABLE: u64 = 0x0b; @@ -368,73 +362,6 @@ impl<'a> Message<'a> for TrackProperties { } } -/// Encode a datagram's relay state after its Group Sequence, or a plain one's as 0. -pub(crate) fn encode_datagram( - w: &mut Encoder<'_>, - wire: Option<&crate::model::datagram::Wire>, -) -> Result<(), EncodeError> { - let Some(wire) = wire else { - return w.varint(0); - }; - let mut state = RELAYED; - for (set, bit) in [ - (wire.object_id.is_some(), OBJECT_ID), - (wire.priority.is_some(), PRIORITY), - (wire.end_of_group, DATAGRAM_END), - (wire.status.is_some(), STATUS), - ] { - if set { - state |= bit; - } - } - w.varint(state)?; - if let Some(id) = wire.object_id { - w.varint(id)?; - } - if let Some(priority) = wire.priority { - w.u8(priority); - } - if let Some(status) = wire.status { - w.varint(status)?; - } - w.bytes(encode_properties(&wire.properties)) -} - -/// Decode a datagram's relay state on a track `framing` describes. -pub(crate) fn decode_datagram( - r: &mut Decoder<'_>, - framing: super::Framing, -) -> Result, DecodeError> { - let state = r.varint()?; - if state == 0 { - return Ok(None); - } - if state & RELAYED == 0 || state & !(RELAYED | OBJECT_ID | PRIORITY | DATAGRAM_END | STATUS) != 0 { - return Err(DecodeError::InvalidValue); - } - // A track published over moq-lite has no moq-transport state. - framing.ietf.ok_or(DecodeError::InvalidValue)?; - let object_id = (state & OBJECT_ID != 0).then(|| r.varint()).transpose()?; - let priority = (state & PRIORITY != 0).then(|| r.u8()).transpose()?; - let status = (state & STATUS != 0).then(|| r.varint()).transpose()?; - // A status object has no frame to hold its time. - let framed = status.is_none_or(|status| status == 0); - let block = r.bytes()?; - let properties = decode_properties(block, framing, framed)?.properties; - let wire = crate::model::datagram::Wire { - object_id, - priority, - end_of_group: state & DATAGRAM_END != 0, - status, - properties, - }; - // A relay holds and forwards it, so it must be one its publisher's draft could send. - if !wire.defined() { - return Err(DecodeError::InvalidValue); - } - Ok(Some(wire)) -} - #[cfg(test)] mod tests { use super::*; @@ -775,138 +702,6 @@ mod tests { } } - fn datagram(wire: &crate::model::datagram::Wire) -> Vec { - let mut buf = Vec::new(); - encode_datagram(&mut Encoder::new(&mut buf, V.into()), Some(wire)).unwrap(); - buf - } - - /// A relay holds and forwards a datagram's Status, so one moq-transport does not define - /// would close the session it reached: it is refused where it arrives. - #[test] - fn datagram_statuses_are_ones_moq_transport_defines() { - for (status, ok) in [(0, true), (1, false), (2, false), (3, true), (4, true), (5, false)] { - let wire = crate::model::datagram::Wire { - object_id: Some(3), - priority: Some(9), - end_of_group: false, - status: Some(status), - properties: Properties::default(), - }; - let decoded = decode_datagram( - &mut Decoder::new(&datagram(&wire), V.into()), - from(Draft::Draft18, None), - ); - assert_eq!(decoded.is_ok(), ok, "{status}"); - } - } - - /// No moq-transport draft defines a status datagram that also ends the group, so one - /// is refused where it arrives, by the check moq-transport ingress applies. - #[test] - fn a_status_datagram_cannot_end_the_group() { - let wire = crate::model::datagram::Wire { - object_id: Some(3), - priority: Some(9), - end_of_group: true, - status: Some(3), - properties: Properties::default(), - }; - assert!( - decode_datagram( - &mut Decoder::new(&datagram(&wire), V.into()), - from(Draft::Draft22, None) - ) - .is_err() - ); - } - - #[test] - fn datagram_state_round_trips() { - for (end_of_group, status) in [(false, Some(3)), (true, None)] { - let wire = crate::model::datagram::Wire { - object_id: Some(3), - priority: Some(9), - end_of_group, - status, - properties: Properties::default(), - }; - let decoded = decode_datagram( - &mut Decoder::new(&datagram(&wire), V.into()), - from(Draft::Draft22, None), - ) - .unwrap(); - assert_eq!(decoded, Some(wire)); - } - - let mut plain = Vec::new(); - encode_datagram(&mut Encoder::new(&mut plain, V.into()), None).unwrap(); - assert_eq!(plain, [0]); - // A plain datagram crosses whether or not its track came from moq-transport. - for framing in [from(Draft::Draft22, None), Default::default()] { - assert_eq!( - decode_datagram(&mut Decoder::new(&plain, V.into()), framing).unwrap(), - None - ); - } - } - - /// A track published over moq-lite has no moq-transport state, so a datagram on one - /// with any is refused. - #[test] - fn datagram_state_needs_a_moq_transport_track() { - let id = crate::model::datagram::Wire { - object_id: Some(3), - priority: None, - end_of_group: false, - status: None, - properties: Properties::default(), - }; - let properties = crate::model::datagram::Wire { - object_id: None, - properties: Properties { - block: crate::ietf::object::block( - &[Property { - id: 0x20, - value: Value::Int(1), - }], - Draft::Draft22, - ) - .unwrap(), - ..Default::default() - }, - ..id.clone() - }; - for wire in [id, properties] { - let buf = datagram(&wire); - assert!(decode_datagram(&mut Decoder::new(&buf, V.into()), from(Draft::Draft22, None)).is_ok()); - assert!( - decode_datagram(&mut Decoder::new(&buf, V.into()), Default::default()).is_err(), - "{wire:?}" - ); - } - } - - /// A retained datagram holds its properties as the bytes they arrived as, not a decoded - /// list many times their size. - #[test] - fn datagram_properties_are_held_at_their_wire_size() { - const COUNT: usize = 500; - let mut block = vec![0x20, 1]; - for _ in 1..COUNT { - block.extend([0, 1]); - } - let mut buf = Vec::new(); - let w = &mut Encoder::new(&mut buf, V.into()); - w.varint(RELAYED).unwrap(); - w.bytes(&block).unwrap(); - let wire = decode_datagram(&mut Decoder::new(&buf, V.into()), from(Draft::Draft18, None)) - .unwrap() - .unwrap(); - assert_eq!(wire.properties.heap(), block.len() as u64); - assert!(wire.properties.heap() < (COUNT * size_of::()) as u64 / 10); - } - /// Drafts 18 and 22 serialize a block alike, so only the track's draft says whether 0x06 /// is SUBGROUP_DELIVERY_TIMEOUT: a draft-18 one is dropped on its way to draft-22. #[test] @@ -927,26 +722,6 @@ mod tests { assert!(decode_end(&[0x20, 1], framing).is_err()); assert!(decode_end(&[], framing).is_ok()); } - - /// A datagram must be one its track's draft defines: a non-zero Status carries no - /// properties. - #[test] - fn a_datagram_block_needs_a_draft_that_sends_its_shape() { - for (status, carried) in [(0, true), (1, false), (3, false), (4, false)] { - for version in [Draft::Draft18, Draft::Draft22] { - // Object 3 at priority 9, which every draft can send beside a status. - let mut buf = Vec::new(); - let w = &mut Encoder::new(&mut buf, V.into()); - w.varint(RELAYED | OBJECT_ID | PRIORITY | STATUS).unwrap(); - w.varint(3).unwrap(); - w.u8(9); - w.varint(status).unwrap(); - w.bytes(&[0x20, 1]).unwrap(); - let decoded = decode_datagram(&mut Decoder::new(&buf, V.into()), from(version, None)); - assert_eq!(decoded.is_ok(), carried, "{status}: {version}"); - } - } - } } /// Peer a relay behind `upstream` over moq-lite 07, the way a cluster does. The client @@ -1311,13 +1086,13 @@ mod hop_tests { } } - /// Groups and datagrams from a moq-lite publisher cross a negotiated hop as plain as - /// they left: no relay state appears on the way, nor a moq-transport draft. + /// Groups from a moq-lite publisher cross a negotiated hop as plain as they left: no + /// relay state appears on the way, nor a moq-transport draft. #[kio::test] async fn plain_media_crosses_unchanged() { let upstream = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); let broadcast = upstream.publish("room", crate::origin::Route::default()).unwrap(); - let mut track = broadcast + let track = broadcast .create_track("video", track::Info::timed().with_timescale(Timescale::MILLI)) .unwrap(); let (downstream, sessions) = test_hop(&upstream, "room", [true, true]).await; @@ -1344,98 +1119,12 @@ mod hop_tests { assert!(frame.fields.is_none()); assert!(next(&mut received).await.is_none()); assert!(received.end_object().is_none()); - - track.insert_datagram(7, ts(9), b"datagram".to_vec()).unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, kio::wait(|waiter| subscriber.poll_recv_wire_datagram(waiter))) - .await - .expect("no datagram") - .unwrap() - .unwrap(); - assert_eq!((datagram.sequence, datagram.timestamp), (7, Some(ts(9)))); - assert_eq!(&datagram.payload[..], b"datagram"); - assert!(datagram.wire.is_none()); drop(sessions); } - /// Only a negotiated hop can preserve several datagram Object IDs. A late or lost - /// object 0 never lets another object's payload pose as the whole group. - #[kio::test] - async fn datagram_objects_cross_only_an_identifying_hop() { - for offer in [[false, false], [false, true], [true, false], [true, true]] { - for objects in [&[0, 1, 2][..], &[1, 2, 0], &[1, 2]] { - let negotiated = offer == [true, true]; - let upstream = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); - let broadcast = upstream.publish("room", crate::origin::Route::default()).unwrap(); - let info = track::Info { - properties: draft22(Vec::new()), - ..track::Info::timed().with_timescale(Timescale::MILLI) - }; - let mut track = broadcast.create_track("video", info).unwrap(); - let (downstream, sessions) = test_hop(&upstream, "room", offer).await; - let (_copy, mut subscriber) = subscribe(&downstream).await; - // Receiving a group proves the subscription and its alias are bound before - // the datagrams, independently of how the session tasks are scheduled. - track.write_frame(ts(0), b"before".as_slice()).unwrap(); - let mut before = recv(&mut subscriber).await; - assert_eq!(&next(&mut before).await.unwrap().payload.contiguous(0)[..], b"before"); - for &object in objects { - track - .insert_wire_datagram(crate::model::datagram::Stored { - datagram: crate::Datagram { - sequence: 1, - timestamp: Some(ts(1)), - payload: bytes::Bytes::from(vec![object as u8]), - }, - wire: Some(Arc::new(crate::model::datagram::Wire { - object_id: Some(object), - priority: Some(200), - end_of_group: object == 2, - status: None, - properties: Default::default(), - })), - deflated: None, - }) - .unwrap(); - } - track.insert_datagram(2, ts(2), b"next".as_slice()).unwrap(); - let mut seen = Vec::new(); - loop { - let datagram = kio::wait(|waiter| subscriber.poll_recv_wire_datagram(waiter)) - .await - .unwrap() - .unwrap(); - if datagram.sequence == 2 { - assert_eq!(&datagram.payload[..], b"next"); - assert!(datagram.wire.is_none()); - break; - } - assert_eq!(datagram.sequence, 1); - assert_eq!(datagram.payload.len(), 1); - let object = datagram.payload[0] as u64; - if negotiated { - let wire = datagram.wire.as_ref().unwrap(); - assert_eq!(wire.object_id, Some(object)); - assert_eq!(wire.priority, Some(200)); - assert_eq!(wire.end_of_group, object == 2); - } else { - assert!(datagram.wire.is_none()); - } - seen.push(object); - } - let expected: Vec<_> = objects.iter().copied().filter(|id| negotiated || *id == 0).collect(); - assert_eq!(seen, expected, "offer={offer:?} arrivals={objects:?}"); - track.write_frame(ts(3), b"after".as_slice()).unwrap(); - let mut after = recv(&mut subscriber).await; - assert_eq!(after.sequence, 3); - assert_eq!(&next(&mut after).await.unwrap().payload.contiguous(0)[..], b"after"); - drop(sessions); - } - } - } - /// Every hop carries IETF times, and provenance keeps them unchecked after a hop that /// negotiated the extension. A peer declining it checks them like native moq-lite, as a - /// listing's copy does. SUBSCRIBE, FETCH, and datagrams must agree with TRACK_INFO. + /// listing's copy does. SUBSCRIBE and FETCH must agree with TRACK_INFO. #[kio::test] async fn ietf_timestamps_keep_their_provenance_across_a_hop() { for offer in [[true, true], [true, false], [false, true]] { @@ -1446,7 +1135,7 @@ mod hop_tests { properties: draft22(Vec::new()), ..track::Info::timed() }; - let mut track = broadcast.create_track("video", info).unwrap(); + let track = broadcast.create_track("video", info).unwrap(); let mut head = track.create_group(0u64.into()).unwrap(); head.write_frame(ts(100), b"frame".to_vec()).unwrap(); head.finish().unwrap(); @@ -1500,21 +1189,12 @@ mod hop_tests { assert_eq!(frame.timestamp, Some(start), "{offer:?}"); assert_eq!(&frame.payload.contiguous(0)[..], b"frame"); assert!(next(&mut received).await.is_none()); - let time = if negotiated { ts(25) } else { ts(200) }; - track.insert_datagram(2, time, b"datagram".to_vec()).unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, kio::wait(|waiter| subscriber.poll_recv_wire_datagram(waiter))) - .await - .unwrap() - .unwrap() - .unwrap(); - assert_eq!(datagram.timestamp, Some(time), "{offer:?}"); - assert_eq!(&datagram.payload[..], b"datagram"); drop(sessions); } } /// A compressed track crosses a negotiated hop as stored: each frame's Inflated Size - /// follows its Object Record, and a datagram's follows its Datagram State. + /// follows its Object Record. #[kio::test] async fn compressed_media_crosses_a_negotiated_hop() { let upstream = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); @@ -1522,7 +1202,7 @@ mod hop_tests { let info = track::Info::timed() .with_timescale(Timescale::MILLI) .with_compress(true); - let mut track = broadcast.create_track("video", info).unwrap(); + let track = broadcast.create_track("video", info).unwrap(); let (downstream, sessions) = test_hop(&upstream, "room", [true, true]).await; let (_copy, mut subscriber) = subscribe(&downstream).await; assert!(subscriber.info().compress); @@ -1542,19 +1222,6 @@ mod hop_tests { assert!(frame.fields.is_none()); } assert!(next(&mut received).await.is_none()); - - track.insert_datagram(7, ts(9), payload(3)).unwrap(); - let datagram = kio::clock::timeout( - TIMEOUT, - kio::wait(|waiter| subscriber.poll_recv_stored_datagram(waiter)), - ) - .await - .expect("no datagram") - .unwrap() - .unwrap(); - assert_eq!((datagram.sequence, datagram.timestamp), (7, Some(ts(9)))); - assert_eq!(&datagram.payload[..], &payload(3)[..]); - assert!(datagram.deflated.is_some() && datagram.wire.is_none()); drop(sessions); } } diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index ff5974a230b..ab24ead9dbd 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -3,7 +3,7 @@ use crate::transport::poll::Session as _; #[cfg(test)] use std::sync::atomic::AtomicU64; -use crate::{SessionError, announce, frame, group, origin, track}; +use crate::{SessionError, announce, datagram, frame, group, origin, track}; use rangemap::{RangeMap, RangeSet}; use std::{ borrow::Cow, @@ -303,6 +303,7 @@ enum ControlState { Fetch(RequestServe), TrackInfo(RequestServe), Tracks(RequestServe), + Datagrams(RequestServe), Probe(ProbeServe), Auth(AuthServe), /// Decoding the peer's GOAWAY, surfaced through [`crate::Session::draining`]. @@ -344,6 +345,7 @@ impl Control { lite::ControlType::Fetch | lite::ControlType::Track => version.has_track_stream(), lite::ControlType::Auth => version.has_auth(), lite::ControlType::Tracks => version.has_tracks(), + lite::ControlType::Datagrams => version.has_datagram_tracks(), _ => true, }); @@ -370,6 +372,9 @@ impl Control { lite::ControlType::Tracks => { ControlState::Tracks(RequestServe::new(self.shared.clone(), stream)) } + lite::ControlType::Datagrams => { + ControlState::Datagrams(RequestServe::new(self.shared.clone(), stream)) + } lite::ControlType::Probe => { ControlState::Probe(ProbeServe::new(self.shared.clone(), self.runtime.clone(), stream)) } @@ -383,6 +388,7 @@ impl Control { ControlState::Fetch(serve) => return serve.poll(waiter), ControlState::TrackInfo(serve) => return serve.poll(waiter), ControlState::Tracks(serve) => return serve.poll(waiter), + ControlState::Datagrams(serve) => return serve.poll(waiter), ControlState::Probe(serve) => return serve.poll(waiter), ControlState::Auth(serve) => return serve.poll(waiter), ControlState::Goaway { stream } => { @@ -1935,8 +1941,10 @@ impl Drop for RequestServe { /// /// The open stream is interest in the track: the query that answered it stays until the /// requester closes its side, so demand holds while the requester moves on to SUBSCRIBE. -struct TrackInfoServe { - querying: track::Resolving, +enum TrackInfoServe { + Track(track::Resolving, Option>), + /// Lite-05/06: a name no stream track has, answered for its datagram track. + Datagrams(datagram::Consumer), } impl Request for TrackInfoServe { @@ -1962,9 +1970,17 @@ impl Request for TrackInfoServe { msg.instance.as_ref() } - fn start(_: &Shared, _: Self::Message, track: &str, broadcast: crate::broadcast::Consumer) -> Result { - let querying = broadcast.track(track)?.info().into_inner(); - Ok(Self { querying }) + fn start( + shared: &Shared, + _: Self::Message, + track: &str, + broadcast: crate::broadcast::Consumer, + ) -> Result { + let legacy = Legacy::new(shared.version, &broadcast, track); + match broadcast.track(track) { + Ok(track) => Ok(Self::Track(track.info().into_inner(), legacy)), + Err(err) => Ok(Self::Datagrams(Legacy::datagrams(legacy, err)?)), + } } fn update(&mut self, update: NoUpdate) { @@ -1978,7 +1994,32 @@ impl Request for TrackInfoServe { waiter: &kio::Waiter, ) -> Poll, Error>> { let ietf = ready!(shared.peer_setup.poll_ietf(waiter)); - let info = ready!(self.querying.poll_ok(waiter))?; + if let Self::Track(querying, legacy) = self + && let Poll::Ready(Err(err)) = querying.poll_ok(waiter) + { + *self = Self::Datagrams(Legacy::datagrams(legacy.take(), err)?); + } + let querying = match self { + Self::Track(querying, _) => querying, + Self::Datagrams(consumer) => { + let info = ready!(consumer.poll_info(waiter))?; + writer.set_priority(crate::coding::TRACK_INFO_SEND_ORDER); + writer.buffer(&lite::TrackInfo { + refresh: None, + priority: info.priority, + max_age: None, + timescale: info.timescale, + compress: false, + })?; + // As a stream track's answer does, though only lite-05/06 get here, which + // never negotiate the extension. + if ietf { + writer.buffer(&lite::ietf::TrackProperties(Default::default()))?; + } + return Poll::Ready(Ok(ControlFlow::Break(()))); + } + }; + let info = ready!(querying.poll_ok(waiter))?; crate::ietf::flate::egress(&info, shared.version.has_compression())?; // Ahead of the track's groups, which a subscriber holds unread until it lands. @@ -2064,7 +2105,7 @@ impl Request for TracksServe { } } -/// Answers a SUBSCRIBE stream: streams the track's groups (and best-effort datagrams) +/// Answers a SUBSCRIBE stream: streams the track's groups /// until the track ends. enum SubscribeServe { /// A time anchor waits for the track's timescale, which places it, before the model @@ -2077,6 +2118,7 @@ enum SubscribeServe { /// The newest group when the SUBSCRIBE arrived; see [`cursor_floor`]. latest: Option, update: Option, + legacy: Option>, }, /// Waiting for the model subscription to be confirmed. Confirm { @@ -2087,10 +2129,22 @@ enum SubscribeServe { /// The newest group when the SUBSCRIBE arrived; see [`cursor_floor`]. latest: Option, update: Option, + legacy: Option>, }, - /// Streaming groups and datagrams. Boxed: by far the largest state, and the enum - /// is moved on every transition. + /// Streaming groups. Boxed: by far the largest state, and the enum is moved on every + /// transition. Run(Box), + /// Lite-05/06: a name no stream track has, served from its datagram track as group + /// datagrams keyed by its sequence. + Datagrams { + consumer: datagram::Consumer, + id: u64, + priority: i8, + /// Set once the track's info is known. + send: Option, + /// One past the highest sequence served, which SUBSCRIBE_FINISH declares as the end. + end: u64, + }, /// The track finished: draining the in-flight group streams before the FIN. Drain { children: kio::Tasks, @@ -2133,7 +2187,11 @@ impl Request for SubscribeServe { track: &str, broadcast: crate::broadcast::Consumer, ) -> Result { - let track = broadcast.track(track)?; + let legacy = Legacy::new(shared.version, &broadcast, track); + let track = match broadcast.track(track) { + Ok(track) => track, + Err(err) => return Ok(Self::datagrams(Legacy::datagrams(legacy, err)?, &msg, None)), + }; let latest = track.latest_sequence(); if Bounds::has_time(&msg.ranges) { let querying = track.info().into_inner(); @@ -2143,16 +2201,29 @@ impl Request for SubscribeServe { querying, latest, update: None, + legacy, }); } let bounds = Bounds::new(&msg.ranges, None)?; - Ok(Self::subscribe(shared, msg, bounds, &track, latest, None)) + Ok(Self::subscribe(shared, msg, bounds, &track, latest, None, legacy)) } fn update(&mut self, update: lite::SubscribeUpdate) { match self { Self::Query { update: pending, .. } | Self::Confirm { update: pending, .. } => *pending = Some(update), Self::Run(run) => run.update(update), + Self::Datagrams { + consumer, + priority, + send, + .. + } => { + *priority = update.priority; + consumer.set_priority(update.priority); + if let Some(send) = send { + send.steer(update.priority); + } + } // The track is over: nothing left to steer. Self::Drain { .. } => {} } @@ -2167,12 +2238,13 @@ impl Request for SubscribeServe { loop { match self { Self::Query { querying, .. } => { - let info = ready!(querying.poll_ok(waiter))?; + let info = ready!(querying.poll_ok(waiter)); let Self::Query { msg, track, latest, update, + legacy, .. } = std::mem::replace( self, @@ -2184,18 +2256,25 @@ impl Request for SubscribeServe { else { unreachable!() }; + let info = match info { + Ok(info) => info, + Err(err) => { + *self = Self::datagrams(Legacy::datagrams(legacy, err)?, &msg, update); + continue; + } + }; let bounds = Bounds::new(&msg.ranges, info.timescale)?; - *self = Self::subscribe(shared, *msg, bounds, &track, latest, update); + *self = Self::subscribe(shared, *msg, bounds, &track, latest, update, legacy); } Self::Confirm { subscribing, .. } => { let ietf = ready!(shared.peer_setup.poll_ietf(waiter)); - let track = ready!(subscribing.poll_ok(waiter))?; - crate::ietf::flate::egress(track.info(), shared.version.has_compression())?; + let track = ready!(subscribing.poll_ok(waiter)); let Self::Confirm { msg, bounds, latest, update, + legacy, .. } = std::mem::replace( self, @@ -2207,6 +2286,14 @@ impl Request for SubscribeServe { else { unreachable!() }; + let track = match track { + Ok(track) => track, + Err(err) => { + *self = Self::datagrams(Legacy::datagrams(legacy, err)?, &msg, update); + continue; + } + }; + crate::ietf::flate::egress(track.info(), shared.version.has_compression())?; // `None` omits the per-frame timestamp prefix: an untimed track on // Lite07+, or any track before Lite05 (whose frames still carry @@ -2239,6 +2326,7 @@ impl Request for SubscribeServe { let session = shared.session.clone(); let publisher = track.info().priority; + let timescale = track.info().timescale; let group = session.send_group(crate::priority::group(msg.priority, publisher)); let sub = Subscription { session, @@ -2262,17 +2350,36 @@ impl Request for SubscribeServe { }; let floor = cursor_floor(shared.version, bounds.groups().start(), latest); let mut run = TrackRun::new(sub, track, bounds, floor, steer_tx); + run.datagrams = LegacyDatagrams::join(legacy, msg.id, msg.priority, timescale); if let Some(update) = update { run.update(update); } *self = Self::Run(Box::new(run)); } Self::Run(run) => { + // One datagram a step, beside the groups. + let sent = match &mut run.datagrams { + Some(datagrams) => match datagrams.poll_send(shared, waiter) { + Poll::Ready(true) => true, + Poll::Ready(false) => { + run.datagrams = None; + false + } + Poll::Pending => false, + }, + None => false, + }; // The live edge reached the boundary, so SUBSCRIBE_FINISH was already sent (or // the version predates the track stream). Drain the in-flight group // machines, then FIN. Lite-07 never gets here: the subscriber's FIN ends it. - if ready!(run.poll_step(writer, waiter))?.is_continue() { - return Poll::Ready(Ok(ControlFlow::Continue(()))); + match run.poll_step(writer, waiter) { + Poll::Ready(Ok(ControlFlow::Continue(()))) => { + return Poll::Ready(Ok(ControlFlow::Continue(()))); + } + Poll::Ready(Ok(ControlFlow::Break(()))) => {} + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Pending if sent => return Poll::Ready(Ok(ControlFlow::Continue(()))), + Poll::Pending => return Poll::Pending, } let Self::Run(run) = std::mem::replace( self, @@ -2293,17 +2400,296 @@ impl Request for SubscribeServe { ready!(children.poll(waiter)); return Poll::Ready(Ok(ControlFlow::Break(()))); } + Self::Datagrams { + consumer, + id, + priority, + send, + end, + } => { + if send.is_none() { + let info = ready!(consumer.poll_info(waiter))?; + *send = Some(DatagramSend::new(shared, *id, info, *priority)); + } + let send = send.as_ref().expect("set above"); + return match ready!(consumer.poll_recv(waiter))? { + Some(datagram) => { + *end = (*end).max(datagram.sequence.saturating_add(1)); + send.send(datagram); + Poll::Ready(Ok(ControlFlow::Continue(()))) + } + None => { + // A lite-05/06 subscriber reads a FIN as the end only after + // SUBSCRIBE_FINISH declared it, as a stream track's does. + writer.buffer(&lite::SubscribeResponse::Finish(lite::SubscribeFinish { + group: *end, + error: None, + }))?; + Poll::Ready(Ok(ControlFlow::Break(()))) + } + }; + } } } } } +/// Lite-05/06 carry a name's stream track and datagram track on one subscription: a name +/// with a stream track carries its datagram track beside it (see [`LegacyDatagrams`]), and +/// one with none is served from its datagram track alone. The stream track's lookup +/// resolves later through an origin, so the request keeps this until it does. +struct Legacy { + broadcast: crate::broadcast::Consumer, + name: String, +} + +impl Legacy { + /// The fallback for a request on `version`, or `None` when the stream track's answer stands. + fn new(version: Version, broadcast: &crate::broadcast::Consumer, name: &str) -> Option> { + (version.has_datagrams() && !version.has_datagram_tracks()).then(|| { + Box::new(Self { + broadcast: broadcast.clone(), + name: name.to_string(), + }) + }) + } + + /// The datagram track to serve instead when the stream track's lookup failed with `err`, + /// or `err` itself. + fn datagrams(legacy: Option>, err: Error) -> Result { + let Some(legacy) = legacy else { + return Err(err); + }; + if !matches!(err, Error::NotFound | Error::Stream(StreamError::NotFound)) { + return Err(err); + } + legacy.broadcast.datagrams(&legacy.name).map_err(|_| err) + } +} + +/// Lite-05/06: a name's datagram track carried on the subscription of its stream track, as +/// one subscription carries both kinds there. A subscriber keeps the kind it asked for. +struct LegacyDatagrams { + consumer: datagram::Consumer, + id: u64, + priority: i8, + /// The stream track's timescale, which TRACK_INFO declared for the subscription and so + /// stamps its datagrams too. + timescale: Option, + /// Set once the track's info is known. + send: Option, +} + +impl LegacyDatagrams { + /// The datagram track of the name `legacy` serves, for subscription `id` at `priority` + /// of a stream track with `timescale`, or `None` when the version has no fallback or + /// no datagram track is live. A relay holds one for each old-version stream track it + /// subscribes to, so this asks no handler: a request would subscribe to the name again. + fn join(legacy: Option>, id: u64, priority: i8, timescale: Option) -> Option { + let legacy = legacy?; + let mut consumer = legacy.broadcast.datagrams_live(&legacy.name)?; + consumer.set_priority(priority); + Some(Self { + consumer, + id, + priority, + timescale, + send: None, + }) + } + + /// Move the datagrams to the subscriber's new `priority`. + fn steer(&mut self, priority: i8) { + self.priority = priority; + self.consumer.set_priority(priority); + if let Some(send) = &mut self.send { + send.steer(priority); + } + } + + /// Send the next datagram: `true` once one went, `false` once none will come, such as a + /// name with no datagram track. The subscription goes on either way. + fn poll_send(&mut self, shared: &Shared, waiter: &kio::Waiter) -> Poll { + if self.send.is_none() { + let Ok(info) = ready!(self.consumer.poll_info(waiter)) else { + return Poll::Ready(false); + }; + let info = info.with_timescale(self.timescale); + self.send = Some(DatagramSend::new(shared, self.id, info, self.priority)); + } + let send = self.send.as_ref().expect("set above"); + match ready!(self.consumer.poll_recv(waiter)) { + Ok(Some(datagram)) => { + send.send(datagram); + Poll::Ready(true) + } + Ok(None) | Err(_) => Poll::Ready(false), + } + } +} + +/// Sends a datagram track's datagrams to one subscription, best-effort. +struct DatagramSend { + session: Transport, + id: u64, + version: Version, + /// How datagrams are stamped, or `None` when they carry no timestamp. + stamp: Option, + /// The clock an untimed track's Lite05/06 send times are read from. + runtime: crate::time::Deadlines, + /// The publisher's priority for the track, which breaks subscriber priority ties. + publisher: i8, + /// The subscription's send group, or `None` on a transport without groups. + group: Option>, +} + +impl DatagramSend { + fn new(shared: &Shared, id: u64, info: datagram::Info, priority: i8) -> Self { + let session = shared.session.clone(); + let group = session.send_group(crate::priority::group(priority, info.priority)); + Self { + session, + id, + version: shared.version, + stamp: Stamp::new(shared.version, info.timescale), + runtime: shared.runtime.clone(), + publisher: info.priority, + group, + } + } + + /// Move the subscription to the subscriber's new `priority`. + fn steer(&mut self, priority: i8) { + if let Some(group) = &mut self.group { + group.set_priority(crate::priority::group(priority, self.publisher)); + } + } + + /// Send one datagram over a QUIC datagram, newest first within the subscription. It is + /// dropped, as a datagram may be anywhere, if its body doesn't fit the transport's + /// datagram size or the transport has no room for it now. + fn send(&self, datagram: crate::Datagram) { + let Ok(timestamp) = self + .stamp + .map(|stamp| stamp.wire(datagram.timestamp, &self.runtime)) + .transpose() + else { + return; + }; + let sequence = datagram.sequence; + let body = lite::Datagram { + subscribe: self.id, + sequence, + timestamp, + payload: datagram.payload, + }; + // The stamp is set wherever the version needs a timestamp, so this never hits a + // version guard. + let Ok(body) = body.encode_bytes(self.version) else { + return; + }; + let max = self.session.max_datagram_size().min(lite::Datagram::MAX_SIZE); + if body.len() > max { + tracing::debug!(sequence, size = body.len(), max, "dropping oversized datagram"); + return; + } + let options = SendOptions { + group: self.group.as_ref(), + priority: crate::priority::in_group(sequence, 0, Order::Descending), + }; + let _ = self.session.send_datagram(body, options); + } +} + +/// Answers a Datagrams stream (lite-07): DATAGRAMS_INFO, then the datagram track's +/// datagrams from now until it ends. +struct DatagramServe { + consumer: datagram::Consumer, + id: u64, + priority: i8, + /// Set once DATAGRAMS_INFO is buffered. + send: Option, +} + +impl Request for DatagramServe { + const KIND: &'static str = "datagrams"; + const GRACEFUL: bool = true; + const SUBSCRIPTION: bool = true; + const DATA: bool = true; + const HOLDS: bool = false; + type Message = lite::Datagrams<'static>; + type Update = lite::DatagramsUpdate; + + fn decode(r: &mut Decoder<'_>, version: Version) -> Result { + lite::Datagrams::decode(r, version).map(lite::Datagrams::into_owned) + } + + fn target(msg: &Self::Message) -> (&crate::Path, &str) { + (&msg.broadcast, &msg.track) + } + + fn take_track(msg: &mut Self::Message) -> Cow<'static, str> { + std::mem::take(&mut msg.track) + } + + fn instance(msg: &Self::Message) -> Option<&crate::Instance> { + msg.instance.as_ref() + } + + fn start( + _: &Shared, + msg: Self::Message, + track: &str, + broadcast: crate::broadcast::Consumer, + ) -> Result { + let mut consumer = broadcast.datagrams(track)?; + consumer.set_priority(msg.priority); + Ok(Self { + consumer, + id: msg.id, + priority: msg.priority, + send: None, + }) + } + + fn update(&mut self, update: lite::DatagramsUpdate) { + self.priority = update.priority; + self.consumer.set_priority(update.priority); + if let Some(send) = &mut self.send { + send.steer(update.priority); + } + } + + fn poll( + &mut self, + shared: &Shared, + writer: &mut Writer, + waiter: &kio::Waiter, + ) -> Poll, Error>> { + if self.send.is_none() { + let info = ready!(self.consumer.poll_info(waiter))?; + // Ahead of the datagrams, which a subscriber drops until it lands. + writer.set_priority(crate::coding::TRACK_INFO_SEND_ORDER); + writer.buffer(&lite::DatagramsInfo::from(info))?; + self.send = Some(DatagramSend::new(shared, self.id, info, self.priority)); + } + ready!(writer.poll_flush(waiter))?; + let send = self.send.as_ref().expect("set above"); + match ready!(self.consumer.poll_recv(waiter))? { + Some(datagram) => { + send.send(datagram); + Poll::Ready(Ok(ControlFlow::Continue(()))) + } + None => Poll::Ready(Ok(ControlFlow::Break(()))), + } + } +} + impl SubscribeServe { /// Open the model subscription for `msg`'s `bounds`. /// - /// One subscriber for the whole subscription: the run loop polls its groups and its - /// best-effort datagrams from this single cursor, so a group-only or datagram-only track - /// opens exactly one subscription (no duplicate demand). + /// One subscriber for the whole subscription, so a track opens exactly one (no duplicate + /// demand). fn subscribe( shared: &Shared, msg: lite::Subscribe<'static>, @@ -2311,6 +2697,7 @@ impl SubscribeServe { track: &track::Consumer, latest: Option, update: Option, + legacy: Option>, ) -> Self { let subscription = crate::track::Subscription { priority: msg.priority, @@ -2328,6 +2715,24 @@ impl SubscribeServe { subscribing, latest, update, + legacy, + } + } + + /// Serve `msg` from the name's datagram track, at the priority of its latest `update`. + fn datagrams( + mut consumer: datagram::Consumer, + msg: &lite::Subscribe<'static>, + update: Option, + ) -> Self { + let priority = update.map_or(msg.priority, |update| update.priority); + consumer.set_priority(priority); + Self::Datagrams { + consumer, + id: msg.id, + priority, + send: None, + end: 0, } } } @@ -2640,7 +3045,7 @@ mod test { // Group 7 arrives first: run_track resolves the start there and raises // the floor. write(&mut producer, 7); - match recv_next(&mut subscriber, false, false).await.unwrap() { + match recv_next(&mut subscriber, false).await.unwrap() { Recv::Group(group) => { assert_eq!(group.sequence, 7); subscriber.set_range(group.sequence..); @@ -2651,7 +3056,7 @@ mod test { // Group 5 lands late: below the resolved start, it must not be served. write(&mut producer, 5); assert!( - recv_next(&mut subscriber, false, false).now_or_never().is_none(), + recv_next(&mut subscriber, false).now_or_never().is_none(), "a group below the resolved start must be suppressed" ); } @@ -2677,39 +3082,18 @@ mod test { }; write(&mut producer, 7); - match recv_next(&mut subscriber, false, false).await.unwrap() { + match recv_next(&mut subscriber, false).await.unwrap() { Recv::Group(group) => assert_eq!(group.sequence, 7), _ => panic!("expected the first group"), } write(&mut producer, 5); - match recv_next(&mut subscriber, false, false).await.unwrap() { + match recv_next(&mut subscriber, false).await.unwrap() { Recv::Group(group) => assert_eq!(group.sequence, 5), _ => panic!("expected the late group above the floor"), } } - #[kio::test] - async fn recv_next_drains_datagram_before_finished() { - let mut producer = track_producer("test"); - let mut subscriber = producer.subscribe(None); - - producer - .append_datagram(Timestamp::from_millis(1).unwrap(), &b"last"[..]) - .unwrap(); - producer.finish().unwrap(); - - match recv_next(&mut subscriber, true, false).await.unwrap() { - Recv::Datagram(datagram) => assert_eq!(&datagram.payload[..], b"last"), - _ => panic!("expected datagram before finished"), - } - - match recv_next(&mut subscriber, true, false).await.unwrap() { - Recv::Finished => {} - _ => panic!("expected finished after datagram"), - } - } - #[kio::test] async fn recv_next_reports_future_boundary_before_finished() { let mut producer = track_producer("test"); @@ -2724,14 +3108,14 @@ mod test { producer.finish_at(track::Position::group(7), None).unwrap(); // Group 5 is delivered first. - match recv_next(&mut subscriber, false, true).await.unwrap() { + match recv_next(&mut subscriber, true).await.unwrap() { Recv::Group(group) => assert_eq!(group.sequence, 5), _ => panic!("expected group 5"), } // With no more groups ready yet, the declared boundary surfaces even though the // track isn't finished (group 6 is still outstanding). - match recv_next(&mut subscriber, false, true).await.unwrap() { + match recv_next(&mut subscriber, true).await.unwrap() { Recv::Boundary(end) => assert_eq!(end.group, 7), _ => panic!("expected the future boundary"), } @@ -2743,11 +3127,11 @@ mod test { .unwrap() .finish() .unwrap(); - match recv_next(&mut subscriber, false, false).await.unwrap() { + match recv_next(&mut subscriber, false).await.unwrap() { Recv::Group(group) => assert_eq!(group.sequence, 6), _ => panic!("expected group 6"), } - match recv_next(&mut subscriber, false, false).await.unwrap() { + match recv_next(&mut subscriber, false).await.unwrap() { Recv::Finished => {} _ => panic!("expected finished once the boundary is reached"), } @@ -2771,7 +3155,7 @@ mod test { .finish_at(track::Position::group(7), Some(Error::App(7))) .unwrap(); let next = |subscriber: &mut track::Subscriber| { - kio::wait(|waiter| poll_recv_next(subscriber, true, false, true, end_error, waiter)).now_or_never() + kio::wait(|waiter| poll_recv_next(subscriber, true, true, end_error, waiter)).now_or_never() }; match (end_error || capped, next(&mut subscriber)) { (true, Some(Ok(Recv::Boundary(end)))) => { @@ -2801,7 +3185,7 @@ mod test { .unwrap() .finish() .unwrap(); - match recv_next(&mut subscriber, false, false).await.unwrap() { + match recv_next(&mut subscriber, false).await.unwrap() { Recv::Group(group) => assert_eq!(group.sequence, 2), _ => panic!("expected group 2"), } @@ -2812,7 +3196,7 @@ mod test { .unwrap() .finish() .unwrap(); - match recv_next(&mut subscriber, false, false).now_or_never() { + match recv_next(&mut subscriber, false).now_or_never() { Some(Ok(Recv::Group(group))) => assert_eq!(group.sequence, 1), Some(_) => panic!("expected the late-arriving group"), None => panic!("the late-arriving group was skipped"), @@ -2821,7 +3205,7 @@ mod test { // Staleness is the max delay window's job, not arrival order's: the track // still finishes normally afterward. producer.finish_at(track::Position::group(3), None).unwrap(); - match recv_next(&mut subscriber, false, false).await.unwrap() { + match recv_next(&mut subscriber, false).await.unwrap() { Recv::Finished => {} _ => panic!("expected finished"), } @@ -3560,7 +3944,7 @@ fn buffer_frame_info( Ok(()) } -/// How a lite hop stamps a track's frames and datagrams. +/// How a lite hop stamps a track's frames and a datagram track's datagrams. #[derive(Clone, Copy, Debug)] struct Stamp { /// The wire timescale TRACK_INFO declared (see [`Version::wire_timescale`]). @@ -3606,12 +3990,15 @@ fn buffer_zigzag_delta( Ok(()) } -/// What [`recv_next`] pulled from the one subscriber: the next group to serve, the next -/// best-effort datagram to forward, the track declaring its exclusive final sequence, or -/// the track finishing (the live edge having reached that boundary). +/// What [`recv_next`] pulled from the one subscriber: the next group to serve, the track +/// declaring its exclusive final sequence, or the track finishing (the live edge having +/// reached that boundary). +#[allow( + clippy::large_enum_variant, + reason = "returned and matched at once, never stored, so boxing would only add an allocation per group" +)] enum Recv { Group(group::Consumer), - Datagram(crate::model::datagram::Stored), Boundary(lite::SubscribeFinish), Finished, } @@ -3622,10 +4009,7 @@ fn final_sequence(end: track::Position) -> u64 { end.final_sequence().unwrap_or(u64::MAX) } -/// Poll a single [`track::Subscriber`] for the next group (cap-aware, in arrival order) or -/// datagram from one `&mut` borrow, so groups and datagrams share the same subscription. Groups -/// are polled first so a datagram burst can't starve them; datagrams are polled only when the -/// transport carries them. +/// Poll a single [`track::Subscriber`] for the next group (cap-aware, in arrival order). /// /// Groups are served in arrival order (`poll_recv_group`), not sequence order: on a relay, a /// burst can be ingested micro-reordered by the upstream leg, and a sequence cursor would then @@ -3636,7 +4020,7 @@ fn final_sequence(end: track::Position) -> u64 { /// [`Recv::Finished`] either. /// /// When `emit_boundary` is set, a declared-but-not-yet-reached final sequence surfaces as -/// [`Recv::Boundary`] in an idle moment (after groups and datagrams), so the caller can send +/// [`Recv::Boundary`] in an idle moment (after groups), so the caller can send /// SUBSCRIBE_FINISH as soon as the ending is known rather than waiting for the live edge to reach /// it. The caller clears `emit_boundary` after the first boundary so it fires once. /// @@ -3645,7 +4029,6 @@ fn final_sequence(end: track::Position) -> u64 { fn poll_recv_next( track: &mut track::Subscriber, groups: bool, - datagrams: bool, emit_boundary: bool, end_error: bool, waiter: &kio::Waiter, @@ -3663,14 +4046,6 @@ fn poll_recv_next( Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), Poll::Pending => {} } - if datagrams { - match track.poll_recv_stored_datagram(waiter)? { - Poll::Ready(Some(datagram)) => return Poll::Ready(Ok(Recv::Datagram(datagram))), - // Datagram side finished but groups are still paused/pending: keep waiting on groups. - Poll::Ready(None) => {} - Poll::Pending => {} - } - } // No live data ready: report the boundary (if declared) before signalling Finished, so a // future boundary reaches the subscriber while the trailing groups are still in flight. if emit_boundary && let Poll::Ready(res) = track.poll_finished(waiter) { @@ -3701,8 +4076,8 @@ fn poll_recv_next( /// The async form of [`poll_recv_next`], for callers with nothing else to poll. #[cfg(test)] -async fn recv_next(track: &mut track::Subscriber, datagrams: bool, emit_boundary: bool) -> Result { - kio::wait(|waiter| poll_recv_next(track, true, datagrams, emit_boundary, true, waiter)).await +async fn recv_next(track: &mut track::Subscriber, emit_boundary: bool) -> Result { + kio::wait(|waiter| poll_recv_next(track, true, emit_boundary, true, waiter)).await } /// Bound `group` to what this subscription asked for, or `None` when it cannot be served @@ -3788,7 +4163,7 @@ impl FramesBench { Self(Bounds::new(&ranges, None).expect("group anchors").frames) } - /// The frames served of `group`, as a served group or datagram looks them up. + /// The frames served of `group`, as a served group looks them up. pub(crate) fn serve(&self, group: u64) -> (u64, Option) { (self.0.start(group), self.0.end(group)) } @@ -3918,7 +4293,7 @@ struct Subscription { /// Last steer observed by this clone, so a change only fires once. steer_seen: Steer, version: Version, - /// How frames and datagrams are stamped, or `None` when they carry no timestamp. + /// How frames are stamped, or `None` when they carry no timestamp. stamp: Option, /// The clock an untimed track's Lite05/06 send times are read from. runtime: crate::time::Deadlines, @@ -3935,7 +4310,7 @@ struct GroupContext { /// Last steer observed by this clone, so a change only fires once. steer_seen: Steer, version: Version, - /// How frames and datagrams are stamped, or `None` when they carry no timestamp. + /// How frames are stamped, or `None` when they carry no timestamp. stamp: Option, /// The clock an untimed track's Lite05/06 send times are read from. runtime: crate::time::Deadlines, @@ -3980,77 +4355,6 @@ impl Subscription { } } - /// Send one datagram best-effort over a QUIC datagram (lite-05 §6.4). - /// - /// It goes in the subscription's send group, ranked like a stream carrying its group, so - /// it merges into the subscription's group order and wins a tie with that group's stream. - /// 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). - /// Returns whether it was handed to the transport. - fn serve_datagram(&mut self, datagram: crate::model::datagram::Stored) -> bool { - // Only the `ietf` extension can say a datagram is a status rather than a payload. - let status = datagram - .wire - .as_ref() - .is_some_and(|wire| wire.status.is_some_and(|status| status != 0)); - if status && !self.ietf { - return false; - } - let Ok(timestamp) = self - .stamp - .filter(|_| !status) - .map(|stamp| stamp.wire(datagram.timestamp, &self.runtime)) - .transpose() - else { - return false; - }; - let ietf = match (self.ietf, &datagram.wire) { - (false, _) => None, - (true, None) => Some(None), - // A sealed Timestamp keeps its units, which the receiver reads as the track's. - (true, Some(wire)) - if wire - .properties - .sealed_time - .is_some_and(|sealed| self.stamp.map(|stamp| stamp.timescale) != Some(sealed.scale())) => - { - return false; - } - (true, Some(wire)) => Some(Some(wire.as_ref().clone())), - }; - let sequence = datagram.sequence; - // A lite-07 peer takes a compressed track's datagram as stored, others inflated. - let (payload, inflated) = datagram.sent(self.version.has_compression()); - let body = lite::Datagram { - subscribe: self.id, - sequence, - timestamp, - inflated, - payload: payload.clone(), - ietf, - }; - // has_datagrams is checked before this runs, and the stamp is set wherever the - // version needs a timestamp, so encoding never hits a version guard. - let Ok(body) = body.encode_bytes(self.version) else { - return false; - }; - - let max = self.session.max_datagram_size().min(lite::Datagram::MAX_SIZE); - if body.len() > max { - tracing::debug!(sequence, size = body.len(), max, "dropping oversized datagram"); - return false; - } - - // The run loop may not have served a group since the last SUBSCRIBE_UPDATE. - let priority = crate::priority::in_group(sequence, 0, self.steer_current().order); - let options = SendOptions { - group: self.group.as_ref(), - priority, - }; - let _ = self.session.send_datagram(body, options); - true - } - /// Read the latest SUBSCRIBE_UPDATE steer, marking it seen. fn steer_current(&mut self) -> Steer { self.steer_seen = *self.steer.read(); @@ -4085,8 +4389,8 @@ impl Subscription { } } -/// A subscription's run loop: one subscriber cursor serving groups, datagrams, -/// and SUBSCRIBE_UPDATE messages, with a machine per group it serves, one at a time +/// A subscription's run loop: one subscriber cursor serving groups and SUBSCRIBE_UPDATE +/// messages, with a machine per group it serves, one at a time /// waiting for stream credit. struct TrackRun { ctx: Subscription, @@ -4120,15 +4424,10 @@ struct TrackRun { bounded: bool, // Lite05+ names every sequence below the end that this subscription never // got with SUBSCRIBE_DROP, so the subscriber settles without waiting out its grace - // for a group that will not come. The sequences served, sent as a datagram, or - // dropped. A gap that never arrived stays until the end sweep names it: this side + // for a group that will not come. The sequences served or dropped. A gap that never arrived stays until the end sweep names it: this side // cannot tell when the subscriber stopped waiting for it. // `None` on other versions. served: Option>, - // Serve datagrams off this same subscriber, but only on lite-05+ over a - // datagram-capable transport (qmux/WebSocket/TCP/UDS report size 0). No group - // fallback: otherwise off. - datagrams: bool, /// The one group waiting for stream credit. The rest of the backlog waits in the cache, /// which bills, ages, and evicts it, and the cursor's budget skips whatever went stale /// meanwhile: a machine per parked group would hold state for as long as the peer @@ -4138,6 +4437,8 @@ struct TrackRun { children: kio::Tasks, /// The track side's last poll, if it parked; see [`Self::poll_step`]. idle: Option>, + /// Lite-05/06: the name's datagram track, which the subscription carries too. + datagrams: Option, } impl TrackRun { @@ -4156,7 +4457,6 @@ impl TrackRun { let track = track.with_range((Bound::Included(floor), cap)).parked(); let emit_range = ctx.version.has_track_stream(); - let datagrams = ctx.version.has_datagrams() && ctx.session.max_datagram_size() > 0; // Lite03 and Lite04 declare no end, so neither side could settle on a drop. let served = emit_range.then(RangeSet::new); let bounded = bounds.ends(ctx.version); @@ -4177,10 +4477,10 @@ impl TrackRun { skipped: RangeMap::new(), end_sent: false, served, - datagrams, opening: None, children: kio::Tasks::new(), idle: None, + datagrams: None, } } @@ -4198,6 +4498,9 @@ impl TrackRun { /// what lets relays pause an upstream subscription across consumer churn without /// tearing it down. Lite-07 keeps every subscription open until the subscriber FINs. fn update(&mut self, upd: lite::SubscribeUpdate) { + if let Some(datagrams) = &mut self.datagrams { + datagrams.steer(upd.priority); + } // The update moves the cursor and the bounds the track side judges against, which // notifies nothing it parked on. Idle::disarm(&mut self.idle); @@ -4318,7 +4621,7 @@ impl TrackRun { writer.poll_flush(waiter) } - /// Serve one ready group or datagram (`Continue`), or `Break` once the live edge + /// Serve one ready group (`Continue`), or `Break` once the live edge /// reaches the track's boundary before lite-07, which then keeps the stream open for /// updates. Returning per item lets the caller watch the requester while work stays ready. fn poll_step( @@ -4378,7 +4681,7 @@ impl TrackRun { } /// The track side of [`Self::poll_step`]: forward drops, resolve the start, and take the - /// next group, datagram, or boundary. Every wait here parks on the track subscriber. + /// next group or boundary. Every wait here parks on the track subscriber. fn poll_track( &mut self, writer: &mut Writer, @@ -4388,8 +4691,7 @@ impl TrackRun { return Poll::Ready(Ok(ControlFlow::Continue(()))); } - // The first group waits for the source to resolve where its feed starts; datagrams - // keep flowing meanwhile. + // The first group waits for the source to resolve where its feed starts. if let Some(group) = self.first.take() { match self.track.poll_start(waiter) { Poll::Ready(source) => { @@ -4398,12 +4700,6 @@ impl TrackRun { } Poll::Pending => { self.first = Some(group); - if self.datagrams - && let Poll::Ready(Some(datagram)) = self.track.poll_recv_stored_datagram(waiter)? - { - self.serve_datagram(datagram); - return Poll::Ready(Ok(ControlFlow::Continue(()))); - } return Poll::Pending; } } @@ -4432,25 +4728,15 @@ impl TrackRun { return Poll::Ready(Ok(ControlFlow::Continue(()))); } - // One cursor drives the whole subscription: poll the cap-aware arrival-order - // group and, when enabled, the next best-effort datagram. Groups are polled - // first so a datagram burst can't starve them; datagrams flow whenever no - // group is ready (including while groups are parked above the cap). + // One cursor drives the whole subscription: poll the cap-aware arrival-order group. let emit_boundary = self.emit_range && !self.end_sent; let groups = self.opening.is_none(); let end_error = self.ctx.version.has_end_error(); - if let Poll::Ready(res) = poll_recv_next( - &mut self.track, - groups, - self.datagrams, - emit_boundary, - end_error, - waiter, - ) { + if let Poll::Ready(res) = poll_recv_next(&mut self.track, groups, emit_boundary, end_error, waiter) { match res? { Recv::Group(group) => { // Never a second stream for a sequence this subscription already accounted - // for (served, sent as a datagram, or dropped): a group re-created after its + // for (served or dropped): a group re-created after its // DROP stays dropped, since the subscriber may settle before it arrives. if self .served @@ -4483,7 +4769,6 @@ impl TrackRun { false => self.serve(group), } } - Recv::Datagram(datagram) => self.serve_datagram(datagram), Recv::Boundary(end) => { // The track declared its exclusive final sequence. Forward it now, // even if trailing groups (below it) are still in flight, then @@ -4537,7 +4822,7 @@ impl TrackRun { } /// Whether a bounded subscription still unanswered has given up every group its ranges - /// name. Datagrams resolve no start, so a range they served stays unanswered. + /// name. fn unservable(&mut self) -> bool { self.bounded && self.start.is_none() @@ -4569,48 +4854,6 @@ impl TrackRun { Ok(true) } - /// Send a datagram, recording its sequence once it went out. - fn serve_datagram(&mut self, datagram: crate::model::datagram::Stored) { - // Untyped lite can name only object 0. Drop other payload objects before - // metering them; status objects keep their existing separate drop path. - if !self.ctx.ietf - && let Some(wire) = &datagram.wire - && wire.status.is_none_or(|status| status == 0) - && wire.object_id.is_some_and(|id| id > 0) - { - tracing::debug!( - subscribe = self.ctx.id, - sequence = datagram.sequence, - object_id = wire.object_id, - "dropping nonzero datagram object on a lite hop without ietf" - ); - return; - } - if let Some(wire) = &datagram.wire - && crate::ietf::flate::egress_object( - &wire.properties, - self.track.info().ietf(), - self.ctx.version.has_compression(), - ) - .is_err() - { - return; - } - let sequence = datagram.sequence; - // The model bounds whole groups, so the peer's frame bounds are applied here, against - // the Object ID a relayed moq-transport datagram keeps (a public one is frame 0). - let object = datagram.wire.as_ref().and_then(|wire| wire.object_id).unwrap_or(0); - if object < self.frames.start(sequence) || self.frames.end(sequence).is_some_and(|end| object > end) { - return; - } - // Metered in the form the wire carries. - let (payload, _) = datagram.sent(self.ctx.version.has_compression()); - self.track.meter_datagram(payload.len() as u64); - if self.ctx.serve_datagram(datagram) { - self.mark_served(sequence); - } - } - /// Record `sequence` as served, for [`Self::drop_unserved`]. fn mark_served(&mut self, sequence: u64) { let Some(served) = &mut self.served else { @@ -6257,61 +6500,19 @@ mod serve_group_test { group.finish().unwrap(); } - /// On a transport with send groups, a subscription opens one at its priority and its - /// group streams join it, ranked newest first by sequence alone: the session-wide queue - /// takes no part. A SUBSCRIBE_UPDATE moves the group once, and an order flip re-stamps - /// the open streams. - #[kio::test] - async fn grouped_streams_rank_within_their_subscription() { - let mut track = track::Producer::new( - Arc::new(broadcast::Info::default()), - "test", - crate::track::Info::timed(), - ); - // Old groups stay worth sending, so both are in flight at once. - let subscriber = track.subscribe( - track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), - ); - let session = SinkSession::new(Log::default()).with_groups().with_unacked_fin(); - let log = session.log.clone(); - let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); - run.ctx.group = crate::transport::poll::Session::send_group(&run.ctx.session, 3); - - write_group(&mut track, 0, 0); - write_group(&mut track, 1, 1); - { - let mut poll = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); - assert!(futures::poll!(poll.as_mut()).is_pending()); - } - assert_eq!(log.groups(), [(0, 3)]); - assert_eq!( - log.joins(), - [(0, 0), (1, 0)], - "each stream joins the subscription's group" - ); - assert_eq!(log.orders(), [(0, 1), (1, 3)], "newer groups first"); - assert!(log.priorities().is_empty(), "the session queue ranks no stream"); - - run.update(lite::SubscribeUpdate { - wanted_group: None, - order: Order::Ascending, - priority: 7, - max_delay: Duration::ZERO, - min_delay: None, - ranges: vec![lite::Range::span(None, 0, None, None)], - }); - { - let mut poll = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); - assert!(futures::poll!(poll.as_mut()).is_pending()); + /// A datagram sender for subscription 0 on `session`, stamping in milliseconds. + fn datagram_send(version: Version, session: SinkSession) -> DatagramSend { + let session = crate::driver::Transport::new(session); + let group = session.send_group(crate::priority::group(3, 0)); + DatagramSend { + session, + id: 0, + version, + stamp: Stamp::new(version, Some(crate::Timescale::MILLI)), + runtime: crate::time::sim(), + publisher: 0, + group, } - assert_eq!( - log.groups(), - [(0, 3), (0, crate::priority::group(7, 0))], - "one move for the subscription" - ); - let mut flipped = log.orders()[2..].to_vec(); - flipped.sort(); - assert_eq!(flipped, [(0, 1), (1, -1)], "older groups first"); } /// The encoded body must fit both the wire cap and the transport's smaller limit. @@ -6320,27 +6521,18 @@ mod serve_group_test { for version in [Version::Lite05, Version::Lite06, Version::Lite07] { for transport in [900usize, 1200, 1400] { for size in [899usize, 900, 901, 1199, 1200, 1201] { - let track = track::Producer::new( - Arc::new(broadcast::Info::default()), - "test", - crate::track::Info::timed(), - ); let session = SinkSession::new(Log::default()).with_datagrams(transport); - let (mut run, _, _) = lite_run(version, session, track.subscribe(None)); - // Lite-07's type adds one byte to the three one-byte group fields. + let log = session.log.clone(); + // Lite-07's type adds one byte to the three one-byte fields. let header = if version.has_padding() { 4 } else { 3 }; - let sent = run.ctx.serve_datagram(crate::model::datagram::Stored { - datagram: crate::Datagram { - sequence: 1, - timestamp: Some(Timestamp::ZERO), - payload: bytes::Bytes::from(vec![0; size - header]), - }, - wire: None, - deflated: None, + datagram_send(version, session).send(crate::Datagram { + sequence: 1, + timestamp: Some(Timestamp::ZERO), + payload: bytes::Bytes::from(vec![0; size - header]), }); assert_eq!( - sent, - size <= transport.min(1200), + log.datagrams.lock().unwrap().len(), + usize::from(size <= transport.min(1200)), "{version}: body {size}, transport {transport}" ); } @@ -6348,251 +6540,93 @@ mod serve_group_test { } } - /// A datagram goes in its subscription's send group, ranked like the stream carrying - /// its group from the start, so the transport merges it into the group order. + /// A datagram goes in its subscription's send group, newest first. #[kio::test] async fn datagrams_rank_within_their_subscription() { - let mut track = track::Producer::new( - Arc::new(broadcast::Info::default()), - "test", - crate::track::Info::timed(), - ); - let subscriber = track.subscribe( - track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), - ); - let session = SinkSession::new(Log::default()) - .with_groups() - .with_datagrams(1200) - .with_unacked_fin(); + let session = SinkSession::new(Log::default()).with_groups().with_datagrams(1200); let log = session.log.clone(); - let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); - run.ctx.group = crate::transport::poll::Session::send_group(&run.ctx.session, 3); - - write_group(&mut track, 0, 0); - let sequence = track - .append_datagram(Timestamp::from_millis(1).unwrap(), b"d".as_slice()) - .unwrap(); - { - let mut poll = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); - assert!(futures::poll!(poll.as_mut()).is_pending()); - } - let order = crate::priority::in_group(sequence, 0, Order::Descending); - assert_eq!(log.datagram_ranks(), [(Some(0), order)]); - assert_eq!(log.orders(), [(0, 1)], "the stream for group 0 ranks below"); - assert!(order > 1); - } - - /// With `ietf` negotiated, frame bounds cut datagrams by Object ID: a public datagram is frame 0, a - /// relayed moq-transport one keeps its own, and both ends are inclusive. - #[kio::test] - async fn frame_bounds_cut_datagrams_by_object_id() { - for (sequence, object_id, served) in [ - (5, None, false), - (5, Some(1), false), - (5, Some(2), true), - (6, None, true), - (7, Some(3), true), - (7, Some(4), false), - ] { - let mut track = track::Producer::new( - Arc::new(broadcast::Info::default()), - "test", - crate::track::Info::timed(), - ); - let subscriber = track.subscribe( - track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), - ); - let session = SinkSession::new(Log::default()).with_groups().with_datagrams(1200); - let log = session.log.clone(); - let (mut run, mut writer, _) = lite_run(Version::Lite07, session.clone(), subscriber); - run.ctx.ietf = true; - run.ctx.group = crate::transport::poll::Session::send_group(&run.ctx.session, 3); - run.update(lite::SubscribeUpdate { - wanted_group: None, - order: Order::Descending, - priority: 0, - max_delay: Duration::from_secs(3600), - min_delay: None, - ranges: vec![lite::Range::span(Some(5), 2, Some(7), Some(3))], + let send = datagram_send(Version::Lite07, session); + for sequence in [4, 9] { + send.send(crate::Datagram { + sequence, + timestamp: Some(Timestamp::ZERO), + payload: bytes::Bytes::from_static(b"d"), }); - - track - .insert_wire_datagram(crate::model::datagram::Stored { - datagram: crate::model::datagram::Datagram { - sequence, - timestamp: Some(Timestamp::from_millis(sequence).unwrap()), - payload: bytes::Bytes::from_static(b"d"), - }, - wire: object_id.map(|object_id| { - Arc::new(crate::model::datagram::Wire { - object_id: Some(object_id), - priority: None, - end_of_group: false, - status: None, - properties: Default::default(), - }) - }), - deflated: None, - }) - .unwrap(); - { - let mut poll = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); - assert!(futures::poll!(poll.as_mut()).is_pending()); - } - assert_eq!( - log.datagram_ranks().len(), - usize::from(served), - "group {sequence} object {object_id:?}" - ); } - } + assert_eq!( + log.datagram_ranks(), + [4, 9].map(|sequence| (Some(0), crate::priority::in_group(sequence, 0, Order::Descending))) + ); - /// An object this hop cannot identify is never egress payload. An admitted object's - /// existing best-effort accounting still counts a transport-size drop as an attempt. - #[kio::test] - async fn unidentified_datagram_objects_are_not_metered() { - use crate::model::ProduceTest; - - for ietf in [false, true] { - for objects in [&[1, 2][..], &[0, 1, 2]] { - for max in [1, 1200] { - let origin = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); - let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); - let info = track::Info { - properties: Some(Arc::new(crate::model::object::TrackProperties { - version: crate::ietf::Version::Draft22, - values: Vec::new(), - })), - ..track::Info::timed() - }; - let _track = broadcast.create_track("test", info).unwrap(); - let registry = crate::stats::Registry::new(Default::default()); - let tagged = origin.consume().with_stats( - registry - .tier(Default::default()) - .session(crate::Path::from_static("egress")), - ); - let served = tagged.request("room", None).await.unwrap(); - let copy = served.track("test").unwrap(); - let subscriber = copy.subscribe(None).await.unwrap(); - let session = SinkSession::new(Log::default()).with_datagrams(max); - let log = session.log.clone(); - let (mut run, _, _) = lite_run(Version::Lite07, session, subscriber); - run.ctx.ietf = ietf; - for &object in objects { - run.serve_datagram(crate::model::datagram::Stored { - datagram: crate::Datagram { - sequence: 5, - timestamp: Some(Timestamp::ZERO), - payload: bytes::Bytes::from_static(b"payload"), - }, - wire: Some(Arc::new(crate::model::datagram::Wire { - object_id: Some(object), - priority: None, - end_of_group: false, - status: None, - properties: Default::default(), - })), - deflated: None, - }); - } - let sent = registry - .snapshot() - .traffic() - .into_iter() - .find(|(_, role, _)| *role == crate::stats::Role::Publisher) - .map(|(_, _, traffic)| traffic) - .unwrap_or_default(); - let admitted = objects.iter().filter(|&&id| ietf || id == 0).count() as u64; - assert_eq!( - (sent.datagrams, sent.groups, sent.frames, sent.bytes), - (admitted, admitted, admitted, 7 * admitted), - "ietf={ietf} ids={objects:?} max={max}" - ); - assert_eq!( - log.datagrams.lock().unwrap().len() as u64, - if max == 1 { 0 } else { admitted } - ); - // Status attempts have no payload and keep their separate accounting; - // only a typed hop can transmit them. - run.serve_datagram(crate::model::datagram::Stored { - datagram: crate::Datagram { - sequence: 6, - timestamp: None, - payload: bytes::Bytes::new(), - }, - wire: Some(Arc::new(crate::model::datagram::Wire { - object_id: Some(2), - priority: None, - end_of_group: false, - status: Some(crate::ietf::object::END_OF_GROUP), - properties: Default::default(), - })), - deflated: None, - }); - let after_status = registry - .snapshot() - .traffic() - .into_iter() - .find(|(_, role, _)| *role == crate::stats::Role::Publisher) - .map(|(_, _, traffic)| traffic) - .unwrap(); - assert_eq!(after_status.datagrams, admitted + 1); - assert_eq!(after_status.bytes, 7 * admitted); - assert_eq!( - log.datagrams.lock().unwrap().len() as u64, - if max == 1 { 0 } else { admitted + u64::from(ietf) } - ); - } - } - } + // Without send groups, the rank goes alone. + let session = SinkSession::new(Log::default()).with_datagrams(1200); + let log = session.log.clone(); + datagram_send(Version::Lite07, session).send(crate::Datagram { + sequence: 4, + timestamp: Some(Timestamp::ZERO), + payload: bytes::Bytes::from_static(b"d"), + }); + assert_eq!( + log.datagram_ranks(), + [(None, crate::priority::in_group(4, 0, Order::Descending))] + ); } - /// A SUBSCRIBE_UPDATE that flips the group order reranks the next datagram, even with no - /// group served in between. + /// On a transport with send groups, a subscription opens one at its priority and its + /// group streams join it, ranked newest first by sequence alone: the session-wide queue + /// takes no part. A SUBSCRIBE_UPDATE moves the group once, and an order flip re-stamps + /// the open streams. #[kio::test] - async fn datagrams_follow_an_updated_group_order() { + async fn grouped_streams_rank_within_their_subscription() { let mut track = track::Producer::new( Arc::new(broadcast::Info::default()), "test", crate::track::Info::timed(), ); + // Old groups stay worth sending, so both are in flight at once. let subscriber = track.subscribe( track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), ); - let session = SinkSession::new(Log::default()).with_groups().with_datagrams(1200); + let session = SinkSession::new(Log::default()).with_groups().with_unacked_fin(); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); run.ctx.group = crate::transport::poll::Session::send_group(&run.ctx.session, 3); - let first = track - .append_datagram(Timestamp::from_millis(1).unwrap(), b"a".as_slice()) - .unwrap(); + write_group(&mut track, 0, 0); + write_group(&mut track, 1, 1); { let mut poll = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); assert!(futures::poll!(poll.as_mut()).is_pending()); } + assert_eq!(log.groups(), [(0, 3)]); + assert_eq!( + log.joins(), + [(0, 0), (1, 0)], + "each stream joins the subscription's group" + ); + assert_eq!(log.orders(), [(0, 1), (1, 3)], "newer groups first"); + assert!(log.priorities().is_empty(), "the session queue ranks no stream"); run.update(lite::SubscribeUpdate { wanted_group: None, order: Order::Ascending, - priority: 3, - max_delay: Duration::from_secs(3600), + priority: 7, + max_delay: Duration::ZERO, min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }); - let second = track - .append_datagram(Timestamp::from_millis(2).unwrap(), b"b".as_slice()) - .unwrap(); { let mut poll = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); assert!(futures::poll!(poll.as_mut()).is_pending()); } - - let descending = crate::priority::in_group(first, 0, Order::Descending); - let ascending = crate::priority::in_group(second, 0, Order::Ascending); - assert_ne!(ascending, crate::priority::in_group(second, 0, Order::Descending)); - assert_eq!(log.datagram_ranks(), [(Some(0), descending), (Some(0), ascending)]); + assert_eq!( + log.groups(), + [(0, 3), (0, crate::priority::group(7, 0))], + "one move for the subscription" + ); + let mut flipped = log.orders()[2..].to_vec(); + flipped.sort(); + assert_eq!(flipped, [(0, 1), (1, -1)], "older groups first"); } /// A group with more frames ready than one poll's budget drains over several polls, @@ -8269,16 +8303,18 @@ mod tests { async fn unknown_control_types_reset_only_their_stream() { use kio::Task; // An unassigned type everywhere, then types a version predates: PROBE before lite-03, - // GOAWAY before lite-04, FETCH and TRACK before lite-05, AUTH and TRACKS before lite-07. + // GOAWAY before lite-04, FETCH and TRACK before lite-05, AUTH, TRACKS, and DATAGRAMS + // before lite-07. let cases = [ - (Version::Lite06, 9), - (Version::Lite07, 9), + (Version::Lite06, 10), + (Version::Lite07, 10), (Version::Lite02, lite::ControlType::Probe as u8), (Version::Lite03, lite::ControlType::Goaway as u8), (Version::Lite04, lite::ControlType::Fetch as u8), (Version::Lite04, lite::ControlType::Track as u8), (Version::Lite06, lite::ControlType::Auth as u8), (Version::Lite06, lite::ControlType::Tracks as u8), + (Version::Lite06, lite::ControlType::Datagrams as u8), ]; for (version, kind) in cases { let session = ScriptedSession::new(vec![kind]); diff --git a/rs/moq-net/src/lite/stream.rs b/rs/moq-net/src/lite/stream.rs index 38698bc9b36..a888641a002 100644 --- a/rs/moq-net/src/lite/stream.rs +++ b/rs/moq-net/src/lite/stream.rs @@ -16,6 +16,7 @@ pub enum ControlType { Track = 6, Auth = 7, Tracks = 8, + Datagrams = 9, } impl<'a> Decode<'a, Version> for ControlType { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 66269e56cf0..3998766d58e 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1,6 +1,6 @@ use crate::driver::Transport; use crate::transport::poll::Session as _; -use crate::{frame, group, origin, track}; +use crate::{datagram, frame, group, origin, track}; use std::{ collections::{BTreeMap, BTreeSet, HashMap}, sync::{Arc, atomic}, @@ -71,6 +71,12 @@ pub(super) struct Subscriber { // local and is never written into a hop chain. session_origin: crate::Hop, subscribes: Lock>, + /// The datagram tracks subscribed upstream, by Subscribe ID, once their datagrams can be + /// read. + datagrams: Lock>, + /// Lite-05/06: the datagram tracks taking the datagrams of their name's stream track + /// subscription, by broadcast and name. One SUBSCRIBE carries both kinds there. + legacy_datagrams: Lock>, /// Why this session ended, once it has. A track still waiting on TRACK_INFO has no /// copy in [`Self::subscribes`], so dropping its request reads this instead of /// becoming [`Error::Dropped`]. @@ -133,6 +139,13 @@ impl Minted { } } +/// A datagram track subscribed upstream, whose datagrams the session routes to it. +struct DatagramEntry { + producer: datagram::Producer, + /// The units of every datagram Timestamp on the wire, or `None` when they carry none. + timescale: Option, +} + struct TrackEntry { /// Where the subscription's groups go, once its TRACK_INFO says how to read them. A /// SUBSCRIBE sent alongside its TRACK registers before. @@ -145,6 +158,9 @@ struct TrackEntry { start: Option, /// Lite-05/06: the first group header, which says where the live feed is. first: Option, + /// Lite-05/06: the broadcast and name whose datagram track takes the datagrams this + /// subscription carries. + legacy: Option<(Path, String)>, } impl TrackEntry { @@ -230,6 +246,7 @@ impl TrackEntry { tail, start: None, first: None, + legacy: None, } } @@ -390,6 +407,8 @@ impl Subscriber { self_origin, session_origin: config.peer_hop.unwrap_or(crate::Hop::UNKNOWN), subscribes: Default::default(), + datagrams: Default::default(), + legacy_datagrams: Default::default(), ended: Default::default(), next_id: Default::default(), version: config.version, @@ -828,75 +847,49 @@ impl Subscriber { self.subscribes.lock().remove(&id); } - /// Decode one datagram body and hand it to the matching subscription's producer. + /// Decode one datagram body and hand it to the datagram track it was sent for. + /// + /// A datagram track's subscription takes its datagrams. On lite-05/06 a stream track's + /// subscription carries its name's datagrams too, which go to that name's datagram track + /// while one reads them, and are dropped otherwise, as are an unknown subscription's or + /// one not yet answered. fn route_datagram(&self, payload: bytes::Bytes) -> Result<(), Error> { if payload.len() > lite::Datagram::MAX_SIZE { return Ok(()); } - // Write through the map rather than cloning the entry out: a `TrackEntry` clone - // is a handful of atomic bumps on every datagram, and a producer held past its - // removal would keep the track (its cached groups, its stats subscription) alive. - // The group path already writes to a producer under this lock. - // Its framing is unknown until the peer's SETUP says whether the `ietf` extension is - // negotiated, and a best-effort datagram is not worth waiting for. - let Some(ietf) = self.peer_setup.ietf() else { - return Ok(()); - }; - let mut subscribes = self.subscribes.lock(); - // An unknown subscription's datagram is dropped below, however it parses. - let Some(dg) = lite::Datagram::decode(payload, self.version, ietf, |id| { - subscribes - .get(&id) - .and_then(|entry| entry.copy.as_ref()) - .map(|copy| copy.framing) - .unwrap_or_default() + let mut target = None; + let Some(dg) = lite::Datagram::decode(payload, self.version, |id| { + target = self.datagram_target(id); + target.as_ref().is_some_and(|(_, timescale)| timescale.is_some()) })? else { return Ok(()); }; - // Unknown or already-closed subscription, or one whose TRACK_INFO has not landed - // yet, which a datagram cannot wait for: drop the datagram. - let Some(TrackEntry { - copy: Some(Accepted { producer, framing }), - tail, - .. - }) = subscribes.get_mut(&dg.subscribe) - else { + let Some((mut producer, timescale)) = target else { return Ok(()); }; - let timestamp = dg .timestamp - .zip(framing.timescale) + .zip(timescale) .map(|(value, scale)| Timestamp::new(value, scale)) .transpose() .map_err(|_| Error::BoundsExceeded(crate::coding::BoundsExceeded))?; + producer.insert(crate::Datagram { + sequence: dg.sequence, + timestamp, + payload: dg.payload, + }) + } - // A datagram is never owed a stream, so it never holds the subscription's end open. - if let Ok(mut tail) = tail.write() { - tail.account(dg.sequence..dg.sequence.saturating_add(1), self.runtime.now()); - } - // A datagram says where the live feed is as well as a group does. Inserted first, so - // the copy goes live with it already showing. - let live = producer.is_live(); - let datagram = crate::model::datagram::Stored { - datagram: crate::Datagram { - sequence: dg.sequence, - timestamp, - payload: dg.payload, - }, - wire: dg.ietf.flatten().map(Arc::new), - deflated: None, - }; - match dg.inflated { - Some(size) => producer.insert_deflated_datagram(datagram, size)?, - None => producer.insert_wire_datagram(datagram)?, + /// The datagram track Subscribe ID `id` delivers to, and its datagrams' timescale. + fn datagram_target(&self, id: u64) -> Option<(datagram::Producer, Option)> { + if let Some(entry) = self.datagrams.lock().get(&id) { + return Some((entry.producer.clone(), entry.timescale)); } - // A datagram-only feed may never answer with SUBSCRIBE_START, which waits for a group. - if !live { - producer.set_live(Some(Position::group(dg.sequence)), None); - } - Ok(()) + let key = self.subscribes.lock().get(&id)?.legacy.clone()?; + let legacy = self.legacy_datagrams.lock(); + let entry = legacy.get(&key)?; + Some((entry.producer.clone(), entry.timescale)) } fn log_path<'a>(&'a self, path: &'a Path) -> crate::path::Rooted<'a> { @@ -910,7 +903,7 @@ impl Subscriber { // Owns the active subscriptions for exactly as long as the driver. A dropped // driver is how a cancelled session unwinds, so cleanup cannot depend on any // poll returning Ready. -struct SubscriptionCleanup(Lock>); +struct SubscriptionCleanup(Lock>, Lock>); impl SubscriptionCleanup { /// End every active subscription with the session's error. Group machines own @@ -921,6 +914,9 @@ impl SubscriptionCleanup { /// [`track::Producer::abort`] ends its readers cleanly instead. Before lite-05 the FIN /// is that end, declared here at the live edge since nothing more can arrive. fn abort(&self, err: &Error) { + for (_, entry) in self.1.lock().drain() { + entry.producer.abort(err.clone()); + } for (_, mut entry) in self.0.lock().drain() { // A track still waiting on TRACK_INFO has no copy yet: its request rejects with // the session's end instead. @@ -977,7 +973,7 @@ impl SubscriberDriver { discovery: crate::discovery::Discovery::new(&subscriber.origin, lazy, subscriber.runtime.clone()), announces: kio::Tasks::new(), prefixes: HashMap::new(), - cleanup: SubscriptionCleanup(subscriber.subscribes.clone()), + cleanup: SubscriptionCleanup(subscriber.subscribes.clone(), subscriber.datagrams.clone()), uni: UniAccept::new(subscriber.clone(), early), bandwidth: Some(RecvBandwidth::new(subscriber.clone())), datagrams: Some(DatagramRecv::new(subscriber.clone())), @@ -2155,6 +2151,9 @@ struct SourceServe { // A dedicated close-watch handle, since each pending operation needs its own. closed: Transport, tracks: kio::Tasks, + /// Answers the datagram tracks consumers ask the source for. + datagram_requests: crate::broadcast::DatagramRequests, + datagrams: kio::Tasks, // The source ended: no more track requests will arrive. ended: bool, /// Answers listings of the source with the peer's TRACKS. @@ -2293,10 +2292,12 @@ impl SourceServe { path: minted.path, instance: minted.instance, source: minted.source, + datagram_requests: minted.dynamic.datagrams(), dynamic: minted.dynamic, route: minted.route, closed, tracks: kio::Tasks::new(), + datagrams: kio::Tasks::new(), ended: false, } } @@ -2365,6 +2366,9 @@ impl kio::Task for SourceServe { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { let _ = self.tracks.poll(waiter); + let _ = self.datagrams.poll(waiter); + // A batch of queued requests must not hold the thread from the session's other tasks. + let mut budget = kio::coop::Budget::new(32); loop { if self.closed.poll_closed(waiter).is_ready() { @@ -2374,13 +2378,28 @@ impl kio::Task for SourceServe { self.poll_listing(waiter); if self.ended { // Done once the tracks in flight are. + ready!(self.datagrams.poll(waiter)); return self.tracks.poll(waiter); } + if let Poll::Ready(Ok(request)) = self.datagram_requests.poll_requested(waiter) { + let serve = DatagramServe::new( + self.subscriber.clone(), + self.path.clone(), + self.instance.clone(), + self.dynamic.validity().clone(), + self.source.consume(), + request, + ); + self.datagrams.push(serve); + ready!(budget.poll_yield(waiter)); + continue; + } match self.dynamic.poll_requested_track(waiter) { Poll::Ready(Ok(request)) => { let serve = TrackServe { validity: self.dynamic.validity().clone(), route: Some(self.route.clone()), + source: Some((*self.source).clone()), subscriber: self.subscriber.clone(), path: self.path.clone(), instance: self.instance.clone(), @@ -2389,6 +2408,7 @@ impl kio::Task for SourceServe { // One machine per track serves its lone subscription and any number // of fetches concurrently. self.tracks.push(TrackServeRun::new(serve, request)); + ready!(budget.poll_yield(waiter)); } // The source was finished (unannounced) or aborted. Poll::Ready(Err(err)) => { @@ -2415,10 +2435,403 @@ impl kio::Task for SourceServe { // Newly requested tracks start now rather than on the next wake. let _ = self.tracks.poll(waiter); + let _ = self.datagrams.poll(waiter); Poll::Pending } } +/// Serves one datagram track a consumer asked a source for: subscribes to it upstream, and +/// keeps the subscriber priority current until nobody reads it or it ends. The session +/// routes its datagrams by Subscribe ID (see [`Subscriber::route_datagram`]). +/// +/// Lite-07 asks on a Datagrams Stream. Lite-05/06 carry a name's stream track and datagram +/// track on one SUBSCRIBE, so this subscribes to the name's stream track through the source, +/// sharing its one upstream subscription with any stream reader, and takes the datagrams it +/// carries. Older versions carry no datagrams. +struct DatagramServe { + subscriber: Subscriber, + path: Path, + instance: Option, + validity: crate::model::broadcast::RequestValidity, + // A dedicated close-watch handle. + closed: Transport, + id: u64, + /// Lite-05/06: the source to subscribe to the stream track through, until it is asked. + source: Option, + state: DatagramState, + /// Cancels the track once the session stops allowing its broadcast. + gate: crate::auth::Gate, + /// Holds the session busy while the track is subscribed. + _working: crate::session::Working, +} + +enum DatagramState { + /// Asking the publisher: DATAGRAMS on lite-07, the stream track on lite-05/06. + Open { + request: datagram::Request, + }, + /// Lite-07: waiting for DATAGRAMS_INFO. + Info { + request: datagram::Request, + stream: Stream, + /// The priority DATAGRAMS carried. + priority: i8, + }, + /// Lite-05/06: waiting for the stream track's subscription. + Subscribe { + request: datagram::Request, + subscribing: track::Subscribing, + }, + /// Lite-07: subscribed, forwarding priority changes until the track ends or nobody + /// reads it. + Live { + producer: datagram::Producer, + stream: Stream, + priority: i8, + }, + /// Lite-05/06: taking the stream track subscription's datagrams, with its priority, + /// until the track ends or nobody reads them. + Shared { + producer: datagram::Producer, + /// Boxed: far larger than the other states. + subscriber: Box, + priority: i8, + }, + /// Our side finished: waiting for the publisher to read it. + Finish { + writer: crate::coding::Writer, + }, + Done, +} + +impl DatagramServe { + fn new( + subscriber: Subscriber, + path: Path, + instance: Option, + validity: crate::model::broadcast::RequestValidity, + source: crate::broadcast::Consumer, + request: datagram::Request, + ) -> Self { + let id = subscriber.next_id.fetch_add(1, atomic::Ordering::Relaxed); + Self { + gate: crate::auth::Gate::new(subscriber.auth.clone(), path.clone(), crate::auth::Direction::Subscribe), + _working: subscriber.session.work().start(), + closed: subscriber.session.clone(), + subscriber, + path, + instance, + validity, + id, + source: Some(source), + state: DatagramState::Open { request }, + } + } + + /// Route the track's datagrams to `producer` from now on. + fn register(&self, producer: &datagram::Producer, timescale: Option) { + self.subscriber.datagrams.lock().insert( + self.id, + DatagramEntry { + producer: producer.clone(), + timescale, + }, + ); + } + + /// Lite-05/06: route the datagrams of the name's stream track subscription to `producer`. + fn register_shared(&self, producer: &datagram::Producer, timescale: Option) { + self.subscriber.legacy_datagrams.lock().insert( + (self.path.clone(), producer.name().to_string()), + DatagramEntry { + producer: producer.clone(), + timescale, + }, + ); + } + + /// Stop routing to `producer`, unless another track took the name since. + fn unregister_shared(&self, producer: &datagram::Producer) { + let mut shared = self.subscriber.legacy_datagrams.lock(); + let key = (self.path.clone(), producer.name().to_string()); + if shared.get(&key).is_some_and(|entry| entry.producer.is_clone(producer)) { + shared.remove(&key); + } + } + + fn name(&self) -> &str { + match &self.state { + DatagramState::Open { request } + | DatagramState::Info { request, .. } + | DatagramState::Subscribe { request, .. } => request.name(), + DatagramState::Live { producer, .. } | DatagramState::Shared { producer, .. } => producer.name(), + DatagramState::Finish { .. } | DatagramState::Done => "", + } + } + + fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { + let version = self.subscriber.version; + loop { + if let Poll::Ready(err) = self.closed.poll_closed(waiter) { + return Poll::Ready(Err(crate::lite::error::from_transport(err))); + } + if self.poll_unused(waiter).is_ready() { + return Poll::Ready(Err(Error::Cancel)); + } + match &mut self.state { + DatagramState::Open { request } => { + self.validity.check(waiter)?; + if !version.has_datagrams() || !version.has_track_stream() || self.closed.max_datagram_size() == 0 { + return Poll::Ready(Err(Error::Unsupported)); + } + let priority = request.priority().unwrap_or_default(); + if !version.has_datagram_tracks() { + let source = self.source.take().expect("asked once"); + let subscription = track::Subscription::default().with_priority(priority); + let subscribing = source.track(request.name())?.subscribe(subscription).into_inner(); + let DatagramState::Open { request } = std::mem::replace(&mut self.state, DatagramState::Done) + else { + unreachable!() + }; + self.state = DatagramState::Subscribe { request, subscribing }; + continue; + } + let mut stream = ready!(Stream::poll_open_request(&self.subscriber.session, version, waiter))? + .with_stats(self.subscriber.origin.stats()); + let DatagramState::Open { request } = std::mem::replace(&mut self.state, DatagramState::Done) + else { + unreachable!() + }; + stream.writer.buffer(&lite::ControlType::Datagrams)?; + stream.writer.buffer(&lite::Datagrams { + id: self.id, + broadcast: self.path.clone(), + instance: self.instance.clone(), + track: request.name().to_string().into(), + priority, + })?; + self.state = DatagramState::Info { + request, + stream, + priority, + }; + } + DatagramState::Info { stream, .. } => { + self.validity.check_unanswered(waiter)?; + ready!(stream.writer.poll_flush(waiter))?; + let info = ready!(stream.reader.poll_decode::(waiter))?; + let DatagramState::Info { + request, + stream, + priority, + } = std::mem::replace(&mut self.state, DatagramState::Done) + else { + unreachable!() + }; + // A change since DATAGRAMS went out reaches the publisher as an update. + let producer = request.accept(info.into()); + self.register(&producer, info.timescale); + self.state = DatagramState::Live { + producer, + stream, + priority, + }; + } + DatagramState::Subscribe { subscribing, .. } => { + let subscriber = ready!(subscribing.poll_ok(waiter))?; + let DatagramState::Subscribe { request, .. } = + std::mem::replace(&mut self.state, DatagramState::Done) + else { + unreachable!() + }; + let info = subscriber.info(); + let timescale = info.timescale; + let priority = subscriber.subscription().priority; + let producer = request.accept( + datagram::Info::default() + .with_priority(info.priority) + .with_timescale(timescale), + ); + self.register_shared(&producer, timescale); + // A change since the subscription went out reaches it on the next pass. + self.state = DatagramState::Shared { + producer, + subscriber: Box::new(subscriber), + priority, + }; + } + DatagramState::Live { + producer, + stream, + priority, + } => { + // Nothing follows DATAGRAMS_INFO but the publisher's end. + let ended = match ready_or(stream.reader.poll_decode_maybe::(waiter)) { + Some(Ok(None)) => Some(Ok(())), + Some(Ok(Some(_))) => return Poll::Ready(Err(Error::ProtocolViolation)), + Some(Err(err)) => Some(Err(err)), + None => None, + }; + if let Some(end) = ended { + self.subscriber.datagrams.lock().remove(&self.id); + let DatagramState::Live { + producer, mut stream, .. + } = std::mem::replace(&mut self.state, DatagramState::Done) + else { + unreachable!() + }; + end.inspect_err(|err| producer.clone().abort(err.clone()))?; + producer.finish(); + if stream.writer.finish().is_ok() && version.waits_for_subscriber_fin() { + self.state = DatagramState::Finish { writer: stream.writer }; + continue; + } + return Poll::Ready(Ok(())); + } + match producer.poll_priority(waiter, Some(*priority)) { + Poll::Ready(Some(next)) => { + *priority = next; + stream.writer.buffer(&lite::DatagramsUpdate { priority: next })?; + continue; + } + // Nobody reads it: cancel, unless a reader arrived meanwhile, whose + // priority the next pass reads. + Poll::Ready(None) => match producer.close_unused() { + true => return Poll::Ready(Err(Error::Cancel)), + false => continue, + }, + Poll::Pending => {} + } + ready!(stream.writer.poll_flush(waiter))?; + return Poll::Pending; + } + DatagramState::Shared { + producer, + subscriber, + priority, + } => { + // The stream track's end is the datagrams' end. + if let Poll::Ready(end) = subscriber.poll_finished(waiter) { + end?; + let DatagramState::Shared { producer, .. } = + std::mem::replace(&mut self.state, DatagramState::Done) + else { + unreachable!() + }; + self.unregister_shared(&producer); + producer.finish(); + return Poll::Ready(Ok(())); + } + match producer.poll_priority(waiter, Some(*priority)) { + Poll::Ready(Some(next)) => { + *priority = next; + let subscription = subscriber.subscription().with_priority(next); + subscriber.update(subscription)?; + continue; + } + Poll::Ready(None) => match producer.close_unused() { + true => return Poll::Ready(Err(Error::Cancel)), + false => continue, + }, + Poll::Pending => return Poll::Pending, + } + } + DatagramState::Finish { writer } => { + let _ = ready!(writer.poll_close(waiter)); + return Poll::Ready(Ok(())); + } + DatagramState::Done => return Poll::Ready(Ok(())), + } + } + } + + /// Ready once nobody waits for the track during setup, which then ended it. Atomic with a + /// joining reader: one that arrived meanwhile parks the poll on it leaving. Once + /// subscribed, the serve watches its priority instead. + fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + loop { + let closed = match &self.state { + DatagramState::Open { request } + | DatagramState::Info { request, .. } + | DatagramState::Subscribe { request, .. } => { + ready!(request.poll_unused(waiter)); + request.close_unused() + } + _ => return Poll::Pending, + }; + if closed { + return Poll::Ready(()); + } + } + } + + /// End the track and the request with `err`, unless it already ended. + fn abort(&mut self, err: Error) { + self.subscriber.datagrams.lock().remove(&self.id); + match std::mem::replace(&mut self.state, DatagramState::Done) { + DatagramState::Open { request } | DatagramState::Subscribe { request, .. } => request.reject(err), + DatagramState::Info { request, stream, .. } => { + stream.writer.abort(&err); + request.reject(err); + } + DatagramState::Live { producer, stream, .. } => { + stream.writer.abort(&err); + producer.abort(err); + } + DatagramState::Shared { producer, .. } => { + self.unregister_shared(&producer); + producer.abort(err); + } + DatagramState::Finish { .. } | DatagramState::Done => {} + } + } +} + +/// A ready poll's value, or `None` while pending. +fn ready_or(poll: Poll) -> Option { + match poll { + Poll::Ready(value) => Some(value), + Poll::Pending => None, + } +} + +impl kio::Task for DatagramServe { + type Output = (); + + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + // Checked before the auth wait too, so an abandoned request never parks on a token. + if self.poll_unused(waiter).is_ready() { + self.abort(Error::Cancel); + return Poll::Ready(()); + } + ready!(self.gate.poll_ready(waiter)); + // Nothing is left to revoke once our side finished, nor on a closed session, whose + // end decides how the track ends. + if !matches!(self.state, DatagramState::Finish { .. } | DatagramState::Done) + && self.gate.poll_denied(waiter).is_ready() + && self.closed.poll_closed(waiter).is_pending() + { + tracing::info!(broadcast = %self.subscriber.log_path(&self.path), track = %self.name(), "datagrams no longer authorized"); + self.abort(Error::Unauthorized); + return Poll::Ready(()); + } + let err = match ready!(self.poll_serve(waiter)) { + Ok(()) => return Poll::Ready(()), + Err(err) => err, + }; + tracing::debug!(id = self.id, broadcast = %self.subscriber.log_path(&self.path), track = %self.name(), %err, "datagrams ended"); + self.abort(err); + Poll::Ready(()) + } +} +impl Drop for DatagramServe { + fn drop(&mut self) { + // Dropped with its source when the session dies. + let err = self.subscriber.end_reason(); + self.abort(err); + } +} + #[cfg(test)] mod tests { use super::*; @@ -3939,6 +4352,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -3962,6 +4376,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate, + datagrams: None, }; kio::wait(|waiter| kio::Task::poll(&mut running, waiter)).await; @@ -4001,6 +4416,7 @@ mod tests { let serve = TrackServe { validity: selection.validity(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -4021,6 +4437,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate, + datagrams: None, }; assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); @@ -4105,6 +4522,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -4125,6 +4543,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate, + datagrams: None, }; kio::wait(|waiter| kio::Task::poll(&mut running, waiter)).await; @@ -4175,6 +4594,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -4201,6 +4621,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate, + datagrams: None, }; assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); @@ -4247,6 +4668,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -4267,6 +4689,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate, + datagrams: None, }; assert!( kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready(), @@ -4361,6 +4784,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -4380,6 +4804,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate, + datagrams: None, }; assert!( kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending(), @@ -4431,6 +4856,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -4454,6 +4880,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate, + datagrams: None, }; assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); @@ -4500,11 +4927,10 @@ mod tests { } } - /// Removing a subscription both stops delivery and releases the session's handle - /// on the producer, so the track (its cached groups, its stats subscription) ends - /// rather than outliving the subscription it belonged to. + /// The session routes a datagram to its datagram track's subscription by Subscribe ID, + /// dropping lite-07 padding and a body past the wire cap. #[kio::test] - async fn unsubscribe_drops_the_datagram_and_releases_the_producer() { + async fn route_datagram_delivers_to_its_subscription() { for version in [Version::Lite05, Version::Lite06, Version::Lite07] { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let subscriber = Subscriber::new(SubscriberConfig { @@ -4520,44 +4946,24 @@ mod tests { auth: crate::auth::Link::new(false), }); - let broadcast = crate::broadcast::Info::new().produce(); - // The broadcast keeps only a weak handle, so the map below owns the only strong - // `track::Producer`: dropping it is what ends the track. - let mut producer = broadcast - .create_track("datagrams", crate::track::Info::timed()) - .unwrap(); - producer.set_idle(); - let mut received = producer.subscribe(None); - subscriber.subscribes.lock().insert( + let producer = datagram::Info::default() + .with_timescale(Timescale::MILLI) + .produce("datagrams"); + let mut received = producer.consume(); + subscriber.datagrams.lock().insert( 1, - TrackEntry::accepted( + DatagramEntry { producer, - lite::Framing { - timescale: Some(Timescale::MILLI), - ietf: None, - compressed: false, - }, - Default::default(), - ), + timescale: Some(Timescale::MILLI), + }, ); + let mut recv = || received.poll_recv(&kio::Waiter::noop()); if version.has_padding() { // These bytes would name the active subscription without the discriminator. for body in [vec![1], vec![1; 1200], vec![1; 1201]] { subscriber.route_datagram(body.into()).unwrap(); - assert!(received.recv_datagram().now_or_never().is_none()); - assert!( - !subscriber - .subscribes - .lock() - .get(&1) - .unwrap() - .copy - .as_ref() - .unwrap() - .producer - .is_live() - ); + assert!(recv().is_pending()); } } @@ -4567,46 +4973,32 @@ mod tests { sequence, timestamp: Some(sequence), payload: bytes::Bytes::from(vec![0; size]), - inflated: None, - ietf: None, } .encode_bytes(version) .unwrap() }; subscriber.route_datagram(payload(1, 1)).unwrap(); - assert_eq!( - received - .recv_datagram() - .now_or_never() - .unwrap() - .unwrap() - .unwrap() - .sequence, - 1 - ); + let Poll::Ready(Ok(Some(datagram))) = recv() else { + panic!("{version}: the datagram was not delivered"); + }; + assert_eq!(datagram.sequence, 1); + assert_eq!(datagram.timestamp, Some(Timestamp::new(1, Timescale::MILLI).unwrap())); let max_payload = 1200 - payload(2, 0).len(); let oversized = payload(2, max_payload + 1); assert_eq!(oversized.len(), 1201); subscriber.route_datagram(oversized).unwrap(); - assert!( - received.recv_datagram().now_or_never().is_none(), - "oversized body was delivered" - ); + assert!(recv().is_pending(), "oversized body was delivered"); let boundary = payload(3, max_payload); assert_eq!(boundary.len(), 1200); subscriber.route_datagram(boundary).unwrap(); - assert_eq!(received.recv_datagram().await.unwrap().unwrap().sequence, 3); + assert!(matches!(recv(), Poll::Ready(Ok(Some(datagram))) if datagram.sequence == 3)); - subscriber.remove_subscribe(1); - subscriber.route_datagram(payload(4, 1)).unwrap(); - // Dropping the last producer is an abrupt teardown, so the track resolves with - // `Dropped` rather than parking. A route that outlived the removal would keep the - // producer alive and leave this pending forever. - assert!( - matches!(received.recv_datagram().now_or_never(), Some(Err(Error::Dropped))), - "the track outlived its subscription" - ); + // An unknown subscription's datagram goes nowhere. + let mut other = payload(4, 1).to_vec(); + other[usize::from(version.has_padding())] = 2; + subscriber.route_datagram(other.into()).unwrap(); + assert!(recv().is_pending()); } } @@ -4641,6 +5033,7 @@ mod tests { TrackServe { validity: Default::default(), route: None, + source: None, subscriber: subscriber.clone(), path: Path::from_static("room"), instance: None, @@ -4715,6 +5108,7 @@ mod tests { let serve = TrackServe { validity: crate::model::broadcast::RequestValidity::token(Some(route.consume())), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -4788,6 +5182,7 @@ mod tests { TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -4840,6 +5235,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber: subscriber.clone(), path: Path::from_static("room"), instance: None, @@ -5171,6 +5567,7 @@ mod tests { TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -5371,6 +5768,7 @@ mod tests { TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -5422,6 +5820,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room/host"), instance: None, @@ -5489,6 +5888,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room/host"), instance: None, @@ -5506,6 +5906,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate: gate_run, + datagrams: None, }; assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); @@ -5558,6 +5959,7 @@ mod tests { serve: TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room/host"), instance: None, @@ -5632,6 +6034,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -5704,6 +6107,7 @@ mod tests { let serve = TrackServe { validity: Default::default(), route: None, + source: None, subscriber, path: Path::from_static("room"), instance: None, @@ -5736,6 +6140,7 @@ mod tests { serve, state: TrackRunState::Serve(serving), gate, + datagrams: None, }; assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); let end = reader.recv_group().now_or_never().expect("the track ended").map(|_| ()); @@ -7986,6 +8391,9 @@ struct TrackServe { validity: crate::model::broadcast::RequestValidity, /// The advertisement that minted this source, absent for standalone test sources. route: Option>, + /// The source broadcast, which a lite-05/06 track adds its name's datagram track to. + /// Absent for standalone test sources. + source: Option, subscriber: Subscriber, path: Path, /// The instance to ask for; the peer refuses a request for another. @@ -8218,10 +8626,12 @@ impl TrackServe { None => tail::grace(subscription.budget()), }; let tail = kio::Producer::new(Tail::new(grace)); - self.subscriber - .subscribes - .lock() - .insert(id, TrackEntry::new(copy, tail.clone())); + let mut entry = TrackEntry::new(copy, tail.clone()); + let version = self.subscriber.version; + if version.has_datagrams() && !version.has_datagram_tracks() { + entry.legacy = Some((self.path.clone(), self.name.clone())); + } + self.subscriber.subscribes.lock().insert(id, entry); let session = self.subscriber.session.clone(); Establish { @@ -8485,6 +8895,81 @@ struct TrackServeRun { state: TrackRunState, /// Cancels the track once the session stops allowing its broadcast. gate: crate::auth::Gate, + /// Lite-05/06: the name's datagram track, taking the datagrams its subscription carries. + datagrams: Option, +} + +/// Lite-05/06: the datagram track of a stream track's name, which the session adds to the +/// source once the track's TRACK_INFO arrives and routes the datagrams of the track's +/// subscription to. It adds no demand of its own, so a reader of either kind shares the +/// one upstream SUBSCRIBE. It ends as the track's serve does: finished with a finished +/// track, and otherwise with the error, or `Cancel` once nobody reads the stream track, so a +/// datagram reader left behind asks for the track again. +struct SharedDatagrams { + subscriber: Subscriber, + key: (Path, String), + producer: Option, +} + +impl SharedDatagrams { + /// The datagram track of `serve`'s name, unless its version carries none beside a + /// stream track or the source already has one, such as a reader's request. + fn new(serve: &TrackServe, info: &track::Info) -> Option { + let version = serve.subscriber.version; + if !version.has_datagrams() || version.has_datagram_tracks() { + return None; + } + let info = datagram::Info::default() + .with_timescale(info.timescale) + .with_priority(info.priority); + let producer = serve + .source + .as_ref()? + .create_datagrams(serve.name.as_str(), info) + .ok()?; + let key = (serve.path.clone(), serve.name.clone()); + serve.subscriber.legacy_datagrams.lock().insert( + key.clone(), + DatagramEntry { + producer: producer.clone(), + timescale: info.timescale, + }, + ); + Some(Self { + subscriber: serve.subscriber.clone(), + key, + producer: Some(producer), + }) + } +} + +impl SharedDatagrams { + /// Stop routing to the track and end it with `end`. + fn end(&mut self, end: Result<(), Error>) { + let Some(producer) = self.producer.take() else { + return; + }; + let mut shared = self.subscriber.legacy_datagrams.lock(); + if shared + .get(&self.key) + .is_some_and(|entry| entry.producer.is_clone(&producer)) + { + shared.remove(&self.key); + } + drop(shared); + match end { + Ok(()) => producer.finish(), + Err(err) => producer.abort(err), + } + } +} + +impl Drop for SharedDatagrams { + fn drop(&mut self) { + // Dropped with its serve when the session dies. + let err = self.subscriber.end_reason(); + self.end(Err(err)); + } } #[expect( @@ -8541,12 +9026,20 @@ impl TrackServeRun { TrackRunState::Serve(ServeLoop::new(&serve, request, info)) }; let gate = serve.gate(); - Self { serve, state, gate } + Self { + serve, + state, + gate, + datagrams: None, + } } /// The session no longer allows the broadcast: end the track and cancel the /// upstream subscription and fetches, leaving the rest of the session alone. fn revoke(&mut self) { + if let Some(datagrams) = &mut self.datagrams { + datagrams.end(Err(Error::Unauthorized)); + } tracing::info!(broadcast = %self.serve.subscriber.log_path(&self.serve.path), track = %self.serve.name, "subscription no longer authorized"); match std::mem::replace(&mut self.state, TrackRunState::Done) { TrackRunState::Info { @@ -8691,6 +9184,8 @@ impl kio::Task for TrackServeRun { // Lite05/06 timestamps may be send times, so the copy's groups // skip the rewind check. info.unchecked = !self.serve.subscriber.version.has_media_time(); + // Before the accept, so a relay serving the track on sees it. + self.datagrams = SharedDatagrams::new(&self.serve, &info); let mut serve_loop = ServeLoop::adopt(&self.serve, request, info.clone(), dynamic, fetches); serve_loop.held = Some(held); if let Some(pipelined) = pipelined { @@ -8745,12 +9240,22 @@ impl kio::Task for TrackServeRun { }, ServeEnd::Finished => { let _ = serve_loop.serving.finish(); + if let Some(datagrams) = &mut self.datagrams { + datagrams.end(Ok(())); + } } ServeEnd::GiveBack(err) => { let err = lite::error::control(&mut self.serve.subscriber.session.clone(), err); + if let Some(datagrams) = &mut self.datagrams { + datagrams.end(Err(err.clone())); + } let _ = serve_loop.serving.abort(err); } } + // Nobody reads the stream track: a datagram reader left behind asks again. + if let Some(datagrams) = &mut self.datagrams { + datagrams.end(Err(Error::Cancel)); + } if let Sub::Active(mut active) = serve_loop.sub { self.serve.subscriber.remove_subscribe(active.id); @@ -9010,7 +9515,7 @@ enum ServeMode { Establish(Establish), /// The publisher FINned once the track ended, as before lite-07, but QUIC does not order /// streams: keep the subscription routable until every owed group is accounted for. The - /// grace bounds an unaccounted sequence, such as a lost datagram. Lite-07 keeps serving + /// grace bounds an unaccounted sequence. Lite-07 keeps serving /// instead, settling its tail in [`ServeLoop::finishing`]. Tail { settle: Settle, diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 483bad4b34d..954246b94db 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -982,7 +982,6 @@ pub struct ScriptedSession { unacked_fin: bool, /// Set by [`Self::with_unacked_uni_fins`]; shared, so every clone counts down one budget. unacked_unis: Arc, - datagram_size: usize, /// Set by [`Self::with_groups`]. groups: bool, /// Set by [`Self::with_send_window`]. @@ -1004,11 +1003,6 @@ impl ScriptedSession { self } - pub fn with_datagrams(mut self, max_size: usize) -> Self { - self.datagram_size = max_size; - self - } - pub fn new(script: Vec) -> Self { Self { log: Log::default(), @@ -1024,7 +1018,6 @@ impl ScriptedSession { incoming_bidis: Arc::new(Mutex::new(std::collections::VecDeque::new())), unacked_fin: false, unacked_unis: Default::default(), - datagram_size: 0, groups: false, send_window: None, } @@ -1288,7 +1281,7 @@ impl poll::Session for ScriptedSession { } fn max_datagram_size(&self) -> usize { - self.datagram_size + 0 } fn address(&self) -> crate::transport::Address { diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 05c9d92a788..b7b83323b7b 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -79,13 +79,23 @@ impl Version { !matches!(self, Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04) } - /// Whether the session may deliver groups over unreliable QUIC datagrams (lite-05 §6.4). - /// Group datagrams route one frame over an existing subscription; lite-07 adds a - /// leading type and padding. Older than lite-05 never sends or accepts datagrams. + /// Whether the session may carry unreliable QUIC datagrams (lite-05 §6.4): single-frame + /// groups on lite-05/06, a datagram track's datagrams on lite-07, which adds a leading + /// type and padding. Older than lite-05 never sends or accepts datagrams. pub fn has_datagrams(self) -> bool { !matches!(self, Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04) } + /// Whether a subscriber subscribes to a datagram track through its own Datagrams Stream + /// (0x9), whose datagrams carry the datagram track's own sequence. Added in lite-07; + /// earlier versions carry single-frame groups as datagrams on a SUBSCRIBE subscription. + pub(crate) fn has_datagram_tracks(self) -> bool { + !matches!( + self, + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 + ) + } + /// Whether announce streams begin with ANNOUNCE_OK and omit the sender's origin /// from each announcement's hop chain. Added in lite-05. pub fn has_announce_ok(self) -> bool { @@ -268,7 +278,7 @@ impl Version { } /// Whether TRACK_INFO carries the compression flag, and a compressed Track's FRAME - /// and datagram its inflated size. Added in lite-07; an earlier peer receives the + /// its inflated size. Added in lite-07; an earlier peer receives the /// frames inflated. pub(crate) fn has_compression(self) -> bool { !matches!( @@ -277,15 +287,6 @@ impl Version { ) } - /// Whether the stream error table has NOT_FETCHABLE, for a FETCH that reached a - /// datagram. Added in lite-07; earlier versions say NOT_FOUND instead. - pub(crate) fn has_not_fetchable(self) -> bool { - !matches!( - self, - Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 - ) - } - /// Whether SUBSCRIBE_DROP's Error Code is a stream error code, ending a dropped group's /// reader as a reset keeping its header would. Added in lite-07; earlier versions carry /// an application-specific code there. @@ -386,7 +387,7 @@ impl Version { } /// The timescale the wire carries for a track with `timescale`, or `None` when its - /// frames and datagrams carry no timestamp. + /// frames or datagrams carry no timestamp. /// /// Lite05 and Lite06 always carry one, so an untimed track declares milliseconds there /// and its payloads carry their send times instead. diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index c0a836556e1..27f2f1999d8 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -7,9 +7,9 @@ //! a publisher ends its content by finishing its tracks, and goes offline by closing. //! //! [Info] is the broadcast's static metadata, fixed for its lifetime. -use crate::{cache, stats, track}; +use crate::{cache, datagram, stats, track}; use std::{ - collections::HashSet, + collections::{HashSet, VecDeque}, sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -125,6 +125,16 @@ struct BroadcastState { // The tracks cached since each [`Demand`] handle last looked, by handle, so a // handle watches each track once rather than rescanning the table. demands: slab::Slab>, + + // Datagram tracks, a namespace apart from stream tracks: published, requested, or + // served. Weak, so a closed entry is reclaimed like a track's. + datagrams: WeakCache, datagram::Weak>, + // Datagram tracks consumers asked for, waiting for a [`DatagramRequests`] to take them. + datagram_requests: VecDeque, + // Live [`DatagramRequests`] handles; a datagram track is requested only while one lives. + datagram_handlers: usize, + // Set on an origin's front: datagram lookups go to the source it serves from, if any. + datagram_source: Option>, } impl BroadcastState { @@ -167,6 +177,10 @@ impl BroadcastState { for request in self.requests.drain_queued() { request.reject(err.clone()); } + for request in self.datagram_requests.drain(..) { + request.reject(err.clone()); + } + self.datagram_source = None; for track in self.tracks.iter() { track.reject(err.clone()); } @@ -384,6 +398,46 @@ impl Producer { Ok(track.listed(unlist)) } + /// Produce a new datagram track and insert it into the broadcast. + /// + /// Datagram tracks are a namespace apart from stream tracks, so this never conflicts with + /// [`Self::create_track`]. Fails with [`Error::Duplicate`] while a live datagram track + /// holds the name. + /// + /// Moq-lite 05 and 06 carry it on its name's stream track subscriptions, but only those + /// that start once it exists: create it before, or with, a stream track of the same name. + /// See [`datagram`](crate::datagram). + pub fn create_datagrams( + &self, + name: impl Into>, + info: datagram::Info, + ) -> Result { + let name = name.into(); + let mut state = self.state.lock(); + if let Some(live) = state.datagrams.get(name.as_ref()) { + // A consumer asked for it before it existed, and no handler took the request yet: + // creating the track serves those consumers. + let queued = state + .datagram_requests + .iter() + .position(|request| live.same_channel(&request.weak())); + let Some(queued) = queued else { + return Err(Error::Duplicate); + }; + let request = state.datagram_requests.remove(queued).expect("found above"); + return Ok(request.with_stats(self.stats.clone()).accept(info)); + } + let producer = datagram::Producer::new(name.clone(), Some(info)).with_stats(self.stats.clone()); + state.datagrams.insert(name, producer.weak()); + Ok(producer) + } + + /// Answer datagram lookups from `source`, the broadcast an origin's front serves from, + /// or refuse them with [`Error::Unroutable`] while it has none. + pub(super) fn serve_datagrams_from(&self, source: Option) { + self.state.lock().datagram_source = Some(source); + } + /// Reserve a track by name without finalizing its [`track::Info`]. /// /// Returns a [`track::Request`] already discoverable by consumers; call @@ -567,7 +621,7 @@ impl Alive { return true; } // A queued track is a reader on its way that let go of its handle. - if !state.requests.is_empty() { + if !state.requests.is_empty() || !state.datagram_requests.is_empty() { return false; } // Held under the lock a consumer's first mint takes, so none appears until the @@ -826,6 +880,12 @@ impl Requests { &self.alive.validity } + /// A handler for the datagram tracks consumers ask for. Until one exists, asking for a + /// datagram track nothing publishes fails with [`Error::NotFound`]. + pub fn datagrams(&self) -> DatagramRequests { + DatagramRequests::new(self.alive.clone(), self.state.clone(), self.stats.clone()) + } + /// Answer the broadcast's track listings, for a handler that knows the tracks it /// would serve, such as a relay listing its upstream's. See [`Lister`]. /// @@ -847,6 +907,62 @@ impl Requests { } } +/// Handles on-demand datagram tracks for a broadcast, like [`Requests`] does stream tracks. +/// +/// Created by [`Requests::datagrams`]. When the last handle drops, the requests it never +/// took are rejected with [`Error::Dropped`]. +pub struct DatagramRequests { + // Keeps the broadcast alive while a handler exists, as `Requests` does. + alive: Arc, + state: kio::Shared, + stats: stats::Scope, +} + +impl DatagramRequests { + fn new(alive: Arc, state: kio::Shared, stats: stats::Scope) -> Self { + state.lock().datagram_handlers += 1; + Self { alive, state, stats } + } + + /// The next datagram track a consumer asks for, to accept or reject. + /// + /// Returns [`Error::Closed`] once the broadcast has ended. + #[cfg_attr(feature = "async", kio::wrapper)] + pub fn poll_requested(&mut self, waiter: &kio::Waiter) -> Poll> { + if self.alive.validity.check(waiter).is_err() { + self.alive.close(); + } + let mut state = ready!(self.state.poll(waiter, |state| { + match !state.datagram_requests.is_empty() || state.closing { + true => Poll::Ready(()), + false => Poll::Pending, + } + })); + match state.datagram_requests.pop_front() { + Some(request) => Poll::Ready(Ok(request.with_stats(self.stats.clone()))), + None => Poll::Ready(Err(Error::Closed)), + } + } +} + +impl Clone for DatagramRequests { + fn clone(&self) -> Self { + Self::new(self.alive.clone(), self.state.clone(), self.stats.clone()) + } +} + +impl Drop for DatagramRequests { + fn drop(&mut self) { + let mut state = self.state.lock(); + state.datagram_handlers -= 1; + if state.datagram_handlers == 0 { + for request in state.datagram_requests.drain(..) { + request.reject(Error::Dropped); + } + } + } +} + #[cfg(test)] use futures::FutureExt; @@ -969,6 +1085,59 @@ impl Consumer { ) } + /// Consume the datagram track `name`, live from now. + /// + /// Joins the track a producer publishes, or asks a [`DatagramRequests`] handler for it, + /// whose answer the consumer reads. Fails with [`Error::Unroutable`] once the broadcast + /// has ended, and [`Error::NotFound`] when nothing publishes the track and no handler + /// would serve a request for it. + pub fn datagrams(&self, name: &str) -> Result { + let mut state = self.state.lock(); + if state.closing { + return Err(Error::Unroutable); + } + if let Some(source) = &state.datagram_source { + let source = source.clone().ok_or(Error::Unroutable)?; + drop(state); + return Ok(source.datagrams(name)?.with_stats(self.stats.clone())); + } + if let Some(live) = state.datagrams.get(name) + && let Some(consumer) = live.try_consume(self.stats.clone()) + { + return Ok(consumer); + } + if state.datagram_handlers == 0 { + return Err(Error::NotFound); + } + let request = datagram::Request::new(name.into()); + let consumer = request + .weak() + .try_consume(self.stats.clone()) + .expect("a fresh request is open"); + state.datagrams.insert(name.into(), request.weak()); + // Requests their readers left are withdrawn, so the queue holds only what someone + // still waits for, however long a handler goes without taking any. + state.datagram_requests.retain(|queued| !queued.is_closed()); + state.datagram_requests.push_back(request); + Ok(consumer) + } + + /// Join the datagram track `name` if one is published or already asked for, asking no + /// handler for it otherwise. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn datagrams_live(&self, name: &str) -> Option { + let state = self.state.lock(); + if state.closing { + return None; + } + if let Some(source) = &state.datagram_source { + let source = source.clone()?; + drop(state); + return Some(source.datagrams_live(name)?.with_stats(self.stats.clone())); + } + state.datagrams.get(name)?.try_consume(self.stats.clone()) + } + /// Join the demand of the track serving `name`, requesting it from a [`Requests`] /// when nothing serves it yet. Registers the subscription before exposing demand. /// Errors as [`Self::track`] does. @@ -2333,4 +2502,144 @@ mod test { assert!(track.abort_unwanted(Error::Cancel).is_ok()); producer.close(); } + + fn recv_datagram(consumer: &mut datagram::Consumer) -> Poll, Error>> { + consumer.poll_recv(&kio::Waiter::noop()) + } + + fn received(consumer: &mut datagram::Consumer) -> u64 { + match recv_datagram(consumer) { + Poll::Ready(Ok(Some(datagram))) => datagram.sequence, + other => panic!("expected a datagram, got {other:?}"), + } + } + + /// A datagram track shares no name with a stream track, and holds its own while live. + #[test] + fn datagram_tracks_are_a_namespace_apart() { + let producer = Info::new().produce(); + let consumer = producer.consume(); + let _track = producer.create_track("audio", crate::track::Info::timed()).unwrap(); + let mut datagrams = producer.create_datagrams("audio", datagram::Info::default()).unwrap(); + assert!(matches!( + producer.create_datagrams("audio", datagram::Info::default()), + Err(Error::Duplicate) + )); + + let mut reader = consumer.datagrams("audio").unwrap(); + datagrams.append(None, "x").unwrap(); + assert_eq!(received(&mut reader), 0); + + // Once it ends, the name is free again, and a reader no longer joins it. + datagrams.finish(); + assert!(matches!(recv_datagram(&mut reader), Poll::Ready(Ok(None)))); + assert!(matches!(consumer.datagrams("audio"), Err(Error::NotFound))); + producer.create_datagrams("audio", datagram::Info::default()).unwrap(); + } + + /// A datagram track nothing publishes is asked of a handler, once however many readers + /// want it, and the handler's answer reaches them all. + #[test] + fn a_datagram_request_resolves_with_its_answer() { + let producer = Info::new().produce(); + let consumer = producer.consume(); + assert!(matches!(consumer.datagrams("audio"), Err(Error::NotFound))); + + let mut handler = producer.requests().datagrams(); + let requested = |handler: &mut DatagramRequests| handler.poll_requested(&kio::Waiter::noop()); + let mut first = consumer.datagrams("audio").unwrap(); + let mut second = consumer.datagrams("audio").unwrap(); + assert!(first.poll_info(&kio::Waiter::noop()).is_pending()); + let Poll::Ready(Ok(request)) = requested(&mut handler) else { + panic!("expected a request"); + }; + assert!(requested(&mut handler).is_pending(), "the readers share one request"); + assert_eq!(request.name(), "audio"); + + let info = datagram::Info::default().with_priority(2); + let mut served = request.accept(info); + assert!(matches!(second.poll_info(&kio::Waiter::noop()), Poll::Ready(Ok(got)) if got == info)); + served.append(None, "x").unwrap(); + assert_eq!(received(&mut first), 0); + assert_eq!(received(&mut second), 0); + + let mut refused = consumer.datagrams("video").unwrap(); + let Poll::Ready(Ok(request)) = requested(&mut handler) else { + panic!("expected a request"); + }; + request.reject(Error::NotFound); + assert!(matches!(recv_datagram(&mut refused), Poll::Ready(Err(Error::NotFound)))); + } + + /// Creating a datagram track serves the readers already asking for it, before any + /// handler takes their request. + #[test] + fn create_datagrams_fulfills_a_queued_request() { + let producer = Info::new().produce(); + let mut handler = producer.requests().datagrams(); + let mut reader = producer.consume().datagrams("audio").unwrap(); + + let info = datagram::Info::default().with_priority(2); + let mut datagrams = producer.create_datagrams("audio", info).unwrap(); + assert!(matches!(reader.poll_info(&kio::Waiter::noop()), Poll::Ready(Ok(got)) if got == info)); + datagrams.append(None, "x").unwrap(); + assert_eq!(received(&mut reader), 0); + assert!(handler.poll_requested(&kio::Waiter::noop()).is_pending()); + } + + /// Closing the broadcast refuses its queued datagram requests and every later one, and + /// ends its handlers. + #[test] + fn close_refuses_datagram_requests() { + let producer = Info::new().produce(); + let mut handler = producer.requests().datagrams(); + let consumer = producer.consume(); + let mut queued = consumer.datagrams("audio").unwrap(); + + producer.close(); + assert!(matches!( + recv_datagram(&mut queued), + Poll::Ready(Err(Error::Unroutable)) + )); + assert!(matches!(consumer.datagrams("audio"), Err(Error::Unroutable))); + assert!(matches!( + handler.poll_requested(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Closed)) + )); + } + + /// A request whose readers all left is withdrawn, so a handler that never polls holds + /// only the requests someone still waits for. + #[test] + fn a_datagram_request_nobody_waits_for_is_withdrawn() { + let producer = Info::new().produce(); + let _handler = producer.requests().datagrams(); + let consumer = producer.consume(); + for name in ["a", "b", "c", "d"] { + let left = consumer.datagrams(name).unwrap(); + drop(left); + } + let mut waiting = consumer.datagrams("e").unwrap(); + assert_eq!(producer.state.lock().datagram_requests.len(), 1); + assert!(recv_datagram(&mut waiting).is_pending()); + // A withdrawn name is asked for afresh. + let _again = consumer.datagrams("a").unwrap(); + assert_eq!(producer.state.lock().datagram_requests.len(), 2); + } + + /// Losing the last datagram handler drops the requests it never took, and readers find + /// nothing to ask after. + #[test] + fn dropping_the_datagram_handler_drops_its_queue() { + let producer = Info::new().produce(); + let handler = producer.requests().datagrams(); + let consumer = producer.consume(); + let mut queued = consumer.datagrams("audio").unwrap(); + + drop(handler.clone()); + assert!(recv_datagram(&mut queued).is_pending(), "a clone still handles it"); + drop(handler); + assert!(matches!(recv_datagram(&mut queued), Poll::Ready(Err(Error::Dropped)))); + assert!(matches!(consumer.datagrams("audio"), Err(Error::NotFound))); + } } diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index a5524a702c8..1666325ee98 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -1,9 +1,7 @@ -//! A shared byte budget for cached groups and buffered datagrams, repaid by write-time eviction. +//! A shared byte budget for cached groups, repaid by write-time eviction. //! //! Every group charges its cached bytes into a [`Pool`] through a crate-internal -//! `Charge`, billed to its track's `Track` account. A track's datagram send buffer bills -//! each datagram it holds the same way, both payload forms of a compressed one included, -//! and pays debt by dropping its oldest once the groups' payment falls short. The pool itself never evicts: it is +//! `Charge`, billed to its track's `Track` account. The pool itself never evicts: it is //! a handful of atomic counters. While the pool is over capacity, each track accrues //! eviction debt as it writes (`accrue`), //! sized proportionally to what it wrote, and pays that debt by aborting its own oldest @@ -124,10 +122,10 @@ impl Config { /// A shared cache policy and byte budget; cloning shares both. /// -/// The pool tracks how many payload bytes are cached across every registered group and -/// datagram buffer, plus the mean last-access time of the evictable groups. It never +/// The pool tracks how many payload bytes are cached across every registered group, +/// plus the mean last-access time of the evictable groups. It never /// evicts on its own: tracks accrue eviction debt as they write and evict their own -/// oldest groups and datagrams to pay it. Idle expiration runs during [`Self::poll`]. The capacity is +/// oldest groups to pay it. Idle expiration runs during [`Self::poll`]. The capacity is /// therefore a target usage converges toward, not a hard limit: carried debt, capped /// payments, and the always-protected live edge all let usage transiently exceed it. #[derive(Clone)] @@ -246,7 +244,7 @@ impl Pool { } } - /// Bytes currently cached across every registered group and datagram buffer. + /// Bytes currently cached across every registered group. pub fn used(&self) -> u64 { self.inner.used.load(Ordering::Relaxed) } @@ -724,12 +722,12 @@ pub(super) struct Track { } /// The greatest location a track has written or its source reported, followed by the track -/// handles' `largest`: each new group or datagram at its head, then each frame written to it. +/// handles' `largest`: each new group at its head, then each frame written to it. #[derive(Default)] struct Largest { position: Option, - // The largest group's content may still grow: no copy of the whole group finished, and it - // is not a datagram. An aborted one stays open, since it can be recreated or fetched. + // The largest group's content may still grow: no copy of the whole group finished. An + // aborted one stays open, since it can be recreated or fetched. open: bool, // The track ended, so no newer position can follow. ended: bool, @@ -745,8 +743,8 @@ impl Track { } } - /// Group `sequence` holds every frame it will have: a whole copy finished, or it is a - /// datagram. A group name fixes its content, so no legal write raises the largest past it. + /// Group `sequence` holds every frame it will have: a whole copy finished. A group name + /// fixes its content, so no legal write raises the largest past it. pub(super) fn finish_largest(&self, sequence: u64) { let mut largest = self.largest.lock(); if largest.position.is_some_and(|position| position.group == sequence) { @@ -921,17 +919,6 @@ impl Track { }) } - /// Bill `bytes` of a buffered datagram, which accrue eviction debt like a group's frames. - pub(super) fn bill(self: &Arc, bytes: u64) -> Bill { - self.pool.add(bytes); - self.written.fetch_add(bytes, Ordering::Relaxed); - Bill { - track: self.clone(), - bytes, - at: self.pool.now(), - } - } - /// Take everything written since the last call, to be turned into eviction debt. pub(super) fn take_written(&self) -> u64 { self.written.swap(0, Ordering::Relaxed) @@ -971,7 +958,7 @@ impl Track { // its teardown on its own clone count rather than the state's. let Some(state) = self.state.upgrade() else { // An ended track's channel is closed, but a stale consumer can still hold its - // groups and datagrams: the sweep expires them in place, or they would never go. + // groups: the sweep expires them in place, or they would never go. if full && scan_expiry { self.state.read(|state| state.expire_closed(state.expiry_scan_drain())); } @@ -1042,7 +1029,7 @@ impl Drop for Track { } } -/// The bytes a held head of a failed group or a buffered datagram bills to its track's +/// The bytes a held head of a failed group bills to its track's /// pool, released on drop. pub(super) struct Bill { track: Arc, @@ -1052,11 +1039,6 @@ pub(super) struct Bill { } impl Bill { - /// What it bills the pool. - pub(super) fn bytes(&self) -> u64 { - self.bytes - } - /// Whether the pool's idle window passed since it was billed. pub(super) fn expired(&self) -> bool { let pool = &self.track.pool; diff --git a/rs/moq-net/src/model/datagram.rs b/rs/moq-net/src/model/datagram.rs index d09ff1edb63..42e45474f53 100644 --- a/rs/moq-net/src/model/datagram.rs +++ b/rs/moq-net/src/model/datagram.rs @@ -1,30 +1,41 @@ -//! A datagram is a single unreliable payload delivered on a track, parallel to groups. +//! Datagram tracks: live, unreliable payloads addressed by their own sequence. //! -//! Unlike a group (an ordered stream of frames over a QUIC stream), a datagram is one self-contained -//! payload carried in a single QUIC datagram: best-effort, unordered, and never retransmitted. It -//! shares the track's monotonic sequence-number namespace with groups but is otherwise independent, -//! produced via [`super::track::Producer::append_datagram`] / [`super::track::Producer::insert_datagram`] -//! and consumed via [`super::track::Subscriber::recv_datagram`]. +//! A datagram track is fixed as one when it is created, independent of any stream track of +//! the same name. Each datagram is one self-contained payload carried in a single QUIC +//! datagram: best-effort, unordered, and never retransmitted, cached, or fetched. It has a +//! sequence from the track's own counter and a timestamp, and no group. //! -//! Delivery is best-effort per hop: a session drops (with a debug log) any datagram whose encoded -//! body exceeds the transport's datagram size, and sessions that can't carry datagrams at all -//! (moq-lite before 05, or stream-only transports like WebSocket) never deliver them. +//! A broadcast creates a [`Producer`] with +//! [`broadcast::Producer::create_datagrams`](crate::broadcast::Producer::create_datagrams), +//! and a reader asks for a [`Consumer`] with +//! [`broadcast::Consumer::datagrams`](crate::broadcast::Consumer::datagrams). A consumer +//! is live from when it is created: it never receives a datagram written before it. //! -//! A datagram is never cached or served by a fetch. Only the short per-track send buffer holds -//! it, which a new subscription may still read. That buffer bills what it retains to the -//! track's [`Pool`](crate::cache::Pool), like cached groups, so pressure drops the oldest. +//! Delivery is best-effort per hop: a session drops (with a debug log) any datagram whose +//! encoded body exceeds the transport's datagram size, and sessions that can't carry +//! datagrams at all (moq-lite before 05, moq-transport for now, or stream-only transports +//! like WebSocket) never deliver them. //! -//! Wire counterparts: [`crate::lite::Datagram`] and moq-transport's -//! [`crate::ietf::ObjectDatagram`], whose Group ID is the sequence. Relays preserve its Object -//! IDs and statuses between moq-transport peers and across a moq-lite hop negotiating `ietf`. -//! The plain reader and a lite hop without that extension deliver only normal object 0 -//! payloads, dropping other objects and statuses without refusing the track. +//! Moq-lite 07 asks for a datagram track on a request of its own. Moq-lite 05 and 06 carry a +//! name's groups and datagrams on one subscription, as moq-transport does: a session serves +//! an old subscription with both of the name's tracks, and a subscriber keeps only the kind +//! it asked for, sharing one subscription with any reader of the other kind. Two limits +//! follow on those versions: +//! +//! - A subscription carries the datagram track that exists when it starts, so create a +//! name's datagram track before, or with, its stream track. +//! - The wire never says a name has no datagram track, so a datagram reader of a name with +//! only a stream track receives nothing until that track ends. + +use std::{ + collections::VecDeque, + sync::Arc, + task::{Poll, ready}, +}; use bytes::Bytes; -use crate::Timestamp; -use crate::cache; -use std::sync::Mutex; +use crate::{Error, Result, Timescale, Timestamp, stats}; /// Hard ceiling on a datagram payload, matching the QUIC DATAGRAM frame limit. /// @@ -32,138 +43,779 @@ use std::sync::Mutex; /// encoded body exceeds the transport's current datagram size (roughly the path MTU minus QUIC /// and MoQ header overhead), so callers should keep payloads well below the minimum path MTU /// of 1200 bytes (e.g. a single audio frame). -pub(super) const MAX_DATAGRAM_PAYLOAD: usize = u16::MAX as usize; +pub(crate) const MAX_PAYLOAD: usize = u16::MAX as usize; -/// A single unreliable payload on a track: a sequence number, an optional presentation timestamp, and the bytes. +/// How many datagrams a track holds for consumers that have not read them yet. /// -/// 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`]). +/// A consumer more than this far behind skips to the oldest one still held, so a stalled +/// reader costs the track at most this many payloads. +const MAX_BUFFERED: usize = 64; + +/// How many payload bytes a track holds for consumers that have not read them yet, beyond +/// which it drops the oldest, though never the newest. Datagrams that fit a path are well +/// under this; it bounds a local writer of large payloads. +const MAX_BUFFERED_BYTES: usize = 64 * 1024; + +/// The default subscriber priority of a [`Consumer`]. +const DEFAULT_PRIORITY: i8 = 0; + +/// A single unreliable payload: its sequence, its timestamp, and the bytes. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Datagram { - /// Per-track sequence number, shared with the group namespace. + /// The datagram track's own sequence number. pub sequence: u64, - /// Presentation timestamp in the track's timescale, or `None` when untimed. + /// Presentation timestamp in the track's timescale, or `None` on a track without one. pub timestamp: Option, /// The datagram payload. pub payload: Bytes, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct Wire { - pub object_id: Option, - pub priority: Option, - pub end_of_group: bool, - pub status: Option, - pub properties: super::object::Properties, +/// Publisher-side properties of a datagram track, fixed when it is created. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct Info { + /// Units per second of each datagram's timestamp, or `None` for a track without one. + /// + /// A track is all timed or all untimed: a write that doesn't match is refused with + /// [`Error::TimestampMismatch`]. + pub timescale: Option, + /// The publisher's priority for this track, higher first, defaulting to 0. It breaks + /// ties between subscriptions of equal subscriber priority. + pub priority: i8, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct Stored { - pub datagram: Datagram, - pub wire: Option>, - /// On a compressed track, the payload as its own DEFLATE stream: a datagram is a - /// one-frame group, so it compresses with a fresh window. Kept beside the inflated - /// `datagram.payload`, since both are small and short-lived. - pub deflated: Option, -} - -impl Stored { - /// The payload a session sends, with its inflated size when that is the DEFLATE slice: - /// only a session that carries `compression` takes a compressed track's stored form. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub fn sent(&self, compression: bool) -> (&Bytes, Option) { - match &self.deflated { - Some(deflated) if compression => (deflated, Some(self.datagram.payload.len() as u64)), - _ => (&self.datagram.payload, None), +impl Info { + /// Set the timestamp scale, or `None` for an untimed track, returning `self` for chaining. + pub fn with_timescale(mut self, timescale: impl Into>) -> Self { + self.timescale = timescale.into(); + self + } + + /// Set the publisher's priority, returning `self` for chaining. + pub fn with_priority(mut self, priority: i8) -> Self { + self.priority = priority; + self + } + + /// `timestamp` at this track's [`timescale`](Self::timescale). + /// + /// A track is all timed or all untimed: a timestamp that doesn't match it, or one the + /// track's scale can't hold, is [`Error::TimestampMismatch`]. A writer that changes + /// state before its datagram reaches the track, such as encrypting it, rescales first so + /// a refused timestamp changes nothing. + pub fn rescale(&self, timestamp: Option) -> Result> { + match (timestamp, self.timescale) { + (Some(timestamp), Some(timescale)) => Ok(Some( + timestamp.convert(timescale).map_err(|_| Error::TimestampMismatch)?, + )), + (None, None) => Ok(None), + _ => Err(Error::TimestampMismatch), } } - /// The bytes the track stores and meters: the DEFLATE slice when it has one. - pub fn stored_len(&self) -> u64 { - self.deflated.as_ref().unwrap_or(&self.datagram.payload).len() as u64 + /// Produce a standalone datagram track with this info, outside any broadcast. + pub fn produce(self, name: impl Into>) -> Producer { + Producer::new(name.into(), Some(self)) } +} - /// What buffering it costs the pool: both payload forms, any wire metadata, and its slot. - fn cache_size(&self) -> u64 { - let wire = self.wire.as_ref().map_or(0, |wire| { - size_of::() + wire.properties.block.as_ref().map_or(0, |block| block.bytes.len()) - }); - let deflated = self.deflated.as_ref().map_or(0, Bytes::len); - (self.datagram.payload.len() + deflated + wire + SLOT_OVERHEAD) as u64 +#[derive(Default)] +struct State { + // `None` while a request waits for a handler to accept it. + info: Option, + buffer: VecDeque, + // The payload bytes `buffer` holds. + bytes: usize, + // The absolute index of `buffer[0]`, so consumers keep a cursor past what was dropped. + offset: u64, + // The sequence `append` assigns next: one past the highest written, or `None` once + // `u64::MAX` was written and no sequence follows it. + next: Option, + // Set once the track ends: `Ok` for a finish. + end: Option>, + // Live producer-side handles: producers and an unanswered request. + producers: usize, + // Each live consumer's subscriber priority. + priorities: slab::Slab, +} + +impl State { + fn closed(&self) -> Result<()> { + match &self.end { + None => Ok(()), + Some(Ok(())) => Err(Error::Closed), + Some(Err(err)) => Err(err.clone()), + } + } + + fn push(&mut self, datagram: Datagram) { + self.bytes += datagram.payload.len(); + while self.buffer.len() >= MAX_BUFFERED || (!self.buffer.is_empty() && self.bytes > MAX_BUFFERED_BYTES) { + let dropped = self.buffer.pop_front().expect("not empty"); + self.bytes -= dropped.payload.len(); + self.offset += 1; + } + self.next = match (self.next, datagram.sequence.checked_add(1)) { + (Some(next), Some(after)) => Some(next.max(after)), + _ => None, + }; + self.buffer.push_back(datagram); + } + + fn end(&mut self, end: Result<()>) { + if self.end.is_some() { + return; + } + // A finish leaves what is buffered for the consumers already reading; an error + // ends the track at once, so nobody reads what is left. + let unread = end.is_err() || self.priorities.is_empty(); + self.end = Some(end); + if unread { + self.release(); + } + } + + /// Drop what is buffered: nobody can read it any more. A consumer joining later starts + /// past it, so this holds for an ended track once its last consumer leaves. + fn release(&mut self) { + self.offset += self.buffer.len() as u64; + self.buffer.clear(); + self.bytes = 0; + } + + /// Whether a consumer at `cursor` has a datagram to read: past what was dropped, it reads + /// the oldest one still held. + fn has_next(&self, cursor: u64) -> bool { + !self.buffer.is_empty() && self.offset + (self.buffer.len() as u64) > cursor + } + + fn priority(&self) -> Option { + self.priorities.iter().map(|(_, priority)| *priority).max() } } -/// Heap a buffered datagram's slot takes, doubled because a `VecDeque` holds up to twice -/// its entries. -const SLOT_OVERHEAD: usize = 2 * size_of::(); +/// The broadcast's handle on a datagram track, which keeps nothing alive. +#[derive(Clone)] +pub(super) struct Weak { + name: Arc, + state: kio::Shared, +} -/// A datagram in a track's send buffer, billed to the track's pool until it leaves. +impl Weak { + /// Join the track as a new consumer, unless it already ended. + pub(super) fn try_consume(&self, stats: stats::Scope) -> Option { + Consumer::join(self.name.clone(), &self.state, stats) + } +} + +impl super::WeakEntry for Weak { + fn is_closed(&self) -> bool { + self.state.read().end.is_some() + } + + fn same_channel(&self, other: &Self) -> bool { + self.state.same_channel(&other.state) + } +} + +/// Writes datagrams to a datagram track. /// -/// Readers take clones of [`Self::stored`], which alias its payloads rather than copy them, -/// so only the buffer is billed. -pub(super) struct Buffered { - pub sequence: u64, - pub timestamp: Option, - // Released in place once an ended track's datagram expires: the closed track state - // refuses the write that would pop it, and a stale reader would otherwise pin it. - held: Mutex>, +/// Clones share the track and its sequence counter. The track ends on [`Self::finish`], +/// [`Self::abort`], or the last producer dropping, which consumers see as +/// [`Error::Dropped`]. +pub struct Producer { + name: Arc, + info: Info, + state: kio::Shared, + stats: stats::Scope, } -impl Buffered { - /// Bill `stored` to `cache`, accruing eviction debt like a group's frames. - pub fn new(stored: Stored, cache: &std::sync::Arc) -> Self { - let bill = cache.bill(stored.cache_size()); +impl Producer { + pub(super) fn new(name: Arc, info: Option) -> Self { + let state = State { + info, + next: Some(0), + producers: 1, + ..Default::default() + }; Self { - sequence: stored.sequence, - timestamp: stored.timestamp, - held: Mutex::new(Some((stored, bill))), + name, + info: info.unwrap_or_default(), + state: kio::Shared::new(state), + stats: stats::Scope::default(), } } - /// A reader's alias of it, `None` once released. - pub fn stored(&self) -> Option { - self.held.lock().unwrap().as_ref().map(|(stored, _)| stored.clone()) + pub(super) fn with_stats(mut self, scope: stats::Scope) -> Self { + self.stats = scope; + self } - /// What it bills the pool, zero once released. - pub fn bytes(&self) -> u64 { - self.held.lock().unwrap().as_ref().map_or(0, |(_, bill)| bill.bytes()) + pub(super) fn weak(&self) -> Weak { + Weak { + name: self.name.clone(), + state: self.state.clone(), + } } - /// Whether nobody has written it for the pool's idle window, or it was released. - pub fn expired(&self) -> bool { - self.held - .lock() - .unwrap() - .as_ref() - .is_none_or(|(_, bill)| bill.expired()) + /// The track's name within its broadcast. + pub fn name(&self) -> &str { + &self.name } - /// Release its payloads and bill once expired, for an ended track that cannot pop it. - /// Returns whether it is released. - pub fn release_expired(&self) -> bool { - let mut held = self.held.lock().unwrap(); - if held.as_ref().is_some_and(|(_, bill)| !bill.expired()) { + /// The track's properties. + pub fn info(&self) -> &Info { + &self.info + } + + /// Append a datagram with the next sequence number, returning that sequence. + /// + /// Pass `None` on an untimed track; a timestamp that doesn't match the track is + /// [`Error::TimestampMismatch`]. Keep payloads well under the 1200-byte minimum path + /// MTU: a hop drops a datagram that does not fit. + pub fn append(&mut self, timestamp: impl Into>, payload: B) -> Result { + let mut state = self.state.lock(); + let sequence = state.next.ok_or(crate::coding::BoundsExceeded)?; + self.push( + &mut state, + Datagram { + sequence, + timestamp: timestamp.into(), + payload: payload.into_bytes(), + }, + )?; + Ok(sequence) + } + + /// Insert a datagram with an explicit sequence, as a relay forwarding one does. + /// + /// [`Self::append`] continues past the highest sequence inserted. Nothing checks the + /// sequence otherwise: datagrams are unordered, so a repeat or an older one is delivered. + pub fn insert(&mut self, datagram: Datagram) -> Result<()> { + let mut state = self.state.lock(); + self.push(&mut state, datagram) + } + + fn push(&self, state: &mut State, mut datagram: Datagram) -> Result<()> { + if datagram.payload.len() > MAX_PAYLOAD { + return Err(Error::FrameTooLarge); + } + state.closed()?; + datagram.timestamp = self.info.rescale(datagram.timestamp)?; + self.stats.meter().datagram(datagram.payload.len() as u64); + state.push(datagram); + Ok(()) + } + + /// A new consumer of the track, starting with the next datagram written. + pub fn consume(&self) -> Consumer { + Consumer::join(self.name.clone(), &self.state, stats::Scope::default()) + .unwrap_or_else(|| Consumer::ended(self.name.clone(), &self.state)) + } + + /// End the track cleanly: consumers read `None` once they have read what is buffered. + pub fn finish(self) { + self.state.lock().end(Ok(())); + } + + /// End the track with an error. + pub fn abort(self, err: Error) { + self.state.lock().end(Err(err)); + } + + /// The highest subscriber priority among the track's consumers, or `None` when it has none. + pub fn priority(&self) -> Option { + self.state.read().priority() + } + + /// Resolves to [`Self::priority`] once it differs from `last`. + pub fn poll_priority(&self, waiter: &kio::Waiter, last: Option) -> Poll> { + let state = ready!(self.state.poll(waiter, |state| match state.priority() == last { + true => Poll::Pending, + false => Poll::Ready(()), + })); + Poll::Ready(state.priority()) + } + + /// Ready once the track has no consumer; see [`Self::close_unused`]. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + let _state = ready!(self.state.poll(waiter, |state| match state.priorities.is_empty() { + true => Poll::Ready(()), + false => Poll::Pending, + })); + Poll::Ready(()) + } + + /// End the track with [`Error::Cancel`] if it has no consumer, returning whether it ended. + /// + /// Atomic with a consumer joining, so a session letting an unread track go never strands + /// a reader that arrived meanwhile: that reader either keeps the track or finds it ended + /// and asks for it again. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn close_unused(&self) -> bool { + let mut state = self.state.lock(); + if !state.priorities.is_empty() { return false; } - *held = None; + state.end(Err(Error::Cancel)); true } + + /// Whether this is the same track as `other`. + pub fn is_clone(&self, other: &Self) -> bool { + self.state.same_channel(&other.state) + } } -impl From for Stored { - fn from(datagram: Datagram) -> Self { +impl Clone for Producer { + fn clone(&self) -> Self { + self.state.lock().producers += 1; Self { - datagram, - wire: None, - deflated: None, + name: self.name.clone(), + info: self.info, + state: self.state.clone(), + stats: self.stats.clone(), } } } -impl std::ops::Deref for Stored { - type Target = Datagram; - fn deref(&self) -> &Datagram { - &self.datagram +impl Drop for Producer { + fn drop(&mut self) { + let mut state = self.state.lock(); + state.producers -= 1; + if state.producers == 0 { + state.end(Err(Error::Dropped)); + } + } +} + +/// A datagram track a consumer asked for that a handler has yet to serve. +/// +/// Yielded by [`broadcast::DatagramRequests::poll_requested`](crate::broadcast::DatagramRequests::poll_requested). +/// [`Self::accept`] it with the track's [`Info`] to start producing, or [`Self::reject`] it. +/// Dropping it rejects it with [`Error::Dropped`]. +pub struct Request { + producer: Producer, +} + +impl Request { + pub(super) fn new(name: Arc) -> Self { + Self { + producer: Producer::new(name, None), + } + } + + pub(super) fn with_stats(mut self, scope: stats::Scope) -> Self { + self.producer = self.producer.with_stats(scope); + self + } + + pub(super) fn weak(&self) -> Weak { + self.producer.weak() + } + + /// The requested track's name. + pub fn name(&self) -> &str { + self.producer.name() + } + + /// Whether the request ended, such as by its last waiting consumer leaving. + pub(super) fn is_closed(&self) -> bool { + self.producer.state.read().end.is_some() + } + + /// The highest subscriber priority among the consumers waiting, or `None` once they all left. + pub fn priority(&self) -> Option { + self.producer.priority() + } + + /// [`Producer::poll_unused`] for a request. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + self.producer.poll_unused(waiter) + } + + /// [`Producer::close_unused`] for a request nobody waits for any more. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn close_unused(&self) -> bool { + self.producer.close_unused() + } + + /// Serve the track with `info`, returning its producer. + pub fn accept(self, info: Info) -> Producer { + let mut producer = self.producer; + producer.state.lock().info = Some(info); + producer.info = info; + producer + } + + /// Refuse the track: its consumers read `err`. + pub fn reject(self, err: Error) { + self.producer.abort(err); + } +} + +/// Reads a datagram track's datagrams, in arrival order, from when it was created. +/// +/// Each consumer holds a subscriber priority that the track's publisher sees aggregated as +/// the highest among its consumers. A clone keeps the original's priority and position. +pub struct Consumer { + name: Arc, + state: kio::Shared, + // This consumer's entry in `State::priorities`, or `None` for one created on an ended track. + slot: Option, + // The absolute index of the next datagram to read. + cursor: u64, + stats: stats::Scope, +} + +impl Consumer { + /// Join a live track, or `None` once it ended. + fn join(name: Arc, state: &kio::Shared, stats: stats::Scope) -> Option { + let mut guard = state.lock(); + if guard.end.is_some() { + return None; + } + let slot = guard.priorities.insert(DEFAULT_PRIORITY); + let cursor = guard.offset + guard.buffer.len() as u64; + drop(guard); + Some(Self { + name, + state: state.clone(), + slot: Some(slot), + cursor, + stats, + }) + } + + fn ended(name: Arc, state: &kio::Shared) -> Self { + // Past what a finish left buffered, which is for the consumers already reading. + let cursor = { + let state = state.read(); + state.offset + state.buffer.len() as u64 + }; + Self { + name, + state: state.clone(), + slot: None, + cursor, + stats: stats::Scope::default(), + } + } + + pub(super) fn with_stats(mut self, scope: stats::Scope) -> Self { + self.stats = scope; + self + } + + /// The track's name within its broadcast. + pub fn name(&self) -> &str { + &self.name + } + + /// The track's properties, once its publisher accepted it. + #[cfg_attr(feature = "async", kio::wrapper)] + pub fn poll_info(&self, waiter: &kio::Waiter) -> Poll> { + let state = ready!( + self.state + .poll(waiter, |state| match state.info.is_some() || state.end.is_some() { + true => Poll::Ready(()), + false => Poll::Pending, + }) + ); + match (state.info, &state.end) { + (Some(info), _) => Poll::Ready(Ok(info)), + (None, Some(Err(err))) => Poll::Ready(Err(err.clone())), + (None, _) => Poll::Ready(Err(Error::Closed)), + } + } + + /// The next datagram, or `None` once the track finished and this consumer read what it + /// buffered. An aborted track errors at once. + /// + /// A consumer that fell behind skips the datagrams the track no longer holds. + #[cfg_attr(feature = "async", kio::wrapper)] + pub fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll>> { + let cursor = self.cursor; + let state = ready!(self.state.poll(waiter, |state| { + match state.end.is_some() || state.has_next(cursor) { + true => Poll::Ready(()), + false => Poll::Pending, + } + })); + // Buffered datagrams first: a finished track still delivers them. + if !state.has_next(cursor) { + let end = state.end.clone().expect("ready only once ended or buffered"); + return Poll::Ready(end.map(|()| None)); + } + let index = self.cursor.max(state.offset); + let datagram = state.buffer[(index - state.offset) as usize].clone(); + drop(state); + self.cursor = index + 1; + self.stats.meter().datagram(datagram.payload.len() as u64); + Poll::Ready(Ok(Some(datagram))) + } + + /// This consumer's subscriber priority. + pub fn priority(&self) -> i8 { + self.slot + .and_then(|slot| self.state.read().priorities.get(slot).copied()) + .unwrap_or(DEFAULT_PRIORITY) + } + + /// Set this consumer's subscriber priority, higher first. + pub fn set_priority(&mut self, priority: i8) { + if let Some(slot) = self.slot { + self.state.lock().priorities[slot] = priority; + } + } + + /// Whether this reads the same track as `other`. + pub fn is_clone(&self, other: &Self) -> bool { + self.state.same_channel(&other.state) + } +} + +impl Clone for Consumer { + fn clone(&self) -> Self { + let slot = self.slot.map(|slot| { + let mut state = self.state.lock(); + let priority = state.priorities[slot]; + state.priorities.insert(priority) + }); + Self { + name: self.name.clone(), + state: self.state.clone(), + slot, + cursor: self.cursor, + stats: self.stats.clone(), + } + } +} + +impl Drop for Consumer { + fn drop(&mut self) { + if let Some(slot) = self.slot { + let mut state = self.state.lock(); + state.priorities.remove(slot); + // Nobody waits for a request no handler accepted yet: withdraw it, so a handler + // that stops polling never holds requests nobody reads. + if state.priorities.is_empty() && state.info.is_none() { + state.end(Err(Error::Cancel)); + } + // The last reader of a finished track leaves what it never read. + if state.priorities.is_empty() && state.end.is_some() { + state.release(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn recv(consumer: &mut Consumer) -> Poll>> { + consumer.poll_recv(&kio::Waiter::noop()) + } + + fn sequence(consumer: &mut Consumer) -> u64 { + match recv(consumer) { + Poll::Ready(Ok(Some(datagram))) => datagram.sequence, + other => panic!("expected a datagram, got {other:?}"), + } + } + + #[test] + fn append_counts_its_own_sequence_past_inserts() { + let mut producer = Info::default().produce("dg"); + let mut consumer = producer.consume(); + assert_eq!(producer.append(None, "a").unwrap(), 0); + producer + .insert(Datagram { + sequence: 10, + timestamp: None, + payload: Bytes::from_static(b"b"), + }) + .unwrap(); + // An older one is still delivered, and moves nothing back. + producer + .insert(Datagram { + sequence: 3, + timestamp: None, + payload: Bytes::from_static(b"c"), + }) + .unwrap(); + assert_eq!(producer.append(None, "d").unwrap(), 11); + let got: Vec = (0..4).map(|_| sequence(&mut consumer)).collect(); + assert_eq!(got, [0, 10, 3, 11]); + assert!(recv(&mut consumer).is_pending()); + } + + /// Past `u64::MAX` no sequence remains, so `append` refuses instead of repeating it. + #[test] + fn append_refuses_once_the_sequence_is_spent() { + let mut producer = Info::default().produce("dg"); + producer + .insert(Datagram { + sequence: u64::MAX, + timestamp: None, + payload: Bytes::from_static(b"last"), + }) + .unwrap(); + assert!(matches!(producer.append(None, "x"), Err(Error::BoundsExceeded(_)))); + } + + #[test] + fn a_consumer_starts_live() { + let mut producer = Info::default().produce("dg"); + producer.append(None, "old").unwrap(); + let mut consumer = producer.consume(); + assert!(recv(&mut consumer).is_pending()); + producer.append(None, "new").unwrap(); + assert_eq!(sequence(&mut consumer), 1); + } + + #[test] + fn a_slow_consumer_skips_to_the_oldest_held() { + let mut producer = Info::default().produce("dg"); + let mut consumer = producer.consume(); + for _ in 0..MAX_BUFFERED + 5 { + producer.append(None, "x").unwrap(); + } + assert_eq!(sequence(&mut consumer), 5); + } + + #[test] + fn a_slow_consumer_holds_a_bounded_number_of_bytes() { + let mut producer = Info::default().produce("dg"); + let mut consumer = producer.consume(); + let payload = Bytes::from(vec![0; MAX_BUFFERED_BYTES / 4]); + for _ in 0..8 { + producer.append(None, payload.clone()).unwrap(); + } + // Four fit, so the oldest four went. + assert_eq!(sequence(&mut consumer), 4); + // The newest is always held, even alone over the budget. + let mut producer = Info::default().produce("dg"); + let mut consumer = producer.consume(); + for _ in 0..2 { + producer.append(None, vec![0; MAX_PAYLOAD]).unwrap(); + } + assert_eq!(sequence(&mut consumer), 1); + } + + #[test] + fn timestamps_must_match_the_track() { + let mut timed = Info::default().with_timescale(Timescale::MILLI).produce("dg"); + let mut consumer = timed.consume(); + assert!(matches!(timed.append(None, "x"), Err(Error::TimestampMismatch))); + timed.append(Timestamp::from_secs(1).unwrap(), "x").unwrap(); + let Poll::Ready(Ok(Some(datagram))) = recv(&mut consumer) else { + panic!("expected a datagram"); + }; + // Converted into the track's scale. + assert_eq!(datagram.timestamp, Some(Timestamp::from_millis(1000).unwrap())); + let mut untimed = Info::default().produce("dg"); + assert!(matches!( + untimed.append(Timestamp::from_secs(1).unwrap(), "x"), + Err(Error::TimestampMismatch) + )); + } + + #[test] + fn the_end_reaches_consumers() { + let mut producer = Info::default().produce("dg"); + let mut finished = producer.consume(); + producer.clone().finish(); + assert!(matches!(recv(&mut finished), Poll::Ready(Ok(None)))); + assert!(matches!(producer.append(None, "x"), Err(Error::Closed))); + + let producer = Info::default().produce("dg"); + let mut dropped = producer.consume(); + drop(producer); + assert!(matches!(recv(&mut dropped), Poll::Ready(Err(Error::Dropped)))); + } + + #[test] + fn a_finish_delivers_what_is_buffered() { + let mut producer = Info::default().produce("dg"); + let mut consumer = producer.consume(); + producer.append(None, "a").unwrap(); + producer.append(None, "b").unwrap(); + producer.finish(); + assert_eq!(sequence(&mut consumer), 0); + assert_eq!(sequence(&mut consumer), 1); + assert!(matches!(recv(&mut consumer), Poll::Ready(Ok(None)))); + + // A consumer arriving after the finish never reads them. + let mut producer = Info::default().produce("dg"); + let _reading = producer.consume(); + producer.append(None, "a").unwrap(); + producer.clone().finish(); + let mut late = producer.consume(); + assert!(matches!(recv(&mut late), Poll::Ready(Ok(None)))); + + // Nobody left to read them, they go: on a finish with no consumer, or with the last + // consumer of a finished track. + let mut producer = Info::default().produce("dg"); + producer.append(None, "a").unwrap(); + producer.clone().finish(); + assert!(producer.state.read().buffer.is_empty()); + let mut producer = Info::default().produce("dg"); + let reading = producer.consume(); + producer.append(None, "a").unwrap(); + producer.clone().finish(); + assert_eq!(producer.state.read().buffer.len(), 1); + drop(reading); + assert!(producer.state.read().buffer.is_empty()); + assert_eq!(producer.state.read().bytes, 0); + + // An abort drops them. + let mut producer = Info::default().produce("dg"); + let mut consumer = producer.consume(); + producer.append(None, "a").unwrap(); + producer.abort(Error::Cancel); + assert!(matches!(recv(&mut consumer), Poll::Ready(Err(Error::Cancel)))); + } + + #[test] + fn the_publisher_sees_the_highest_priority() { + let producer = Info::default().produce("dg"); + assert_eq!(producer.priority(), None); + let mut low = producer.consume(); + let mut high = low.clone(); + high.set_priority(5); + low.set_priority(-3); + assert_eq!(producer.priority(), Some(5)); + assert!(producer.poll_priority(&kio::Waiter::noop(), Some(5)).is_pending()); + drop(high); + assert_eq!( + producer.poll_priority(&kio::Waiter::noop(), Some(5)), + Poll::Ready(Some(-3)) + ); + assert!(!producer.close_unused()); + drop(low); + assert!(producer.close_unused()); + } + + #[test] + fn a_request_resolves_its_consumers() { + let request = Request::new("dg".into()); + let consumer = request.weak().try_consume(stats::Scope::default()).unwrap(); + assert!(consumer.poll_info(&kio::Waiter::noop()).is_pending()); + let info = Info::default().with_priority(3); + let _producer = request.accept(info); + assert!(matches!(consumer.poll_info(&kio::Waiter::noop()), Poll::Ready(Ok(got)) if got == info)); + + let request = Request::new("dg".into()); + let mut consumer = request.weak().try_consume(stats::Scope::default()).unwrap(); + request.reject(Error::NotFound); + assert!(matches!(recv(&mut consumer), Poll::Ready(Err(Error::NotFound)))); + assert!(matches!( + consumer.poll_info(&kio::Waiter::noop()), + Poll::Ready(Err(Error::NotFound)) + )); } } diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 2b3c4570184..402628550b2 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -1020,7 +1020,6 @@ impl GroupState { bytes: self.cache + self.unwritten(), frames: self.next_index.saturating_sub(self.held()) as u64, groups: 1, - datagrams: 0, } } @@ -1048,7 +1047,6 @@ impl GroupState { bytes, frames: (end - start) as u64, groups: 0, - datagrams: 0, } } @@ -2582,14 +2580,12 @@ impl Handle { && !matches!( err, Error::Cancel - | Error::Lagged | Error::NotFetchable - | Error::Stream( - StreamError::Cancel - | StreamError::Old | StreamError::Evicted - | StreamError::DeliveryTimeout - | StreamError::TooFarBehind - | StreamError::NotFetchable - ) + | Error::Lagged | Error::Stream( + StreamError::Cancel + | StreamError::Old | StreamError::Evicted + | StreamError::DeliveryTimeout + | StreamError::TooFarBehind + ) ); if cut { let _ = self.alive.cut.set(err.clone()); diff --git a/rs/moq-net/src/model/listing.rs b/rs/moq-net/src/model/listing.rs index 56043e62269..c0a2b435c5f 100644 --- a/rs/moq-net/src/model/listing.rs +++ b/rs/moq-net/src/model/listing.rs @@ -735,6 +735,18 @@ mod tests { assert_eq!(drain(&mut tracks), ["+a"]); } + /// TRACK_INFO can't say a track is datagrams, so a listing never carries one. + #[test] + fn a_datagram_track_is_never_listed() { + let broadcast = Info::new().produce(); + let _early = broadcast.create_datagrams("early", Default::default()).unwrap(); + let mut tracks = broadcast.consume().tracks().unwrap(); + assert_eq!(drain(&mut tracks), ["synced"]); + let _late = broadcast.create_datagrams("late", Default::default()).unwrap(); + let _a = broadcast.create_track("a", None).unwrap(); + assert_eq!(drain(&mut tracks), ["+a"]); + } + #[test] fn a_listed_track_reports_its_info() { let broadcast = Info::new().produce(); diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index d554e1c48e5..a4b29958b6f 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -1,6 +1,7 @@ pub mod bandwidth; pub mod broadcast; pub mod cache; +pub mod datagram; pub mod frame; pub mod group; pub mod track; @@ -19,7 +20,6 @@ mod front; mod aborts; mod bytes; mod clock; -pub(crate) mod datagram; mod delivered; mod expiry; // The early exit a read nothing woke takes. @@ -44,9 +44,8 @@ use request_queue::RequestQueue; use weak_cache::{WeakCache, WeakEntry}; pub use bytes::*; -// Datagram stays flat at the crate root (a small track-adjacent wire type), -// not under a role module. -pub use datagram::*; +// The payload type stays flat at the crate root beside `Timestamp`. +pub use datagram::Datagram; pub use time::*; pub use timed::Timed; diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 3753d041fbe..7b1a6a9181f 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -3526,6 +3526,11 @@ async fn serve_front(task: FrontTask) -> Vec { // discovering its path for as long as it lives, so the route outlives whatever // reader found it, such as a listing that has since closed. let mut discovered: Option = None; + // The source the broadcast's datagram lookups go to, as last handed over: datagram + // tracks are live only, so a lookup joins the serving source's own track rather than a + // copy the front would splice. + let mut datagram_source: Option = None; + broadcast.serve_datagrams_from(None); // Read the table for the machine: the best route and whether // the serving source is on its way out. Also what the watch wakes for. @@ -3970,6 +3975,11 @@ async fn serve_front(task: FrontTask) -> Vec { } } + if front.serving() != datagram_source { + datagram_source = front.serving(); + broadcast.serve_datagrams_from(datagram_source.and_then(|id| sources.get(&id)).cloned()); + } + let step = kio::wait(|waiter| { if let Poll::Ready(Ok(request)) = dynamic.poll_requested_track(waiter) { return Poll::Ready(Step::Assigned(request)); @@ -11711,6 +11721,25 @@ mod tests { assert!(matches!(err, Error::Closed), "unexpected end: {err}"); } + /// A datagram track a published broadcast creates reaches a reader through the origin: + /// the front looks it up on the source it serves from. + #[kio::test] + #[cfg(feature = "async")] + async fn a_front_serves_its_sources_datagram_tracks() { + let producer = origin(1).produce(); + let publication = producer.publish("room", Route::default()).unwrap(); + let mut datagrams = publication + .create_datagrams("audio", crate::datagram::Info::default()) + .unwrap(); + + let front = producer.consume().request("room", None).await.unwrap(); + let mut reader = front.datagrams("audio").unwrap(); + datagrams.append(None, "x").unwrap(); + let datagram = reader.recv().now_or_never().unwrap().unwrap().unwrap(); + assert_eq!(datagram.sequence, 0); + assert!(matches!(front.datagrams("video"), Err(Error::NotFound))); + } + /// A track first subscribed after the front is already serving another still /// replays what its source holds, like the first track did. #[kio::test] diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 112d10ce6d6..3e95f815a15 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -14,8 +14,8 @@ //! grace a passed group is waited for), and while a group still read is read from it or may //! continue from it (it holds that group, or may yet get it). Nothing under the bound is //! waited for past the reader's budget. The serving copy draining cleanly ends the track -//! only once nothing it replaced may still deliver below that end. Datagrams are ephemeral, -//! so no copy is kept for them: a reader that never asked for a group keeps none. +//! only once nothing it replaced may still deliver below that end. A reader that never asked +//! for a group keeps no copy. //! //! A new copy is asked from where the reader got to, so it never has the groups below the //! reader's local floor that the copies it replaced cached. Lowering that floor asks the @@ -40,7 +40,7 @@ //! former serving copy gave up and the reader had yet to report. use rangemap::{RangeMap, RangeSet}; -use std::collections::{BTreeSet, VecDeque}; + use std::ops::Bound; use std::sync::{Arc, PoisonError, Weak}; use std::sync::{Mutex, MutexGuard}; @@ -369,7 +369,6 @@ impl Consumer { newest: None, resumed: None, window, - datagrams: Datagrams::default(), groups: (0, None), groups_read: false, out: Vec::new(), @@ -489,15 +488,13 @@ struct Copy { /// Each of those ranges resolved, by index, so clamping never widens one: as the /// serving copy had them when this one was subscribed, until this one answers. resolved: Vec>, - /// Replaced by a newer route: its datagrams are the newer copy's to deliver. + /// Replaced by a newer route. replaced: bool, /// Bounded once the newer copy delivered its first group: nothing past this group is /// taken from it. Unset while it may still feed a group the newer copy never has. until: Option>, /// The group channel ran out. done: Option>, - /// The datagram channel ran out. - datagrams_done: Option>, /// When a newer group superseded every group under the bound: the newer copy's first /// group, or the track's end. What the reader still lacks from it is waited for from /// here, for no longer than the reader's budget. @@ -594,8 +591,7 @@ impl Copy { /// How a reader still needs this copy, once replaced. fn retains(&mut self, reader: &Retain<'_>) -> Retains { - // Datagrams are ephemeral, so only groups keep a copy: a reader that only reads - // datagrams keeps none, whatever its budget. + // Only groups keep a copy: a reader that never read one keeps none, whatever its budget. if !reader.groups_read { return Retains::No; } @@ -824,16 +820,6 @@ impl Subscriber { self.reader().take(candidate) } - /// Poll for the next datagram in arrival order. `delivered` decides which replaced - /// copies still have a group to hand out, once groups were asked for. - pub(super) fn poll_recv_datagram( - &mut self, - delivered: &Delivered, - waiter: &kio::Waiter, - ) -> Poll>> { - self.reader().poll_recv_datagram(delivered, waiter) - } - /// Forget the gaps in `delivered` below each group past the reader's budget, judged on /// the newest copy that answered; see [`track::Subscriber::forget_past_budget`]. pub(super) fn forget_past_budget(&self, delivered: &mut Delivered) { @@ -939,18 +925,14 @@ impl Subscriber { } /// The final sequence and the error the logical track ended with, once the serving copy - /// completed and drained the channel read, its datagrams for `datagrams`, to that end. - pub(super) fn end_error(&self, datagrams: bool) -> Option<(u64, Error)> { + /// completed and its groups drained to that end. + pub(super) fn end_error(&self) -> Option<(u64, Error)> { let reader = self.reader(); let serving = reader .copies .last() .filter(|copy| Some(copy.generation) == reader.generation)?; - let done = match datagrams { - true => &serving.datagrams_done, - false => &serving.done, - }; - if !matches!(done, Some(Ok(()))) + if !matches!(serving.done, Some(Ok(()))) || !matches!(serving.track.poll_complete(&kio::Waiter::noop()), Poll::Ready(Ok(()))) { return None; @@ -1022,48 +1004,6 @@ impl Candidate { } } -/// Exact identities within the reader's budget. The last delivery never ages until -/// another supersedes it, as a group's budget starts at its immediate successor. -/// A replacement copy replays at most its buffered datagrams, so no more than that many -/// superseded identities are kept, whatever the budget: a publisher cannot grow it. -#[derive(Default)] -struct Datagrams { - latest: Option<(u64, u64)>, - seen: BTreeSet<(u64, u64)>, - superseded: VecDeque<(Instant, (u64, u64))>, - /// The highest identity ever handed out, which a declared end must stay past. - newest: Option<(u64, u64)>, -} - -impl Datagrams { - fn expire(&mut self, at: Instant, budget: Duration) { - while let Some((since, id)) = self.superseded.front() { - if at.saturating_duration_since(*since) < budget { - break; - } - self.seen.remove(id); - self.superseded.pop_front(); - } - } - - fn insert(&mut self, id: (u64, u64), at: Instant) -> bool { - if self.latest == Some(id) || self.seen.contains(&id) { - return false; - } - self.newest = self.newest.max(Some(id)); - if let Some(previous) = self.latest.replace(id) { - if self.superseded.len() == super::track::MAX_DATAGRAMS - && let Some((_, oldest)) = self.superseded.pop_front() - { - self.seen.remove(&oldest); - } - self.seen.insert(previous); - self.superseded.push_back((at, previous)); - } - true - } -} - /// A [`Subscriber`]'s state. struct Reader { /// The first exclusive end any copy declared, refined by a matching mid-group end. @@ -1091,8 +1031,6 @@ struct Reader { resumed: Option<(Option, Vec>)>, /// The reader's ranges resolved; see [`Reader::window`]. window: Window, - /// Identities handed out within the reader's budget, by Group and Object ID, and the highest ever. - datagrams: Datagrams, /// The local floor and exclusive cap; see [`track::Subscriber::with_range`]. groups: (u64, Option), /// Whether groups were ever asked for; until then the reader keeps no replaced copy. @@ -1257,7 +1195,6 @@ impl Reader { copy.until = None; copy.since = None; copy.done = None; - copy.datagrams_done = None; // Resume where a fresh copy would: a group it buffered while replaced, below the // newest another route handed out, is not handed out. copy.floor = copy.floor.max(self.newest); @@ -1288,7 +1225,6 @@ impl Reader { replaced: false, until: None, done: None, - datagrams_done: None, since: None, lease: Arc::new(()), feeds: true, @@ -1331,7 +1267,7 @@ impl Reader { self.declared_error = copy.track.declared_error(); } } - // A group, frame, or datagram at or past it, handed out or on any copy, claims + // A group or frame at or past it, handed out or on any copy, claims // other content. if let Some(declared) = self.declared { let end = declared.final_sequence(); @@ -1344,10 +1280,6 @@ impl Reader { .is_some_and(|group| group.frame_count() as u64 > declared.frame) }; if seen(self.newest) - || self - .datagrams - .newest - .is_some_and(|(group, frame)| Position { group, frame } >= declared) || self .copies .iter() @@ -1735,84 +1667,6 @@ impl Reader { .is_ready() } - fn poll_recv_datagram( - &mut self, - delivered: &Delivered, - waiter: &kio::Waiter, - ) -> Poll>> { - loop { - if let Some(res) = self.poll_datagram_once(delivered, waiter) { - return res; - } - } - } - - fn poll_datagram_once( - &mut self, - delivered: &Delivered, - waiter: &kio::Waiter, - ) -> Option>>> { - self.sync(waiter); - if self.refused { - return Some(Poll::Ready(Err(Error::ProtocolViolation))); - } - self.let_go(delivered, waiter); - let at = Instant::now(); - self.datagrams.expire(at, self.mirrored.budget()); - let groups = self.groups; - for index in (0..self.copies.len()).rev() { - loop { - let copy = &mut self.copies[index]; - if copy.datagrams_done.is_some() || copy.replaced { - break; - } - let sub = match copy.poll_ready(groups, waiter) { - Poll::Pending => break, - Poll::Ready(Err(err)) => { - copy.datagrams_done = Some(Err(err)); - break; - } - Poll::Ready(Ok(sub)) => sub, - }; - if let Some(err) = sub.refused() { - return Some(Poll::Ready(Err(err))); - } - match sub.poll_recv_wire_datagram(waiter) { - Poll::Pending => break, - Poll::Ready(Ok(Some(datagram))) => { - // A relayed group can carry several datagrams, told apart by Object ID. - let id = ( - datagram.sequence, - datagram.wire.as_ref().and_then(|wire| wire.object_id).unwrap_or(0), - ); - if !self.datagrams.insert(id, at) { - continue; - } - if self.past_end(Position { - group: id.0, - frame: id.1, - }) { - return Some(Poll::Ready(Err(Error::ProtocolViolation))); - } - return Some(Poll::Ready(Ok(Some(datagram)))); - } - Poll::Ready(Ok(None)) => { - copy.datagrams_done = Some(match copy.track.poll_complete(&kio::Waiter::noop()) { - Poll::Ready(end) => end, - Poll::Pending => Ok(()), - }); - break; - } - Poll::Ready(Err(err)) => { - copy.datagrams_done = Some(Err(err)); - break; - } - } - } - } - self.poll_end(waiter, |copy| copy.datagrams_done.clone()) - } - fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll> { loop { self.sync(waiter); @@ -2005,7 +1859,6 @@ impl Reader { since: Some(track.now()), track, done: None, - datagrams_done: None, lease: Arc::new(()), feeds: true, }; @@ -2793,13 +2646,6 @@ mod test { sub.poll_finished(&kio::Waiter::noop()).map(Result::unwrap), Poll::Ready(end) ); - // A datagram-only reader lets the replaced copy go, but keeps what it declared. - assert!(sub.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - assert_eq!(copies(&routes), 1); - assert_eq!( - sub.poll_finished(&kio::Waiter::noop()).map(Result::unwrap), - Poll::Ready(end) - ); } /// A route switch keeps the error the logical end was declared with, even when the copy @@ -2829,27 +2675,6 @@ mod test { )); } - /// A routed reader that reads only datagrams ends with the end's error, as a direct one does. - #[kio::test] - async fn a_routed_datagram_reader_ends_with_the_end_error() { - let routes = Producer::new(); - let logical = logical(&routes); - let mut a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, HOUR); - write(&a, 0); - a.insert_datagram(0, ts(0), b"d".as_ref()).unwrap(); - a.finish_at(Position::group(1), Some(Error::App(7))).unwrap(); - assert!(matches!( - sub.poll_recv_datagram(&kio::Waiter::noop()), - Poll::Ready(Ok(Some(_))) - )); - assert!(matches!( - sub.poll_recv_datagram(&kio::Waiter::noop()), - Poll::Ready(Err(Error::App(7))) - )); - } - #[kio::test] async fn copies_must_agree_on_the_declared_end() { for next in [Position::group(5), Position { group: 4, frame: 3 }, Position::group(6)] { @@ -2869,10 +2694,6 @@ mod test { sub.poll_recv_group(&kio::Waiter::noop()), Poll::Ready(Err(Error::ProtocolViolation)) )); - assert!(matches!( - sub.poll_recv_datagram(&kio::Waiter::noop()), - Poll::Ready(Err(Error::ProtocolViolation)) - )); } else { assert_eq!(result.map(Result::unwrap), Poll::Ready(next)); } @@ -2882,36 +2703,25 @@ mod test { /// A copy that delivers past an end another copy declared contradicts it. #[kio::test] async fn content_past_the_declared_end_refuses_the_track() { - for datagram in [false, true] { - let routes = Producer::new(); - let logical = logical(&routes); - let mut a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, HOUR); - a.finish_at(Position::group(5), None).unwrap(); - let mut b = copy(); - routes.serve(b.consume()); - write(&b, 4); - assert_eq!(recv(&mut sub).sequence, 4); - let res = match datagram { - false => { - write(&b, 5); - sub.poll_recv_group(&kio::Waiter::noop()).map_ok(|_| ()) - } - true => { - b.insert_datagram(5, ts(5), b"d".as_ref()).unwrap(); - sub.poll_recv_datagram(&kio::Waiter::noop()).map_ok(|_| ()) - } - }; - assert!( - matches!(res, Poll::Ready(Err(Error::ProtocolViolation))), - "datagram={datagram} {res:?}" - ); - assert!(matches!( - sub.poll_finished(&kio::Waiter::noop()), - Poll::Ready(Err(Error::ProtocolViolation)) - )); - } + let routes = Producer::new(); + let logical = logical(&routes); + let mut a = copy(); + routes.serve(a.consume()); + let mut sub = subscribe(&logical, HOUR); + a.finish_at(Position::group(5), None).unwrap(); + let b = copy(); + routes.serve(b.consume()); + write(&b, 4); + assert_eq!(recv(&mut sub).sequence, 4); + write(&b, 5); + assert!(matches!( + sub.poll_recv_group(&kio::Waiter::noop()), + Poll::Ready(Err(Error::ProtocolViolation)) + )); + assert!(matches!( + sub.poll_finished(&kio::Waiter::noop()), + Poll::Ready(Err(Error::ProtocolViolation)) + )); } /// A mid-group end another copy declared caps the group it falls in, even for frames @@ -2967,81 +2777,24 @@ mod test { } } - /// A mid-group end learned late, below a frame or datagram object already seen, claims - /// other content. + /// A mid-group end learned late, below a frame already seen, claims other content. #[kio::test] async fn a_late_mid_group_end_below_a_seen_object_refuses_the_track() { - for datagram in [false, true] { - let routes = Producer::new(); - let logical = logical(&routes); - let mut a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, HOUR); - let _held = match datagram { - false => { - let mut group = a.create_group(group::Info { sequence: 4 }).unwrap(); - for frame in 0..4 { - group.write_frame(ts(frame), b"x".as_ref()).unwrap(); - } - Some((group, recv(&mut sub))) - } - true => { - a.insert_wire_datagram(super::super::datagram::Stored { - datagram: super::super::datagram::Datagram { - sequence: 4, - timestamp: Some(ts(4)), - payload: bytes::Bytes::from_static(b"d"), - }, - wire: Some(Arc::new(super::super::datagram::Wire { - object_id: Some(4), - priority: None, - end_of_group: false, - status: None, - properties: Default::default(), - })), - deflated: None, - }) - .unwrap(); - let Poll::Ready(Ok(Some(datagram))) = sub.poll_recv_stored_datagram(&kio::Waiter::noop()) else { - panic!("the typed reader did not receive the object"); - }; - assert_eq!(datagram.wire.as_ref().unwrap().object_id, Some(4)); - None - } - }; - let mut b = copy(); - routes.serve(b.consume()); - b.finish_at(Position { group: 4, frame: 2 }, None).unwrap(); - let res = sub.poll_finished(&kio::Waiter::noop()); - assert!( - matches!(res, Poll::Ready(Err(Error::ProtocolViolation))), - "datagram={datagram}: {res:?}" - ); - } - } - - /// The newest datagram handed out bounds a late end after the budget forgets its identity. - #[kio::test] - async fn an_end_below_an_expired_datagram_refuses_the_track() { let routes = Producer::new(); let logical = logical(&routes); - let mut a = copy(); + let a = copy(); routes.serve(a.consume()); - let mut sub = subscribe(&logical, Duration::from_secs(1)); - a.insert_datagram(10, ts(10), b"d".as_ref()).unwrap(); - assert!(sub.poll_recv_datagram(&kio::Waiter::noop()).is_ready()); + let mut sub = subscribe(&logical, HOUR); + let mut group = a.create_group(group::Info { sequence: 4 }).unwrap(); + for frame in 0..4 { + group.write_frame(ts(frame), b"x".as_ref()).unwrap(); + } + let _held = (group, recv(&mut sub)); let mut b = copy(); routes.serve(b.consume()); - b.insert_datagram(1, ts(1), b"d".as_ref()).unwrap(); - assert!(sub.poll_recv_datagram(&kio::Waiter::noop()).is_ready()); - kio::clock::sleep(Duration::from_secs(2)).await; - assert!(sub.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - assert_eq!(copies(&routes), 1); - b.finish_at(Position::group(5), None).unwrap(); - assert!(matches!( - sub.poll_finished(&kio::Waiter::noop()), - Poll::Ready(Err(Error::ProtocolViolation)) - )); + b.finish_at(Position { group: 4, frame: 2 }, None).unwrap(); + let res = sub.poll_finished(&kio::Waiter::noop()); + assert!(matches!(res, Poll::Ready(Err(Error::ProtocolViolation))), "{res:?}"); } /// A gap that stays open across route flaps keeps one backfill per distinct route: a @@ -4776,30 +4529,6 @@ mod test { } } - /// Datagrams are ephemeral: a reader that only reads them keeps no replaced copy, whatever - /// its budget and whatever unread groups the copy holds, so a route that keeps moving - /// leaves no copy per swap behind. - #[test] - #[cfg(feature = "async")] - fn datagram_readers_keep_no_replaced_copy() { - for budget in [Duration::from_millis(100), Duration::MAX] { - let routes = Producer::new(); - let logical = logical(&routes); - let mut sub = subscribe(&logical, budget); - let mut tracks = Vec::new(); - for sequence in 0..20 { - let track = copy(); - routes.serve(track.consume()); - write(&track, sequence); - assert!(sub.recv_datagram().now_or_never().is_none()); - assert_eq!(copies(&routes), 1, "after {sequence} swaps, budget {budget:?}"); - tracks.push(track); - } - // Only the serving copy's group is left. - assert_eq!(drain(&mut sub, false), [19], "budget {budget:?}"); - } - } - /// A copy whose route failed still continues a failed group it finished: a failed track /// keeps its finished groups and drops its open ones, so only a whole group is taken. #[test] @@ -5729,69 +5458,6 @@ mod test { } } - #[test] - #[cfg(feature = "async")] - fn datagrams_a_new_copy_replays_are_handed_out_once() { - let routes = Producer::new(); - let logical = logical(&routes); - let mut a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, Duration::from_secs(10)); - let mut b = copy(); - for track in [&mut a, &mut b] { - for sequence in [1, 2] { - track.insert_datagram(sequence, ts(sequence), b"d".as_ref()).unwrap(); - } - } - b.insert_datagram(3, ts(3), b"d".as_ref()).unwrap(); - - let recv = - |sub: &mut track::Subscriber| sub.recv_datagram().now_or_never().map(|d| d.unwrap().unwrap().sequence); - assert_eq!(recv(&mut sub), Some(1)); - assert_eq!(recv(&mut sub), Some(2)); - routes.serve(b.consume()); - assert_eq!(recv(&mut sub), Some(3)); - assert_eq!(recv(&mut sub), None); - } - - #[test] - fn datagram_identities_expire_with_the_readers_budget() { - for budget in [Duration::ZERO, Duration::from_millis(10)] { - let mut delivered = Datagrams::default(); - let initial = Instant::ZERO; - assert!(delivered.insert((10, 0), initial)); - let at = initial + Duration::from_secs(60); - // An idle last delivery is never stale. - delivered.expire(at, budget); - assert!(!delivered.insert((10, 0), at)); - assert!(delivered.insert((5, 0), at)); - if !budget.is_zero() { - delivered.expire(at + budget / 2, budget); - assert!(!delivered.insert((10, 0), at)); - } - delivered.expire(at + budget, budget); - assert!(delivered.seen.is_empty()); - assert!(delivered.superseded.is_empty()); - assert!(!delivered.insert((5, 0), at)); - assert!(delivered.insert((10, 0), at + budget)); - } - } - - #[test] - fn datagram_identities_are_capped_by_the_replay_buffer() { - let mut delivered = Datagrams::default(); - let max = super::super::track::MAX_DATAGRAMS as u64; - // Superseded identities never expire under the longest budget, so only the cap bounds them. - for sequence in 0..=max + 1 { - assert!(delivered.insert((sequence, 0), Instant::ZERO)); - } - delivered.expire(Instant::ZERO + HOUR, Duration::MAX); - assert_eq!(delivered.seen.len(), max as usize); - assert_eq!(delivered.superseded.len(), max as usize); - assert!(!delivered.insert((1, 0), Instant::ZERO)); - assert!(delivered.insert((0, 0), Instant::ZERO)); - } - #[test] fn the_fronts_subscription_resumes_from_each_readers_floor() { let routes = Producer::new(); @@ -5819,73 +5485,6 @@ mod test { assert_eq!(routes.subscription(demand, 1).start(), Some(10)); } - #[kio::test] - async fn a_replacement_delivers_unseen_datagrams_below_the_newest_sequence() { - let routes = Producer::new(); - let front = logical(&routes); - let mut a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&front, Duration::from_secs(10)); - a.insert_datagram(10, ts(10), b"10".as_ref()).unwrap(); - assert_eq!( - sub.poll_recv_datagram(&kio::Waiter::noop()) - .map(|d| d.unwrap().unwrap().sequence), - Poll::Ready(10) - ); - let mut b = copy(); - routes.serve(b.consume()); - b.insert_datagram(10, ts(10), b"10".as_ref()).unwrap(); - b.insert_datagram(5, ts(5), b"5".as_ref()).unwrap(); - assert_eq!( - sub.poll_recv_datagram(&kio::Waiter::noop()) - .map(|d| d.unwrap().unwrap().sequence), - Poll::Ready(5) - ); - assert!(sub.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - } - - #[test] - #[cfg(feature = "async")] - fn a_new_copy_hands_out_unseen_objects_of_a_seen_group() { - let object = |id: u64| super::super::datagram::Stored { - datagram: super::super::datagram::Datagram { - sequence: 1, - timestamp: Some(ts(id)), - payload: bytes::Bytes::from_static(b"d"), - }, - wire: Some(Arc::new(super::super::datagram::Wire { - object_id: Some(id), - priority: None, - end_of_group: false, - status: None, - properties: Default::default(), - })), - deflated: None, - }; - let routes = Producer::new(); - let front = logical(&routes); - let mut a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&front, Duration::from_secs(10)); - let mut b = copy(); - a.insert_wire_datagram(object(6)).unwrap(); - b.insert_wire_datagram(object(6)).unwrap(); - b.insert_wire_datagram(object(2)).unwrap(); - b.insert_wire_datagram(object(8)).unwrap(); - - let mut recv = || { - sub.poll_recv_wire_datagram(&kio::Waiter::noop()).map(|datagram| { - let datagram = datagram.unwrap().unwrap(); - datagram.wire.as_ref().unwrap().object_id.unwrap() - }) - }; - assert_eq!(recv(), Poll::Ready(6)); - routes.serve(b.consume()); - assert_eq!(recv(), Poll::Ready(2)); - assert_eq!(recv(), Poll::Ready(8)); - assert!(recv().is_pending()); - } - #[derive(Default)] struct Wake(std::sync::atomic::AtomicBool); @@ -6758,141 +6357,6 @@ mod test { )); } - #[test] - #[cfg(feature = "async")] - fn a_datagram_route_failure_waits_for_replacement() { - let routes = Producer::new(); - let logical = logical(&routes); - let a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, Duration::from_secs(10)); - a.abort(Error::Dropped).unwrap(); - assert!(sub.recv_datagram().now_or_never().is_none()); - let mut b = copy(); - routes.serve(b.consume()); - b.insert_datagram(1, ts(0), b"b".as_ref()).unwrap(); - assert_eq!( - sub.recv_datagram().now_or_never().unwrap().unwrap().unwrap().sequence, - 1 - ); - } - - /// Datagrams and groups are independent channels: once a reader asked for groups, reading - /// datagrams first must not release a replaced copy whose buffered groups it has yet to read. - #[test] - #[cfg(feature = "async")] - fn datagram_reads_keep_unread_groups_on_a_replaced_copy() { - for datagrams_first in [false, true] { - let routes = Producer::new(); - let logical = logical(&routes); - let a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, Duration::from_secs(10)); - assert!(sub.poll_recv_group(&kio::Waiter::noop()).is_pending()); - assert!(sub.recv_datagram().now_or_never().is_none()); - let mut group = a.create_group(group::Info { sequence: 0 }).unwrap(); - group.write_frame(ts(0), b"a0".as_ref()).unwrap(); - let b = copy(); - routes.serve(b.consume()); - if datagrams_first { - assert!(sub.recv_datagram().now_or_never().is_none()); - } - let mut next = recv(&mut sub); - assert_eq!(next.sequence, 0, "datagrams_first={datagrams_first}"); - assert_eq!(read(&mut next), Some(b"a0".to_vec())); - } - } - - #[test] - #[cfg(feature = "async")] - fn datagram_retention_preserves_ordered_backlog() { - for ordered_first in [false, true] { - let routes = Producer::new(); - let logical = logical(&routes); - let a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, Duration::from_secs(10)); - assert!(sub.poll_next_group(&kio::Waiter::noop()).is_pending()); - assert!(sub.recv_datagram().now_or_never().is_none()); - let _zero = ordered_first.then(|| a.create_group(group::Info { sequence: 0 }).unwrap()); - if ordered_first { - assert_eq!( - kio::wait(|waiter| sub.poll_next_group(waiter)) - .now_or_never() - .unwrap() - .unwrap() - .unwrap() - .sequence, - 0 - ); - } - let _two = a.create_group(group::Info { sequence: 2 }).unwrap(); - let _one = a.create_group(group::Info { sequence: 1 }).unwrap(); - let b = copy(); - routes.serve(b.consume()); - assert!(sub.recv_datagram().now_or_never().is_none()); - for sequence in [1, 2] { - assert_eq!( - kio::wait(|waiter| sub.poll_next_group(waiter)) - .now_or_never() - .unwrap() - .unwrap() - .unwrap() - .sequence, - sequence - ); - } - } - } - - #[test] - #[cfg(feature = "async")] - fn datagram_retention_obeys_changed_group_bounds() { - for ordered in [false, true] { - for raise_floor in [false, true] { - let routes = Producer::new(); - let logical = logical(&routes); - let a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, Duration::from_secs(10)); - assert!(sub.poll_recv_group(&kio::Waiter::noop()).is_pending()); - assert!(sub.recv_datagram().now_or_never().is_none()); - let _zero = a.create_group(group::Info { sequence: 0 }).unwrap(); - let b = copy(); - routes.serve(b.consume()); - assert!(sub.recv_datagram().now_or_never().is_none()); - if raise_floor { - sub.set_range(1..); - } else { - sub.set_range(..0); - } - assert!( - if ordered { - sub.poll_next_group(&kio::Waiter::noop()) - } else { - sub.poll_recv_group(&kio::Waiter::noop()) - } - .is_pending() - ); - if !raise_floor { - sub.set_range(..); - let next = if ordered { - sub.poll_next_group(&kio::Waiter::noop()) - } else { - sub.poll_recv_group(&kio::Waiter::noop()) - }; - assert_eq!( - match next { - Poll::Ready(Ok(Some(group))) => group.sequence, - _ => panic!("a group"), - }, - 0 - ); - } - } - } - } - /// A relative start, as the first copy resolved it, stays once a route switch lets that /// copy go. #[test] @@ -7044,22 +6508,6 @@ mod test { assert_eq!(sub.next_in_range(11), None); } - #[test] - #[cfg(feature = "async")] - fn datagram_readers_release_replaced_subscriptions() { - let routes = Producer::new(); - let logical = logical(&routes); - let a = copy(); - routes.serve(a.consume()); - let mut sub = subscribe(&logical, Duration::from_secs(10)); - let b = copy(); - routes.serve(b.consume()); - assert!(sub.recv_datagram().now_or_never().is_none()); - let readers = routes.readers.lock().unwrap(); - let reader = readers[0].upgrade().unwrap(); - assert_eq!(reader.lock().unwrap().copies.len(), 1); - } - #[test] fn ending_the_front_releases_a_pending_fetch() { let routes = Producer::new(); diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index cd07a2368d4..c0d61d143af 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -201,9 +201,7 @@ pub enum Anchor { /// after it is known and every sequence below that one is accounted for (started, /// dropped, or never to start). Until then reads stop at the first held group that /// may still start, so at the live edge at most that one group past the end is read - /// before its first frame shows where it starts. A datagram skips all of that: it is - /// judged on its own, its timestamp against the range's times and its sequence against - /// the range's absolute groups and the relative ones resolved so far. An untimed track + /// before its first frame shows where it starts. An untimed track /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 1cbc2fa50b9..1620d8e9858 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -211,8 +211,6 @@ pub(in crate::model) struct Window { /// [`Self::named`] and [`Self::start`]. Kept so a probe reads them without a walk. named: Option, all_named: bool, - /// The ranges' bounds on a datagram, indexed for [`Self::admits_datagram`]. - admission: Admission, } /// Parked walks whose waits overlap, so a change reaching any of them wakes them all: the @@ -474,11 +472,6 @@ impl Walk { // Settle once every sequence up to the first group starting past the ends can no // longer start inside them. - // - // Datagrams stay ephemeral and never index where their group starts, so a sequence - // that arrived only as a datagram counts as gone here. A stream copy of that group - // arriving later is read even if it starts past the ends: accepted, since a - // publisher that streams a group it also sent as a datagram is rare. let after = self.trail.after?; // Everything at or below `known` is in range, and `after` lies above it. self.trail.accounted = self.trail.accounted.max(self.trail.unknown().unwrap_or(after)); @@ -593,7 +586,6 @@ impl Window { covered: None, named: None, all_named: true, - admission: Admission::default(), }; window.place(latest, holdings)?; Ok(window) @@ -836,16 +828,6 @@ impl Window { self.pending || self.groups.contains(sequence) } - /// Whether a datagram is in range, judged on its own and never through a time end's - /// settling: its `timestamp` against each range's times, and its `sequence` against the - /// range's absolute groups and the relative ones resolved so far. - pub fn admits_datagram(&self, sequence: u64, timestamp: Option) -> bool { - let Some(timestamp) = timestamp else { - return self.contains(sequence); - }; - self.admission.admits(sequence, timestamp) - } - /// The lowest group in range, `None` while an anchor waits. Nothing in range floors /// nowhere: group 0. pub fn floor(&self) -> Option { @@ -1161,7 +1143,6 @@ impl Window { waiting: false, }); } - self.admission = Admission::new(&self.ranges, &self.spans); self.name(); self.resolved = Runs::new(fixed); self.resolved_missable = Runs::new(fixed_missable); @@ -1230,135 +1211,6 @@ impl Window { } } -/// The ranges' bounds on a datagram, split by the kind of anchor at each end, so judging one -/// costs a few sorted lookups however many ranges there are. -/// -/// A datagram is one frame, so a time anchor judges its own timestamp, and a group anchor -/// its sequence: each range bounds it from below by one and from above by one. Within each -/// pairing, the ranges sorted by their lower bound carry the highest upper bound of any -/// range at or below them, so the ranges whose lower bound admits a datagram admit it once -/// the last of them reaches it. -#[derive(Debug, Default)] -struct Admission { - groups: Stab, - group_to_time: Stab, - time_to_group: Stab, - times: Stab, -} - -/// One bound of a range on a datagram: the first or last sequence it admits, or the time -/// it presents from or through. -#[derive(Clone, Copy)] -enum Bound { - Group(u64), - Time(Timestamp), -} - -impl Admission { - /// Index `ranges`, with `spans` their resolution in the same order. - fn new(ranges: &[Range], spans: &[Span]) -> Self { - let mut admission = Self::default(); - let mut spans = spans.iter().peekable(); - for (index, range) in ranges.iter().enumerate().filter(|(_, range)| !range.is_empty()) { - // A relative anchor still waiting on a latest group bounds nothing yet. - let span = spans.next_if(|span| span.index == index); - let lower = match range.start { - Anchor::Time(pts) => Bound::Time(pts), - Anchor::Absolute(start) => Bound::Group(start), - Anchor::Relative(_) => Bound::Group(span.map_or(0, |span| span.start)), - }; - // The last sequence a group end admits: one ending at group 0 admits none. - let last = |end: u64| end.checked_sub(1).map(Bound::Group); - let upper = match range.end { - None => Some(Bound::Group(u64::MAX)), - Some(Anchor::Time(pts)) => Some(Bound::Time(pts)), - Some(Anchor::Absolute(end)) => last(end), - Some(Anchor::Relative(_)) => match span.map(|span| span.end) { - Some(End::Before(end)) => last(end), - _ => Some(Bound::Group(u64::MAX)), - }, - }; - match (lower, upper) { - (_, None) => {} - (Bound::Group(lower), Some(Bound::Group(upper))) => admission.groups.push(lower, upper), - (Bound::Group(lower), Some(Bound::Time(upper))) => admission.group_to_time.push(lower, upper), - (Bound::Time(lower), Some(Bound::Group(upper))) => admission.time_to_group.push(lower, upper), - (Bound::Time(lower), Some(Bound::Time(upper))) => admission.times.push(lower, upper), - } - } - admission.groups.index(); - admission.group_to_time.index(); - admission.time_to_group.index(); - admission.times.index(); - admission - } - - /// Whether any one range admits a datagram's `sequence` and `timestamp` together. - fn admits(&self, sequence: u64, timestamp: Timestamp) -> bool { - self.groups.admits(sequence, sequence) - || self.group_to_time.admits(sequence, timestamp) - || self.time_to_group.admits(timestamp, sequence) - || self.times.admits(timestamp, timestamp) - } -} - -/// A point a datagram is judged at, ordered as instants for a time. -trait Point: Copy { - fn order(self, other: Self) -> std::cmp::Ordering; -} - -impl Point for u64 { - fn order(self, other: Self) -> std::cmp::Ordering { - self.cmp(&other) - } -} - -impl Point for Timestamp { - fn order(self, other: Self) -> std::cmp::Ordering { - instant(self, other) - } -} - -/// Ranges bounded below by an `L` and above by a `U`, both inclusive, sorted by the lower -/// bound, each carrying the highest upper bound at or before it once indexed. -#[derive(Debug)] -struct Stab { - bounds: Vec<(L, U)>, -} - -impl Default for Stab { - fn default() -> Self { - Self { bounds: Vec::new() } - } -} - -impl Stab { - fn push(&mut self, lower: L, upper: U) { - self.bounds.push((lower, upper)); - } - - fn index(&mut self) { - self.bounds.sort_by(|a, b| a.0.order(b.0)); - let mut highest: Option = None; - for (_, upper) in &mut self.bounds { - let top = match highest { - Some(top) if top.order(*upper).is_gt() => top, - _ => *upper, - }; - *upper = top; - highest = Some(top); - } - } - - /// Whether a range bounded at or below `lower` reaches `upper`. - fn admits(&self, lower: L, upper: U) -> bool { - let below = self.bounds.partition_point(|(start, _)| start.order(lower).is_le()); - below - .checked_sub(1) - .is_some_and(|last| upper.order(self.bounds[last].1).is_le()) - } -} - /// Where a range starts. enum Start { /// At this group, and whether it names that group rather than reaching back to the @@ -1949,14 +1801,6 @@ mod tests { } } - /// A datagram is one frame, so its own timestamp places it against a time end. - #[test] - fn a_datagram_is_judged_by_its_own_timestamp() { - let window = Window::new(&[range(Anchor::Absolute(0), time(3500))], None, &Groups::default()).unwrap(); - assert!(window.admits_datagram(9, Some(at(3500)))); - assert!(!window.admits_datagram(9, Some(at(3600)))); - } - /// Groups whose `racing` one starts past the end after the first time it is asked /// about, as when its first frame lands between the walks of one settle. struct Racing { @@ -2038,20 +1882,6 @@ mod tests { assert_eq!(window.watch_starts(), None, "nothing above the last sequence can start"); } - #[test] - fn a_settled_window_still_judges_a_datagram_by_its_times() { - // 1 is a datagram the starts index never sees: the end settles before 2 by - // sequence, but 1 presents past it. - let groups = Groups { - gone: std::iter::once(1..2).collect(), - ..Groups::new([Groups::started(0, 0), Groups::started(2, 10_000)]) - }; - let window = Window::new(&[range(time(0), time(5000))], None, &groups).unwrap(); - assert_eq!(window.resolved(), vec![Some(Resolved { start: 0, end: Some(2) })]); - assert!(window.admits_datagram(1, Some(at(4000)))); - assert!(!window.admits_datagram(1, Some(at(7000)))); - } - /// A time start no held group has started for yet reads nothing of its range, rather /// than guess the latest group, and resolves once one starts. A track that can start /// nothing more reaches back to the oldest group. @@ -2127,148 +1957,6 @@ mod tests { } } - /// A waiting time start that resolves after a later range still bounds datagrams by its - /// own relative end. - #[test] - fn a_resolved_waiting_start_judges_datagrams_by_its_own_end() { - let mut groups = Groups::new((2..10).map(|sequence| (sequence, Held::Pending))); - let ranges = [ - range(time(2500), Anchor::Relative(0)), - range(Anchor::Absolute(20), Anchor::Absolute(21)), - ]; - let mut window = Window::new(&ranges, Some(9), &groups).unwrap(); - groups.held.insert(2, Held::Started(at(2000))); - assert!(window.settle(Some(9), &groups)); - assert_eq!( - window.resolved()[0], - Some(Resolved { - start: 2, - end: Some(10) - }) - ); - assert!(window.admits_datagram(9, Some(at(3000)))); - assert!(!window.admits_datagram(15, Some(at(3000))), "past the relative end"); - } - - #[test] - fn a_waiting_window_judges_a_datagram_against_both_times() { - let window = Window::new(&[range(time(3000), time(3500))], None, &Groups::default()).unwrap(); - assert!(window.is_pending(), "no group has started"); - assert!(!window.admits_datagram(0, Some(at(2900)))); - assert!(window.admits_datagram(1, Some(at(3000)))); - assert!(window.admits_datagram(2, Some(at(3500)))); - assert!(!window.admits_datagram(3, Some(at(3600)))); - } - - #[test] - fn a_waiting_window_judges_a_datagram_against_absolute_groups_too() { - let window = Window::new( - &[range(time(3000), Some(Anchor::Absolute(10)))], - None, - &Groups::default(), - ) - .unwrap(); - assert!(window.is_pending(), "no group has started"); - assert!(window.admits_datagram(9, Some(at(3500)))); - assert!(!window.admits_datagram(10, Some(at(3500)))); - assert!(!window.admits_datagram(9, Some(at(2900)))); - } - - /// A datagram is in range only when one range admits its sequence and time together, - /// not when each passes a different range. - #[test] - fn a_datagram_is_judged_against_one_range_at_a_time() { - let groups = Groups::new([Groups::started(5, 5000)]); - // The time range covers group 5 whole, but only from 5500 on. - let window = Window::new( - &[range(Anchor::Absolute(6), None), range(time(5500), None)], - Some(5), - &groups, - ) - .unwrap(); - assert!(!window.admits_datagram(5, Some(at(5000)))); - assert!(window.admits_datagram(5, Some(at(5500)))); - assert!(window.admits_datagram(6, Some(at(5000)))); - } - - /// The indexed admission agrees with judging each range on its own, over mixed anchors, - /// scales, and relative anchors resolved or still waiting. - #[test] - fn datagram_admission_matches_each_range_judged_alone() { - fn admits_alone(window: &Window, sequence: u64, timestamp: Timestamp) -> bool { - window.ranges.iter().enumerate().any(|(index, range)| { - if range.is_empty() { - return false; - } - let span = window.spans.iter().find(|span| span.index == index); - let started = match range.start { - Anchor::Time(pts) => !later(pts, timestamp), - Anchor::Absolute(start) => sequence >= start, - Anchor::Relative(_) => span.is_none_or(|span| sequence >= span.start), - }; - let ended = match range.end { - None => true, - Some(Anchor::Time(pts)) => !later(timestamp, pts), - Some(Anchor::Absolute(end)) => sequence < end, - Some(Anchor::Relative(_)) => { - span.is_none_or(|span| !matches!(span.end, End::Before(end) if sequence >= end)) - } - }; - started && ended - }) - } - - // A small deterministic generator, so a failure replays. - let seed = Cell::new(0x9e37_79b9_7f4a_7c15_u64); - let next = |bound: u64| { - let mut x = seed.get(); - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - seed.set(x); - x % bound - }; - let micros = |value| Timestamp::new(value, crate::Timescale::MICRO).unwrap(); - let groups = Groups::new((0..20).map(|sequence| Groups::started(sequence, sequence * 100))); - for round in 0..300 { - let anchor = |end: bool| match next(if end { 4 } else { 3 }) { - 0 => Anchor::Absolute(next(20)), - 1 => Anchor::Relative(next(6)), - 2 if next(2) == 0 => time(next(2000)), - 2 => Anchor::Time(micros(next(2_000_000))), - _ => Anchor::Absolute(u64::MAX), - }; - let ranges: Vec = (0..1 + next(6)) - .map(|_| { - let start = anchor(false); - let end = match next(4) { - 0 => None, - _ => Some(anchor(true)), - }; - range(start, end) - }) - .collect(); - let latest = (round % 3 != 0).then(|| next(20)); - let held = match latest { - Some(_) => &groups, - None => &Groups::default(), - }; - let window = Window::new(&ranges, latest, held).unwrap(); - for _ in 0..40 { - let sequence = next(24); - let timestamp = match next(2) { - 0 => at(next(2400)), - _ => micros(next(2_400_000)), - }; - assert_eq!( - window.admits_datagram(sequence, Some(timestamp)), - admits_alone(&window, sequence, timestamp), - "{ranges:?} at {latest:?}: datagram {sequence} at {timestamp:?}" - ); - } - } - } - /// A coverage check resumes where the last stopped, but not below its floor or past its /// cap: a lower floor or a wider cap reaches groups no check has seen. #[test] diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 36ca52ffa7b..b592110afc9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -17,7 +17,6 @@ use crate::{Error, Result, StreamError, Timescale, Timestamp, coding}; use crate::{broadcast, cache, group, stats}; use std::sync::Mutex; -use super::Datagram; use super::delivered::Delivered; use super::idle; use super::skipped::Skipped; @@ -55,13 +54,6 @@ use std::{ // and 127 on IETF, which flips priority (lower first): one urgency on both. const DEFAULT_PRIORITY: i8 = 0; -/// Maximum number of datagrams retained in the per-track send buffer. -/// -/// Datagrams are a best-effort send buffer, not a replay cache (unlike groups): only the last -/// 64 datagrams are kept, so a stalled consumer cannot retain an unbounded backlog. The buffer -/// bills its bytes to the track's [`cache::Pool`], which drops the oldest under pressure. -pub(super) const MAX_DATAGRAMS: usize = 64; - /// Slack before the eviction order is rebuilt, so a track holding just a few groups /// doesn't rebuild on every write. const EVICT_SLACK: usize = 64; @@ -110,7 +102,7 @@ pub struct Info { /// Units per second for per-frame timestamps on this track, or `None` for an untimed /// track. /// - /// A track is all timed or all untimed: every frame and datagram on a timed track + /// A track is all timed or all untimed: every frame 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 `None` (untimed). On /// Lite05+ it is reported in TRACK_INFO and the publisher zigzag-delta encodes @@ -299,9 +291,8 @@ enum Feed { /// from: the newest group when it went live, or else the first it produced since. /// Kept after the group is evicted. first: Option, - /// The group the route answered with, which its datagrams can show before any - /// group reaches it: relative starts count back from it until a newer group - /// arrives. + /// The group the route answered with, which can be ahead of any group cached: + /// relative starts count back from it until a newer group arrives. edge: Option, }, /// The upstream subscription ended with the copy still held, so how stale the cache @@ -408,21 +399,12 @@ pub(crate) struct TrackState { // what each track writes and never touches another track's cache. debt: u64, - // Datagrams in arrival order, bounded by `MAX_DATAGRAMS` and billed to the pool, which - // drops the oldest under pressure. Shares the group sequence namespace but is otherwise - // independent. - datagrams: VecDeque, - - // Number of datagrams dropped off the front (over capacity), mapping a subscriber's absolute - // cursor to an index into `datagrams` (mirrors `offset` for groups). - datagram_offset: usize, - // We've popped the front of `arrival` this many times, mapping a subscriber's // absolute cursor to an index. offset: usize, - // The highest sequence number successfully appended to the track. Shared with - // datagrams, so it can run ahead of any cached group. + // The highest sequence number successfully appended to the track, which can run ahead + // of any cached group. max_sequence: Option, // The newest group the publisher produced, which fetched backfill never moves: where @@ -447,9 +429,8 @@ pub(crate) struct TrackState { // The sequence of the newest cached group: the live edge, protected from // eviction by never entering the eviction order until the track is `closed`. - // Tracked separately from `max_sequence` because datagrams advance that shared - // counter, and the live edge must still demote correctly when the next group - // lands past one. + // Tracked separately from `max_sequence`, which a reserved sequence can push past + // any cached group. latest_group: Option, // Incarnation counter for `Slot::stamp`. @@ -471,7 +452,7 @@ pub(crate) struct TrackState { // end at the boundary with groups still to arrive. tail: Option, - // The sequences the live feed delivered, as a group or a datagram: what a pending tail + // The sequences the live feed delivered: what a pending tail // is judged by. Fetched copies never count, and eviction never touches it. A gap folds // away a subscription's grace after a later arrival opened it, as the session's tail // does, so this holds only the gaps still worth waiting for. @@ -1305,66 +1286,6 @@ impl TrackState { } } - /// Find the next datagram at or after the subscriber's absolute `index`. - /// - /// Returns the datagram and its absolute index so the consumer can advance past it. A - /// consumer whose `index` has fallen behind `datagram_offset` (older datagrams dropped) - /// resumes at the oldest still-buffered datagram, skipping the lost ones. - fn poll_recv_datagram(&self, index: usize) -> Poll>> { - let start = index.saturating_sub(self.datagram_offset); - // An ended track's expiry releases its oldest in place, which a reader skips like a drop. - for (i, datagram) in self - .datagrams - .iter() - .enumerate() - .skip(start) - .filter(|_| self.readable()) - { - if let Some(stored) = datagram.stored() { - return Poll::Ready(Ok(Some((stored, self.datagram_offset + i)))); - } - } - - // Nothing buffered at the cursor: the track ending terminates the datagram stream too. - if self.is_complete() { - Poll::Ready(Ok(None)) - } else if let Some(err) = &self.abort { - Poll::Ready(Err(err.clone())) - } else { - Poll::Pending - } - } - - /// Whether `sequence` was sent as a datagram still in the send buffer. - fn holds_datagram(&self, sequence: u64) -> bool { - self.datagrams.iter().any(|datagram| datagram.sequence == sequence) - } - - /// Push a datagram, dropping the oldest when the send buffer is full. - /// - /// Billed before the debt is settled, so its bytes accrue debt, but pushed after, so the - /// newcomer is never a victim of its own write. - fn push_datagram(&mut self, datagram: super::datagram::Stored) { - self.latch_settled(); - let datagram = super::datagram::Buffered::new(datagram, &self.cache); - self.charge_debt(); - if self.datagrams.len() == MAX_DATAGRAMS { - self.drop_datagram(); - } - let sequence = datagram.sequence; - self.datagrams.push_back(datagram); - let now = self.cache.pool().now_wall(); - self.record_arrival(sequence, now); - self.shown(sequence); - } - - /// Drop the oldest buffered datagram, releasing its bill. - fn drop_datagram(&mut self) { - if self.datagrams.pop_front().is_some() { - self.datagram_offset += 1; - } - } - /// Find the smallest-sequence cached group in `window` satisfying /// `next_sequence <= seq < end_sequence (if set)`. Used by /// [`Subscriber::next_group`] so the range can be widened (or unset) @@ -1955,11 +1876,9 @@ impl TrackState { } } - self.datagrams.front().is_some_and(|datagram| datagram.expired()) - || self - .arrival - .front() - .is_some_and(|(sequence, stamp)| !self.is_current(*sequence, *stamp)) + self.arrival + .front() + .is_some_and(|(sequence, stamp)| !self.is_current(*sequence, *stamp)) || self .evict .front() @@ -1967,10 +1886,9 @@ impl TrackState { || self.evict.len() > 2 * self.lookup.len() + EVICT_SLACK } - /// Expire an ended track's idle groups and datagrams, whose closed channel refuses the - /// write [`Self::evict_expired_scan`] takes: abort the groups in place, releasing their - /// frames, retire expired cuts, and release the datagrams, leaving entries that every - /// read path skips. + /// Expire an ended track's idle groups, whose closed channel refuses the write + /// [`Self::evict_expired_scan`] takes: abort the groups in place, releasing their + /// frames, and retire expired cuts, leaving entries that every read path skips. pub(super) fn expire_closed(&self, scan: ExpiryScan) { for (sequence, stamp) in &self.evict { let Some(slot) = self.lookup.get(sequence) else { @@ -1985,12 +1903,6 @@ impl TrackState { slot.abort(Error::Old); } } - // Released oldest first, so the expired ones stay a prefix that readers skip. - for datagram in &self.datagrams { - if !datagram.release_expired() { - break; - } - } } /// Apply a scan previously selected by [`Self::expiry_scan`] or @@ -2036,11 +1948,6 @@ impl TrackState { } } - // A datagram is never the live edge a late joiner needs, so even the newest goes. - while self.datagrams.front().is_some_and(|datagram| datagram.expired()) { - self.drop_datagram(); - } - // Trim dead leading arrival entries to advance the subscriber offset. An // entry is dead once its slot is gone or re-stamped by a newer incarnation. while let Some((sequence, stamp)) = self.arrival.front() { @@ -2280,9 +2187,8 @@ impl TrackState { self.next_stamp = self.next_stamp.wrapping_add(1); let stamp = self.next_stamp; - // The live edge is tracked separately from `max_sequence`, which datagrams - // share and can push past any cached group: demotion must still fire when - // the next group lands beyond a datagram-advanced counter. + // The live edge is tracked separately from `max_sequence`, which can run past + // any cached group: demotion must still fire when the next group lands beyond it. if self.latest_group.is_none_or(|latest| sequence >= latest) { // Demote the previous latest: it joins the eviction order (and the // pool's access average) like any other cached group. @@ -2432,8 +2338,7 @@ impl TrackState { // Cap each payment at twice what was written so one write never dumps // a deep backlog at once; the remainder carries to the next write. let cap = written.saturating_mul(2); - let paid = self.pay_debt(&pool, cap); - self.pay_datagrams(cap.saturating_sub(paid)); + self.pay_debt(&pool, cap); } // Under capacity there is nothing to work off, and stale debt would // cause a spurious eviction burst at the next pressure spike. @@ -2541,20 +2446,6 @@ impl TrackState { paid } - /// Drop this track's oldest buffered datagrams toward the debt its groups left, freeing - /// at most `cap` bytes. Like a group, one larger than the remaining debt stays. - fn pay_datagrams(&mut self, cap: u64) { - let mut paid = 0u64; - while let Some(datagram) = self.datagrams.front() - && paid < cap - && datagram.bytes() <= self.debt - { - self.debt -= datagram.bytes(); - paid = paid.saturating_add(datagram.bytes()); - self.drop_datagram(); - } - } - /// Group `sequence` once cached, `None` once the feed starts past it or ends before it, /// and pending while it still may land. fn landing(&self, sequence: u64) -> Poll> { @@ -2653,7 +2544,7 @@ impl TrackState { /// The lowest group in `groups` and `window` a sequence-order reader still waits for: /// one only a subscription owes, so a received copy's live feed may still land it. Not a - /// group accounted for by its arrivals (since evicted, aborted, or a datagram), dropped, + /// group accounted for by its arrivals (since evicted or aborted), dropped, /// outside the feed, or in a gap its grace stopped waiting for. A local track owes none. fn owed(&self, groups: ops::Range, window: &Window) -> Option { if !self.received || self.closed || !self.readable() { @@ -2820,12 +2711,12 @@ impl TrackState { /// up, as under an unbounded max delay, whose grace runs past the clock's range. `None` /// when nothing is missing. /// - /// A missing sequence is one no live group or datagram delivered and the publisher did + /// A missing sequence is one no live group delivered and the publisher did /// not drop. A gap below an arrival is given up a grace after that arrival opened it, as /// the session's tail does. The gap above the last arrival is given up a grace after /// the end or a start was declared, or a group last arrived, so a feed still delivering toward an - /// end declared ahead of it is not cut off. Judged by the arrivals, not the cache, so an evicted group or a - /// datagram counts as delivered, and the check costs the same however much is cached. + /// end declared ahead of it is not cut off. Judged by the arrivals, not the cache, so an evicted group + /// counts as delivered, and the check costs the same however much is cached. fn tail_deadline(&self, fin: u64) -> Option> { let active = self.tail?; let grace = self.arrival_grace(); @@ -2912,8 +2803,7 @@ impl TrackState { } /// The upstream subscription ended with the copy still held: what it cached may go - /// stale, so readers get nothing from it until the route answers again. Buffered - /// datagrams go too, since a reader returning later must not be handed them. + /// stale, so readers get nothing from it until the route answers again. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] fn set_idle(&mut self) { let newest = self @@ -2923,8 +2813,6 @@ impl TrackState { .find(|(_, slot)| slot.is_shown()) .map(|(sequence, _)| *sequence); self.feed = Feed::Idle { newest }; - self.datagram_offset += self.datagrams.len(); - self.datagrams.clear(); // An open group stops because this copy dropped its feed, not because the publisher // gave it up: its stream resets as a cut would, so it leaves the cache first, and the // next feed sends it again. @@ -2996,7 +2884,6 @@ impl TrackState { #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] fn shows(&self, sequence: u64) -> bool { self.lookup.range(sequence..).any(|(_, slot)| slot.is_shown()) - || self.datagrams.iter().any(|datagram| datagram.sequence >= sequence) } /// The newest live group the cache holds, and the number of frames it has so far. @@ -3461,141 +3348,6 @@ impl Producer { Ok(group) } - /// Append a datagram with the next sequence number, returning the assigned sequence. - /// - /// A datagram is delivered best-effort over a single QUIC datagram, parallel to the - /// track's groups but drawing from the same sequence namespace (so interleaving with - /// [`Self::append_group`] never reuses a number). There is no group fallback: each - /// session drops (with a debug log) any datagram whose encoded body exceeds the - /// transport's datagram size, and sessions that can't carry datagrams at all (moq-lite - /// before 05, or stream-only transports like WebSocket) never deliver them. Keep payloads well under the 1200-byte minimum path MTU. - /// A datagram is never cached or served by a fetch, so use a group for anything a late - /// joiner needs. An origin publisher uses this; a relay preserving upstream numbering - /// uses [`Self::insert_datagram`]. - /// - /// 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); - } - // Resolved before the state guard borrows `self`. - let meter = self.stats.meter(); - let mut state = self.modify()?; - // Normalize into the track's timescale, like frames (see `group::Producer::create_frame`). - let timestamp = state.info.as_ref().unwrap().rescale(timestamp.into())?; - let sequence = state.next_sequence()?; - if let Some(fin) = state.final_sequence() - && sequence >= fin - { - return Err(Error::Closed); - } - state.advance(sequence, false); - state.cache.finish_largest(sequence); - // A datagram is its own group, so it compresses alone. - let deflated = state - .info - .as_ref() - .unwrap() - .compress - .then(|| super::flate::Encoder::new().frame(&payload)); - let datagram = super::datagram::Stored { - datagram: Datagram { - sequence, - timestamp, - payload, - }, - wire: None, - deflated, - }; - meter.datagram(datagram.stored_len()); - state.push_datagram(datagram); - let cache = state.cache.clone(); - let deferred = state.deferred(); - drop(state); - deferred.wake(); - cache.settle(None); - Ok(sequence) - } - - /// Insert a datagram with an explicit sequence number. - /// - /// Preserves the supplied sequence (bumping the shared `max_sequence` if needed), so a - /// relay can forward a datagram without renumbering it. Most origin publishers want - /// [`Self::append_datagram`] instead. - pub fn insert_datagram( - &mut self, - sequence: u64, - timestamp: impl Into>, - payload: B, - ) -> Result<()> { - self.insert_wire_datagram( - Datagram { - sequence, - timestamp: timestamp.into(), - payload: payload.into_bytes(), - } - .into(), - ) - } - - pub(crate) fn insert_wire_datagram(&mut self, mut datagram: super::datagram::Stored) -> Result<()> { - if datagram.payload.len() > super::datagram::MAX_DATAGRAM_PAYLOAD { - return Err(Error::FrameTooLarge); - } - let meter = self.stats.meter(); - let mut state = self.modify()?; - let info = state.info.as_ref().unwrap(); - let status = datagram.wire.as_ref().and_then(|wire| wire.status); - // A datagram is its own group, so it compresses alone. A status has no payload. - if info.compress && datagram.deflated.is_none() && status.is_none() { - datagram.deflated = Some(super::flate::Encoder::new().frame(&datagram.payload)); - } - if status.is_none_or(|status| status == 0) { - datagram.datagram.timestamp = info.rescale(datagram.timestamp)?; - } - if state.final_sequence().is_some_and(|fin| datagram.sequence >= fin) { - return Err(Error::Closed); - } - state.advance(datagram.sequence, false); - state.cache.finish_largest(datagram.sequence); - meter.datagram(datagram.stored_len()); - state.push_datagram(datagram); - let cache = state.cache.clone(); - let deferred = state.deferred(); - drop(state); - deferred.wake(); - cache.settle(None); - Ok(()) - } - - /// Insert a received datagram of a compressed track, whose payload is its own DEFLATE - /// stream that must inflate to exactly `size` bytes. Kept as it arrived, beside the - /// inflated payload. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn insert_deflated_datagram(&mut self, mut datagram: super::datagram::Stored, size: u64) -> Result<()> { - if !self.info.compress { - return Err(Error::ProtocolViolation); - } - // Refused before inflating, like an oversized plain payload. - let size = usize::try_from(size) - .ok() - .filter(|size| *size <= super::datagram::MAX_DATAGRAM_PAYLOAD) - .ok_or(Error::FrameTooLarge)?; - let payload = super::flate::Decoder::new().frame(&datagram.payload, size)?; - let inflated = payload.len() as u64; - datagram.deflated = Some(std::mem::replace(&mut datagram.datagram.payload, payload)); - self.insert_wire_datagram(datagram)?; - // Only once stored, so a refused datagram counts toward nothing. - self.stats.meter().inflated(inflated); - Ok(()) - } - /// Create a group with a single frame, at the given presentation timestamp. /// /// The timestamp is converted into the track's timescale. Pass `None` on an untimed @@ -4039,13 +3791,13 @@ impl Producer { self.state.read().max_sequence } - /// The track's largest location: its newest group or datagram at the newest frame (0 while empty), `None` before any. + /// The track's largest location: its newest group at the newest frame (0 while empty), `None` before any. pub fn largest(&self) -> Option { self.state.read().cache.largest() } /// Ready once the track holds content past `sequence`, or is closed: a later group whose - /// first frame opened or that finished, or a later datagram. A frameless group header + /// first frame opened or that finished. A frameless group header /// does not count, since nothing says the copy reached it yet. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn poll_past(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { @@ -4629,8 +4381,7 @@ impl Demand { /// /// A publisher opens a group whenever `n` passes its largest. The first consumer of a /// track with no group asks for `Some(0)`, and a join judged by [`Info::refresh`] - /// asks for the next free sequence until a group lands there, so a datagram taking it - /// moves the ask past. `n` never goes back, so once a group reaches it nothing is + /// asks for the next free sequence until a group lands there. `n` never goes back, so once a group reaches it nothing is /// pending until another join asks. pub fn wanted(&self) -> Option { if !self.is_wanted() { @@ -5311,8 +5062,7 @@ impl Consumer { /// or `group::Fetch::default()`. /// /// The returned future resolves to [`Error::NotFound`] when the group can never be served - /// (past the final sequence, or no [`Requests`] on the track), [`Error::NotFetchable`] - /// instead when this track sent the sequence as a datagram still buffered, the handler's + /// (past the final sequence, or no [`Requests`] on the track), the handler's /// rejection (a relay's upstream miss is [`StreamError::NotFound`](StreamError::NotFound)), /// or the track's abort error if it's already closed. A miss queues a one-group [`Fill`], /// which concurrent fetches of the same sequence join. @@ -5489,7 +5239,7 @@ impl Consumer { }) } - /// The track's largest location, its newest group or datagram at the newest frame (0 while empty): the current one, then each newer one, and `None` once the track ends. + /// The track's largest location, its newest group at the newest frame (0 while empty): the current one, then each newer one, and `None` once the track ends. #[cfg(feature = "async")] pub async fn largest(&mut self) -> Option { kio::wait(|waiter| self.poll_largest(waiter)).await @@ -5874,15 +5624,11 @@ impl kio::Task for Fetching { } // Never queued: no handler existed when the fetch was made. Fail fast while // that's still true; a handler that appeared since may yet fill the cache. - // A sequence this track sent as a datagram says so, rather than a plain miss. return match fills.poll(waiter, |fills| match fills.has_handlers() { false => Poll::Ready(()), true => Poll::Pending, }) { - Poll::Ready(_guard) => Poll::Ready(Err(match state.read().holds_datagram(sequence) { - true => Error::NotFetchable, - false => Error::NotFound, - })), + Poll::Ready(_guard) => Poll::Ready(Err(Error::NotFound)), Poll::Pending => Poll::Pending, }; }; @@ -6007,9 +5753,9 @@ pub(super) struct Unread<'a> { } /// How a [`Subscriber`] reads: its track's own cache, or a front's routes. -#[expect( +#[allow( clippy::large_enum_variant, - reason = "the plain cursor is the hot path, so it stays inline and the front's reader is boxed" + reason = "the plain cursor is the hot path, so it stays inline and the front's reader is boxed; an allow, since the size gap varies by build" )] enum Inner { Plain(Cursor), @@ -6681,8 +6427,6 @@ struct Cursor { subscription: Preferences, /// Arrival-order cursor used by `recv_group`. index: usize, - /// Arrival-order cursor used by `recv_datagram`, independent of groups. - datagram_index: usize, /// Minimum sequence to return from any `recv` method. Set by `set_range`. min_sequence: u64, /// This subscriber's own ranges, resolved. Boxed: every reader embeds a cursor. @@ -6911,7 +6655,7 @@ impl Cursor { if subscription.read().joins_live() { TrackState::join(state.read()); } - let (filter, datagram_index, cache) = { + let (filter, cache) = { let preferences = subscription.read(); let ranges_revision = subscription.ranges_revision(); let state = state.read(); @@ -6919,22 +6663,6 @@ impl Cursor { Ok(window) => (window, None), Err(err) => (Window::new(&[], None, &*state).expect("nothing to refuse"), Some(err)), }; - // Arrival order need not be timestamp order. Only skip the initial stale prefix; - // subsequent delivery stays in arrival order, including late datagrams. - let newest = state.datagrams.iter().filter_map(|datagram| datagram.timestamp).max(); - let start = newest.map_or(0, |newest| { - // Every datagram carries the track's scale: convert the cutoff into it once, - // rounded up, rather than each timestamp into a `Duration` (a 128-bit division). - let cutoff = Duration::from(newest).saturating_sub(preferences.budget()); - let scale = newest.scale().as_u64() as u128; - let cutoff = (cutoff.as_nanos() * scale).div_ceil(1_000_000_000) as u64; - let cutoff = Timestamp::new(cutoff, newest.scale()).expect("at most the newest"); - state - .datagrams - .iter() - .position(|datagram| datagram.timestamp.is_none_or(|timestamp| timestamp >= cutoff)) - .expect("the newest datagram is within max delay") - }); let filter = Filter { window, resolved: ranges_revision, @@ -6946,7 +6674,7 @@ impl Cursor { lookups: Vec::new(), written_off: RangeSet::new(), }; - (filter, state.datagram_offset + start, state.cache.clone()) + (filter, state.cache.clone()) }; let published = Published::new(Servable::of(&filter.window, &Servable::NONE), filter.resolved, None); Self { @@ -6956,7 +6684,6 @@ impl Cursor { min_sequence: 0, filter: Box::new(filter), index: 0, - datagram_index, next_sequence: Some(0), end_sequence: None, // Uncapped, the servable groups are the window's. @@ -8009,37 +7736,6 @@ impl Cursor { .cloned() } - /// The next datagram inside this subscriber's range, dropping the ones outside it. - /// - /// The floor, cap and time/group ranges select whole datagram groups here; frame - /// bounds are applied by the sending session. A datagram is judged when the cursor reaches - /// it, against the range in force then, and one outside it is gone for good: unlike a - /// group past the cap, nothing holds it for a later raise. - fn poll_recv_datagram(&mut self, waiter: &kio::Waiter) -> Poll>> { - loop { - // Refuse before reading, so a refused subscription consumes no datagram. - self.sync(); - if let Some(err) = &self.filter.refused { - return Poll::Ready(Err(err.clone())); - } - let Some((datagram, found_index)) = - ready!(self.poll(waiter, |state| state.poll_recv_datagram(self.datagram_index))?) - else { - return Poll::Ready(Ok(None)); - }; - self.datagram_index = found_index + 1; - self.sync(); - - let sequence = datagram.sequence; - // A datagram is a whole group, so its own timestamp places it against a time end. - let admits = super::subscription::before_end(sequence, self.end_sequence) - && self.filter.window.admits_datagram(sequence, datagram.timestamp); - if sequence >= self.floor() && admits { - return Poll::Ready(Ok(Some(datagram))); - } - } - } - /// Pass groups another copy already handed out, without delivering or metering them. fn skip_ordered(&mut self, floor: Option) { let next = self.next_sequence.zip(floor).map(|(next, floor)| next.max(floor)); @@ -8473,7 +8169,7 @@ impl Subscriber { // What the peek skipped for age counts even when nothing is handed out. self.count_stale(&self.stats.meter()); match res { - Poll::Ready(Ok(None)) => Poll::Ready(self.ended(false)), + Poll::Ready(Ok(None)) => Poll::Ready(self.ended()), res => res.map(|res| res.map(|_| None)), } } @@ -8552,15 +8248,15 @@ impl Subscriber { /// Whether this reader ran out at an end declared with an error, rather than an abort. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn ended_with_error(&self) -> bool { - self.ended::<()>(false).is_err() + self.ended::<()>().is_err() } - /// What a reader that ran out of groups, or of datagrams for `datagrams`, gets: the end - /// of stream, or the error the track ended with once this reader's range reaches that end. - fn ended(&self, datagrams: bool) -> Result> { + /// What a reader that ran out of groups gets: the end of stream, or the error the track + /// ended with once this reader's range reaches that end. + fn ended(&self) -> Result> { let end = match &self.inner { Inner::Plain(cursor) => cursor.state.read().end_error(), - Inner::Resume(resume, _) => resume.end_error(datagrams), + Inner::Resume(resume, _) => resume.end_error(), }; match end { Some((fin, err)) if self.reaches(fin) => Err(err), @@ -8698,86 +8394,6 @@ impl Subscriber { kio::wait(|waiter| self.poll_recv_group(waiter)).await } - /// Poll for the next datagram in arrival order, without blocking. - /// - /// Datagrams are a separate best-effort channel from groups (see - /// [`Producer::append_datagram`]); they share only the sequence namespace, and - /// neither cursor moves the other. A new timed subscriber skips the buffered prefix older - /// than its max delay behind the highest timestamp; untimed subscribers keep the whole - /// buffer. Later arrival-order delivery is unchanged, and any outside its group range - /// are skipped. A consumer that falls too far behind silently loses the oldest datagrams. - /// - /// A moq-transport group delivers only its normal Object ID 0 payload here. Other - /// Object IDs and statuses are skipped; relay paths preserve them with their IDs intact. - /// - /// Returns `Poll::Ready(Ok(Some(datagram)))` when one is available, - /// `Poll::Ready(Ok(None))` when the track is finished, `Poll::Ready(Err(e))` when the track - /// is aborted or ended with an error, or `Poll::Pending` when none is buffered yet. - pub fn poll_recv_datagram(&mut self, waiter: &kio::Waiter) -> Poll>> { - loop { - let Some(datagram) = ready!(self.poll_recv_stored_datagram(waiter))? else { - return Poll::Ready(self.ended(true)); - }; - // A plain payload cannot identify another object in the same group or a status. - if let Some(wire) = &datagram.wire - && (wire.object_id.is_some_and(|id| id > 0) || wire.status.is_some_and(|status| status != 0)) - { - tracing::debug!( - sequence = datagram.sequence, - object_id = wire.object_id, - status = wire.status, - "dropping datagram not representable as a plain payload" - ); - continue; - } - self.meter_datagram(datagram.payload.len() as u64); - return Poll::Ready(Ok(Some(datagram.datagram))); - } - } - - /// Count a datagram handed on as `bytes` of payload, the single-frame group it stands in - /// for. Unlike a group (metered lazily as its frames are read), a datagram goes whole. - pub(crate) fn meter_datagram(&self, bytes: u64) { - self.stats.meter().datagram(bytes); - } - - /// The next datagram as the track stores it: its wire metadata, any status, and a - /// compressed track's DEFLATE slice. Unmetered: the caller meters the form it hands on - /// with [`Self::meter_datagram`]. - pub(crate) fn poll_recv_stored_datagram( - &mut self, - waiter: &kio::Waiter, - ) -> Poll>> { - match &mut self.inner { - Inner::Plain(cursor) => cursor.poll_recv_datagram(waiter), - Inner::Resume(resume, _) => resume.poll_recv_datagram(&self.delivered, waiter), - } - } - - /// [`Self::poll_recv_stored_datagram`], metered as its plain payload: for a session that - /// never carries compression. - pub(crate) fn poll_recv_wire_datagram( - &mut self, - waiter: &kio::Waiter, - ) -> Poll>> { - let res = self.poll_recv_stored_datagram(waiter); - if let Poll::Ready(Ok(Some(datagram))) = &res { - self.meter_datagram(datagram.payload.len() as u64); - } - res - } - - /// Receive the next datagram in arrival order. - /// - /// A best-effort channel parallel to [`Self::recv_group`]; the two share only the sequence - /// namespace. To receive both concurrently from one subscriber, poll - /// [`Self::poll_recv_group`] and [`Self::poll_recv_datagram`] together in a single `poll` - /// closure (sequential `&mut` borrows), rather than awaiting the two `recv` futures at once. - #[cfg(feature = "async")] - pub async fn recv_datagram(&mut self) -> Result> { - kio::wait(|waiter| self.poll_recv_datagram(waiter)).await - } - /// The sequence cursor behind [`Ordered`], which owns the only public door to it. pub(super) fn poll_next_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.poll_group(true, waiter) @@ -8789,9 +8405,6 @@ impl Subscriber { /// arrival-order [`Subscriber`] or a sequence-order [`Ordered`], never both at once. /// The two advance independently, and interleaving them produces a group stream that /// is neither, which is why the choice is a handle rather than a method. - /// - /// Datagrams come along: they are a separate cursor either way, so the choice of group - /// order says nothing about them. pub fn ordered(self) -> Ordered { Ordered { inner: self } } @@ -9110,7 +8723,7 @@ impl Subscriber { /// Poll for the track's largest location within [`Self::with_range`]: the current one, then each newer one, and `None` once the track ends. /// - /// The largest is the newest group or datagram at the newest frame (0 while empty), as + /// The largest is the newest group at the newest frame (0 while empty), as /// the source reported it and its live feed delivered since, so nothing reports until /// the track's feed is live. A largest past the cap or below the floor reports nothing: /// the cache does not stand in for it, so a cap moved below the largest stays pending. @@ -9224,8 +8837,8 @@ impl Subscriber { /// subscription feeds, a group that may still land, reordered behind its successor, is /// read first, until the same budget gives up on it. Without a budget (`None`, which a /// history or VOD read uses) it waits until the hole resolves. A group the track knows will never land costs nothing: -/// one the publisher dropped, one that arrived and was since evicted or aborted, a -/// datagram's sequence, or one below where the feed starts. A locally produced track owes +/// one the publisher dropped, one that arrived and was since evicted or aborted, or one +/// below where the feed starts. A locally produced track owes /// nothing, so there the cursor seeks to the lowest cached group in range. /// /// An unlimited or long budget only avoids blocking where the feed signals its gaps: @@ -9279,28 +8892,6 @@ impl Ordered { kio::wait(|waiter| self.poll_next_group(waiter)).await } - /// Poll for the next datagram in arrival order, without blocking. - /// - /// Datagrams are a separate best-effort channel from groups (see - /// [`Producer::append_datagram`]); they share only the sequence namespace, and neither - /// cursor moves the other. Unordered by construction, so this behaves identically on - /// either handle; it is here so a track carrying both channels needs one subscription - /// rather than two. - pub fn poll_recv_datagram(&mut self, waiter: &kio::Waiter) -> Poll>> { - self.inner.poll_recv_datagram(waiter) - } - - /// Receive the next datagram in arrival order. - /// - /// To read groups and datagrams concurrently from one handle, poll - /// [`Self::poll_next_group`] and [`Self::poll_recv_datagram`] together in a single - /// `poll` closure (sequential `&mut` borrows), rather than awaiting the two `recv` - /// futures at once. - #[cfg(feature = "async")] - pub async fn recv_datagram(&mut self) -> Result> { - kio::wait(|waiter| self.poll_recv_datagram(waiter)).await - } - /// Limit subsequent reads to these group sequences, returning `self` for chaining. /// /// See [`Subscriber::with_range`] for how the bounds apply. An ordered read never goes @@ -9822,29 +9413,6 @@ mod test { .unwrap() } - /// Helper: non-blocking datagram receive that must be ready with a datagram. - #[cfg(feature = "async")] - fn recv_datagram(dg: &mut Subscriber) -> Datagram { - dg.recv_datagram() - .now_or_never() - .expect("datagram would have blocked") - .expect("would have errored") - .expect("track was closed") - } - - #[test] - fn append_datagram_shares_group_sequence() { - let mut producer = track_producer("test", Info::timed()); - let ts = Timestamp::from_millis(10).unwrap(); - - // Interleave groups and datagrams: they draw from one monotonic counter. - assert_eq!(producer.append_group().unwrap().sequence, 0); - assert_eq!(producer.append_datagram(ts, &b"a"[..]).unwrap(), 1); - assert_eq!(producer.append_group().unwrap().sequence, 2); - assert_eq!(producer.append_datagram(ts, &b"b"[..]).unwrap(), 3); - assert_eq!(producer.latest_sequence(), Some(3)); - } - /// An empty group raises the largest at its head, so its first frame names nothing newer. #[cfg(feature = "async")] #[kio::test] @@ -9973,16 +9541,6 @@ mod test { ); } - /// A datagram raises the largest like a group, at its only object. - #[test] - fn a_datagram_raises_the_largest() { - let mut producer = track_producer("test", Info::default()); - producer.append_group().unwrap().finish().unwrap(); - let sequence = producer.append_datagram(None, &b"d"[..]).unwrap(); - assert_eq!(sequence, 1); - assert_eq!(producer.largest(), Some(Position::group(1))); - } - /// A reader that starts at the largest group while it is still empty is at the live edge: /// it gets that group, which ends with nothing, then the next one. #[test] @@ -10521,29 +10079,6 @@ mod test { assert_eq!(ready_groups(&mut reader), vec![3]); } - /// A datagram can show the route's answer before any group does, as it does when a - /// datagram is the first thing the resumed feed sends. A relative start counts back - /// from that answer, not from the older groups cached below it. - #[test] - #[cfg(feature = "async")] - fn a_relative_start_on_an_idle_copy_counts_from_a_datagram_edge() { - let mut producer = idle_copy(); - let mut reader = producer.subscribe(replay().with_ranges([Range::live()])); - producer - .insert_datagram(4, None, bytes::Bytes::from_static(b"x")) - .unwrap(); - producer.set_live(Some(Position::group(4)), None); - assert_eq!( - ready_groups(&mut reader), - Vec::::new(), - "no group reached the edge" - ); - assert_eq!(reader.floor(), Some(4), "the answer anchors the start"); - - framed(&producer, 5, 1).finish().unwrap(); - assert_eq!(ready_groups(&mut reader), vec![5]); - } - /// A live join's answer anchors a relative start where it says the feed is, both for the /// copy's readers and for the bounds sent upstream, even once a newer group lands before /// the one it named. @@ -10564,185 +10099,6 @@ mod test { assert_eq!(reader.floor(), Some(2), "the answer anchors the start"); } - /// An idle copy forgets the edge its previous feed answered with, which only a datagram - /// it dropped showed: when it ends without going live, a relative start counts back - /// from its newest cached group. - #[test] - #[cfg(feature = "async")] - fn an_idle_copy_that_ends_forgets_an_earlier_datagram_edge() { - let mut producer = idle_copy(); - producer - .insert_datagram(4, None, bytes::Bytes::from_static(b"x")) - .unwrap(); - producer.set_live(Some(Position::group(4)), None); - producer.set_idle(); - let mut reader = producer.subscribe(replay().with_ranges([Range::live()])); - assert_eq!(ready_groups(&mut reader), Vec::::new(), "the copy is idle"); - producer.finish().unwrap(); - assert_eq!(ready_groups(&mut reader), vec![3]); - } - - #[test] - #[cfg(feature = "async")] - fn append_datagram_roundtrip() { - let mut producer = track_producer("test", Info::timed()); - let mut dg = producer.subscribe(None); - - let ts = Timestamp::from_millis(42).unwrap(); - let seq = producer.append_datagram(ts, &b"hello"[..]).unwrap(); - - let got = recv_datagram(&mut dg); - assert_eq!(got.sequence, seq); - assert_eq!(got.timestamp, Some(ts)); - assert_eq!(&got.payload[..], b"hello"); - } - - #[test] - #[cfg(feature = "async")] - fn insert_datagram_preserves_sequence() { - let mut producer = track_producer("test", Info::timed()); - let mut dg = producer.subscribe(None); - - let ts = Timestamp::from_millis(5).unwrap(); - // A relay forwarding an upstream datagram keeps its sequence number. - producer - .insert_datagram(100, ts, bytes::Bytes::from_static(b"x")) - .unwrap(); - - assert_eq!(recv_datagram(&mut dg).sequence, 100); - // max_sequence advanced, so the next appended group/datagram continues past it. - assert_eq!(producer.append_group().unwrap().sequence, 101); - } - - #[test] - #[cfg(feature = "async")] - fn insert_datagram_leaves_a_gap() { - let mut producer = track_producer("test", Info::timed()); - let mut dg = producer.subscribe(None); - let ts = Timestamp::from_millis(0).unwrap(); - - producer - .insert_datagram(10, ts, bytes::Bytes::from_static(b"gap")) - .unwrap(); - assert_eq!(recv_datagram(&mut dg).sequence, 10); - assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 11); - assert_eq!(producer.append_group().unwrap().sequence, 12); - } - - #[test] - #[cfg(feature = "async")] - fn insert_datagram_out_of_order_does_not_rewind() { - let mut producer = track_producer("test", Info::timed()); - let mut dg = producer.subscribe(None); - let ts = Timestamp::from_millis(0).unwrap(); - - producer - .insert_datagram(10, ts, bytes::Bytes::from_static(b"high")) - .unwrap(); - producer - .insert_datagram(5, ts, bytes::Bytes::from_static(b"low")) - .unwrap(); - - assert_eq!(recv_datagram(&mut dg).sequence, 10); - assert_eq!(recv_datagram(&mut dg).sequence, 5); - assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 11); - } - - #[test] - #[cfg(feature = "async")] - fn insert_datagram_duplicate_is_best_effort() { - let mut producer = track_producer("test", Info::timed()); - let mut dg = producer.subscribe(None); - let ts = Timestamp::from_millis(0).unwrap(); - - producer - .insert_datagram(3, ts, bytes::Bytes::from_static(b"first")) - .unwrap(); - producer - .insert_datagram(3, ts, bytes::Bytes::from_static(b"again")) - .unwrap(); - - assert_eq!(&recv_datagram(&mut dg).payload[..], b"first"); - assert_eq!(&recv_datagram(&mut dg).payload[..], b"again"); - assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 4); - } - - #[test] - #[cfg(feature = "async")] - fn insert_datagram_stale_does_not_rewind_after_append() { - let mut producer = track_producer("test", Info::timed()); - let mut dg = producer.subscribe(None); - let ts = Timestamp::from_millis(0).unwrap(); - - assert_eq!(producer.append_datagram(ts, &b"0"[..]).unwrap(), 0); - assert_eq!(producer.append_datagram(ts, &b"1"[..]).unwrap(), 1); - producer - .insert_datagram(0, ts, bytes::Bytes::from_static(b"stale")) - .unwrap(); - - assert_eq!(recv_datagram(&mut dg).sequence, 0); - assert_eq!(recv_datagram(&mut dg).sequence, 1); - assert_eq!(recv_datagram(&mut dg).sequence, 0); - assert_eq!(producer.append_datagram(ts, &b"2"[..]).unwrap(), 2); - assert_eq!(producer.append_group().unwrap().sequence, 3); - } - - #[test] - #[cfg(feature = "async")] - fn insert_datagram_cloned_producers_share_counter() { - let mut producer = track_producer("test", Info::timed()); - let mut other = producer.clone(); - let mut dg = producer.subscribe(None); - let ts = Timestamp::from_millis(0).unwrap(); - - producer - .insert_datagram(4, ts, bytes::Bytes::from_static(b"a")) - .unwrap(); - assert_eq!(other.append_datagram(ts, &b"b"[..]).unwrap(), 5); - other.insert_datagram(8, ts, bytes::Bytes::from_static(b"c")).unwrap(); - assert_eq!(producer.append_group().unwrap().sequence, 9); - - assert_eq!(recv_datagram(&mut dg).sequence, 4); - assert_eq!(recv_datagram(&mut dg).sequence, 5); - assert_eq!(recv_datagram(&mut dg).sequence, 8); - } - - #[test] - fn insert_datagram_after_finish_is_closed() { - let mut producer = track_producer("test", Info::timed()); - let ts = Timestamp::from_millis(0).unwrap(); - producer.finish().unwrap(); - assert!(matches!( - producer.insert_datagram(0, ts, bytes::Bytes::from_static(b"x")), - Err(Error::Closed) - )); - assert!(matches!(producer.append_datagram(ts, &b"x"[..]), Err(Error::Closed))); - } - - #[test] - fn insert_datagram_after_abort_fails() { - let producer = track_producer("test", Info::timed()); - let mut other = producer.clone(); - let ts = Timestamp::from_millis(0).unwrap(); - producer.abort(Error::Cancel).unwrap(); - assert!(other.insert_datagram(0, ts, bytes::Bytes::from_static(b"x")).is_err()); - } - - #[test] - fn insert_datagram_respects_finish_at() { - let mut producer = track_producer("test", Info::timed()); - let ts = Timestamp::from_millis(0).unwrap(); - producer.finish_at(Position::group(10), None).unwrap(); - producer - .insert_datagram(5, ts, bytes::Bytes::from_static(b"ok")) - .unwrap(); - assert!(matches!( - producer.insert_datagram(10, ts, bytes::Bytes::from_static(b"late")), - Err(Error::Closed) - )); - assert_eq!(producer.append_group().unwrap().sequence, 6); - } - /// A cached observer must not keep an unfinished writer alive or serve its dead copy. #[kio::test] async fn dropped_group_writer_aborts_and_releases_its_cached_copy() { @@ -10811,442 +10167,26 @@ mod test { let _partial = group .create_frame(frame::Info { size: 2, - timestamp: Some(Timestamp::ZERO), - }) - .unwrap(); - assert_eq!(handle.frame_count(), 2); - drop(_partial); - assert_eq!(handle.resume_frame(), Some(1)); - assert_eq!(consumer.resume_position(), Some(Position { group: 3, frame: 1 })); - } - - /// Datagram sequence advances do not move a route takeover past an open group. - #[test] - fn resume_position_uses_the_latest_group() { - let mut datagram_only = track_producer("datagram-only", Info::timed()); - let datagram_only_consumer = datagram_only.consume(); - datagram_only - .insert_datagram(8, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - assert_eq!( - datagram_only_consumer.resume_position(), - None, - "a datagram creates no group position to resume" - ); - - let mut producer = track_producer("mixed", Info::timed()); - let consumer = producer.consume(); - let mut group = producer.create_group(group::Info { sequence: 3 }).unwrap(); - group - .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"head")) - .unwrap(); - producer - .insert_datagram(8, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - - assert_eq!( - consumer.resume_position(), - Some(Position { group: 3, frame: 1 }), - "the replacement must continue the open group" - ); - group.abort(Error::Cancel).unwrap(); - } - - /// Datagrams and groups are separate channels that share only a sequence namespace, - /// so consuming one must not move the other's cursor. - #[test] - #[cfg(feature = "async")] - fn recv_datagram_leaves_the_ordered_cursor_alone() { - let mut producer = track_producer("test", Info::timed()); - let mut datagrams = producer.subscribe(replay()); - let mut subscriber = producer.subscribe(replay()).ordered(); - let ts = Timestamp::from_millis(5).unwrap(); - - producer - .insert_datagram(5, ts, bytes::Bytes::from_static(b"x")) - .unwrap(); - assert_eq!(recv_datagram(&mut datagrams).sequence, 5); - - producer - .create_group(group::Info { sequence: 3 }) - .unwrap() - .finish() - .unwrap(); - producer - .create_group(group::Info { sequence: 6 }) - .unwrap() - .finish() - .unwrap(); - - let mut next = || { - subscriber - .next_group() - .now_or_never() - .expect("group would have blocked") - .expect("would have errored") - .expect("track was closed") - .sequence - }; - assert_eq!(next(), 3, "the datagram at sequence 5 did not consume group 3"); - assert_eq!(next(), 6); - } - - #[test] - #[cfg(feature = "async")] - fn datagram_normalized_to_track_timescale() { - let info = Info::default().with_timescale(Timescale::MICRO); - let mut producer = track_producer("test", info); - let mut dg = producer.subscribe(None); - - // Supplied at millis; stored/emitted at the track's micro timescale. - producer - .append_datagram(Timestamp::from_millis(2).unwrap(), &b"z"[..]) - .unwrap(); - let got = recv_datagram(&mut dg); - assert_eq!(got.timestamp.unwrap().scale(), Timescale::MICRO); - assert_eq!(got.timestamp.unwrap().value(), 2_000); - } - - #[test] - fn datagram_rejects_oversized() { - let mut producer = track_producer("test", Info::timed()); - let big = bytes::Bytes::from(vec![0u8; crate::model::datagram::MAX_DATAGRAM_PAYLOAD + 1]); - let ts = Timestamp::from_millis(0).unwrap(); - assert!(matches!( - producer.append_datagram(ts, big.clone()), - Err(Error::FrameTooLarge) - )); - assert!(matches!( - producer.insert_datagram(0, ts, big), - Err(Error::FrameTooLarge) - )); - } - - #[test] - #[cfg(feature = "async")] - fn datagram_fanout_to_subscribers() { - let mut producer = track_producer("test", Info::timed()); - // Two independent subscribers, each with its own datagram cursor. - let mut a = producer.subscribe(None); - let mut b = producer.subscribe(None); - let ts = Timestamp::from_millis(1).unwrap(); - - producer.append_datagram(ts, &b"first"[..]).unwrap(); - producer.append_datagram(ts, &b"second"[..]).unwrap(); - - // Both receive every datagram in order, independently. - assert_eq!(&recv_datagram(&mut a).payload[..], b"first"); - assert_eq!(&recv_datagram(&mut a).payload[..], b"second"); - assert_eq!(&recv_datagram(&mut b).payload[..], b"first"); - assert_eq!(&recv_datagram(&mut b).payload[..], b"second"); - } - - #[test] - #[cfg(feature = "async")] - fn datagram_replay_starts_within_max_delay() { - let info = Info::default().with_timescale(Timescale::MICRO); - let mut producer = track_producer("sparse", info); - let timestamps = [0, 179_949, 179_950, 180_000, 179_975]; - for millis in timestamps { - producer - .append_datagram(Timestamp::from_millis(millis).unwrap(), b"x".as_slice()) - .unwrap(); - elapse(&producer, Duration::from_secs(1)); - } - let mut subscriber = producer - .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(50)))); - // The edge is captured at attachment, not when the subscriber first reads. - producer - .append_datagram(Timestamp::from_secs(600).unwrap(), b"newer".as_slice()) - .unwrap(); - for sequence in [2, 3, 4, 5] { - assert_eq!(recv_datagram(&mut subscriber).sequence, sequence); - } - assert!(subscriber.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - - // The initial bound does not filter arrivals after attachment, even when they are old. - producer - .append_datagram(Timestamp::from_millis(0).unwrap(), b"later".as_slice()) - .unwrap(); - assert_eq!(recv_datagram(&mut subscriber).sequence, 6); - } - - #[test] - #[cfg(feature = "async")] - fn datagram_replay_cutoff_between_track_units() { - // A 90 kHz unit doesn't divide the nanosecond cutoff, which lands inside unit 89_995. - let info = Info::default().with_timescale(Timescale::new(90_000).unwrap()); - let mut producer = track_producer("video", info); - for units in [89_995, 89_996, 90_000] { - producer - .append_datagram(Timestamp::from_scale(units, 90_000).unwrap(), b"x".as_slice()) - .unwrap(); - } - let mut subscriber = producer - .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_micros(50)))); - for sequence in [1, 2] { - assert_eq!(recv_datagram(&mut subscriber).sequence, sequence); - } - assert!(subscriber.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - } - - #[test] - #[cfg(feature = "async")] - fn datagram_replay_starts_after_evicted_entries_with_zero_delay() { - let mut producer = track_producer("test", Info::timed()); - for _ in 0..MAX_DATAGRAMS + 1 { - producer.append_datagram(Timestamp::ZERO, b"old".as_slice()).unwrap(); - } - let sequence = producer - .append_datagram(Timestamp::from_secs(180).unwrap(), b"new".as_slice()) - .unwrap(); - let mut subscriber = - producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::ZERO))); - assert_eq!(recv_datagram(&mut subscriber).sequence, sequence); - assert!(subscriber.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - } - - #[test] - #[cfg(feature = "async")] - fn untimed_datagram_replay_keeps_the_buffer() { - let mut producer = untimed_producer(); - // Far past the delay, but within the pool's idle window. - for _ in 0..3 { - producer.append_datagram(None, b"x".as_slice()).unwrap(); - elapse(&producer, Duration::from_secs(5)); - } - let mut subscriber = - producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::ZERO))); - for sequence in 0..3 { - assert_eq!(recv_datagram(&mut subscriber).sequence, sequence); - } - assert!(subscriber.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - } - - #[test] - #[cfg(feature = "async")] - fn datagram_buffer_drops_oldest_at_capacity() { - let mut producer = track_producer("test", Info::timed()); - let mut slow = producer.subscribe(None); - let mut fast = producer.subscribe(None); - let count = MAX_DATAGRAMS * 3; - for sequence in 0..count { - producer.append_datagram(Timestamp::ZERO, b"x".as_slice()).unwrap(); - assert_eq!(recv_datagram(&mut fast).sequence, sequence as u64); - } - assert_eq!(producer.state.read().datagrams.len(), MAX_DATAGRAMS); - for sequence in count - MAX_DATAGRAMS..count { - assert_eq!(recv_datagram(&mut slow).sequence, sequence as u64); - } - assert!(slow.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - } - - #[test] - #[cfg(feature = "async")] - fn datagram_recv_pends_until_written() { - let mut producer = track_producer("test", Info::timed()); - let mut dg = producer.subscribe(None); - - assert!( - dg.recv_datagram().now_or_never().is_none(), - "should block with no datagrams" - ); - - producer - .append_datagram(Timestamp::from_millis(0).unwrap(), &b"go"[..]) - .unwrap(); - assert_eq!(&recv_datagram(&mut dg).payload[..], b"go"); - } - - /// A fetch never serves a datagram group, and says so rather than reporting a plain - /// miss, whether or not a group followed it. - #[test] - fn a_datagram_group_is_not_fetchable() { - let mut producer = track_producer("test", Info::timed()); - let consumer = producer.consume(); - let _subscriber = producer.subscribe(None); - let sequence = producer.append_datagram(Timestamp::ZERO, &b"x"[..]).unwrap(); - let fetch = |sequence| consumer.fetch_group(sequence, None).now_or_never(); - assert!( - matches!(fetch(sequence), Some(Err(Error::NotFetchable))), - "the newest sequence" - ); - - let _open = producer.append_group().unwrap(); - assert!( - matches!(fetch(sequence), Some(Err(Error::NotFetchable))), - "below a newer group" - ); - // A sequence that was never a datagram is a plain miss. - assert!( - matches!(fetch(sequence + 2), Some(Err(Error::NotFound))), - "a missing group" - ); - } - - /// A fill's end is exclusive, so the last sequence can't be asked of a handler: its fetch - /// fails at once rather than parking until every handler goes. - #[test] - #[cfg(feature = "async")] - fn the_last_sequence_misses_with_a_handler() { - let producer = track_producer("test", None); - let dynamic = producer.requests(); - let consumer = producer.consume(); - let fetch = consumer.fetch_group(u64::MAX, None).now_or_never(); - assert!(matches!(fetch, Some(Err(Error::NotFound)))); - assert!(dynamic.requested_fill().now_or_never().is_none(), "nothing to fill"); - } - - /// The subscription's floor and cap bound datagrams as they bound groups. The datagrams - /// in range, sent between the dropped ones, show the reader is live, so the range is - /// what dropped the others. - #[test] - #[cfg(feature = "async")] - fn the_group_range_bounds_datagrams() { - let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_start(5)); - subscriber.set_range(..7); - for sequence in [4, 5, 7, 6] { - producer - .insert_datagram(sequence, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - } - - assert_eq!(recv_datagram(&mut subscriber).sequence, 5); - assert_eq!(recv_datagram(&mut subscriber).sequence, 6); - assert!(subscriber.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - } - - /// A range update judges the datagrams still buffered when they are read, and one it - /// dropped stays dropped once the cap rises again: nothing holds it like a parked group. - #[test] - #[cfg(feature = "async")] - fn a_range_update_applies_to_buffered_datagrams() { - let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(None); - for sequence in 1..=4 { - producer - .insert_datagram(sequence, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - } - - subscriber.set_range(2..4); - assert_eq!(recv_datagram(&mut subscriber).sequence, 2); - assert_eq!(recv_datagram(&mut subscriber).sequence, 3); - assert!(subscriber.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - - subscriber.set_range(..); - assert!(subscriber.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); - producer - .insert_datagram(5, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - assert_eq!(recv_datagram(&mut subscriber).sequence, 5); - } - - /// Plain readers cannot identify another object in the same datagram group. Skipping it - /// must neither consume a late object 0 nor alter the typed reader's arrival stream. - #[test] - fn plain_datagrams_never_substitute_another_object() { - for objects in [&[0, 1, 2][..], &[1, 2, 0], &[1, 2]] { - let mut producer = track_producer("test", Info::default()); - let mut plain = producer.subscribe(None); - let mut typed = producer.subscribe(None); - let waiter = kio::Waiter::noop(); - for &object in objects { - let stored = super::super::datagram::Stored { - datagram: Datagram { - sequence: 5, - timestamp: None, - payload: bytes::Bytes::from(vec![object as u8]), - }, - wire: Some(Arc::new(super::super::datagram::Wire { - object_id: Some(object), - priority: Some(200), - end_of_group: object == 2, - status: None, - properties: Default::default(), - })), - deflated: None, - }; - producer.insert_wire_datagram(stored.clone()).unwrap(); - let Poll::Ready(Ok(Some(kept))) = typed.poll_recv_stored_datagram(&waiter) else { - panic!("typed reader lost object {object}"); - }; - assert_eq!(kept, stored); - match object { - 0 => { - let Poll::Ready(Ok(Some(datagram))) = plain.poll_recv_datagram(&waiter) else { - panic!("object 0 was lost in {objects:?}"); - }; - assert_eq!((datagram.sequence, &datagram.payload[..]), (5, &[0][..])); - } - _ => assert!( - plain.poll_recv_datagram(&waiter).is_pending(), - "object {object} substituted" - ), - } - } - producer.insert_datagram(6, None, b"next".as_slice()).unwrap(); - producer.finish().unwrap(); - let Poll::Ready(Ok(Some(datagram))) = plain.poll_recv_datagram(&waiter) else { - panic!("the next datagram was lost in {objects:?}"); - }; - assert_eq!((datagram.sequence, &datagram.payload[..]), (6, &b"next"[..])); - assert!(matches!(plain.poll_recv_datagram(&waiter), Poll::Ready(Ok(None)))); - } - } - - /// Exercises the full producer -> publisher-encode -> subscriber-decode -> producer seam - /// (everything but the QUIC datagram send/recv), catching any field-order mismatch between - /// the wire codec and the model. - #[test] - #[cfg(feature = "async")] - fn datagram_wire_roundtrip_between_tracks() { - use crate::coding::Encode; - use crate::lite; - - let version = lite::Version::Lite05; - - // Origin publishes a datagram; the publisher reads it and encodes the wire body. - let mut origin = track_producer("test", Info::timed()); - let mut origin_dg = origin.subscribe(None); - let ts = Timestamp::from_millis(7).unwrap(); - let seq = origin.append_datagram(ts, &b"payload"[..]).unwrap(); - - let d = recv_datagram(&mut origin_dg); - let body = lite::Datagram { - subscribe: 5, - sequence: d.sequence, - timestamp: Some(d.timestamp.unwrap().value()), - payload: d.payload.clone(), - inflated: None, - ietf: None, - } - .encode_bytes(version) - .unwrap(); - - // Subscriber decodes the body and writes it downstream, preserving the sequence. - let wire = lite::Datagram::decode(body, version, false, |_| lite::Framing { - timescale: Some(crate::Timescale::MICRO), - ietf: None, - compressed: false, - }) - .unwrap() - .unwrap(); - let mut downstream = track_producer("test", Info::timed()); - let mut downstream_dg = downstream.subscribe(None); - downstream - .insert_datagram( - wire.sequence, - Timestamp::new(wire.timestamp.unwrap(), Timescale::MILLI).unwrap(), - wire.payload, - ) + timestamp: Some(Timestamp::ZERO), + }) .unwrap(); + assert_eq!(handle.frame_count(), 2); + drop(_partial); + assert_eq!(handle.resume_frame(), Some(1)); + assert_eq!(consumer.resume_position(), Some(Position { group: 3, frame: 1 })); + } - let got = recv_datagram(&mut downstream_dg); - assert_eq!(got.sequence, seq); - assert_eq!(got.timestamp, Some(ts)); - assert_eq!(&got.payload[..], b"payload"); + /// A fill's end is exclusive, so the last sequence can't be asked of a handler: its fetch + /// fails at once rather than parking until every handler goes. + #[test] + #[cfg(feature = "async")] + fn the_last_sequence_misses_with_a_handler() { + let producer = track_producer("test", None); + let dynamic = producer.requests(); + let consumer = producer.consume(); + let fetch = consumer.fetch_group(u64::MAX, None).now_or_never(); + assert!(matches!(fetch, Some(Err(Error::NotFound)))); + assert!(dynamic.requested_fill().now_or_never().is_none(), "nothing to fill"); } #[test] @@ -11741,34 +10681,6 @@ mod test { assert!(expired, "a streamed chunk runs expiry"); } - #[test] - fn appended_datagram_expires_idle_groups() { - let mut producer = track_producer_expiring("test", Duration::from_secs(1)); - producer.append_group().unwrap().finish().unwrap(); // seq 0 - producer.append_group().unwrap().finish().unwrap(); // seq 1 - - elapse(&producer, Duration::from_secs(2)); - producer.append_datagram(Timestamp::ZERO, b"x".as_slice()).unwrap(); - - let expired = !producer.state.read().lookup.contains_key(&0); - assert!(expired, "an appended datagram runs expiry"); - } - - #[test] - fn forwarded_datagram_expires_idle_groups() { - let mut producer = track_producer_expiring("test", Duration::from_secs(1)); - producer.append_group().unwrap().finish().unwrap(); // seq 0 - producer.append_group().unwrap().finish().unwrap(); // seq 1 - - elapse(&producer, Duration::from_secs(2)); - producer - .insert_datagram(2, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - - let expired = !producer.state.read().lookup.contains_key(&0); - assert!(expired, "a forwarded datagram runs expiry"); - } - /// A stalled track keeps its newest group and gives up the ones before it by the wall /// clock: `max_age` measures how long ago each group's successor arrived as well as /// media time, so a publisher that stops writing cannot pin its superseded groups. @@ -13940,62 +12852,6 @@ mod test { 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() { - 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", Info::timed()); - 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] - #[cfg(feature = "async")] - fn ordered_carries_datagrams() { - let mut producer = track_producer("test", Info::timed()); - let mut sub = producer.subscribe(None).ordered(); - - producer - .insert_datagram(5, Timestamp::from_millis(5).unwrap(), bytes::Bytes::from_static(b"x")) - .unwrap(); - producer - .create_group(group::Info { sequence: 3 }) - .unwrap() - .finish() - .unwrap(); - - let datagram = sub - .recv_datagram() - .now_or_never() - .expect("datagram would have blocked") - .expect("would have errored") - .expect("track was closed"); - assert_eq!(datagram.sequence, 5); - - // The datagram did not consume the group cursor. - let group = sub - .next_group() - .now_or_never() - .expect("group would have blocked") - .expect("would have errored") - .expect("track was closed"); - assert_eq!(group.sequence, 3); - } - /// A subscriber read by either cursor, so one test covers both. #[cfg(feature = "async")] enum Reader { @@ -15936,25 +14792,6 @@ mod test { assert!(arrival.recv_group().now_or_never().unwrap().unwrap().is_none()); } - /// A datagram is a live arrival too, so it fills its sequence in a pending tail. - #[test] - #[cfg(feature = "async")] - fn a_datagram_fills_a_pending_tail() { - let mut producer = track_producer("test", None); - producer.received(); - let mut arrival = producer.subscribe(replay()); - producer.start_at(0).unwrap(); - producer.finish_at(Position::group(2), None).unwrap(); - producer.insert_datagram(0, None, b"x".as_slice()).unwrap(); - producer - .create_group(group::Info { sequence: 1 }) - .unwrap() - .finish() - .unwrap(); - assert_eq!(arrival.assert_group().sequence, 1); - assert!(arrival.recv_group().now_or_never().unwrap().unwrap().is_none()); - } - /// The wire's end can land after the highest group, with lower groups still owed. A /// reader already past that group stays held at the hole: the end never shows without /// its pending tail. @@ -16557,26 +15394,20 @@ mod test { assert_eq!(drain_ordered(&mut consumer), [3]); } - /// What the track knows will never land is passed at once: a drop, a datagram, an - /// aborted group whose slot was reclaimed, or a group from below where the feed starts. + /// What the track knows will never land is passed at once: a drop, an aborted group + /// whose slot was reclaimed, or a group from below where the feed starts. #[test] #[cfg(feature = "async")] fn next_group_passes_what_is_not_owed() { let subscription = Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(30))); - let mut producer = received_producer(None); + let producer = received_producer(None); let mut consumer = producer.subscribe(subscription.clone()).ordered(); put(&producer, 0, None); - assert_eq!(producer.append_datagram(None, &b"d"[..]).unwrap(), 1); - put(&producer, 2, None); - assert_eq!( - drain_ordered(&mut consumer), - [0, 2], - "a datagram took group 1's sequence" - ); + assert_eq!(drain_ordered(&mut consumer), [0]); - producer.drop_groups(3..4, StreamError::Old); + producer.drop_groups(1..4, StreamError::Old); put(&producer, 4, None); - assert_eq!(drain_ordered(&mut consumer), [4], "group 3 was dropped"); + assert_eq!(drain_ordered(&mut consumer), [4], "groups 1 to 3 were dropped"); let aborted = producer.create_group(group::Info { sequence: 5 }).unwrap(); aborted.abort(Error::Cancel).unwrap(); @@ -19712,171 +18543,6 @@ mod test { subscriber.assert_no_group(); } - /// Datagrams share `max_sequence` but must not break group demotion: the live - /// edge is tracked per group, so interleaving datagrams can't strand groups - /// outside the eviction order and bypass the budget. - #[test] - fn datagrams_do_not_block_eviction() { - let (mut producer, pool) = pooled_producer(1_000); - for _ in 0..10 { - finished_group(&mut producer, 1_000); - producer.append_datagram(Timestamp::ZERO, &b"beat"[..]).unwrap(); - } - - let consumer = producer.consume(); - assert!(consumer.peek_group(0).is_none(), "old groups still evict"); - assert!( - pool.used() < 4 * 1_256, - "interleaved datagrams must not bypass the budget: {}", - pool.used() - ); - } - - /// A received compressed datagram inflates far past its wire size, so the send buffer - /// bills both forms to the pool, and pressure drops each track's oldest instead of - /// letting every track retain a full buffer of inflated payloads. - #[test] - #[cfg(feature = "async")] - fn received_compressed_datagrams_are_billed() { - let capacity = 1024; - let pool = cache::Pool::new(cache::Config::default().with_capacity(capacity)); - let broadcast = Arc::new(broadcast::Info { - pool: pool.clone(), - ..Default::default() - }); - let plain = bytes::Bytes::from(vec![0u8; super::super::datagram::MAX_DATAGRAM_PAYLOAD]); - let deflated = super::super::flate::Encoder::new().frame(&plain); - assert!( - deflated.len() < 128, - "the payload compresses to {} bytes", - deflated.len() - ); - - let tracks = 16; - let mut held = Vec::new(); - for track in 0..tracks { - let mut producer = Producer::new( - broadcast.clone(), - format!("track{track}").as_str(), - Info::timed().with_compress(true), - ); - let subscriber = producer.subscribe(None); - for sequence in 0..MAX_DATAGRAMS as u64 { - let datagram = Datagram { - sequence, - timestamp: Some(Timestamp::ZERO), - payload: deflated.clone(), - }; - producer - .insert_deflated_datagram(datagram.into(), plain.len() as u64) - .unwrap(); - } - held.push((producer, subscriber)); - } - - let retained: u64 = held - .iter() - .flat_map(|(producer, _)| { - let state = producer.state.read(); - state - .datagrams - .iter() - .map(|datagram| { - let stored = datagram.stored().unwrap(); - (stored.payload.len() + stored.deflated.as_ref().unwrap().len()) as u64 - }) - .collect::>() - }) - .sum(); - assert!( - pool.used() >= retained, - "{retained} retained bytes, {} billed", - pool.used() - ); - // Each track keeps only its newest, which no write ever evicts on its own account. - let newest = (plain.len() + deflated.len()) as u64; - assert!( - retained <= tracks * newest, - "{retained} bytes retained across {tracks} tracks" - ); - - for (_, subscriber) in &mut held { - assert_eq!( - recv_datagram(subscriber).payload, - plain, - "the newest is still delivered" - ); - } - drop(held); - assert_eq!(pool.used(), 0, "the buffers release their bill when the tracks go"); - } - - /// A datagram the full buffer pushes out releases its bill, so the pool holds exactly - /// what the buffer retains. - #[test] - fn a_full_datagram_buffer_bills_what_it_retains() { - let pool = cache::Pool::unbounded(); - let mut producer = track_producer_pooled("test", pool.clone()); - for _ in 0..MAX_DATAGRAMS * 2 { - producer.append_datagram(Timestamp::ZERO, vec![0u8; 1000]).unwrap(); - } - let state = producer.state.read(); - assert_eq!(state.datagrams.len(), MAX_DATAGRAMS); - let billed: u64 = state.datagrams.iter().map(|datagram| datagram.bytes()).sum(); - assert_eq!(pool.used(), billed); - } - - /// A track that stops sending datagrams cannot pin its buffer in the shared pool: the - /// sweep reclaims it once idle, the newest too, since no late joiner needs it. - #[test] - fn pool_sweep_expires_idle_datagrams() { - let pool = cache::Pool::new(cache::Config::default().with_expiry(Duration::from_secs(1))); - let mut producer = track_producer_pooled("test", pool.clone()); - for _ in 0..3 { - producer.append_datagram(Timestamp::ZERO, b"x".as_slice()).unwrap(); - } - assert!(pool.used() > 0); - - elapse(&producer, Duration::from_secs(2)); - pool.sweep(); - - assert!( - producer.state.read().datagrams.is_empty(), - "the sweep reclaimed idle datagrams" - ); - assert_eq!(pool.used(), 0); - } - - /// An ended track's closed state refuses the write that pops a datagram, so a stale - /// reader would pin its buffer: the sweep releases each idle one in place instead, - /// and the reader skips it like a dropped one. - #[test] - #[cfg(feature = "async")] - fn pool_sweep_expires_an_ended_tracks_datagrams() { - let pool = cache::Pool::new(cache::Config::default().with_expiry(Duration::from_secs(10))); - let mut producer = track_producer_pooled("test", pool.clone()); - let mut stale = producer.subscribe(None); - producer.append_datagram(Timestamp::ZERO, b"old".as_slice()).unwrap(); - pool.step(Duration::from_secs(6)); - producer.append_datagram(Timestamp::ZERO, b"new".as_slice()).unwrap(); - producer.finish().unwrap(); - drop(producer); - - // Only the oldest is idle for the whole window. - pool.step(Duration::from_secs(6)); - pool.sweep(); - assert!(pool.used() > 0, "the newest is still within the window"); - assert_eq!( - recv_datagram(&mut stale).payload, - b"new".as_slice(), - "the reader skips the released one" - ); - - pool.step(Duration::from_secs(6)); - pool.sweep(); - assert_eq!(pool.used(), 0, "the sweep released every idle datagram"); - } - /// An aborted group releases its access sample along with its bytes, from any /// handle: ghost samples must not linger in the pool mean where they'd hold it /// in the past and over-protect every live group. @@ -20445,37 +19111,6 @@ mod test { assert_eq!(producer.demand().wanted(), None); } - /// A datagram past the newest group took the sequence after it, so a join over the - /// ratio wants the next free one rather than one already written. - #[test] - fn a_join_wants_past_a_later_datagram() { - let mut producer = track_producer("test", Info::default().with_refresh(1)); - refresh_group(&producer, 4); - assert_eq!(producer.append_datagram(None, &b"dgram"[..]).unwrap(), 1); - let _subscriber = producer.subscribe(None); - assert_eq!(producer.demand().wanted(), Some(2)); - } - - /// A datagram taking the sequence a pending refresh asked for moves the ask past it, so - /// it still names a group the publisher has to open. The group landing answers it. - #[test] - fn a_datagram_never_overtakes_a_pending_refresh() { - let mut producer = track_producer("test", Info::default().with_refresh(1)); - let mut demand = producer.demand(); - refresh_group(&producer, 4); - let _subscriber = producer.subscribe(None); - assert_eq!(wanted_changed(&mut demand), Some(Some(1))); - - assert_eq!(producer.append_datagram(None, &b"dgram"[..]).unwrap(), 1); - assert_eq!(wanted_changed(&mut demand), Some(Some(2)), "the datagram took group 1"); - - refresh_group(&producer, 0); - assert_eq!(wanted_changed(&mut demand), None, "group 2 answers it"); - producer.append_datagram(None, &b"dgram"[..]).unwrap(); - assert_eq!(wanted_changed(&mut demand), None, "nothing is pending"); - assert_eq!(demand.wanted(), Some(2)); - } - /// Joins while a request is pending ask for the same group, so the publisher hears it /// once. The group reaching it leaves nothing pending, and only a join over the ratio of /// that group asks again. @@ -22505,35 +21140,6 @@ mod test { assert_eq!(drain(&mut live), vec![0, 1]); } - /// A live range starts at the newest group even when a datagram took a later sequence. - #[test] - #[cfg(feature = "async")] - fn a_live_range_ignores_a_datagram_past_the_newest_group() { - let mut producer = track_producer("test", Info::timed()); - append_at(&mut producer, 0); - assert_eq!( - producer - .append_datagram(Timestamp::from_millis(1).unwrap(), &b"x"[..]) - .unwrap(), - 1 - ); - let mut live = producer.subscribe(replay().with_ranges([Range::live()])); - assert_eq!(drain(&mut live), vec![0]); - } - - /// A live range on a track without groups delivers datagrams as they arrive, rather than - /// holding them for a group a datagram-only track never produces. - #[test] - #[cfg(feature = "async")] - fn a_live_range_on_a_track_without_groups_delivers_datagrams() { - let mut producer = track_producer("test", Info::timed()); - let mut live = producer.subscribe(replay().with_ranges([Range::live()])); - producer - .append_datagram(Timestamp::from_millis(0).unwrap(), &b"x"[..]) - .unwrap(); - assert_eq!(recv_datagram(&mut live).sequence, 0); - } - /// Fetched backfill past the newest produced group moves no relative start, whether the /// track had a produced group at subscribe or not. #[kio::test] @@ -23035,24 +21641,6 @@ mod test { assert_eq!(drain(&mut subscriber), vec![6]); } - /// Datagrams outside a subscriber's ranges are dropped like the groups. - #[test] - #[cfg(feature = "async")] - fn datagrams_outside_the_ranges_are_skipped() { - let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(replay().with_ranges([Range::groups(1..2), Range::groups(3..)])); - for millis in 0..5 { - producer - .append_datagram(Timestamp::from_millis(millis).unwrap(), &b"x"[..]) - .unwrap(); - } - let mut sequences = Vec::new(); - while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { - sequences.push(datagram.sequence); - } - assert_eq!(sequences, vec![1, 3, 4]); - } - /// Only the sequences a subscriber asked for are named dropped to it. #[test] #[cfg(feature = "async")] @@ -23536,53 +22124,11 @@ mod test { ); } - /// A datagram is a whole group of one frame, so its own timestamp says whether it falls - /// inside a time end, without one ever settling from the groups. - #[test] - #[cfg(feature = "async")] - fn a_datagram_is_judged_against_a_time_end_by_its_timestamp() { - let at = |millis| Timestamp::from_millis(millis).unwrap(); - let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(replay().with_ranges([Range { - start: Anchor::Absolute(0), - end: Some(Anchor::Time(at(3500))), - }])); - for millis in [3000, 3400, 3600, 3700] { - producer.append_datagram(at(millis), &b"x"[..]).unwrap(); - } - let mut read = Vec::new(); - while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { - read.push(datagram.sequence); - } - assert_eq!(read, vec![0, 1]); - } - - /// A track of only datagrams starts no group, so a time start is judged against each - /// datagram's own timestamp rather than letting every one through. - #[test] - #[cfg(feature = "async")] - fn a_datagram_is_judged_against_a_time_start_by_its_timestamp() { - let at = |millis| Timestamp::from_millis(millis).unwrap(); - let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(replay().with_ranges([Range { - start: Anchor::Time(at(3500)), - end: None, - }])); - for millis in [3000, 3400, 3500, 3700] { - producer.append_datagram(at(millis), &b"x"[..]).unwrap(); - } - let mut read = Vec::new(); - while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { - read.push(datagram.sequence); - } - assert_eq!(read, vec![2, 3]); - } - /// An untimed track refuses a time anchor wherever the ranges reach it. #[test] #[cfg(feature = "async")] fn an_untimed_track_refuses_time_anchors() { - let mut producer = untimed_producer(); + let producer = untimed_producer(); let timed = replay().with_ranges([Range { start: Anchor::Time(Timestamp::from_millis(0).unwrap()), end: None, @@ -23610,22 +22156,13 @@ mod test { refused.recv_group().now_or_never(), Some(Err(Error::TimestampMismatch)) )); - producer.append_datagram(None, &b"x"[..]).unwrap(); - assert!(matches!( - refused.recv_datagram().now_or_never(), - Some(Err(Error::TimestampMismatch)) - )); // Ranges it can serve lift the refusal. refused.update(replay()).unwrap(); let mut group = producer.append_group().unwrap(); group.finish().unwrap(); let read = refused.recv_group().now_or_never().expect("the group is cached"); - assert_eq!( - read.unwrap().map(|group| group.sequence), - Some(1), - "the datagram took sequence 0" - ); + assert_eq!(read.unwrap().map(|group| group.sequence), Some(0)); } /// A widened budget re-offers the cached groups the cursor skipped as stale, newest diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index d7ed553ead5..8fc49494f23 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -31,7 +31,7 @@ pub(super) struct Subscriptions { wanted: u64, /// A join over the track's refresh ratio asked for a group past every sequence written, /// and no such group has landed yet. Read against `next` rather than stored as a number, - /// so a datagram taking the next sequence can never leave the ask behind. + /// so a sequence written without answering it can never leave the ask behind. refresh: bool, /// One past the highest sequence written, which a pending refresh asks for. next: u64, @@ -119,8 +119,8 @@ impl Subscriptions { } /// Sequence `sequence` was written. One at or past the next free sequence moves it: a - /// group subscribers receive answers a pending refresh, and anything else, such as a - /// datagram, moves the sequence it asks for. + /// group subscribers receive answers a pending refresh, and anything else moves the + /// sequence it asks for. pub(super) fn wrote(&mut self, sequence: u64, answers: bool) -> kio::WaiterList { let next = self.next; self.next = next.max(sequence.saturating_add(1)); diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 66ee59971cc..ff6cdc38084 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -1045,9 +1045,8 @@ impl Fill { self.inner.result.clone() } - /// Refuse `groups` with the source's answer to a request for them: only a lone datagram - /// group, or [`Error::Old`] under the publisher's max age (with every older group of the - /// fill), is named dropped. + /// Refuse `groups` with the source's answer to a request for them: only [`Error::Old`] + /// under the publisher's max age (with every older group of the fill) is named dropped. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn reject_groups(&self, groups: ops::Range, err: Error) { self.inner.reject(groups, err, self.max_delay()); @@ -1232,9 +1231,6 @@ impl Inner { #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] fn reject(&self, groups: ops::Range, err: Error, asked: Option) { match err { - // A datagram is never cached, so no request ever gets it. Only a request for that - // one group says which group it was. - Error::NotFetchable if groups.end - groups.start == 1 => self.drop_groups(groups, err, asked), // Stale under the fill's budget, and so is every group older than it: a timed // track's starts never rewind. Only the publisher's max age binds every reader; // under the fill's own budget they are withdrawn, for a reader with a wider one. @@ -1428,9 +1424,9 @@ impl GroupFill { self.inner.accept(self.sequence, info.into()) } - /// Refuse the group with the source's answer, for the readers waiting now. Only a - /// datagram, or [`Error::Old`] under the publisher's max age (with every older group of - /// the fill), names it dropped. + /// Refuse the group with the source's answer, for the readers waiting now. Only + /// [`Error::Old`] under the publisher's max age (with every older group of the fill) + /// names it dropped. pub(crate) fn reject(self, err: Error) { self.inner.reject(self.sequence..self.sequence + 1, err, self.max_delay); } diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index f9272e7dee7..3575e5ebcbd 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -913,10 +913,6 @@ impl Misses { for run in asks.iter() { self.deferred.remove(run.clone()); } - // A buffered datagram arrived, and is served as one. - for datagram in &state.datagrams { - asks.remove(datagram.sequence..datagram.sequence.saturating_add(1)); - } // A gap the feed still waits for becomes a miss when it folds. if let Some(at) = self.deferred.deadline() { state.cache.watch_wall(at, waiter); diff --git a/rs/moq-net/src/stats.rs b/rs/moq-net/src/stats.rs index f3cda585b0d..b1393c0340f 100644 --- a/rs/moq-net/src/stats.rs +++ b/rs/moq-net/src/stats.rs @@ -52,15 +52,12 @@ //! `frames` / `groups`. //! * `bytes` / `frames` / `groups`: cumulative payload counters bumped as //! groups/frames are read (egress) or written (ingress) in the model. -//! * `datagrams`: cumulative single-frame groups carried over unreliable QUIC -//! datagrams. A datagram is metered as the group it stands in for, so it also -//! bumps `groups`, `frames`, and `bytes`; this counter breaks out how many of -//! those took the datagram path. +//! * `datagrams`: cumulative datagrams of datagram tracks. A datagram belongs to +//! no group, so it bumps `bytes` but not `groups` or `frames`. //! * `inflated`: cumulative bytes inflated from compressed groups for plain //! readers (in-process consumers, and egress that cannot carry compression). //! Every plain reader of a group shares one inflated copy, so each byte counts -//! once, on the side of the reader whose read inflated it. A received compressed -//! datagram inflates on arrival, counted on the receiving side. Kept separate from +//! once, on the side of the reader whose read inflated it. Kept separate from //! `bytes`, which counts payload as stored or sent. //! * `requests` ([`Presence`]): cumulative request/response admissions under the //! label and tier at admission. [`Handle::request`] meters their traffic @@ -183,7 +180,7 @@ pub(crate) struct Counters { bytes: AtomicU64, frames: AtomicU64, groups: AtomicU64, - // Subset of `groups` carried over an unreliable QUIC datagram. + // Datagrams of datagram tracks, disjoint from `groups`. datagrams: AtomicU64, // Bytes inflated from compressed groups for plain readers, once per group. inflated: AtomicU64, @@ -198,7 +195,6 @@ struct ContentCounters { bytes: AtomicU64, frames: AtomicU64, groups: AtomicU64, - datagrams: AtomicU64, } impl ContentCounters { @@ -207,7 +203,6 @@ impl ContentCounters { bytes: self.bytes.load(Ordering::Relaxed), frames: self.frames.load(Ordering::Relaxed), groups: self.groups.load(Ordering::Relaxed), - datagrams: self.datagrams.load(Ordering::Relaxed), } } @@ -215,7 +210,6 @@ impl ContentCounters { self.bytes.fetch_add(content.bytes, Ordering::Relaxed); self.frames.fetch_add(content.frames, Ordering::Relaxed); self.groups.fetch_add(content.groups, Ordering::Relaxed); - self.datagrams.fetch_add(content.datagrams, Ordering::Relaxed); } } @@ -276,8 +270,6 @@ pub struct Content { pub frames: u64, /// Cumulative groups. pub groups: u64, - /// Cumulative single-frame groups carried as unreliable datagrams. - pub datagrams: u64, } impl Content { @@ -286,7 +278,6 @@ impl Content { self.bytes += other.bytes; self.frames += other.frames; self.groups += other.groups; - self.datagrams += other.datagrams; } } @@ -478,13 +469,11 @@ pub struct Traffic { pub frames: u64, /// Cumulative groups delivered. pub groups: u64, - /// Cumulative single-frame groups delivered over an unreliable QUIC datagram. - /// A subset of `groups`: each one also counts there and its payload in - /// `frames` / `bytes`. + /// Cumulative datagrams of datagram tracks delivered. Disjoint from `groups` and + /// `frames`; their payload counts in `bytes`. pub datagrams: u64, /// Cumulative bytes inflated from compressed groups for plain readers. Every plain - /// reader shares one inflated copy per group, so each byte counts once. A received - /// compressed datagram counts here as it inflates on arrival. `bytes` still counts + /// reader shares one inflated copy per group, so each byte counts once. `bytes` still counts /// payload as stored or sent. pub inflated: u64, /// Content skipped because it fell behind a subscriber's @@ -1872,14 +1861,10 @@ impl Meter { } } - /// Record one datagram of `n` payload bytes. A datagram stands in for the - /// single-frame group it replaces, so this bumps `groups`, `frames`, and - /// `bytes` alongside `datagrams`. + /// Record one datagram of `n` payload bytes, which belongs to no group. pub(crate) fn datagram(&self, n: u64) { if let Some(counters) = self.counters() { counters.datagrams.fetch_add(1, Ordering::Relaxed); - counters.groups.fetch_add(1, Ordering::Relaxed); - counters.frames.fetch_add(1, Ordering::Relaxed); counters.bytes.fetch_add(n, Ordering::Relaxed); } self.session_bytes(n); diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index 4d9082c5612..b7ba8dab557 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -327,8 +327,6 @@ fn lite_varint_interop() { sequence: 300, timestamp: Some((1 << 50) + 5), payload: bytes::Bytes::from_static(b"x"), - inflated: None, - ietf: None, } .encode_bytes(version) .unwrap(); @@ -352,8 +350,6 @@ fn lite_varint_interop() { sequence: 1 << 30, timestamp: None, payload: bytes::Bytes::from_static(b"untimed"), - inflated: None, - ietf: None, } .encode_bytes(version); let untimed_datagram = match untimed { @@ -413,7 +409,7 @@ fn lite_varint_interop() { assert_eq!(echo.padding, padding, "{version}: padding"); if version.has_padding() { assert!( - lite::Datagram::decode(echo.padding.into(), version, false, |_| { + lite::Datagram::decode(echo.padding.into(), version, |_| { panic!("padding must not look up a subscription") }) .unwrap() diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index a5d6b2bb392..a6a38b67c17 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -1297,6 +1297,75 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { .expect("timed out"); } +/// Losing a grant cancels a datagram track the peer publishes to us, and leaves the session up. +#[kio::test] +async fn lite_07_a_revoked_grant_cancels_its_datagram_subscriptions() { + within(async { + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/x").unwrap(); + let mut audio = down + .create_datagrams( + "audio", + moq_net::datagram::Info::default().with_timescale(moq_net::Timescale::MILLI), + ) + .unwrap(); + down.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(LITE_07), + client_publish: Some(produce_origin(2)), + client_subscribe: Some(received.clone()), + server_publish: Some(server_origin.clone()), + server_subscribe: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| { + token.is_empty().then(|| grant(&["up"], &["room"])) + }); + let (_, setup) = issued.next().await.unwrap(); + + let remote = received + .consume() + .follow("room/x", None) + .unwrap() + .available() + .await + .unwrap(); + let mut reader = remote.datagrams("audio").unwrap(); + reader.info().await.unwrap(); + // Sent until one arrives: a datagram is best-effort. + let first = loop { + audio + .append(moq_net::Timestamp::from_millis(0).unwrap(), b"x".as_ref()) + .unwrap(); + if let Some(Ok(datagram)) = futures::FutureExt::now_or_never(reader.recv()) { + break datagram; + } + kio::clock::sleep(Duration::from_millis(10)).await; + }; + assert!(first.is_some()); + + setup.revoke(SessionError::Unauthorized, "expired"); + let err = loop { + match reader.recv().await { + Ok(Some(_)) => continue, + Ok(None) => panic!("datagram subscription finished instead of ending"), + Err(err) => break err, + } + }; + assert!( + matches!(err, Error::Unauthorized | Error::Stream(StreamError::Unauthorized)), + "{err:?}" + ); + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + /// A grant that fits no single AUTH_OK is withheld, never trimmed: the presenter is told /// it is unsupported, nothing of the message reaches the wire, and the session and the /// token that did fit are untouched. diff --git a/rs/moq-net/tests/integration/compress.rs b/rs/moq-net/tests/integration/compress.rs index b1e33fc8a80..f11f2ed7f55 100644 --- a/rs/moq-net/tests/integration/compress.rs +++ b/rs/moq-net/tests/integration/compress.rs @@ -228,101 +228,6 @@ async fn relay_serves_every_version() { } } -/// A compressed track's datagrams read back as written, whether the subscriber takes them -/// compressed or inflated. -#[kio::test] -async fn datagrams_serve_every_version() { - for &version in &["moq-lite-07-wip", "moq-lite-06", "moq-transport-18", "moq-transport-22"] { - kio::clock::timeout(TIMEOUT, async { - let mut fixture = connect("moq-lite-07-wip", version).await; - fixture - .track - .insert_datagram(0, Timestamp::from_millis(0).unwrap(), payload(0)) - .unwrap(); - let datagram = fixture.subscriber.recv_datagram().await.unwrap().expect("datagram"); - assert_eq!(&datagram.payload[..], &payload(0)[..], "{version}"); - }) - .await - .unwrap_or_else(|_| panic!("{version}: timed out")); - } -} - -/// What the sessions under `registry` metered as `role`. -fn traffic(registry: &moq_net::stats::Registry, role: moq_net::stats::Role) -> moq_net::stats::Traffic { - registry - .snapshot() - .traffic() - .into_iter() - .find(|(_, row, _)| *row == role) - .map(|(_, _, traffic)| traffic) - .unwrap_or_default() -} - -/// A relay meters a compressed track's datagram in the form each wire carries: compressed -/// on ingress and on moq-lite 07 egress, inflated to an older subscriber. Only `inflated` -/// counts the bytes the relay inflated. -#[kio::test] -async fn datagrams_meter_the_bytes_on_the_wire() { - for &version in &["moq-lite-07-wip", "moq-lite-06"] { - kio::clock::timeout(TIMEOUT, async { - let publisher = produce_origin(1); - let broadcast = publisher.create_broadcast("bcast").unwrap(); - let mut track = broadcast.create_track("data", compressed()).unwrap(); - broadcast.announce(Default::default()).unwrap(); - - // The relay accepts both hops, so its sessions are the ones metered. - let relay = produce_origin(2); - let ingress = moq_net::stats::Registry::new(Default::default()); - let mut options = MockConnectOptions::new("moq-lite-07-wip".parse::().unwrap()); - options.client_publish = Some(publisher.consume()); - options.server_subscribe = Some(relay.clone()); - options.server_stats = ingress - .tier(Default::default()) - .session(moq_net::Path::from_static("ingress")); - let upstream = connect_mock(options).await; - - let origin = produce_origin(3); - let egress = moq_net::stats::Registry::new(Default::default()); - let mut options = MockConnectOptions::new(version.parse::().unwrap()); - options.server_publish = Some(relay.consume()); - options.client_subscribe = Some(origin.clone()); - options.server_stats = egress - .tier(Default::default()) - .session(moq_net::Path::from_static("egress")); - let downstream = connect_mock(options).await; - - let consumer = origin.consume(); - let remote = consumer.follow("bcast", None).unwrap().available().await.unwrap(); - let mut subscriber = remote.track("data").unwrap().subscribe(None).await.unwrap(); - support::harness::subscribed(&track).await; - - track - .insert_datagram(0, Timestamp::from_millis(0).unwrap(), payload(0)) - .unwrap(); - let datagram = subscriber.recv_datagram().await.unwrap().expect("datagram"); - assert_eq!(&datagram.payload[..], &payload(0)[..], "{version}"); - - let plain = payload(0).len() as u64; - let received = traffic(&ingress, moq_net::stats::Role::Subscriber); - assert_eq!(received.datagrams, 1, "{version}: ingress"); - assert!(received.bytes < plain, "{version}: ingress metered {}", received.bytes); - assert_eq!(received.inflated, plain, "{version}: the relay inflated it once"); - - let sent = traffic(&egress, moq_net::stats::Role::Publisher); - assert_eq!(sent.datagrams, 1, "{version}: egress"); - let expected = match version { - "moq-lite-07-wip" => received.bytes, - _ => plain, - }; - assert_eq!(sent.bytes, expected, "{version}: egress metered as sent"); - assert_eq!(sent.inflated, 0, "{version}: egress inflated nothing"); - drop((upstream, downstream, publisher, relay, origin)); - }) - .await - .unwrap_or_else(|_| panic!("{version}: timed out")); - } -} - /// Negotiated IETF ingress stays compressed across a relay to IETF or lite-07 egress. #[kio::test] async fn ietf_ingress_stays_compressed_across_protocols() { @@ -346,21 +251,6 @@ async fn ietf_ingress_stays_compressed_across_protocols() { ); } assert!(received.read_frame().await.unwrap().is_none()); - fixture - .track - .insert_datagram(1, Timestamp::from_millis(3).unwrap(), payload(3)) - .unwrap(); - assert_eq!( - fixture - .subscriber - .recv_datagram() - .await - .unwrap() - .unwrap() - .payload - .as_ref(), - payload(3) - ); }) .await .unwrap_or_else(|_| panic!("{upstream} -> {downstream}: timed out")); diff --git a/rs/moq-net/tests/integration/datagram.rs b/rs/moq-net/tests/integration/datagram.rs index 0e8087bd623..dcd32922325 100644 --- a/rs/moq-net/tests/integration/datagram.rs +++ b/rs/moq-net/tests/integration/datagram.rs @@ -1,273 +1,455 @@ #![cfg(feature = "async")] -//! Datagram delivery over the in-memory mock transport, on moq-lite and moq-transport. +//! Datagram tracks end to end over the mock transport: what a publisher's datagram track +//! delivers to a remote consumer on each version, and what the consumer's priority and +//! demand do to the publisher. //! -//! Covers the whole receive path end to end: publisher encoding, the transport's -//! datagram channel, the subscriber's receive loop and `route_datagram`, and -//! delivery through the model. The mock delivers queued datagrams -//! deterministically, so every wait here is on an observable event. +//! The mock delivers datagrams in order and loses none, so every wait is on an observable +//! event. A remote consumer takes datagrams once its request reached the publisher, which +//! sees it as a priority, and the answer came back, which the consumer sees as its info, so +//! each test waits for both before writing. A datagram and the track's end take different +//! paths, so each test reads its datagrams before ending the track. use crate::support; use std::time::Duration; -use moq_net::{Hop, Timestamp, Version}; -use support::harness::{MockConnectOptions, MockPair, connect_mock, peer}; +use moq_net::{Error, Hop, StreamError, Timescale, Timestamp, Version, broadcast, datagram, origin}; +use support::datagram_priority; +use support::harness::{MockPair, peer_with_latency}; -/// A millisecond track: the frames written here carry timestamps. -fn timed() -> moq_net::track::Info { - moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) -} - -/// Maximum time any single test may run before being treated as a deadlock. -const TEST_TIMEOUT: Duration = Duration::from_secs(10); +const TIMEOUT: Duration = Duration::from_secs(10); -const PAYLOAD: &[u8] = b"datagram payload"; +const TRACK: &str = "audio"; -/// Build an origin producer, spawning its driver on the ambient runtime. -fn produce_origin(hop: u64) -> moq_net::origin::Producer { - let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); +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 } -struct Fixture { - producer: moq_net::track::Producer, - subscriber: moq_net::track::Subscriber, - _broadcast: moq_net::origin::Publication, - _pair: MockPair, +fn millis(ms: u64) -> Timestamp { + Timestamp::from_millis(ms).unwrap() } -/// A publisher and a subscriber joined over the mock transport, sharing one -/// datagram-carrying track. -async fn connect_datagram_track() -> Fixture { - let publisher = produce_origin(1); - let consumer_origin = produce_origin(2); - - let broadcast = publisher.create_broadcast("bench").unwrap(); - let producer = broadcast.create_track("datagrams", timed()).unwrap(); - broadcast.announce(Default::default()).unwrap(); - - let mut options = MockConnectOptions::new("moq-lite-07-wip".parse::().unwrap()); - options.server_publish = Some(publisher.consume()); - options.client_subscribe = Some(consumer_origin.clone()); - let pair = connect_mock(options).await; - - let consumer = consumer_origin.consume(); - let remote = consumer.follow("bench", None).unwrap().available().await.unwrap(); - let subscriber = remote.track("datagrams").unwrap().subscribe(None).await.unwrap(); - // The subscriber is handed over once TRACK_INFO answers, before its SUBSCRIBE reaches - // the publisher, and a datagram only goes to a subscription already there. - support::harness::subscribed(&producer).await; - - Fixture { - producer, - subscriber, - _broadcast: broadcast, - _pair: pair, - } +fn timed() -> datagram::Info { + datagram::Info::default().with_timescale(Timescale::MILLI) } -/// Datagrams written by the publisher reach the subscriber over the wire with -/// their sequence, timestamp scale and payload intact. -/// -/// Datagrams are best-effort and the model evicts them by wall-clock age, so this -/// asserts what delivery actually guarantees rather than a fixed count: whatever -/// arrives is intact and in order, and the last one written arrives, since nothing -/// is pushed during the drain to evict it. -#[kio::test] -async fn datagrams_reach_the_subscriber_in_order() { - kio::clock::timeout(TEST_TIMEOUT, async { - let mut fixture = connect_datagram_track().await; - const COUNT: u64 = 32; - - for sequence in 0..COUNT { - fixture - .producer - .insert_datagram( - sequence, - Timestamp::from_millis(sequence).unwrap(), - bytes::Bytes::from_static(PAYLOAD), - ) - .unwrap(); - } +/// A broadcast published at the first of a chain of origins, one session per hop, each hop +/// on its own version. +struct Chain { + nodes: Vec, + pairs: Vec, + publication: origin::Publication, +} + +impl Chain { + async fn new(hops: &[&str]) -> Self { + Self::with_latency(hops, Duration::ZERO).await + } - let last = COUNT - 1; - let mut seen = Vec::new(); - loop { - let datagram = fixture.subscriber.recv_datagram().await.unwrap().unwrap(); - assert_eq!(&datagram.payload[..], PAYLOAD, "payload corrupted in transit"); - assert_eq!( - datagram.timestamp, - Some(Timestamp::from_millis(datagram.sequence).unwrap()), - "timestamp did not survive the wire" - ); - if let Some(previous) = seen.last() { - assert!(datagram.sequence > *previous, "datagrams delivered out of order"); - } - seen.push(datagram.sequence); - if datagram.sequence >= last { - break; - } + /// A chain whose every link delays stream data by `latency` each way. + async fn with_latency(hops: &[&str], latency: Duration) -> 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) { + let version = version.parse::().unwrap(); + pairs.push(peer_with_latency(version, &pair[0], &pair[1], latency).await); } + let publication = nodes[0].create_broadcast("room").unwrap(); + publication.announce(Default::default()).unwrap(); + Self { + nodes, + pairs, + publication, + } + } - assert!(seen.contains(&last), "the last datagram written never arrived"); - assert!(seen.iter().all(|s| *s < COUNT), "delivered an unwritten sequence"); - }) - .await - .expect("timed out"); -} + /// The broadcast as the last node sees it, once announced there: versions before + /// lite-05 can't ask a peer about a path it has not announced. + async fn remote(&self) -> broadcast::Consumer { + let consumer = self.nodes.last().unwrap().consume(); + kio::clock::timeout(TIMEOUT, support::announced(&consumer, "room")) + .await + .expect("announce timeout"); + kio::clock::timeout(TIMEOUT, consumer.request("room", None)) + .await + .expect("resolve timeout") + .expect("broadcast resolves") + } -/// MoQ Transport carries a datagram as an OBJECT_DATAGRAM at object 0, so it arrives as a -/// datagram with its sequence, alongside the groups on streams. -#[kio::test] -async fn ietf_delivers_datagrams() { - for version in ["moq-transport-18", "moq-transport-22"] { - kio::clock::timeout(TEST_TIMEOUT, ietf_delivers_datagrams_on(version)) + /// A remote consumer of `producer`'s track that takes its datagrams. + /// + /// The consumer takes `priority` first, so the publisher seeing it can't be anything + /// but this consumer's request. + async fn consume(&self, producer: &datagram::Producer, priority: i8) -> datagram::Consumer { + let mut consumer = self.remote().await.datagrams(producer.name()).unwrap(); + consumer.set_priority(priority); + kio::clock::timeout(TIMEOUT, datagram_priority(producer, Some(priority))) .await - .unwrap_or_else(|_| panic!("{version}: timed out")); + .expect("the request never reached the publisher"); + kio::clock::timeout(TIMEOUT, consumer.info()) + .await + .expect("info timeout") + .expect("the publisher answered"); + consumer } } -async fn ietf_delivers_datagrams_on(version: &str) { - let publisher = produce_origin(1); - let consumer_origin = produce_origin(2); +async fn recv(consumer: &mut datagram::Consumer) -> Result, Error> { + kio::clock::timeout(TIMEOUT, consumer.recv()) + .await + .expect("recv timeout") +} + +/// A datagram track reaches a remote consumer with each datagram's sequence, timestamp and +/// payload, from when the consumer joined, and the publisher's finish ends it cleanly. +#[kio::test] +async fn a_datagram_track_crosses_a_session() { + for timescale in [Some(Timescale::MILLI), None] { + let chain = Chain::new(&["moq-lite-07-wip"]).await; + let info = datagram::Info::default().with_timescale(timescale).with_priority(3); + let mut producer = chain.publication.create_datagrams(TRACK, info).unwrap(); + let timestamp = |ms| timescale.map(|_| millis(ms)); + // Nobody reads it yet, so nobody ever will. + assert_eq!(producer.append(timestamp(1), &b"early"[..]).unwrap(), 0); + + let mut consumer = chain.consume(&producer, 0).await; + assert_eq!(consumer.info().await.unwrap(), info, "{timescale:?}"); + assert_eq!(producer.append(timestamp(2), &b"live"[..]).unwrap(), 1); + let datagram = recv(&mut consumer).await.unwrap().expect("a datagram"); + assert_eq!(datagram.sequence, 1, "{timescale:?}"); + assert_eq!(datagram.timestamp, timestamp(2), "{timescale:?}"); + assert_eq!(&datagram.payload[..], b"live", "{timescale:?}"); + + producer.finish(); + assert!(recv(&mut consumer).await.unwrap().is_none(), "{timescale:?}"); + } +} - let broadcast = publisher.create_broadcast("bench").unwrap(); - let mut producer = broadcast.create_track("datagrams", timed()).unwrap(); - broadcast.announce(Default::default()).unwrap(); +/// A publisher's abort reaches the remote consumer as the error. +#[kio::test] +async fn an_aborted_datagram_track_ends_with_its_error() { + let chain = Chain::new(&["moq-lite-07-wip"]).await; + let producer = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let mut consumer = chain.consume(&producer, 0).await; + producer.abort(Error::App(7)); + let err = recv(&mut consumer).await.unwrap_err(); + assert!(matches!(err, Error::Stream(StreamError::App(7))), "{err:?}"); +} - let mut options = MockConnectOptions::new(version.parse::().unwrap()); - options.server_publish = Some(publisher.consume()); - options.client_subscribe = Some(consumer_origin.clone()); - let _pair = connect_mock(options).await; +/// A consumer's priority, raised or lowered, is what the publisher sees, including a change +/// made after the request reached the publisher but before its answer came back. +#[kio::test] +async fn a_consumer_priority_reaches_the_publisher() { + let chain = Chain::with_latency(&["moq-lite-07-wip"], Duration::from_millis(10)).await; + let producer = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let mut consumer = chain.remote().await.datagrams(TRACK).unwrap(); + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, Some(0))) + .await + .expect("the request never reached the publisher"); + for priority in [5, -3] { + consumer.set_priority(priority); + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, Some(priority))) + .await + .unwrap_or_else(|_| panic!("priority {priority} never arrived")); + } +} - let consumer = consumer_origin.consume(); - let remote = consumer.follow("bench", None).unwrap().available().await.unwrap(); - let mut subscriber = remote.track("datagrams").unwrap().subscribe(None).await.unwrap(); +/// Dropping the last remote consumer ends the subscription upstream. +#[kio::test] +async fn the_last_consumer_leaving_ends_the_subscription() { + let chain = Chain::new(&["moq-lite-07-wip"]).await; + let producer = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let consumer = chain.consume(&producer, 0).await; + drop(consumer); + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, None)) + .await + .expect("the publisher still serves it"); +} - // A group first, so the subscription's alias is bound before any datagram lands. - producer - .write_frame(Timestamp::from_millis(0).unwrap(), &b"before"[..]) - .unwrap(); - let mut before = subscriber.recv_group().await.unwrap().unwrap(); - assert_eq!(before.sequence, 0, "{version}"); - let first = before.next_frame().await.unwrap().unwrap().timestamp; - assert_eq!(first.map(|timestamp| timestamp.is_zero()), Some(true), "{version}"); +/// A relay carries datagrams, priority, and the end across both of its lite-07 hops. +#[kio::test] +async fn a_relay_carries_a_datagram_track() { + let chain = Chain::new(&["moq-lite-07-wip", "moq-lite-07-wip"]).await; + let mut producer = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let mut consumer = chain.consume(&producer, 2).await; + + assert_eq!(producer.append(millis(5), &b"relayed"[..]).unwrap(), 0); + let datagram = recv(&mut consumer).await.unwrap().expect("a datagram"); + assert_eq!(datagram.sequence, 0); + assert_eq!(datagram.timestamp, Some(millis(5))); + assert_eq!(&datagram.payload[..], b"relayed"); + + consumer.set_priority(4); + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, Some(4))) + .await + .expect("the priority never crossed the relay"); + + producer.finish(); + assert!(recv(&mut consumer).await.unwrap().is_none()); +} - producer - .insert_datagram( - 5, - Timestamp::from_millis(7).unwrap(), - bytes::Bytes::from_static(PAYLOAD), - ) +/// A stream track and a datagram track of the same name are separate tracks: each carries +/// only its own data and ends on its own. +#[kio::test] +async fn a_stream_track_and_a_datagram_track_share_a_name_independently() { + let chain = Chain::new(&["moq-lite-07-wip"]).await; + let mut track = chain + .publication + .create_track(TRACK, moq_net::track::Info::default().with_timescale(Timescale::MILLI)) .unwrap(); - 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}"); - let expected = Timestamp::from_millis(7).unwrap(); + let mut datagrams = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let remote = chain.remote().await; + let mut subscriber = kio::clock::timeout(TIMEOUT, remote.track(TRACK).unwrap().subscribe(None)) + .await + .expect("subscribe timeout") + .expect("subscribe"); + support::harness::subscribed(&track).await; + let mut consumer = chain.consume(&datagrams, 0).await; + + datagrams.append(millis(1), &b"datagram"[..]).unwrap(); + track.write_frame(millis(2), &b"frame"[..]).unwrap(); + let datagram = recv(&mut consumer).await.unwrap().expect("a datagram"); + assert_eq!(datagram.sequence, 0); + assert_eq!(&datagram.payload[..], b"datagram"); + datagrams.finish(); + assert!(recv(&mut consumer).await.unwrap().is_none()); + + // The datagram track's end leaves the stream track live, with only its own groups. + let mut group = kio::clock::timeout(TIMEOUT, subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("a group"); + assert_eq!(group.sequence, 0); assert_eq!( - datagram - .timestamp - .map(|timestamp| timestamp.convert(expected.scale()).unwrap()), - Some(expected), - "{version}" + &group.read_frame().await.unwrap().expect("a frame").payload[..], + b"frame" ); + track.write_frame(millis(3), &b"after"[..]).unwrap(); + let group = kio::clock::timeout(TIMEOUT, subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("a group"); + assert_eq!(group.sequence, 1); +} - // The group sequence continues past the datagram. - producer - .write_frame(Timestamp::from_millis(8).unwrap(), &b"after"[..]) - .unwrap(); - let after = subscriber.recv_group().await.unwrap().unwrap(); - assert_eq!(after.sequence, 6, "{version}"); +/// On lite-07 a name that has only a datagram track has no stream track to subscribe to. +#[kio::test] +async fn a_lite07_stream_subscription_never_takes_datagrams() { + let chain = Chain::new(&["moq-lite-07-wip"]).await; + let _datagrams = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let remote = chain.remote().await; + let result = kio::clock::timeout(TIMEOUT, remote.track(TRACK).unwrap().subscribe(None)) + .await + .expect("subscribe timeout"); + let Err(err) = result else { + panic!("subscribed to a stream track that does not exist"); + }; + assert!( + matches!(err, Error::NotFound | Error::Stream(StreamError::NotFound)), + "{err:?}" + ); } -/// Explicit insert keeps the origin sequence on the lite wire, including a gap. +/// Lite-05/06 carry a datagram track as a SUBSCRIBE of its name, keeping only the group +/// datagrams. Both ends run this crate, so this covers asking an old publisher for a +/// datagram track and serving an old subscriber's SUBSCRIBE from one. #[kio::test] -async fn inserted_sequences_survive_the_lite_wire() { - kio::clock::timeout(TEST_TIMEOUT, async { - let mut fixture = connect_datagram_track().await; - - fixture - .producer - .insert_datagram( - 5, - Timestamp::from_millis(5).unwrap(), - bytes::Bytes::from_static(PAYLOAD), - ) - .unwrap(); - fixture - .producer - .insert_datagram( - 2, - Timestamp::from_millis(2).unwrap(), - bytes::Bytes::from_static(PAYLOAD), - ) +async fn lite05_and_lite06_map_a_datagram_track_onto_a_subscription() { + for version in ["moq-lite-05", "moq-lite-06"] { + let chain = Chain::new(&[version]).await; + let mut producer = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + producer.append(millis(1), &b"early"[..]).unwrap(); + // A priority other than the default, which the TRACK lookup ahead of the SUBSCRIBE + // holds while it is answered. + let mut consumer = chain.consume(&producer, 3).await; + assert_eq!( + consumer.info().await.unwrap().timescale, + Some(Timescale::MILLI), + "{version}" + ); + + assert_eq!(producer.append(millis(9), &b"mapped"[..]).unwrap(), 1); + let datagram = recv(&mut consumer).await.unwrap().expect("a datagram"); + assert_eq!(datagram.sequence, 1, "{version}"); + assert_eq!(datagram.timestamp, Some(millis(9)), "{version}"); + assert_eq!(&datagram.payload[..], b"mapped", "{version}"); + + consumer.set_priority(6); + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, Some(6))) + .await + .unwrap_or_else(|_| panic!("{version}: the priority never arrived")); + + producer.finish(); + assert!(recv(&mut consumer).await.unwrap().is_none(), "{version}"); + } +} + +/// Lite-05/06 carry a name's stream track and datagram track on one subscription, as +/// moq-transport does, so a name with both delivers each to its own reader, directly and +/// through a relay whose hops are both old. The datagrams ride the subscription, at its +/// priority. +#[kio::test] +async fn lite05_and_lite06_carry_both_kinds_of_a_name() { + for hops in [&["moq-lite-05"][..], &["moq-lite-06"], &["moq-lite-06", "moq-lite-06"]] { + let chain = Chain::new(hops).await; + let mut track = chain + .publication + .create_track(TRACK, moq_net::track::Info::default().with_timescale(Timescale::MILLI)) .unwrap(); + // Another scale than the stream track's, whose TRACK_INFO declares the one both + // kinds are stamped in on the wire. + let info = datagram::Info::default().with_timescale(Timescale::MICRO); + let mut producer = chain.publication.create_datagrams(TRACK, info).unwrap(); + let remote = chain.remote().await; + let mut subscriber = kio::clock::timeout(TIMEOUT, remote.track(TRACK).unwrap().subscribe(None)) + .await + .expect("subscribe timeout") + .unwrap_or_else(|err| panic!("{hops:?}: {err:?}")); + // The publisher sends the datagrams on the stream subscription. + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, Some(0))) + .await + .unwrap_or_else(|_| panic!("{hops:?}: the subscription never carried the datagrams")); + let mut consumer = remote.datagrams(TRACK).unwrap(); + kio::clock::timeout(TIMEOUT, consumer.info()) + .await + .expect("info timeout") + .unwrap_or_else(|err| panic!("{hops:?}: {err:?}")); + + producer.append(millis(1), &b"datagram"[..]).unwrap(); + let datagram = recv(&mut consumer).await.unwrap().expect("a datagram"); + assert_eq!(&datagram.payload[..], b"datagram", "{hops:?}"); + let timestamp = datagram.timestamp.expect("a timestamp"); + assert_eq!(timestamp.convert(Timescale::MILLI).unwrap(), millis(1), "{hops:?}"); - let first = fixture.subscriber.recv_datagram().await.unwrap().unwrap(); - assert_eq!(first.sequence, 5); + track.write_frame(millis(2), &b"frame"[..]).unwrap(); + let mut group = kio::clock::timeout(TIMEOUT, subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("a group"); assert_eq!( - first.timestamp, - Some(Timestamp::from_millis(5).unwrap()), - "timestamp did not survive the wire" + &group.read_frame().await.unwrap().expect("a frame").payload[..], + b"frame", + "{hops:?}" ); - assert_eq!(&first.payload[..], PAYLOAD); - - let second = fixture.subscriber.recv_datagram().await.unwrap().unwrap(); - assert_eq!(second.sequence, 2); - assert_eq!(second.timestamp, Some(Timestamp::from_millis(2).unwrap())); - assert_eq!(&second.payload[..], PAYLOAD); - }) - .await - .expect("timed out"); + + let subscription = subscriber.subscription().with_priority(6); + subscriber.update(subscription).unwrap(); + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, Some(6))) + .await + .unwrap_or_else(|_| panic!("{hops:?}: the priority never arrived")); + } } -/// A FETCH for a sequence the publisher sent as a datagram is refused with NOT_FETCHABLE on -/// moq-lite-07, and with the NOT_FOUND an earlier version has for it. A relay carries the -/// answer across in the downstream version's terms. The moq-transport side (DOES_NOT_EXIST) -/// is covered by the IETF publisher's own tests. +/// On lite-05/06 the datagrams a stream track's subscription carries end as it does: a +/// stream track that aborts ends its datagram reader with the error, not a finish. #[kio::test] -async fn a_fetched_datagram_is_refused_per_version() { - const NOT_FETCHABLE: &str = "not fetchable"; - const NOT_FOUND: &str = "not found"; - - /// Fetch a datagram's sequence over one hop per version, publisher first. - async fn fetch_a_datagram(versions: &[&str]) -> &'static str { - let nodes: Vec<_> = (1..=versions.len() as u64 + 1).map(produce_origin).collect(); - let mut _pairs = Vec::new(); - for (pair, version) in nodes.windows(2).zip(versions) { - _pairs.push(peer(version.parse().unwrap(), &pair[0], &pair[1]).await); - } +async fn lite06_shared_datagrams_end_with_their_subscription() { + let chain = Chain::new(&["moq-lite-06"]).await; + let track = chain + .publication + .create_track(TRACK, moq_net::track::Info::default().with_timescale(Timescale::MILLI)) + .unwrap(); + let producer = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let remote = chain.remote().await; + let _subscriber = kio::clock::timeout(TIMEOUT, remote.track(TRACK).unwrap().subscribe(None)) + .await + .expect("subscribe timeout") + .expect("subscribe"); + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, Some(0))) + .await + .expect("the subscription never carried the datagrams"); + let mut consumer = remote.datagrams(TRACK).unwrap(); + kio::clock::timeout(TIMEOUT, consumer.info()) + .await + .expect("info timeout") + .expect("info"); + + track.abort(Error::App(7)).unwrap(); + let end = recv(&mut consumer).await; + assert!(matches!(end, Err(Error::Stream(StreamError::App(7)))), "{end:?}"); +} - let broadcast = nodes[0].create_broadcast("room").unwrap(); - let mut producer = broadcast.create_track("datagrams", timed()).unwrap(); - broadcast.announce(Default::default()).unwrap(); - let sequence = producer.append_datagram(Timestamp::ZERO, &b"x"[..]).unwrap(); - - let consumer = nodes.last().unwrap().consume(); - let remote = consumer.follow("room", None).unwrap().available().await.unwrap(); - let err = match remote.track("datagrams").unwrap().fetch_group(sequence, None).await { - Ok(_) => panic!("{versions:?}: a datagram was fetched"), - Err(err) => err, - }; - match err { - moq_net::Error::Stream(moq_net::StreamError::NotFetchable) => NOT_FETCHABLE, - moq_net::Error::NotFound | moq_net::Error::Stream(moq_net::StreamError::NotFound) => NOT_FOUND, - err => panic!("{versions:?}: refused with {err:?}"), +/// A lite-05/06 subscriber reads a name as a stream track, as one predating datagram tracks +/// does, so the datagram fallback ends with SUBSCRIBE_FINISH ahead of its FIN: a FIN alone +/// is a protocol violation there. +#[kio::test] +async fn lite05_and_lite06_finish_a_datagram_track_as_a_stream_subscriber_expects() { + for version in ["moq-lite-05", "moq-lite-06"] { + let chain = Chain::new(&[version]).await; + let mut producer = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let remote = chain.remote().await; + let mut subscriber = kio::clock::timeout(TIMEOUT, remote.track(TRACK).unwrap().subscribe(None)) + .await + .expect("subscribe timeout") + .unwrap_or_else(|err| panic!("{version}: {err:?}")); + // Only the SUBSCRIBE carries an update, so the publisher seeing it serves the + // subscription from the datagram track. + let subscription = subscriber.subscription().with_priority(5); + subscriber.update(subscription).unwrap(); + kio::clock::timeout(TIMEOUT, datagram_priority(&producer, Some(5))) + .await + .unwrap_or_else(|_| panic!("{version}: the subscription never reached the publisher")); + producer.append(millis(1), &b"datagram"[..]).unwrap(); + producer.finish(); + let end = kio::clock::timeout(TIMEOUT, subscriber.recv_group()) + .await + .expect("end timeout"); + match end { + Ok(None) => {} + Ok(Some(group)) => panic!("{version}: a group {}", group.sequence), + Err(err) => panic!("{version}: {err:?}"), } } +} - for (versions, expected) in [ - (&["moq-lite-05"][..], NOT_FOUND), - (&["moq-lite-06"], NOT_FOUND), - (&["moq-lite-07-wip"], NOT_FETCHABLE), - (&["moq-lite-07-wip"], NOT_FETCHABLE), - (&["moq-lite-07-wip", "moq-lite-06"], NOT_FOUND), - ] { - let refused = kio::clock::timeout(TEST_TIMEOUT, fetch_a_datagram(versions)) - .await - .unwrap_or_else(|_| panic!("{versions:?}: timed out")); - assert_eq!(refused, expected, "{versions:?}"); +/// A datagram track can't be asked for over a version or transport without datagrams. +#[kio::test] +async fn a_datagram_track_needs_datagrams() { + for (version, transport) in [("moq-lite-03", true), ("moq-lite-04", true), ("moq-lite-07-wip", false)] { + let chain = Chain::new(&[version]).await; + if !transport { + // The second node subscribes through the session it accepted. + chain.pairs[0].server_transport.disable_datagrams(); + } + let _producer = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let mut consumer = chain.remote().await.datagrams(TRACK).unwrap(); + let err = recv(&mut consumer).await.unwrap_err(); + assert!(matches!(err, Error::Unsupported), "{version}: {err:?}"); } } + +/// moq-transport carries no datagram tracks: asking for one finds nothing, and the stream +/// track of the same name is served as usual. +#[kio::test] +async fn moq_transport_has_no_datagram_tracks() { + let chain = Chain::new(&["moq-transport-22"]).await; + let mut track = chain + .publication + .create_track(TRACK, moq_net::track::Info::default().with_timescale(Timescale::MILLI)) + .unwrap(); + let _datagrams = chain.publication.create_datagrams(TRACK, timed()).unwrap(); + let remote = chain.remote().await; + assert!(matches!(remote.datagrams(TRACK), Err(Error::NotFound))); + + let mut subscriber = kio::clock::timeout(TIMEOUT, remote.track(TRACK).unwrap().subscribe(None)) + .await + .expect("subscribe timeout") + .expect("subscribe"); + support::harness::subscribed(&track).await; + track.write_frame(millis(1), &b"frame"[..]).unwrap(); + let mut group = kio::clock::timeout(TIMEOUT, subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("a group"); + assert_eq!( + &group.read_frame().await.unwrap().expect("a frame").payload[..], + b"frame" + ); +} diff --git a/rs/moq-net/tests/integration/support/mock.rs b/rs/moq-net/tests/integration/support/mock.rs index 0b7b82880f7..5de67580b38 100644 --- a/rs/moq-net/tests/integration/support/mock.rs +++ b/rs/moq-net/tests/integration/support/mock.rs @@ -3,7 +3,7 @@ //! [`MockSession`] wraps one end of a [`memory`] pair, so stream payloads cross //! zero-copy and flow-controlled exactly as between two in-process peers. The knobs //! bend delivery the ways a real network may: link latency, streams held back or -//! reordered, a first write split from the rest, lost datagrams, and FINs that are +//! reordered, a first write split from the rest, no datagram support, and FINs that are //! never sent or never acknowledged. //! //! The transport hands streams to the acceptor in the order they were opened, so each @@ -446,8 +446,8 @@ struct Side { inc: Arc, /// Whether the peer has withheld uni stream credit, parking every open. withheld: AtomicBool, - /// Whether the datagrams this side sends are lost. - lossy: AtomicBool, + /// Whether this side reports no datagram support, as a stream-only transport does. + no_datagrams: AtomicBool, /// Whether this side drops the FIN of the bidi streams it opens. withhold_bidi_fins: Arc, /// The first byte of each bidi stream this side opened. @@ -611,7 +611,7 @@ impl poll::Session for MockSession { payload: Bytes, options: poll::SendOptions<'_, poll::Ungrouped>, ) -> Result<(), Self::Error> { - if self.side.lossy.load(Ordering::Relaxed) { + if self.side.no_datagrams.load(Ordering::Relaxed) { return Ok(()); } self.inner.send_datagram(payload, options) @@ -622,6 +622,9 @@ impl poll::Session for MockSession { } fn max_datagram_size(&self) -> usize { + if self.side.no_datagrams.load(Ordering::Relaxed) { + return 0; + } self.inner.max_datagram_size() } @@ -858,9 +861,9 @@ impl MockSession { *gate.lock() = true; } } - /// Lose every datagram this side sends from now on. - pub fn lose_datagrams(&self) { - self.side.lossy.store(true, Ordering::Relaxed); + /// Report no datagram support from now on, as qmux or WebSocket does. + pub fn disable_datagrams(&self) { + self.side.no_datagrams.store(true, Ordering::Relaxed); } /// Delay stream data and STOP_SENDING sent from now on by `latency` in each direction, @@ -928,7 +931,7 @@ pub fn create_mock_session_pair(config: memory::Config) -> (MockSession, MockSes out: out.clone(), inc: inc.clone(), withheld: Default::default(), - lossy: Default::default(), + no_datagrams: Default::default(), withhold_bidi_fins: Default::default(), bidi_types: Default::default(), resets: Default::default(), diff --git a/rs/moq-net/tests/integration/support/mod.rs b/rs/moq-net/tests/integration/support/mod.rs index 0bd52c80366..3ee59309894 100644 --- a/rs/moq-net/tests/integration/support/mod.rs +++ b/rs/moq-net/tests/integration/support/mod.rs @@ -3,7 +3,7 @@ pub mod harness; pub mod mock; -use moq_net::{announce, goaway, origin, track}; +use moq_net::{announce, datagram, goaway, origin, track}; /// Wait until anyone wants the track, returning the group they want. #[allow(dead_code, reason = "shared by several test binaries, each using only part of it")] @@ -22,6 +22,16 @@ pub async fn unwanted(mut demand: track::Demand) -> moq_net::Result<()> { Ok(()) } +/// Wait until a datagram track's highest subscriber priority is `want`, where `None` means +/// nobody reads it. +#[allow(dead_code, reason = "shared by several test binaries, each using only part of it")] +pub async fn datagram_priority(producer: &datagram::Producer, want: Option) { + let mut priority = producer.priority(); + while priority != want { + priority = moq_net::kio::wait(|waiter| producer.poll_priority(waiter, priority)).await; + } +} + /// Wait until `consumer` sees a route announced at exactly `path`, and return it. #[allow(dead_code, reason = "shared by several test binaries, each using only part of it")] pub async fn announced(consumer: &origin::Consumer, path: &str) -> origin::Route { diff --git a/rs/moq-net/tests/integration/track_tail.rs b/rs/moq-net/tests/integration/track_tail.rs index d1bb4b2b7d9..88c07b45d78 100644 --- a/rs/moq-net/tests/integration/track_tail.rs +++ b/rs/moq-net/tests/integration/track_tail.rs @@ -403,64 +403,6 @@ async fn ietf_leaving_cancels_a_blocked_end_of_track() { } } -/// A lost datagram is indistinguishable from a reordered stream header, so its hole -/// waits out the tail grace on every lite version. -#[kio::test] -async fn a_lost_datagram_delays_the_end() { - for version in ["moq-lite-05", "moq-lite-07-wip"] { - let Pair { - pair, - mut track, - remote, - _keep, - } = connect(version).await; - - let reader = kio::clock::sim::spawn(async move { - let subscription = moq_net::track::Subscription::default().with_range(0..); - let mut sub = remote - .track("video") - .unwrap() - .subscribe(subscription) - .await - .expect("subscribe"); - let mut groups = Vec::new(); - while let Some(group) = sub.recv_group().await.expect("track aborted") { - groups.push(group.sequence); - } - (groups, kio::clock::now()) - }); - kio::clock::timeout(TIMEOUT, support::harness::subscribed(&track)) - .await - .expect("no subscriber appeared"); - - for datagram in [false, true, false] { - if datagram { - pair.server_transport.lose_datagrams(); - track.append_datagram(Timestamp::ZERO, PAYLOAD).unwrap(); - } else { - let mut group = track.append_group().unwrap(); - group.write_frame(Timestamp::ZERO, PAYLOAD).unwrap(); - group.finish().unwrap(); - } - kio::clock::sleep(GRACE / 100).await; - } - let finished = kio::clock::now(); - track.finish().unwrap(); - - let (groups, ended) = kio::clock::timeout(TIMEOUT, reader) - .await - .expect("the subscription never ended") - .expect("reader panicked"); - assert_eq!(groups, [0, 2], "{version}"); - let elapsed = ended - finished; - assert!( - (GRACE / 2..GRACE * 2).contains(&elapsed), - "{version}: ended after {elapsed:?}" - ); - drop((pair, _keep)); - } -} - /// A publisher can declare the end, then open each group's stream before writing its first /// frame. The subscriber withholds a group until that frame lands, and the end waits for /// it: a reader that ended at the boundary would miss every group. diff --git a/rs/moq-net/tests/integration/untimed.rs b/rs/moq-net/tests/integration/untimed.rs index 4c5e68c8a02..db12b66ff39 100644 --- a/rs/moq-net/tests/integration/untimed.rs +++ b/rs/moq-net/tests/integration/untimed.rs @@ -12,7 +12,7 @@ use crate::support; use std::time::Duration; use bytes::Bytes; -use moq_net::{Hop, Timescale, Timestamp, Version, origin, track}; +use moq_net::{Hop, Timescale, Timestamp, Version, datagram, origin, track}; use support::harness::peer; /// A millisecond track: the frames written here carry timestamps. @@ -205,30 +205,38 @@ async fn an_explicit_start_holds_on_an_untimed_track() { } } -/// An untimed track's datagram arrives untimed, except that lite-05 and lite-06 carry its -/// send time. +/// An untimed datagram track's datagram arrives untimed, except that lite-05 and lite-06 +/// carry its send time on a millisecond track. #[kio::test] async fn datagrams_arrive_as_published() { - for version in ["moq-lite-05", "moq-lite-07-wip", "moq-transport-22"] { + for version in ["moq-lite-05", "moq-lite-07-wip"] { let chain = Chain::new(&[version]).await; let broadcast = chain.nodes[0].create_broadcast("room").unwrap(); - let mut producer = broadcast.create_track("data", untimed()).unwrap(); + let mut producer = broadcast.create_datagrams("data", datagram::Info::default()).unwrap(); broadcast.announce(Default::default()).unwrap(); - kio::clock::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; + let consumer = kio::clock::timeout(TIMEOUT, chain.nodes[1].consume().request("room", None)) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + let mut consumer = consumer.datagrams("data").unwrap(); + // Not the default, which the lite-05 TRACK lookup ahead of the SUBSCRIBE holds too. + consumer.set_priority(1); + kio::clock::timeout(TIMEOUT, support::datagram_priority(&producer, Some(1))) + .await + .expect("the request never reached the publisher"); + // Datagrams count once the answer is back. + let timescale = consumer.info().await.unwrap().timescale; - producer.append_datagram(None, &b"untimed"[..]).unwrap(); - let datagram = kio::clock::timeout(TIMEOUT, subscriber.recv_datagram()) + producer.append(None, &b"untimed"[..]).unwrap(); + let datagram = kio::clock::timeout(TIMEOUT, consumer.recv()) .await .expect("datagram timeout") .unwrap() .expect("a datagram"); let sent = expected(version) == Arrives::SendTimes; assert_eq!(datagram.timestamp.is_some(), sent, "{version}"); + assert_eq!(timescale, sent.then_some(Timescale::MILLI), "{version}"); } } diff --git a/rs/moq-relay/src/fetch.rs b/rs/moq-relay/src/fetch.rs index 6f1432a86af..f7cacd67c3a 100644 --- a/rs/moq-relay/src/fetch.rs +++ b/rs/moq-relay/src/fetch.rs @@ -5,9 +5,7 @@ /// read off the subscription, so an evicted group is retrieved from upstream /// instead of waited on forever. Fails before returning when the group can never /// be served: [`moq_net::Error::NotFound`] locally, including a track that ends -/// before any group, or [`moq_net::StreamError::NotFound`] from upstream. A newest -/// sequence sent as a datagram is [`moq_net::Error::NotFetchable`] rather than an older -/// group passed off as the newest. +/// before any group, or [`moq_net::StreamError::NotFound`] from upstream. pub async fn fetch_group( track: &moq_net::track::Consumer, sequence: Option, @@ -46,21 +44,4 @@ mod test { .unwrap(); assert_eq!(group.sequence, 1); } - - /// A newest sequence sent as a datagram is refused, not served as an older group. - #[test] - fn a_newest_datagram_is_not_fetchable() { - let broadcast = moq_net::broadcast::Info::default().produce(); - let mut track = broadcast.create_track("t", moq_net::track::Info::default()).unwrap(); - let mut first = track.append_group().unwrap(); - first.write_frame(None, &b"a"[..]).unwrap(); - first.finish().unwrap(); - assert_eq!(track.append_datagram(None, &b"d"[..]).unwrap(), 1); - - let handle = broadcast.consume().track("t").unwrap(); - let fetched = fetch_group(&handle, None) - .now_or_never() - .expect("the datagram is cached"); - assert!(matches!(fetched, Err(moq_net::Error::NotFetchable))); - } } diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index 8280b061e7d..fea46fb2876 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -729,7 +729,7 @@ impl Scrape { ("moq_relay_groups", "Media groups transferred.", |c| c.groups), ( "moq_relay_datagrams", - "Single-frame groups carried over unreliable QUIC datagrams; a subset of groups.", + "Datagrams of datagram tracks transferred, disjoint from groups.", |c| c.datagrams, ), ( @@ -752,11 +752,6 @@ impl Scrape { "Media groups skipped after drifting beyond a subscriber's latency budget.", |c| c.stale.groups, ), - ( - "moq_relay_stale_datagrams", - "Media datagrams skipped after drifting beyond a subscriber's latency budget.", - |c| c.stale.datagrams, - ), ("moq_relay_fetches", "One-shot group fetches requested.", |c| c.fetches), ("moq_relay_announces_started", "Broadcast announces started.", |c| { c.announces_started @@ -1906,10 +1901,6 @@ mod tests { body.contains("moq_relay_stale_groups_total{tier=\"\",role=\"publisher\"} 1"), "stale egress groups:\n{body}" ); - assert!( - body.contains("moq_relay_stale_datagrams_total{tier=\"\",role=\"publisher\"} 0"), - "stale egress datagrams:\n{body}" - ); assert!( body.contains("moq_relay_sessions_opened_total{tier=\"\"} 1"), "session presence:\n{body}" @@ -2092,7 +2083,6 @@ mod tests { # TYPE moq_relay_sessions_opened_total counter # TYPE moq_relay_sessions_refused_total counter # TYPE moq_relay_stale_bytes_total counter -# TYPE moq_relay_stale_datagrams_total counter # TYPE moq_relay_stale_frames_total counter # TYPE moq_relay_stale_groups_total counter # TYPE moq_relay_stun_dropped_total counter @@ -2138,8 +2128,6 @@ counter moq_relay_sessions_refused_total{reason="request"} 0 counter moq_relay_sessions_refused_total{reason="unavailable"} 1 counter moq_relay_stale_bytes_total{role="publisher",tier="edge"} 0 counter moq_relay_stale_bytes_total{role="subscriber",tier="edge"} 0 -counter moq_relay_stale_datagrams_total{role="publisher",tier="edge"} 0 -counter moq_relay_stale_datagrams_total{role="subscriber",tier="edge"} 0 counter moq_relay_stale_frames_total{role="publisher",tier="edge"} 0 counter moq_relay_stale_frames_total{role="subscriber",tier="edge"} 0 counter moq_relay_stale_groups_total{role="publisher",tier="edge"} 0 diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 512e82dd05d..2e45bee8216 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -1143,16 +1143,12 @@ async fn serve_fetch( }; let group = match async { crate::fetch_group(&broadcast.track(&track)?, sequence).await }.await { Ok(group) => group, - // A miss upstream arrives as the stream reset that refused the FETCH. A datagram - // group is never cached, and a group past the track's max age is gone, so both - // are misses too. + // A miss upstream arrives as the stream reset that refused the FETCH. A group past + // the track's max age is gone, so it is a miss too. Err( moq_net::Error::NotFound - | moq_net::Error::NotFetchable | moq_net::Error::Old - | moq_net::Error::Stream( - moq_net::StreamError::NotFound | moq_net::StreamError::NotFetchable | moq_net::StreamError::Old, - ), + | moq_net::Error::Stream(moq_net::StreamError::NotFound | moq_net::StreamError::Old), ) => { return Err(StatusCode::NOT_FOUND); } diff --git a/rs/moq-stats/src/aggregate.rs b/rs/moq-stats/src/aggregate.rs index b1ab9a7c032..faa4d6c9dcf 100644 --- a/rs/moq-stats/src/aggregate.rs +++ b/rs/moq-stats/src/aggregate.rs @@ -312,7 +312,6 @@ impl Mergeable for Traffic { || self.stale.bytes < prev.stale.bytes || self.stale.frames < prev.stale.frames || self.stale.groups < prev.stale.groups - || self.stale.datagrams < prev.stale.datagrams } } diff --git a/rs/moq-tokio/tests/integration/datagram_delivery.rs b/rs/moq-tokio/tests/integration/datagram_delivery.rs index a0035758069..b6fb82f4c66 100644 --- a/rs/moq-tokio/tests/integration/datagram_delivery.rs +++ b/rs/moq-tokio/tests/integration/datagram_delivery.rs @@ -45,8 +45,8 @@ fn bufferbloat() -> moq_shaper::Profile { } } -fn timed() -> moq_net::track::Info { - moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) +fn timed() -> moq_net::datagram::Info { + moq_net::datagram::Info::default().with_timescale(moq_net::Timescale::MILLI) } /// One offered load. @@ -79,7 +79,7 @@ fn decode(payload: &[u8]) -> u64 { /// A viewer's session to the server, `through` a shaper or direct. struct Viewer { name: &'static str, - track: moq_net::track::Subscriber, + datagrams: moq_net::datagram::Consumer, conn: driver::connection::Connection, _session: moq_net::Session, _endpoint: driver::endpoint::Endpoint, @@ -105,34 +105,21 @@ impl Viewer { .await .expect("resolve timeout") .expect("broadcast resolves"); - let track = kio::clock::timeout(TIMEOUT, broadcast.track("audio").unwrap().subscribe(None)) + let datagrams = broadcast.datagrams("audio").expect("datagram track"); + // Answered once the publisher's session reads the track, so what it sends next arrives. + kio::clock::timeout(TIMEOUT, datagrams.info()) .await .expect("subscribe timeout") .expect("subscribe"); Self { name, - track, + datagrams, conn, _session: session, _endpoint: endpoint, } } - /// Read the priming group, which binds a moq-transport subscription's alias before any - /// datagram needs it. - async fn primed(&mut self) { - let mut group = kio::clock::timeout(TIMEOUT, self.track.recv_group()) - .await - .expect("group timed out") - .expect("group") - .expect("track ended"); - kio::clock::timeout(TIMEOUT, group.read_frame()) - .await - .expect("frame timed out") - .expect("frame") - .expect("group ended"); - } - /// Every datagram until [`QUIET`] passes without one after `done` is set: each one's /// index and arrival time. async fn receive(mut self, done: Arc) -> (Self, Vec<(u64, kio::clock::Instant)>) { @@ -141,7 +128,7 @@ impl Viewer { tokio::pin!(finished); let mut stopping = false; loop { - let next = self.track.recv_datagram(); + let next = self.datagrams.recv(); let datagram = if stopping { match kio::clock::timeout(QUIET, next).await { Ok(datagram) => datagram, @@ -213,7 +200,7 @@ async fn measure(version: &str, cell: Cell) { let publisher = moq_tokio::origin::spawn(); let broadcast = publisher.create_broadcast("room").expect("broadcast"); - let mut track = broadcast.create_track("audio", timed()).unwrap(); + let mut datagrams = broadcast.create_datagrams("audio", timed()).unwrap(); broadcast.announce(Default::default()).expect("announce"); let mut config = moq_tokio::listen::Config::default(); @@ -246,16 +233,10 @@ async fn measure(version: &str, cell: Cell) { } }); - let mut viewers = [ + let viewers = [ Viewer::connect("loopback", version, server_addr).await, Viewer::connect("bufferbloat", version, shaper.addr()).await, ]; - track - .write_frame(Timestamp::ZERO, &b"prime"[..]) - .expect("priming group"); - for viewer in &mut viewers { - viewer.primed().await; - } let done = Arc::new(tokio::sync::Notify::new()); let receivers: Vec<_> = viewers @@ -269,8 +250,8 @@ async fn measure(version: &str, cell: Cell) { for index in 0..offered { ticks.tick().await; sent.push(kio::clock::now()); - track - .append_datagram(Timestamp::from_millis(index).unwrap(), payload(index)) + datagrams + .append(Timestamp::from_millis(index).unwrap(), payload(index)) .expect("append datagram"); } done.notify_waiters(); @@ -339,7 +320,8 @@ async fn benchmark_datagram_delivery() { println!( "version,cell,viewer,offered,lost_packets,overflowed_packets,undelivered_frame_packets,reordered_packets,p50_ms,p99_ms,max_ms" ); - for version in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-22"] { + // moq-transport has no datagram tracks. + for version in ["moq-lite-06", "moq-lite-07-wip"] { for cell in cells { measure(version, cell).await; } diff --git a/swift/README.md b/swift/README.md index ed144aef582..69583d35e42 100644 --- a/swift/README.md +++ b/swift/README.md @@ -82,7 +82,7 @@ Raw tracks also expose best-effort datagrams: `TrackProducer.appendDatagram(_:ti returns the assigned sequence number (`insertDatagram(_:sequence:timestamp:)` takes the caller's own), `TrackConsumer.recvDatagram()` receives one datagram, and `TrackConsumer.datagrams` streams them in arrival order. Payloads are capped at 1200 bytes. -Datagrams require a datagram-capable transport and lite-05 or newer moq-lite, or moq-transport; +Datagrams require a datagram-capable transport and lite-05 or newer moq-lite; moq-transport, pre-lite-05, WebSocket, and TCP paths do not deliver them, and there is no stream fallback. JSON tracks live under the `Json` namespace, mirroring the `moq-json` crate, and carry your own diff --git a/swift/Sources/Moq/Track.swift b/swift/Sources/Moq/Track.swift index aafee311f61..b6fdf7f0931 100644 --- a/swift/Sources/Moq/Track.swift +++ b/swift/Sources/Moq/Track.swift @@ -195,7 +195,7 @@ public final class TrackProducer: Sendable { try ffi.insertDatagram(sequence: sequence, frame: Frame(payload: payload, timestamp: timestamp)) } - /// The newest group or datagram written, at its newest frame, or `nil` before any. + /// The newest group written, at its newest frame, or `nil` before any. Datagrams never move it. public func largest() throws -> Position? { try ffi.largest() } diff --git a/test/drill/mutations/subscriber-leaks-broadcasts.patch b/test/drill/mutations/subscriber-leaks-broadcasts.patch index 788ace53e7e..c484dbf1dc4 100644 --- a/test/drill/mutations/subscriber-leaks-broadcasts.patch +++ b/test/drill/mutations/subscriber-leaks-broadcasts.patch @@ -9,10 +9,10 @@ # session is gone, so a cancelled reader's broadcast parks forever instead of # closing. Leaking only one is not a leak: the other still closes the front. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs -index a3f03296c7..3046b3af8f 100644 +index adbc9aeec1..e4edd44fcf 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs -@@ -1536,7 +1536,8 @@ struct PrefixRun { +@@ -1903,7 +1903,8 @@ struct PrefixRun { /// it comes from the connect config or the peer's SETUP, neither of which /// changes for the life of the session. link_cost: u64, @@ -22,7 +22,7 @@ index a3f03296c7..3046b3af8f 100644 // Lite06+: announce ids. Each received `active` implicitly assigns the next // per-stream ordinal; `ended`/`restart` reference it instead of repeating the // path, and lite-07 bases name it too. Tracked even for announces we drop -@@ -1624,7 +1625,10 @@ impl AnnouncePrefix { +@@ -1991,7 +1992,10 @@ impl AnnouncePrefix { let mut run: PrefixRun = PrefixRun { responder_origin, link_cost, @@ -34,7 +34,7 @@ index a3f03296c7..3046b3af8f 100644 decoder: lite::AnnounceDecoder::default(), auth_epoch: 0, initial, -@@ -1756,7 +1760,8 @@ struct SourceServe { +@@ -2123,7 +2127,8 @@ struct SourceServe { subscriber: Subscriber, path: Path, instance: Option, @@ -44,12 +44,12 @@ index a3f03296c7..3046b3af8f 100644 dynamic: crate::broadcast::Requests, route: kio::Consumer<()>, // A dedicated close-watch handle, since each pending operation needs its own. -@@ -1899,7 +1904,7 @@ impl SourceServe { +@@ -2269,7 +2274,7 @@ impl SourceServe { subscriber, path: minted.path, instance: minted.instance, - source: minted.source, + source: std::mem::ManuallyDrop::new(minted.source), + datagram_requests: minted.dynamic.datagrams(), dynamic: minted.dynamic, route: minted.route, - closed, diff --git a/test/interop/README.md b/test/interop/README.md index 03eaaf3895f..f03a7edcaac 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -213,11 +213,6 @@ one cell. The tails check their own connections, so a fallback fails the lane. The in-tree control lanes also run with `--tail`: -- Four untimed 900-byte datagrams cross the relay in both directions between Rust - and each native JS runtime. The subscriber acknowledges resolved metadata before - sending begins, then acknowledges each packet, so this checks framing and bytes - rather than best-effort burst capacity. The publisher stays open until the read is - acknowledged, keeping a reliable track end from overtaking the datagrams. - A JS wire requester gets the Rust publisher's exact largest location through TRACK\_STATUS on every draft. Rust also queries the JS publisher's non-default priority before subscribing, when the answer can describe a track. @@ -231,7 +226,7 @@ The in-tree control lanes also run with `--tail`: The standalone control endpoints use OS-assigned ports and production QMux, SETUP codecs, and request sessions. They verify their negotiated version directly. -The datagram and query lanes through moq-relay use its usual version-log check. +The query lanes through moq-relay use their usual version-log check. Some cases change shape on moq-transport: diff --git a/test/interop/clients/js-native/tail.ts b/test/interop/clients/js-native/tail.ts index 5ff5fabae80..2219454d63b 100644 --- a/test/interop/clients/js-native/tail.ts +++ b/test/interop/clients/js-native/tail.ts @@ -30,7 +30,6 @@ const compress = mode === "compress"; console.debug = console.error; console.info = console.error; const join = mode === "join" || mode === "status"; -const datagram = mode === "datagram"; const status = mode === "status"; const cached = mode === "cached"; const history = mode === "history"; @@ -39,10 +38,10 @@ if ( !url || !path || !["publish", "subscribe"].includes(role) || - ![undefined, "drop", "join", "compress", "datagram", "status", "cached", "history", "late"].includes(mode) + ![undefined, "drop", "join", "compress", "status", "cached", "history", "late"].includes(mode) ) { throw new Error( - "usage: tail.ts publish|subscribe URL BROADCAST [VERSION [drop|join|compress|datagram|status|cached|history|late]]", + "usage: tail.ts publish|subscribe URL BROADCAST [VERSION [drop|join|compress|status|cached|history|late]]", ); } @@ -144,15 +143,8 @@ try { } else if (!status && !cached && !history) { const demand = track.demand(); while (demand.wanted.peek() === undefined) await demand.wanted.changed(); - if (datagram) { - for (let sequence = 0; sequence < GROUPS; sequence++) { - assert.equal((await commands.next()).value, `datagram ${sequence}`, "missing datagram command"); - track.appendDatagram(undefined, new Uint8Array(900).fill(sequence)); - } - } else { - write(track); - track.close(); - } + write(track); + track.close(); } try { const ack = await commands.next(); @@ -179,23 +171,7 @@ try { if (compress) assert.equal((await track.info()).compress, version === "moq-lite-07-wip"); const seen: number[] = []; try { - if (datagram) { - await track.info(); - process.stdout.write("datagram ready\n"); - for (let sequence = 0; sequence < GROUPS; sequence++) { - const frame = await track.recvDatagram(); - assert(frame, "missing datagram"); - assert.equal(frame.sequence, BigInt(sequence), "wrong datagram sequence"); - assert.equal(frame.payload.byteLength, 900, "truncated datagram"); - assert( - frame.payload.every((byte) => byte === sequence), - "corrupt datagram", - ); - assert.equal(frame.timestamp, undefined, "unexpected IETF timestamp"); - process.stdout.write(`datagram ${sequence}\n`); - } - console.error("datagram clean groups=0,1,2,3 bytes=3600"); - } else if (late) { + if (late) { for (let sequence = 0; sequence < LATE_GROUPS; sequence++) { const group = await track.recvGroup(); assert(group, "missing group"); diff --git a/test/interop/clients/rust/tail.rs b/test/interop/clients/rust/tail.rs index db1e2fb6307..bcd95c5776e 100644 --- a/test/interop/clients/rust/tail.rs +++ b/test/interop/clients/rust/tail.rs @@ -76,14 +76,14 @@ async fn main() -> anyhow::Result<()> { let args: Vec<_> = std::env::args().collect(); ensure!( (4..=6).contains(&args.len()), - "usage: interop-tail publish|subscribe URL BROADCAST [VERSION [drop|join|cached|compress|datagram|status|late]]" + "usage: interop-tail publish|subscribe URL BROADCAST [VERSION [drop|join|cached|compress|status|late]]" ); let url: url::Url = args[2].parse()?; let mode = args.get(5).map(String::as_str); ensure!( matches!( mode, - None | Some("drop" | "join" | "cached" | "compress" | "datagram" | "status" | "late") + None | Some("drop" | "join" | "cached" | "compress" | "status" | "late") ), "unknown mode: {mode:?}" ); @@ -91,7 +91,6 @@ async fn main() -> anyhow::Result<()> { let join = matches!(mode, Some("join" | "status")); let cached = mode == Some("cached"); let compress = mode == Some("compress"); - let datagram = mode == Some("datagram"); let status = mode == Some("status"); let late = mode == Some("late"); let mut commands = tokio::io::BufReader::new(tokio::io::stdin()).lines(); @@ -110,7 +109,7 @@ async fn main() -> anyhow::Result<()> { let mut track = broadcast.create_track( "tail", moq_net::track::Info::default() - .with_timescale((!datagram).then_some(moq_net::Timescale::MILLI)) + .with_timescale(moq_net::Timescale::MILLI) .with_compress(compress), )?; broadcast.announce(Default::default())?; @@ -151,19 +150,7 @@ async fn main() -> anyhow::Result<()> { } else if !cached { let mut demand = track.demand(); while demand.wanted_changed().await?.is_none() {} - if datagram { - // Keep the track open until the harness acknowledges: a reliable end - // must not overtake best-effort datagrams on their independent channel. - for sequence in 0..GROUPS { - ensure!( - commands.next_line().await?.as_deref() == Some(&format!("datagram {sequence}")), - "missing datagram command" - ); - track.append_datagram(None, vec![sequence as u8; 900])?; - } - } else { - write(&mut track, mode)?; - } + write(&mut track, mode)?; } // A finished track is not a drained transport. Keep the session alive // until the harness confirms the subscriber checked every byte and EOF. @@ -171,9 +158,6 @@ async fn main() -> anyhow::Result<()> { commands.next_line().await?.as_deref() == Some("clean end"), "reader did not acknowledge a clean end" ); - if datagram { - track.finish()?; - } broadcast.close(); session.close().await?; eprintln!("tail acknowledged"); @@ -228,24 +212,6 @@ async fn main() -> anyhow::Result<()> { .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(if drop { 60 } else { 5 }))) .with_range(0..); let mut track = remote.subscribe(Some(options)).await?; - if datagram { - println!("datagram ready"); - for sequence in 0..GROUPS { - let frame = track.recv_datagram().await?.context("missing datagram")?; - ensure!(frame.sequence == sequence, "wrong datagram sequence"); - ensure!(frame.payload.len() == 900, "truncated datagram"); - ensure!( - frame.payload.iter().all(|byte| *byte == sequence as u8), - "corrupt datagram" - ); - ensure!(frame.timestamp.is_none(), "unexpected IETF timestamp"); - println!("datagram {sequence}"); - } - eprintln!("datagram clean groups=0,1,2,3 bytes=3600"); - session.close().await?; - client.close().await; - return Ok(()); - } if compress { ensure!( track.info().compress == args.get(4).is_some_and(|version| version == "moq-lite-07-wip"), diff --git a/test/interop/interop.sh b/test/interop/interop.sh index ee23c7580a5..21001ba94ef 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -511,8 +511,6 @@ plan_pass() { record "not run" case=tail publisher=rust subscriber="$runtime" record "not run" case=tail publisher="$runtime" subscriber=rust if [[ "$VERSION" == moq-transport-* ]]; then - record "not run" case=datagram publisher=rust subscriber="$runtime" - record "not run" case=datagram publisher="$runtime" subscriber=rust record "not run" case="wire track status" publisher=rust subscriber="$runtime" record "not run" case=goaway publisher=rust subscriber="$runtime" [[ "$runtime" != js-native-bun ]] || record "not run" case=goaway publisher="$runtime" subscriber=rust @@ -1927,10 +1925,7 @@ run_tail_pair() { mode="join" want='tail join clean end=4 groups=[0-3](,[0-3])*' fi - if [[ "$kind" == datagram ]]; then - mode=datagram - want='datagram clean groups=0,1,2,3 bytes=3600' - elif [[ "$kind" == "track status" ]]; then + if [[ "$kind" == "track status" ]]; then mode=status want="track status answered" elif [[ "$kind" == compress ]]; then @@ -1951,26 +1946,8 @@ run_tail_pair() { # Each side's token grants this broadcast alone, like a media round's. harness_spawn "$name-pub" "$LOGS/$name-pub.log" run_tail_publisher "$fifo" "$pub" publish "$broadcast" "$(token_url --publish "$(publish_grant "$broadcast")")" "$version" "$mode" pid="$HARNESS_PID" - if [[ "$mode" == datagram ]]; then - # Subscription metadata, on the clients' serialized task runtimes, confirms the alias is bound. An acknowledgement - # per datagram avoids testing a best-effort receive buffer's burst capacity. - mkfifo "$LOGS/datagram-ack" - exec 8<>"$LOGS/datagram-ack" - harness_spawn "$name-sub" "$LOGS/$name-sub.log" run_tail_subscriber "$LOGS/datagram-ack" "$sub" subscribe "$broadcast" "$(token_url --subscribe "$(subscribe_grant "$broadcast")")" "$version" "$mode" - subscriber_pid="$HARNESS_PID" - local ack sequence - if read -r -t "$TIMEOUT" ack <&8 && [[ "$ack" == "datagram ready" ]]; then - for sequence in 0 1 2 3; do - printf 'datagram %s\n' "$sequence" >&9 - read -r -t "$TIMEOUT" ack <&8 && [[ "$ack" == "datagram $sequence" ]] || break - done - fi - exec 8>&- - rm "$LOGS/datagram-ack" - else - harness_spawn "$name-sub" "$LOGS/$name-sub.log" run_tail_client "$sub" subscribe "$broadcast" "$(token_url --subscribe "$(subscribe_grant "$broadcast")")" "$version" "$mode" - subscriber_pid="$HARNESS_PID" - fi + harness_spawn "$name-sub" "$LOGS/$name-sub.log" run_tail_client "$sub" subscribe "$broadcast" "$(token_url --subscribe "$(subscribe_grant "$broadcast")")" "$version" "$mode" + subscriber_pid="$HARNESS_PID" harness_wait "$subscriber_pid" || true # Like run_native, a verified data result survives the NAPI addon's exit crash. if read=$(grep -xE "$want" "$LOGS/$name-sub.log") && { [[ "$mode" != status ]] || grep -qx "track status answered" "$LOGS/$name-sub.log"; }; then @@ -2462,8 +2439,6 @@ run_tails() { run_tail_pair rust "$runtime" run_tail_pair "$runtime" rust if [[ "$VERSION" == moq-transport-* ]]; then - run_tail_pair rust "$runtime" datagram - run_tail_pair "$runtime" rust datagram run_control_case "$runtime" status run_control_case "$runtime" goaway [[ "$runtime" != js-native-bun ]] || run_control_case "$runtime" reverse-goaway