diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 59605edbf21..b84c326c9c7 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -25,12 +25,13 @@ maps everything else to "not supported" or a harmless equivalent. The [moq-lite page](/concept/moq-lite#what-moq-lite-leaves-out) lists what the subset drops. What a peer actually observes against this implementation: -- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined; announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited, and we also ask for every prefix we may discover. The solicit `SETUP` option makes us wait to be asked. Without it, a draft 16+ peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`: on drafts 16 and 17 a `SUBSCRIBE_NAMESPACE` asking only for `PUBLISH` is refused, one asking for both gets only `NAMESPACE`, and any other Subscribe Options value closes the session. +- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined; announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited, and we also ask for every prefix we may discover. The solicit `SETUP` option makes us wait to be asked. Without it, a draft 16+ peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We send `PUBLISH` only to answer a `SUBSCRIBE_TRACKS` (below): on drafts 16 and 17 a `SUBSCRIBE_NAMESPACE` asking only for `PUBLISH` is refused, one asking for both gets only `NAMESPACE`, and any other Subscribe Options value closes the session. +- **`SUBSCRIBE_TRACKS` lists, it never pushes.** In Rust, on draft 18 and later, it lists every track of every broadcast under its prefix, later ones included. With `FORWARD=0`, each track gets a paused `PUBLISH` carrying its Track Properties, and an error `PUBLISH_DONE` once the track is no longer listed, never `TRACK_ENDED`, since the track may go on elsewhere. A `REQUEST_UPDATE` with `FORWARD=1` resumes one, served from the cache like a `SUBSCRIBE` with the update's filter and answered with `LARGEST_OBJECT`; `FORWARD=0` pauses it again. Each paused `PUBLISH` holds a subscription slot, and past the cap a track gets `PUBLISH_SKIPPED` (`PUBLISH_BLOCKED` on draft 18) instead. A listing that omits `FORWARD` or sets it to 1 asks for every track's objects: from draft 19 each track gets `PUBLISH_SKIPPED`, and the subscriber `SUBSCRIBE`s to what it wants; draft 18 has no such skip, so the request is refused with `NOT_SUPPORTED`. A prefix overlapping another listing gets `PREFIX_OVERLAP`, a `TRACK_PROPERTY_FILTER` gets `NOT_SUPPORTED`, and a `REQUEST_UPDATE` on the listing itself ends it. On draft 18, a `SUBSCRIBE` to a track we `PUBLISH`ed gets `DUPLICATE_SUBSCRIPTION`. Cancelling the listing stops new `PUBLISH`es, while those sent live on. A broadcast is listed only while our publish grant covers it, from scratch once it does. A Rust relay does not yet send `SUBSCRIBE_TRACKS` upstream, and JavaScript refuses it with `NOT_SUPPORTED`. - **History is one group.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`. - **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a request cut short by FIN, or a second `GOAWAY` on one stream; a first `GOAWAY` on a request stream is legal. -- **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, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, 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 renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 16, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. -- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, 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 renews request tokens and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). 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`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. +- **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, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, 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 renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 16, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. JavaScript's `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. +- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0` on a `SUBSCRIBE`, JavaScript's `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, 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 renews request tokens and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). 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`. On draft 18 and later 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 see consecutive frames and normal datagram payloads; 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 (drafts 14 through 16), 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 across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their 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` (draft 15 and later) 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`. On drafts 15 and 16, whose `TRACK_STATUS` answer has no room for it, a relay copy first learned from `TRACK_STATUS` (a fetch-only reader) does not advertise it to later subscribers. 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. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. 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. diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index d528cdb82c3..362ba42499b 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -30,9 +30,10 @@ archive (per-track `.timeline.z` records named by the catalog's root lists each broadcast's tracks as they appear, and the archive SUBSCRIBEs per track, gating the lite-07 cut (`broadcast::Consumer::tracks` in Rust; [TRACKS in js/net](/quest/m1/archive/tracks-js.md)). - moq-transport answers SUBSCRIBE_TRACKS through the same hook and learns an + moq-transport answers SUBSCRIBE_TRACKS through the same hook (Rust now, + [js/net](/quest/m1/archive/subscribe-tracks-js.md) next) and learns an IETF peer's tracks with a FORWARD=0 SUBSCRIBE_TRACKS - ([SUBSCRIBE_TRACKS](/quest/m1/archive/subscribe-tracks.md)). Rejected: + ([outbound SUBSCRIBE_TRACKS](/quest/m1/archive/subscribe-tracks-outbound.md)). Rejected: SUBSCRIBE_ALL, whose relay had to merge pushed feeds into pulled copies ([moq-dev/moq#5931](https://github.com/moq-dev/moq/pull/5931)). The archive no longer selects tracks from a catalog. @@ -87,7 +88,8 @@ from a host. Each child fixes the stale docs it touches. - [TRACKS in js/net](/quest/m1/archive/tracks-js.md) - `@moq/net` lists a broadcast's tracks over lite-07's Tracks Stream and answers listings of its own broadcasts, mirroring Rust - [Archive consumer](/quest/m1/archive/consumer.md) - `archive::Consumer` serves a stored prefix as a broadcast from its key listing, and `moq export/import archive` use the new pair - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the root `archive` section is gone; an opt-in per-rendition `hls` field marks each aligned HLS segment's first group and pts and declares its nominal duration -- [SUBSCRIBE_TRACKS](/quest/m1/archive/subscribe-tracks.md) - moq-transport SUBSCRIBE_TRACKS is answered through the TRACKS hook, and an IETF peer's tracks are learned with FORWARD=0, instead of NOT_SUPPORTED +- [Outbound SUBSCRIBE_TRACKS](/quest/m1/archive/subscribe-tracks-outbound.md) - an IETF peer's tracks are listed through the TRACKS hook by a FORWARD=0 SUBSCRIBE_TRACKS, whose held PUBLISH resumes for data +- [SUBSCRIBE_TRACKS in js/net](/quest/m1/archive/subscribe-tracks-js.md) - `@moq/net` answers SUBSCRIBE_TRACKS as Rust does, instead of NOT_SUPPORTED - [Fixed HLS target duration](/quest/m1/archive/hls-target.md) - one `EXT-X-TARGETDURATION` for the run, from the declared `hls` duration; an overrun is listed with a warning - [History from the retained floor](/quest/m1/archive/history-floor.md) - HLS history over an archive consumer broadcast whose start was pruned lists from its oldest stored segment diff --git a/quest/m1/archive/subscribe-tracks-js.md b/quest/m1/archive/subscribe-tracks-js.md new file mode 100644 index 00000000000..32fd6e0b86a --- /dev/null +++ b/quest/m1/archive/subscribe-tracks-js.md @@ -0,0 +1,22 @@ +# [M] SUBSCRIBE_TRACKS in js/net + +## Goal + +`@moq/net` answers a moq-transport SUBSCRIBE_TRACKS (draft-18 on) as Rust +does, instead of refusing it with NOT_SUPPORTED: a paused PUBLISH per listed +track for FORWARD=0, resumed and paused with REQUEST_UPDATE; PUBLISH_SKIPPED +past the subscription cap and for every track of a FORWARD=1 listing from +draft-19; NOT_SUPPORTED for FORWARD=1 on draft-18; PREFIX_OVERLAP for an +overlapping prefix; and draft-18's DUPLICATE_SUBSCRIPTION for a SUBSCRIBE to +a PUBLISHed track. + +## Plan + +Mirror `rs/moq-net/src/ietf/publisher/tracks.rs`, reading tracks through the +TRACKS listing that [TRACKS in js/net](/quest/m1/archive/tracks-js.md) adds. +Mirror its tests, and drop the JavaScript caveat from +`doc/concept/standard.md`. + +## Required + +- [TRACKS in js/net](/quest/m1/archive/tracks-js.md) - `@moq/net` lists a broadcast's tracks, which this listing reads diff --git a/quest/m1/archive/subscribe-tracks-outbound.md b/quest/m1/archive/subscribe-tracks-outbound.md new file mode 100644 index 00000000000..a44489c106c --- /dev/null +++ b/quest/m1/archive/subscribe-tracks-outbound.md @@ -0,0 +1,103 @@ +# [L] Outbound SUBSCRIBE_TRACKS + +## Goal + +A lite TRACKS whose broadcast is served by a moq-transport peer (draft-18 on) +learns that peer's tracks by sending SUBSCRIBE_TRACKS with FORWARD=0 and +reading the PUBLISH each track produces, without its objects. A listed +track's data comes by resuming that same subscription when a reader wants it. +Today the Rust subscriber never sends SUBSCRIBE_TRACKS, so a lite listing over +a moq-transport upstream lists nothing. + +An unsolicited inbound PUBLISH stays refused: moq-net never merges a pushed +track into a pulled copy. A FORWARD=0 PUBLISH answering our own +SUBSCRIBE_TRACKS carries data only once we resume it with the filter our own +SUBSCRIBE would send, so it is a pull, not that merge. + +## Plan + +Split from the inbound half, which answers a peer's SUBSCRIBE_TRACKS through +`broadcast::Consumer::tracks` (`rs/moq-net/src/ietf/publisher/tracks.rs`). +The maintainer's 2026-10-19 decisions, recorded with TRACKS: "we don't need an +extension; we could issue forward=0 to learn about all tracks from IETF peers". + +TRACKS' events map as follows: + +| TRACKS event | We send SUBSCRIBE_TRACKS with FORWARD=0 | +|---|---| +| Track added, with TRACK_INFO | a PUBLISH, TRACK_INFO from its Track Properties; or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), TRACK_INFO from the SUBSCRIBE_OK of a paused SUBSCRIBE we open for it | +| Synced | not needed: the lite side sends SYNCED for this hop at once, and PUBLISHes arrive as live adds | +| Track removed | PUBLISH_DONE with TRACK_ENDED on the held paused PUBLISH or SUBSCRIBE; any other status fails the listing | + +- **One subscription per track: the held PUBLISH carries the data.** + PUBLISH_DONE is the only removal signal, so an outbound PUBLISH is accepted + paused (PUBLISH_OK, FORWARD=0) and held as the listing entry. It is also + the track's upstream copy: when a reader wants the data, REQUEST_UPDATE it + to FORWARD=1 with the filter our SUBSCRIBE would have sent + (SUBSCRIPTION_FILTER through draft-19, LOCATION_FILTER and FILL_PARAMETERS + from draft-20, whose section 3.6.2 names this join), and back to FORWARD=0 + when the last reader leaves, rather than unsubscribing. On draft-18 and + draft-19 a filter does not deliver objects already published, so a start + below the Largest Object also sends a Joining FETCH on the held + subscription's request ID, as the existing pre-draft-20 SUBSCRIBE path + does. Draft-18 section 5.1 allows one subscription per track per role and + fails a second with DUPLICATE_SUBSCRIPTION; draft-19 on allow several. + The upstream copy is one subscription carrying the union of reader ranges + either way, and the existing SUBSCRIBE path adopts a held PUBLISH as the + track's copy instead of opening one. Conversely, a PUBLISH for a track we + already SUBSCRIBEd (established or pending) is redundant: the existing + SUBSCRIBE becomes the entry, and the PUBLISH is refused with + DUPLICATE_SUBSCRIPTION on draft-18 and UNINTERESTED from draft-19. Only + TRACK_ENDED is a removal. Any other status fails the listing loudly: the + lite TRACKS stream resets and the lister requests again, never turning it + into a removal that would hide a live track for good. +- **A skipped track is still listed.** A PUBLISH_SKIPPED (PUBLISH_BLOCKED on + draft-18) names a track that exists, so open a paused SUBSCRIBE (FORWARD=0) + for it and hold that as the entry and the copy. Its SUBSCRIBE_OK supplies + the TRACK_INFO, so the add goes downstream once it arrives, and its + PUBLISH_DONE with TRACK_ENDED is the removal. A refused paused SUBSCRIBE, + or one ending with another status, fails the listing loudly. +- **One SUBSCRIBE_TRACKS per nesting chain, never widened in place.** Two + prefixes overlap only when one is a prefix of the other, which the peer + refuses with PREFIX_OVERLAP. Siblings get separate requests. A nested + listing under a broader request reuses it and filters locally. A broader + listing cancels the nested requests, keeping the PUBLISH subscriptions they + made, and opens only once the peer has closed every cancelled stream: close + our sending half (FIN on draft-18, RESET_STREAM from draft-19), keep + reading until the peer closes its half, and delay STOP_SENDING. If the + broader request is refused, every listing it was meant to serve resets + loudly and re-requests. Rejected: widening in place with REQUEST_UPDATE, + and one request per session at the common ancestor. +- **Push inventory only.** An IETF relay lists the tracks it has been + PUBLISHed or already holds. A track served only on request is unlisted, + which matches TRACKS' rule. +- **Properties.** A property TRACK_INFO needs but PUBLISH lacks takes the + same default it takes from SUBSCRIBE_OK today. +- **Draft-22 parameters.** Draft-22 section 9.18 drops SUBSCRIBER_PRIORITY, + LOCATION_FILTER, and NEW_GROUP_REQUEST from SUBSCRIBE_TRACKS, while section + 3.6.2 still allows any subscription parameter. Send only the section 9.18 + set on draft-22 (`ietf::SubscribeTracks` encodes all of them today), and put + the join filter on the resuming REQUEST_UPDATE. The inbound decoder accepts + both readings. +- **A non-conforming peer** may send objects despite FORWARD=0; stop reading + them rather than buffer. + +Untrusted input: a hostile publisher can PUBLISH without bound. Each held +paused PUBLISH counts against the session's `announces` cap, as a lite listed +track does; past it, refuse the PUBLISH (EXCESSIVE_LOAD). The stream and +request-ID credit we grant bounds what is in flight. + +Test on mock time over drafts 18 and later: a lite TRACKS over a +moq-transport upstream lists its tracks with FORWARD=0; a lite SUBSCRIBE +resumes the held PUBLISH with its join filter and gets a new track's first +group with no second upstream SUBSCRIBE; a past start on draft-18 and +draft-19 gets cached groups through a Joining FETCH; the last reader leaving +pauses it and keeps the entry; TRACK_ENDED removes the entry and another +status resets the listing; a skipped track lists once its paused SUBSCRIBE +answers; TRACKS(`/a/b`) after TRACKS(`/a`) sends nothing; TRACKS(`/a`) after +TRACKS(`/a/b`) and TRACKS(`/a/c`) cancels both and opens only after the peer +closes them; a PUBLISH for a track we already SUBSCRIBEd is refused; an +unsolicited PUBLISH is refused. + +Public API: none beyond the TRACKS hook. Wire: moq-transport support for an +existing message. diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md deleted file mode 100644 index 5440254c9f8..00000000000 --- a/quest/m1/archive/subscribe-tracks.md +++ /dev/null @@ -1,247 +0,0 @@ -# [L] SUBSCRIBE_TRACKS - -## Goal - -moq-net speaks moq-transport SUBSCRIBE_TRACKS (draft-18 on) in both -directions through the TRACKS model hook (`broadcast::Consumer::tracks` -and `broadcast::Lister`), -instead of refusing it with NOT_SUPPORTED: - -- **Inbound.** A moq-transport subscriber's SUBSCRIBE_TRACKS lists every - track of every broadcast under its namespace prefix, including later tracks - and broadcasts, but never pushes their data unasked. The peer resumes or - SUBSCRIBEs what it wants, served from the cache like any SUBSCRIBE. -- **Outbound.** A lite TRACKS whose broadcast is served by a moq-transport - peer learns that peer's tracks by sending SUBSCRIBE_TRACKS with FORWARD=0 - and reading the PUBLISH each track produces, without its objects. A listed - track's data comes by resuming that same subscription when a reader wants - it. - -An unsolicited inbound PUBLISH stays refused: moq-net never merges a pushed -track into a pulled copy. A FORWARD=0 PUBLISH answering our own -SUBSCRIBE_TRACKS carries data only once we resume it with the filter our own -SUBSCRIBE would send, so it is a pull, not that merge. - -## Plan - -Decided by the maintainer on 2026-10-19 with TRACKS (see its Plan for the -quotes): "we don't need an extension; we could issue forward=0 to learn about -all tracks from IETF peers". This replaces the 2026-10-17 plan, which mapped -SUBSCRIBE_TRACKS 1:1 onto SUBSCRIBE_ALL and accepted inbound PUBLISH as a -pushed track. Accepting PUBLISH is dropped: it is the push-into-pull merge the -maintainer declined. - -FORWARD=0, verified against the draft-18 and draft-22 text (draft-22 sections -3.1.1, 3.6, 9.8 and 9.20.18): each PUBLISH a SUBSCRIBE_TRACKS produces starts its -subscription paused, carrying the full track name, Track Properties, and -LARGEST_OBJECT once the track has published an object (omitted before), and no -objects flow until the subscriber resumes it. - -TRACKS' three events (see its Plan) map as follows: - -| TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS by its FORWARD | Outbound: we send it with FORWARD=0 | -|---|---|---| -| Track added, with TRACK_INFO | FORWARD=0: a paused PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today, or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) past the subscription cap. FORWARD=1 or omitted: a PUBLISH_SKIPPED naming the track from draft-19 on; on draft-18 the whole request is refused with REQUEST_ERROR | a PUBLISH, TRACK_INFO from its Track Properties; or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), TRACK_INFO from the SUBSCRIBE_OK of a paused SUBSCRIBE we open for it | -| Synced | nothing to send: moq-transport has no marker | not needed: the lite side sends SYNCED for this hop at once, and PUBLISHes arrive as live adds | -| Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH; nothing for a skipped track, whose removal reaches the peer only through its own SUBSCRIBE ending with TRACK_ENDED | PUBLISH_DONE with TRACK_ENDED on the held paused PUBLISH or SUBSCRIBE; any other status fails the listing | - -- **Inbound answers by the peer's FORWARD**, decided by the maintainer on - 2026-10-19. We never push every track's data unasked. - - FORWARD=0: a paused PUBLISH per track, which carries Track Properties and - a TRACK_ENDED removal, and PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) - past the subscription cap. The peer resumes the PUBLISH to get data, and - we serve the update's filter from the cache as we would a SUBSCRIBE's. On - draft-18 its SUBSCRIBE to a track we PUBLISHed fails with - DUPLICATE_SUBSCRIPTION, as section 5.1 requires. - - FORWARD=1, or omitted (it defaults to 1): no draft lets the publisher - downgrade it to a paused PUBLISH. Drafts 18 and 19 say the resulting - PUBLISHes "will set the FORWARD parameter to 1"; drafts 20 to 22 use the - SUBSCRIBE_TRACKS parameters as each PUBLISH's initial subscription - parameters, and forbid PUBLISH_STATE_NOTIFY from changing a - subscriber-controlled one unasked. The maintainer's rule follows: "if we - can't deny their request and do forward=0 instead, then PUBLISH_SKIPPED". - From draft-19 on, which allows a skip "for any other reason", answer each - listed track with PUBLISH_SKIPPED, and the peer SUBSCRIBEs what it wants. - A skip holds no subscription and has no PUBLISH_DONE, so a removal - reaches the peer only through its own SUBSCRIBE. Draft-18 has no such - skip: PUBLISH_BLOCKED is only for a publisher out of bidirectional - streams, so it would falsely report exhaustion. There, refuse the whole - SUBSCRIBE_TRACKS with REQUEST_ERROR (NOT_SUPPORTED, its reason naming - FORWARD=0), and the peer retries with FORWARD=0 or SUBSCRIBEs directly. -- **SYNCED has no IETF equivalent, and needs none.** Synced means synced - with this hop (see TRACKS), so a relay answers a lite TRACKS at - once with what it holds, whatever its upstream speaks, and every PUBLISH the - FORWARD=0 SUBSCRIBE_TRACKS brings is a live add. Inbound, nothing stands in - for it. -- **One subscription per track: the held PUBLISH carries the data.** - PUBLISH_DONE is the only removal signal, so an outbound PUBLISH is accepted - paused (PUBLISH_OK, FORWARD=0) and held as the listing entry. It is also - the track's upstream copy: when a reader wants the data, REQUEST_UPDATE it - to FORWARD=1 with the filter our SUBSCRIBE would have sent - (SUBSCRIPTION_FILTER through draft-19, LOCATION_FILTER and FILL_PARAMETERS - from draft-20, whose section 3.6.2 names this join), and back to FORWARD=0 - when the last reader leaves, rather than unsubscribing. On draft-18 and - draft-19 a filter does not deliver objects already published, so a start - below the Largest Object also sends a Joining FETCH on the held - subscription's request ID, as the existing pre-draft-20 SUBSCRIBE path - does (draft-18 section 5.1.3, draft-19 section 5.1.5). A second SUBSCRIBE - is not an option on every draft: draft-18 section 5.1 allows one - subscription per track per role and fails a second with - DUPLICATE_SUBSCRIPTION; draft-19 on allow several. Nothing is lost: the - upstream copy is one subscription carrying the union of reader ranges - either way, and readers keep their own lite subscriptions downstream. The - costs are that a listed track's upstream subscription lives as long as its - entry, paused while unread, and that the existing SUBSCRIBE path adopts a - held PUBLISH as the track's copy instead of opening one. Conversely, on - every draft, a PUBLISH for a track we already SUBSCRIBEd (established or - pending) is redundant: the existing SUBSCRIBE becomes the entry and stays - the one copy, and the PUBLISH is refused, with DUPLICATE_SUBSCRIPTION on - draft-18 and UNINTERESTED from draft-19. Draft-18 section 5.1 only asks a - subscriber to end its pending SUBSCRIBE before sending PUBLISH_OK, which a - refusal never sends. A pending SUBSCRIBE's add goes downstream once its - SUBSCRIBE_OK arrives, and a refused one fails the listing loudly, as for a - skipped track. Only TRACK_ENDED ("no longer being published") is a removal, - and a later PUBLISH for the name is a new add. Any other status - (GOING_AWAY, EXCESSIVE_LOAD, ...) ends only that subscription while the - track may still be published, and the peer need not PUBLISH it again, so it - fails the listing loudly: the lite TRACKS stream resets and the lister - requests again, which re-issues SUBSCRIBE_TRACKS. Never turn it into a - removal, which would hide a live track for good; any reader on that copy - re-subscribes, as after any upstream loss. The cost is a paused - subscription per listed track at both ends, bounded below. -- **A skipped track is still listed**, decided by the maintainer on - 2026-10-19. A PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) answering our - FORWARD=0 SUBSCRIBE_TRACKS names a track that exists, so it is an add. It - carries only the name, with no Track Properties and no stream to end, so - open a paused SUBSCRIBE (FORWARD=0) for it and hold that as the entry and - the track's copy, resumed and paused for readers as the held PUBLISH is. Its SUBSCRIBE_OK supplies the - TRACK_INFO, so the add goes downstream once it arrives, and its - PUBLISH_DONE with TRACK_ENDED is the removal. A refused paused SUBSCRIBE, - or one ending with another status, fails the listing loudly, since nothing - else would ever say the track ended. That SUBSCRIBE needs the same request - credit the upstream may have run short of when it skipped, so an upstream - short on capacity fails and retries the listing rather than leave an entry - that is never removed. -- **One SUBSCRIBE_TRACKS per nesting chain, never widened in place.** - Decided by the maintainer on 2026-10-19. - SUBSCRIBE_TRACKS matches every namespace under its prefix, and a session - may not hold two whose prefixes "share a common prefix" (PREFIX_OVERLAP). - Read that strictly: two prefixes overlap only when one is a prefix of the - other, so their matched namespaces intersect. Draft-14 section 9.28, where - the rule began for SUBSCRIBE_NAMESPACE, says "a prefix of, suffix of, or - equal to"; draft-16 section 9.25 rephrased it as "shares a common prefix" - under "A subscriber cannot make overlapping namespace subscriptions"; and - siblings such as `/a/b` and `/a/c` match disjoint namespaces. No draft - forbids siblings outright. Asking for the exact namespace does not avoid - the rule, since `/a` is a prefix of `/a/b`, so: - - Siblings get separate SUBSCRIBE_TRACKS requests. - - A nested listing that arrives while a broader request covers it reuses - that request and filters locally. TRACKS(`/a/b`) after TRACKS(`/a`) - sends nothing. - - A broader listing that arrives while nested requests exist cancels them, - then opens the broader request. TRACKS(`/a`) over `/a/b` and `/a/c` - cancels both and sends SUBSCRIBE_TRACKS(`/a`). The cancel ends only - those requests, and the PUBLISH subscriptions they produced keep their - own streams (draft-18 section 6.1), so their entries stay. When the new - request PUBLISHes a track we already hold, keep the held one as the - entry and refuse the new one: DUPLICATE_SUBSCRIPTION on draft-18, - UNINTERESTED from draft-19. - - The broader request opens only once every cancelled request's stream - has closed on the peer's side, since streams are not ordered against - each other and a peer that reads the new request first answers - PREFIX_OVERLAP. Cancel by closing only our sending half (FIN on - draft-18, where a FIN cancels; RESET_STREAM from draft-19, as draft-22 - section 6.4.2.3 describes), then keep reading the response half until - the peer FINs or resets it. Delay STOP_SENDING: QUIC answers it with an - automatic RESET_STREAM before the peer's application has seen the - cancel, while the peer's own close of its half comes from its - application once the request is gone (the existing publisher ends the - request on `poll_cancel` in `rs/moq-net/src/ietf/request_stream.rs`). - A peer that never closes its half stalls only the broader listing. - Should PREFIX_OVERLAP still come back, it is a refusal like any other: - the listings reset loudly below, with no automatic retry. - - A track created and removed between that cancel and the open is missed, - which the listing contract allows: a listing reports current state, not - history, and a lagging lite reader misses a short-lived track the same - way, since a removal for a still-queued add cancels both. - - Each lite listing filters its request's results to its exact namespace. - - If the broader request is refused, every listing it was meant to serve, - the new one and each nested one whose request was cancelled, resets - loudly and re-requests. The held PUBLISHes keep their own streams and - are adopted again when a re-request lists them. A request never narrows - while a listing under it remains, and is cancelled when the last one - closes. - - Cost: the extra tracks held are only those of broadcasts nested under a - listed one, bounded by the `announces` cap below; past it the listings - reset as for any other overflow. - - Rejected: widening a request in place with REQUEST_UPDATE carrying - TRACK_NAMESPACE_PREFIX, which avoids the cancel-to-open gap but needs - its own refused-update rules for a gap the listing contract already - allows; and one request per session at the common ancestor, which for - `/a/b` and `/c/d` is the root and holds every track the peer has. -- **Push inventory only.** An IETF relay lists the tracks it has been - PUBLISHed or already holds, not what an upstream would serve on SUBSCRIBE. - A track served only on request is unlisted, which matches TRACKS' rule. -- **Properties.** A property TRACK_INFO needs but PUBLISH lacks takes the - same default it takes from SUBSCRIBE_OK today. -- **A non-conforming peer** may send objects despite FORWARD=0; stop reading - them rather than buffer. - -Untrusted input: - -- Inbound, a hostile subscriber's FORWARD=0 SUBSCRIBE_TRACKS makes us open a - paused PUBLISH per track. Each holds a subscription slot; past the cap, tell - the peer rather than hold more or omit the track silently: PUBLISH_BLOCKED on - draft-18, where the cap is the bidirectional streams we allow ourselves for - PUBLISH, and PUBLISH_SKIPPED (its rename) from draft-19 on. Draft-17 has no - SUBSCRIBE_TRACKS (its PUBLISH half rode SUBSCRIBE_NAMESPACE, which stays - refused), so nothing changes there. A FORWARD=1 listing holds no - subscription per track, only the listing reader's state, as on lite. -- Outbound, a hostile publisher can PUBLISH without bound. Each held paused - PUBLISH counts against the session's `announces` cap, as a lite listed track - does; past it, refuse the PUBLISH (EXCESSIVE_LOAD) rather than hold more. The - stream and request-ID credit we grant bounds what is in flight. -- Either way, PUBLISH and PUBLISH_DONE churn costs a stream per add and holds - only the live set, as on lite. - -Guidance: - -- TRACK_PROPERTY_FILTER and any MUST that adds cost without a behavioral - benefit may be refused or skipped; say which in the PR. -- Cover drafts 18 and later, plus `just test interop --all` and the IETF - passthrough suite where it exercises these messages. Update - `doc/concept/standard.md`, which lists SUBSCRIBE_TRACKS as refused. - -Test on mock time through a relay: a moq-transport subscriber's FORWARD=0 -SUBSCRIBE_TRACKS receives a paused PUBLISH per track, including a later track -and a later broadcast under the prefix, with no objects until it resumes one, -and a PUBLISH_SKIPPED past the subscription cap (PUBLISH_BLOCKED on -draft-18); one with FORWARD=1 or none receives a PUBLISH_SKIPPED per track and -no PUBLISH from draft-19 on, and its SUBSCRIBE to a listed track gets data, -while on draft-18 it gets REQUEST_ERROR; a lite TRACKS over a moq-transport upstream -lists its tracks with FORWARD=0, a lite SUBSCRIBE resumes the held PUBLISH -with its join filter and gets a new track's first group with no second -upstream SUBSCRIBE (on draft-18 and draft-19 on), a past start on draft-18 -and draft-19 gets the cached groups through a Joining FETCH, the last reader leaving -pauses it and keeps the entry, a PUBLISH_DONE with TRACK_ENDED removes the entry, and one with -another status resets the listing; a PUBLISH_SKIPPED (PUBLISH_BLOCKED on -draft-18) lists the track once its paused SUBSCRIBE answers, resuming it -gets the data, a TRACK_ENDED on that paused SUBSCRIBE removes it, -and a refused paused SUBSCRIBE resets the listing; TRACKS(`/a/b`) after -TRACKS(`/a`) on one session reuses the `/a` request, sends nothing, and -lists only `/a/b`'s tracks; TRACKS(`/a/b`) and TRACKS(`/a/c`) open two -requests and hold no track outside them, and a later TRACKS(`/a`) cancels -both and opens SUBSCRIBE_TRACKS(`/a`) only after the peer closes both -cancelled streams, even when it is slow to, keeping every held entry and -refusing any duplicate PUBLISH; when that SUBSCRIBE_TRACKS(`/a`) is refused, -the `/a`, `/a/b`, and `/a/c` listings all reset and re-request; a PUBLISH for a track we already -SUBSCRIBEd, established or pending, lists it through that SUBSCRIBE and is -refused (DUPLICATE_SUBSCRIPTION on draft-18, UNINTERESTED from draft-19), -leaving one upstream subscription; a moq-transport subscriber resuming our paused -PUBLISH gets the groups its filter asks for from the cache; a moq-transport subscriber gets -PUBLISH_DONE when a lite track is removed; an unsolicited PUBLISH is refused. - -Public API: none beyond the TRACKS hook. Wire: moq-transport support for an -existing message, a FORWARD=0 PUBLISH held as a listing entry and resumed -for data, and an -inbound listing that answers FORWARD=1 with skips rather than data. diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 1f96483fc23..dcbbb6d4e76 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -117,7 +117,7 @@ const IETF_VERSIONS: &[ietf::Version] = &[ const LITE_KINDS: u8 = 22; /// How many types [`ietf_wire`] dispatches over. -const IETF_KINDS: u8 = 40; +const IETF_KINDS: u8 = 41; /// Split the two selector bytes off the input: a version and a type. fn select(data: &[u8], versions: usize) -> Option<(usize, u8, &[u8])> { @@ -555,7 +555,7 @@ pub fn ietf_wire(data: &[u8]) -> bool { 14 => roundtrip::(rest, version), 15 => roundtrip::, _>(rest, version), 16 => roundtrip::, _>(rest, version), - 17 => roundtrip::, _>(rest, version), + 17 => roundtrip::, _>(rest, version), 18 => roundtrip::, _>(rest, version), 19 => roundtrip::(rest, version), 20 => roundtrip::, _>(rest, version), @@ -578,6 +578,7 @@ pub fn ietf_wire(data: &[u8]) -> bool { 37 => roundtrip::(rest, version), 38 => roundtrip::(rest, version), 39 => roundtrip::(rest, version), + 40 => roundtrip::, _>(rest, version), _ => unreachable!("kind is taken modulo IETF_KINDS"), } } diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index b9f67273b83..38a79297c23 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -1096,7 +1096,7 @@ fn classify(type_id: u64, body: &Bytes, version: Version, namespaces: &Namespace // SUBSCRIBE_TRACKS (draft-18+, #1542): the half of the SUBSCRIBE_NAMESPACE split // that subscribes to all tracks under a prefix. We don't implement PUBLISH // replication, so this is impossible to honor. Reject loudly. - ietf::SUBSCRIBE_TRACKS_ID => { + ietf::SubscribeTracks::ID => { tracing::error!( version = ?version, "received SUBSCRIBE_TRACKS (0x51); not supported in moq-lite (no PUBLISH replication)" @@ -1600,6 +1600,7 @@ mod tests { assert_eq!(shared.control.pop().await.unwrap(), first); let ok = ietf::RequestOk { + largest: None, request_id: Some(RequestId(6)), active: None, }; diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index e61120b6dcb..23e5abb67df 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -189,7 +189,14 @@ pub(crate) mod request { const TIMEOUT: u64 = 0x2; /// The endpoint does not implement this request at all, as opposed to refusing this one. - const NOT_SUPPORTED: u64 = 0x3; + pub(crate) const NOT_SUPPORTED: u64 = 0x3; + + /// A second subscription to a track the peer already subscribes to. Draft-16 and later. + pub(crate) const DUPLICATE_SUBSCRIPTION: u64 = 0x19; + + /// A SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS prefix overlapping an established one. + /// Draft-15 and later. + pub(crate) const PREFIX_OVERLAP: u64 = 0x30; /// A GOAWAY is draining the session, so no new request is accepted. Draft-17 and later. const GOING_AWAY: u64 = 0x6; diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 6b08cb6ea84..e1f93db94aa 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -45,6 +45,7 @@ mod session; pub mod solicit; mod subscribe; mod subscribe_namespace; +mod subscribe_tracks; #[cfg(feature = "async")] mod subscriber; pub(crate) mod token; @@ -73,6 +74,7 @@ pub use request::*; pub use session::*; pub use subscribe::*; pub use subscribe_namespace::*; +pub use subscribe_tracks::*; #[cfg(feature = "async")] use subscriber::*; pub use track::*; diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index 231fe8f1688..30d465b3aee 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -239,7 +239,12 @@ pub struct Publish<'a> { /// Metadata about the track, sent as Track Properties (draft-17+). pub properties: Properties, - // pub parameters: Parameters, + /// The subscription's SUBSCRIBER_PRIORITY (draft-20+), `None` when absent: 128. + pub subscriber_priority: Option, + /// The subscription's GROUP_ORDER (draft-20+), `None` when absent: the track's own. + pub group_order: Option, + /// The subscription's Location Filter (draft-20+), `None` when absent: unfiltered. + pub filter: Option, } impl Message for Publish<'_> { @@ -272,18 +277,24 @@ impl Message for Publish<'_> { w.u8(0); } _ => { - // GROUP_ORDER is a legal PUBLISH parameter only through draft-15; a later peer - // closes the session with PROTOCOL_VIOLATION when it sees one. The publisher's - // preference is a DEFAULT_PUBLISHER_GROUP_ORDER track property instead, which we - // write from draft-17 on. Draft-16 gets neither form; see Properties::encode. + // GROUP_ORDER is the publisher's preference on a draft-15 PUBLISH, illegal from + // draft-16 to draft-19 (a peer closes the session with PROTOCOL_VIOLATION), and + // from draft-20 one of the subscription's parameters, which moved here. The + // publisher's preference is a DEFAULT_PUBLISHER_GROUP_ORDER track property + // instead, which we write from draft-17 on. Draft-16 gets neither form; see + // Properties::encode. + let draft20 = Filter::is_draft20(version); let group_order = match version { Version::Draft15 => self.properties.group_order, + _ if draft20 => self.group_order, _ => None, }; encode_params!(w, version, 0x09 => self.largest_location, 0x10 => self.forward, + 0x20 => self.subscriber_priority.filter(|_| draft20), + 0x21 => self.filter.filter(|_| draft20), 0x22 => group_order, ); @@ -327,6 +338,9 @@ impl Message for Publish<'_> { group_order: Some(group_order), ..Default::default() }, + subscriber_priority: None, + group_order: None, + filter: None, }) } _ => { @@ -337,7 +351,7 @@ impl Message for Publish<'_> { // The draft-20 parameters have to parse even though reverse publishing is // unsupported: an unlisted parameter fails the whole message, which would // kill the session instead of letting the request reach NOT_SUPPORTED. - // OBJECT_DELIVERY_TIMEOUT is also legal in draft-15. The values are dropped. + // OBJECT_DELIVERY_TIMEOUT is also legal in draft-15. The timeouts are dropped. decode_params!(r, version, 0x02 => _object_delivery_timeout: Option where version == Version::Draft15 || Filter::is_draft20(version), 0x03 => authorization_token: Vec, @@ -345,14 +359,20 @@ impl Message for Publish<'_> { 0x08 => _expires: Option, 0x09 => largest_location: Option, 0x10 => forward: Option, - 0x20 => _subscriber_priority: Option where Filter::is_draft20(version), - 0x21 => _filter: Option where Filter::is_draft20(version), + 0x20 => subscriber_priority: Option where Filter::is_draft20(version), + 0x21 => filter: Option where Filter::is_draft20(version), 0x22 => group_order: Option where version == Version::Draft15 || Filter::is_draft20(version), ); let mut properties = Properties::decode(r, version)?; // Last, so a malformed field closes the session before a token refusal. super::token::single(authorization_token)?; - properties.group_order = properties.group_order.or(group_order); + // Draft-15's GROUP_ORDER is the publisher's preference; draft-20's is the + // subscription's. + let (publisher_order, group_order) = match version { + Version::Draft15 => (group_order, None), + _ => (None, group_order), + }; + properties.group_order = properties.group_order.or(publisher_order); let forward = forward.unwrap_or(true); @@ -364,6 +384,9 @@ impl Message for Publish<'_> { largest_location, forward, properties, + subscriber_priority, + group_order, + filter, }) } } @@ -374,10 +397,14 @@ impl Message for Publish<'_> { pub struct PublishOk { pub request_id: Option, pub forward: bool, - pub subscriber_priority: u8, - pub group_order: GroupOrder, - pub filter: Filter, - // pub parameters: Parameters, + /// SUBSCRIBER_PRIORITY, `None` when absent: the subscription keeps the one it had. + pub subscriber_priority: Option, + /// GROUP_ORDER (through draft-18), `None` when absent. + pub group_order: Option, + /// The Location Filter (through draft-19), `None` when absent. + pub filter: Option, + /// NEW_GROUP_REQUEST (drafts 16 to 19), which a dynamic track judges as a join. + pub new_group: Option, } impl Message for PublishOk { @@ -395,11 +422,11 @@ impl Message for PublishOk { match version { Version::Draft14 => { w.bool(self.forward); - w.u8(self.subscriber_priority); - self.group_order.encode(w, version)?; + w.u8(self.subscriber_priority.unwrap_or(128)); + self.group_order.unwrap_or(GroupOrder::Descending).encode(w, version)?; // Same as SUBSCRIBE: the Location an absolute filter carries is dropped on // decode, so encoding one would truncate the message. - self.filter.encode(w, version)?; + self.filter.unwrap_or(Filter::Unfiltered).encode(w, version)?; // no parameters w.u8(0); } @@ -413,12 +440,20 @@ impl Message for PublishOk { version, Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 ) - .then_some(self.group_order); + .then_some(self.group_order) + .flatten(); + let new_group = matches!( + version, + Version::Draft16 | Version::Draft17 | Version::Draft18 | Version::Draft19 + ) + .then_some(self.new_group) + .flatten(); encode_params!(w, version, 0x10 => self.forward, 0x20 => self.subscriber_priority, 0x21 => self.filter, 0x22 => group_order, + 0x32 => new_group, ); } } @@ -446,15 +481,15 @@ impl Message for PublishOk { Ok(Self { request_id, forward, - subscriber_priority, - group_order, - filter, + subscriber_priority: Some(subscriber_priority), + group_order: Some(group_order), + filter: Some(filter), + new_group: None, }) } _ => { - // EXPIRES is ignored, as in SUBSCRIBE_OK. Delivery timeouts and - // NEW_GROUP_REQUEST are legal on the drafts that list them and dropped: - // nothing here acts on a publisher's timeout. Draft-20 moved the + // EXPIRES is ignored, as in SUBSCRIBE_OK. Delivery timeouts are legal on the + // drafts that list them and dropped: nothing here acts on a publisher's timeout. Draft-20 moved the // subscription parameters off PUBLISH_OK, and draft-19 already dropped // GROUP_ORDER. decode_params!(r, version, @@ -465,20 +500,16 @@ impl Message for PublishOk { 0x20 => subscriber_priority: Option where !Filter::is_draft20(version), 0x21 => filter: Option where !Filter::is_draft20(version), 0x22 => group_order: Option where matches!(version, Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18), - 0x32 => _new_group_request: Option where matches!(version, Version::Draft16 | Version::Draft17 | Version::Draft18 | Version::Draft19), + 0x32 => new_group: Option where matches!(version, Version::Draft16 | Version::Draft17 | Version::Draft18 | Version::Draft19), ); - let forward = forward.unwrap_or(true); - let subscriber_priority = subscriber_priority.unwrap_or(128); - let group_order = group_order.unwrap_or(GroupOrder::Descending); - let filter = filter.unwrap_or(Filter::Unfiltered); - Ok(Self { request_id, - forward, + forward: forward.unwrap_or(true), subscriber_priority, group_order, filter, + new_group, }) } } @@ -584,6 +615,9 @@ mod tests { group_order: Some(GroupOrder::Descending), ..Default::default() }, + subscriber_priority: None, + group_order: None, + filter: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -610,6 +644,9 @@ mod tests { group_order: Some(GroupOrder::Descending), ..Default::default() }, + subscriber_priority: None, + group_order: None, + filter: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -628,9 +665,10 @@ mod tests { let msg = PublishOk { request_id: Some(RequestId(7)), forward: true, - subscriber_priority: 128, - group_order: GroupOrder::Descending, - filter: Filter::NextObject, + subscriber_priority: Some(128), + group_order: Some(GroupOrder::Descending), + filter: Some(Filter::NextObject), + new_group: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -638,7 +676,7 @@ mod tests { assert_eq!(decoded.request_id, Some(RequestId(7))); assert!(decoded.forward); - assert_eq!(decoded.subscriber_priority, 128); + assert_eq!(decoded.subscriber_priority, Some(128)); } #[test] @@ -646,9 +684,10 @@ mod tests { let msg = PublishOk { request_id: Some(RequestId(7)), forward: true, - subscriber_priority: 128, - group_order: GroupOrder::Descending, - filter: Filter::NextObject, + subscriber_priority: Some(128), + group_order: Some(GroupOrder::Descending), + filter: Some(Filter::NextObject), + new_group: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -656,7 +695,7 @@ mod tests { assert_eq!(decoded.request_id, Some(RequestId(7))); assert!(decoded.forward); - assert_eq!(decoded.subscriber_priority, 128); + assert_eq!(decoded.subscriber_priority, Some(128)); } #[test] @@ -672,6 +711,9 @@ mod tests { group_order: Some(GroupOrder::Descending), ..Default::default() }, + subscriber_priority: None, + group_order: None, + filter: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -690,9 +732,10 @@ mod tests { let msg = PublishOk { request_id: None, forward: true, - subscriber_priority: 128, - group_order: GroupOrder::Descending, - filter: Filter::NextObject, + subscriber_priority: Some(128), + group_order: Some(GroupOrder::Descending), + filter: Some(Filter::NextObject), + new_group: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -700,7 +743,7 @@ mod tests { assert_eq!(decoded.request_id, None); assert!(decoded.forward); - assert_eq!(decoded.subscriber_priority, 128); + assert_eq!(decoded.subscriber_priority, Some(128)); } fn publish_with_param(version: Version, key: u64, value: u64) -> Vec { @@ -832,6 +875,9 @@ mod tests { group_order: Some(GroupOrder::Descending), ..Default::default() }, + subscriber_priority: None, + group_order: None, + filter: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -866,6 +912,9 @@ mod tests { largest_location: None, forward: true, properties: Properties::default(), + subscriber_priority: None, + group_order: None, + filter: None, }; let baseline = encode_message(&msg, version); msg.properties.priority = Some(37); @@ -906,6 +955,9 @@ mod tests { priority: None, group_order: Some(GroupOrder::Descending), }, + subscriber_priority: None, + group_order: None, + filter: None, }; #[rustfmt::skip] @@ -944,6 +996,9 @@ mod tests { priority: None, group_order: Some(GroupOrder::Descending), }, + subscriber_priority: None, + group_order: None, + filter: None, }; #[rustfmt::skip] @@ -978,6 +1033,9 @@ mod tests { group_order: Some(GroupOrder::Descending), ..Default::default() }, + subscriber_priority: None, + group_order: None, + filter: None, }; let v17 = encode_message(&msg, Version::Draft17); @@ -990,9 +1048,10 @@ mod tests { let msg = PublishOk { request_id: None, forward: true, - subscriber_priority: 128, - group_order: GroupOrder::Descending, - filter: Filter::NextObject, + subscriber_priority: Some(128), + group_order: Some(GroupOrder::Descending), + filter: Some(Filter::NextObject), + new_group: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1000,7 +1059,7 @@ mod tests { assert_eq!(decoded.request_id, None); assert!(decoded.forward); - assert_eq!(decoded.subscriber_priority, 128); + assert_eq!(decoded.subscriber_priority, Some(128)); } /// Draft-18 lets PUBLISH_OK carry EXPIRES; it is ignored rather than rejected. @@ -1013,6 +1072,36 @@ mod tests { assert!(decoded.forward); } + /// An absent parameter stays absent, so the subscription keeps the one it had: draft-19 + /// has no GROUP_ORDER on PUBLISH_OK at all. + #[test] + fn test_publish_ok_absent_parameters_stay_absent() { + // No parameters. + let decoded: PublishOk = decode_message(&[0x00], Version::Draft19).unwrap(); + assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, None); + assert_eq!(decoded.group_order, None); + assert_eq!(decoded.filter, None); + assert_eq!(decoded.new_group, None); + } + + /// NEW_GROUP_REQUEST is kept on the drafts that allow it on PUBLISH_OK. + #[test] + fn test_publish_ok_carries_new_group_request() { + for version in [Version::Draft18, Version::Draft19] { + let msg = PublishOk { + request_id: None, + forward: true, + subscriber_priority: None, + group_order: None, + filter: None, + new_group: Some(9), + }; + let decoded: PublishOk = decode_message(&encode_message(&msg, version), version).unwrap(); + assert_eq!(decoded.new_group, Some(9), "{version}"); + } + } + #[test] fn test_publish_done_preserves_unknown_status() { const UNKNOWN: u64 = 0xface; diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 7c75b1a667e..3f98a8c421d 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -16,7 +16,7 @@ use crate::transport::poll::{GroupOf, SendOptions}; use crate::{ AsPath, DecodeError, Error, Timescale, Timestamp, - coding::{Decode as _, Encode as _, Encoder, Stream, Writer}, + coding::{Encode as _, Encoder, Stream, Writer}, ietf::{self, Control, EndLocation, FetchHeader, FetchType, Filter, GroupOrder, Location, RequestId}, priority::{Priority, PriorityHandle, PriorityQueue}, track::{Order, Subscription}, @@ -25,6 +25,8 @@ use crate::{ use super::{Message, Version, cluster, error::request, peer, request_update}; +mod tracks; + /// Largest millisecond duration every implementation can carry losslessly. const MAX_SAFE_DELAY_MS: u64 = super::max_age::MAX_MILLIS; @@ -527,6 +529,21 @@ enum Served { /// (drafts 16 section 9.11.1, 18 section 10.9.1). The update was already answered, and /// PUBLISH_DONE says UPDATE_FAILED, or on draft 14 the refusal itself. UpdateFailed(Error), + /// A REQUEST_UPDATE set FORWARD=0 on a subscription we initiated with PUBLISH, which + /// stays established, paused. The update is not answered yet. + Paused, +} + +/// What reading a subscriber's next message on its request stream found. +enum UpdateRead { + /// A REQUEST_UPDATE, already decoded. + Update(super::request_stream::Update), + /// The subscriber cancelled. + Cancelled, + /// A malformed message, which a protocol violation makes the session's to report. + Malformed(Error), + /// The update named a Request ID we never granted; the session is already closing. + Failed(Error), } /// How an admitted subscription came out of serving. @@ -557,6 +574,8 @@ impl Served { } Self::Track(Err(_)) => (ietf::PublishDoneStatus::InternalError, "internal error"), Self::UpdateFailed(_) => (ietf::PublishDoneStatus::UpdateFailed, "update failed"), + // Pausing ends no subscription, so nothing reports it. + Self::Paused => (ietf::PublishDoneStatus::InternalError, "paused"), } } } @@ -659,6 +678,8 @@ pub(super) struct Publisher { pub(super) fetches: crate::session::Slots, // Ranks every subscription's group streams against each other for the transport. priority: PriorityQueue, + // The peer's SUBSCRIBE_TRACKS requests and the PUBLISHes they made. + listings: kio::Shared, } /// Authorization selected by dispatch: a standalone path or a live parent subscription. @@ -740,6 +761,7 @@ where subscriptions: Default::default(), fetches: Default::default(), priority: Default::default(), + listings: Default::default(), } } @@ -965,28 +987,25 @@ where } .maybe_boxed() } - // SUBSCRIBE_TRACKS asks for every track under a prefix via PUBLISH, which we - // never send. Read its prefix to recognize reserved namespaces before refusing. - ietf::SUBSCRIBE_TRACKS_ID - if !matches!( - this.version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) => - { - let request_id = RequestId::decode(&mut data, this.version)?; - let namespace = super::namespace::decode_namespace(&mut data)?; - if let Some(error) = super::namespace::refusal(&namespace, this.version) { - return Ok(async move { - if let Err(err) = super::request_stream::reject(stream, error).await { - tracing::debug!(%err, "reserved namespace refusal failed"); + ietf::SubscribeTracks::ID if super::subscribe_tracks::has_subscribe_tracks(this.version) => { + let msg = match ietf::SubscribeTracks::decode_msg(&mut data, this.version) { + Err(err) if super::token::refuses_request(&err) => { + // Junk after the fields is fatal, which a token refusal must not hide. + if !data.is_empty() { + return Err(Error::WrongSize); } + let kind = request::Kind::SubscribeNamespace; + return Ok(this.reject_token(stream, kind, body.clone(), err, serve)); } - .maybe_boxed()); + msg => msg?, + }; + if !data.is_empty() { + return Err(Error::WrongSize); } + tracing::debug!(message = ?msg, "received subscribe_tracks"); async move { - let reason = "SUBSCRIBE_TRACKS is not supported"; - if let Err(err) = this.reject_namespace_request(stream, request_id, reason).await { - tracing::debug!(%err, "subscribe_tracks refusal failed"); + if let Err(err) = this.serve_subscribe_tracks_stream(stream, msg).await { + tracing::debug!(%err, "subscribe_tracks stream error"); } } .maybe_boxed() @@ -1077,6 +1096,26 @@ where if let Some(error) = super::namespace::refusal(&msg.track_namespace, self.version) { return super::request_stream::reject(stream, error).await; } + // Draft-18 allows one subscription per track and role (section 5.1), so a track a + // SUBSCRIBE_TRACKS PUBLISHed is subscribed already, and one subscribed here is not + // PUBLISHed. Later drafts dropped the rule. + let _subscribed = match self.version { + Version::Draft18 => { + match tracks::Subscribed::subscribe(&self.listings, &msg.track_namespace, &msg.track_name) { + Some(subscribed) => Some(subscribed), + None => { + let error = ietf::RequestError { + request_id: None, + error_code: request::DUPLICATE_SUBSCRIPTION, + reason_phrase: "the track is PUBLISHed to this session".into(), + retry_interval: 0, + }; + return super::request_stream::reject(stream, error).await; + } + } + } + _ => None, + }; let request_id = msg.request_id; let track_name = msg.track_name.clone(); let absolute = self.origin.absolute(&msg.track_namespace).to_owned(); @@ -1240,7 +1279,7 @@ where // join: bound the subscription before subscribing so the ratio never judges it. // From the start of its group, so a relay's copy holds the group's head for // its other readers, as a live join would; the resolved range trims below. - let fixed = subscribe_range(&msg, LiveEdge::default(), self.version); + let fixed = subscribe_range(msg.filter, LiveEdge::default(), self.version); if let Some(start) = fixed.start { ServeRange { start: Some(Location { object: 0, ..start }), @@ -1272,7 +1311,7 @@ where // lets the draft's current-group join (Next Object plus a StartGroup=1 fill) // cover the group with no gap and no overlap. let edge = live_edge(&cache); - let range = subscribe_range(&msg, edge, self.version); + let range = subscribe_range(msg.filter, edge, self.version); range.apply(&mut subscription); let _ = track.update(subscription); // A Timestamp goes out wherever the draft can declare its units and the track @@ -1375,39 +1414,9 @@ where let completed = served.is_some(); let served = served.unwrap_or(Served::Track(Ok(()))); - // Draft-14 on carries no end location in PUBLISH_DONE: an END_OF_TRACK object - // is what tells the subscriber where the track ended. A cancelled subscription - // is owed nothing more, and one cancelled while the marker waits for stream - // credit abandons it. - if completed - && matches!(served, Served::Track(Ok(()))) - && let Some(end) = track_serve.end() - { - let mut marker = std::pin::pin!(track_serve.write_end_of_track(end)); - let mut closed_session = self.session.clone(); - let written = kio::wait(|waiter| { - if let Poll::Ready(res) = waiter.poll_future(marker.as_mut()) { - return Poll::Ready(res); - } - let mut cx = waiter.context(); - if super::request_stream::poll_cancel( - &mut stream, - &mut request_finished, - &mut goaways, - self.version, - &mut cx, - ) - .is_ready() || closed_session.poll_closed(&mut cx).is_ready() - { - return Poll::Ready(Err(Error::Cancel)); - } - Poll::Pending - }) - .await; - // A failure only costs the subscriber the early boundary. - if let Err(err) = written { - tracing::debug!(%err, id = %request_id, "end of track failed"); - } + if completed && matches!(served, Served::Track(Ok(()))) { + self.write_end_of_track(&mut stream, &mut track_serve, &mut request_finished, &mut goaways) + .await; } // PUBLISH_DONE must follow the close of every data stream. A subscription that @@ -1451,32 +1460,80 @@ where } }, }; - let (status, reason) = served.status(self.version); - let res = match served { - Served::Track(res) => res, - Served::UpdateFailed(err) => Err(err), - }; + self.write_publish_done(stream, request_id, served, streams).await + } + } - // Send PublishDone - let _ = stream.writer.varint(ietf::PublishDone::ID).await; - let _ = stream - .writer - .encode(&ietf::PublishDone { - request_id: match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(request_id), - _ => None, - }, - status_code: status.code(self.version), - stream_count: streams, - reason_phrase: reason.into(), - }) - .await; + /// Send the END_OF_TRACK a subscription that ran to the track's end still owes: draft-14 + /// on carries no end location in PUBLISH_DONE, so the marker is what tells the subscriber + /// where the track ended. One cancelled while the marker waits for stream credit abandons + /// it. + async fn write_end_of_track( + &self, + stream: &mut Stream, + track_serve: &mut TrackServe, + request_finished: &mut bool, + goaways: &mut super::request_stream::Messages, + ) { + let Some(end) = track_serve.end() else { + return; + }; + let request_id = track_serve.request_id; + let mut marker = std::pin::pin!(track_serve.write_end_of_track(end)); + let mut closed_session = self.session.clone(); + let written = kio::wait(|waiter| { + if let Poll::Ready(res) = waiter.poll_future(marker.as_mut()) { + return Poll::Ready(res); + } + let mut cx = waiter.context(); + if super::request_stream::poll_cancel(stream, request_finished, goaways, self.version, &mut cx).is_ready() + || closed_session.poll_closed(&mut cx).is_ready() + { + return Poll::Ready(Err(Error::Cancel)); + } + Poll::Pending + }) + .await; + // A failure only costs the subscriber the early boundary. + if let Err(err) = written { + tracing::debug!(%err, id = %request_id, "end of track failed"); + } + } - // PUBLISH_DONE is the last thing on this stream, so it needs the acknowledgement too. - let _ = stream.writer.close().await; + /// End a subscription on the wire with the PUBLISH_DONE `served` reports, after `streams` + /// group streams, returning how serving ended. + async fn write_publish_done( + &self, + mut stream: Stream, + request_id: RequestId, + served: Served, + streams: u64, + ) -> Result<(), Error> { + let (status, reason) = served.status(self.version); + let res = match served { + Served::Track(res) => res, + Served::UpdateFailed(err) => Err(err), + Served::Paused => Ok(()), + }; - res - } + let _ = stream.writer.varint(ietf::PublishDone::ID).await; + let _ = stream + .writer + .encode(&ietf::PublishDone { + request_id: match self.version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(request_id), + _ => None, + }, + status_code: status.code(self.version), + stream_count: streams, + reason_phrase: reason.into(), + }) + .await; + + // PUBLISH_DONE is the last thing on this stream, so it needs the acknowledgement too. + let _ = stream.writer.close().await; + + res } async fn run_subscription( @@ -1558,35 +1615,12 @@ where Poll::Pending => {} } } else { - use super::request_stream::{Next, Update}; - while !*finished { - match stream.reader.poll_decode_maybe::>(&mut cx) { - Poll::Ready(Ok(Some(Next::Message(mut update)))) => { - if let Err(err) = self.control.accept(update.request_id) { - if matches!(err, Error::GoingAway) { - update.refused = Some(err); - return Poll::Ready(Turn::Update(update)); - } - session.clone().close( - super::error::to_session_code(&crate::SessionError::from(&err)), - "invalid request ID", - ); - return Poll::Ready(Turn::Served(Err(err))); - } - return Poll::Ready(Turn::Update(update)); - } - Poll::Ready(Ok(Some(Next::GoAway { redirect }))) => { - if let Err(err) = goaways.receive(redirect) { - return Poll::Ready(Turn::Malformed(err)); - } - } - Poll::Ready(Ok(None)) if !super::request_stream::fin_cancels(self.version) => { - *finished = true - } - Poll::Ready(Ok(None)) => return Poll::Ready(Turn::Cancelled), - Poll::Ready(Err(err)) => return Poll::Ready(Turn::Malformed(err)), - Poll::Pending => break, - } + match self.poll_update(stream, finished, goaways, &mut cx) { + Poll::Ready(UpdateRead::Update(update)) => return Poll::Ready(Turn::Update(update)), + Poll::Ready(UpdateRead::Cancelled) => return Poll::Ready(Turn::Cancelled), + Poll::Ready(UpdateRead::Malformed(err)) => return Poll::Ready(Turn::Malformed(err)), + Poll::Ready(UpdateRead::Failed(err)) => return Poll::Ready(Turn::Served(Err(err))), + Poll::Pending => {} } } } @@ -1636,7 +1670,7 @@ where // On accept the old grant is dropped (ending the old token) and the deadline // re-armed at the new expiry. let Err(refusal) = rg.renew(verdict, res) else { - if answer && let Err(err) = self.write_request_ok(&mut stream.writer, rid).await { + if answer && let Err(err) = self.write_request_ok(&mut stream.writer, rid, None).await { return Some(Served::Track(Err(err))); } continue; @@ -1678,6 +1712,45 @@ where } } + /// Read the requester's next REQUEST_UPDATE on a draft-17+ request stream, admitting its + /// Request ID. Pending once the requester finished sending where a FIN does not cancel. + fn poll_update( + &self, + stream: &mut Stream, + finished: &mut bool, + goaways: &mut super::request_stream::Messages, + cx: &mut std::task::Context<'_>, + ) -> Poll { + use super::request_stream::{Next, Update}; + while !*finished { + match ready!(stream.reader.poll_decode_maybe::>(cx)) { + Ok(Some(Next::Message(mut update))) => { + if let Err(err) = self.control.accept(update.request_id) { + if matches!(err, Error::GoingAway) { + update.refused = Some(err); + return Poll::Ready(UpdateRead::Update(update)); + } + self.session.clone().close( + super::error::to_session_code(&crate::SessionError::from(&err)), + "invalid request ID", + ); + return Poll::Ready(UpdateRead::Failed(err)); + } + return Poll::Ready(UpdateRead::Update(update)); + } + Ok(Some(Next::GoAway { redirect })) => { + if let Err(err) = goaways.receive(redirect) { + return Poll::Ready(UpdateRead::Malformed(err)); + } + } + Ok(None) if !super::request_stream::fin_cancels(self.version) => *finished = true, + Ok(None) => return Poll::Ready(UpdateRead::Cancelled), + Err(err) => return Poll::Ready(UpdateRead::Malformed(err)), + } + } + Poll::Pending + } + /// Apply one REQUEST_UPDATE on an in-flight subscription, with no verdict pending: refuse /// unsupported parameters, apply a priority, then start verifying a renewal's token, or /// answer at once. A token-less update, or any update on a union-authorized request (which @@ -1704,6 +1777,15 @@ where Some(Error::Decode(err)) => Some((super::token::reason(&err), Error::Decode(err))), Some(Error::GoingAway) => Some(("going away", Error::GoingAway)), Some(err) => Some(("REQUEST_UPDATE parameters not supported", err)), + // A SUBSCRIBE serves a forwarding subscription over the range it asked for. Only a + // subscription we initiated with PUBLISH pauses and resumes, and it decodes those + // parameters itself. + None if update.filter.is_some() + || update.fill.is_some() + || (update.forward == Some(false) && !serve.pausable) => + { + Some(("REQUEST_UPDATE parameters not supported", Error::Unsupported)) + } None if update.authorization_token.is_some() && request_grant.is_none() && self.auth.token_decides(crate::auth::Direction::Publish) => @@ -1714,10 +1796,14 @@ where )) } // The draft forbids it on a track that did not advertise DYNAMIC_GROUPS. - None if update.new_group.is_some() && serve.track.info().refresh.is_none() => Some(( + None if update.new_group.is_some() && !serve.dynamic => Some(( "NEW_GROUP_REQUEST on a track without DYNAMIC_GROUPS", Error::Unsupported, )), + // A paused subscription opens no groups, so one is asked for only as it resumes. + None if update.new_group.is_some() && update.forward == Some(false) => { + Some(("NEW_GROUP_REQUEST while pausing", Error::Unsupported)) + } None => None, }; if let Some((reason, err)) = refused { @@ -1742,19 +1828,24 @@ where if let Some(value) = update.new_group { serve.request_new_group(value); } + // The caller stops forwarding, then answers. + if update.forward == Some(false) { + return Err(Served::Paused); + } if let (Some(token), Some(grant)) = (update.authorization_token, request_grant) { renewal.verify(grant.verify_renewal(&self.auth, token.0), update.request_id); return Ok(()); } if answer { - self.write_request_ok(&mut stream.writer, update.request_id) + self.write_request_ok(&mut stream.writer, update.request_id, None) .await .map_err(failed)?; } Ok(()) } - /// Answer a REQUEST_UPDATE with REQUEST_OK. + /// Answer a REQUEST_UPDATE with REQUEST_OK, carrying the `largest` Location a resumed + /// subscription now starts from. /// /// Drafts 15 and 16 answer on the control stream, naming the update's own Request ID; /// later drafts name none. Draft-14 answers nothing and never reaches here. @@ -1762,10 +1853,12 @@ where &self, writer: &mut Writer, request_id: RequestId, + largest: Option, ) -> Result<(), Error> { writer.buffer_varint(ietf::RequestOk::ID)?; writer .encode(&ietf::RequestOk { + largest, request_id: matches!(self.version, Version::Draft15 | Version::Draft16).then_some(request_id), active: None, }) @@ -3789,6 +3882,7 @@ where stream .writer .encode(&ietf::RequestOk { + largest: None, request_id: Some(msg.request_id), active, }) @@ -3799,6 +3893,7 @@ where stream .writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active, }) @@ -4043,6 +4138,7 @@ where stream .writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) @@ -4192,6 +4288,11 @@ struct TrackServe { datagrams: bool, /// A track that always has another group ready still lets the session's other tasks run. budget: kio::coop::Budget, + /// A subscription we initiated with PUBLISH, which a REQUEST_UPDATE may pause. + pausable: bool, + /// Whether the track advertised DYNAMIC_GROUPS, which an update's NEW_GROUP_REQUEST + /// needs. + dynamic: bool, } impl TrackServe { @@ -4217,6 +4318,7 @@ impl TrackServe { // never write TIMESCALE, whatever the caller passed in, so datagrams and group // objects on those drafts go out unstamped. let timescale = timescale.filter(|_| ietf::Properties::sends_timescale(version)); + let dynamic = track.info().refresh.is_some(); let priority = kio::Producer::new(track.subscription().priority); let mut session = session; let ranking = Ranking { @@ -4244,6 +4346,8 @@ impl TrackServe { end: None, terminal: Default::default(), budget: kio::coop::Budget::new(32), + pausable: false, + dynamic, } } @@ -11488,6 +11592,7 @@ mod serve_tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: matches!(version, Version::Draft15 | Version::Draft16).then_some(request_id), active: None, }) @@ -12083,6 +12188,7 @@ mod serve_tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) @@ -13275,6 +13381,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: Some(1), }) @@ -13539,6 +13646,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: Some(RequestId(1)), active: None, }) @@ -13550,6 +13658,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) @@ -13774,6 +13883,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: Some(0), }) @@ -14460,6 +14570,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: Some(0), }) @@ -15879,15 +15990,15 @@ struct GroupSlice { /// /// Next Object is honored on every draft so a joining FETCH owns the cached prefix. /// Other pre-draft-20 filters retain their existing serving behavior. -fn subscribe_range(msg: &ietf::Subscribe<'_>, edge: LiveEdge, version: Version) -> ServeRange { - if !Filter::is_draft20(version) && msg.filter != Filter::NextObject { - if !matches!(msg.filter, Filter::NextObject | Filter::Unfiltered) { - tracing::warn!(filter = ?msg.filter, "filter not supported before draft-20, ignoring"); +fn subscribe_range(filter: Filter, edge: LiveEdge, version: Version) -> ServeRange { + if !Filter::is_draft20(version) && filter != Filter::NextObject { + if !matches!(filter, Filter::NextObject | Filter::Unfiltered) { + tracing::warn!(?filter, "filter not supported before draft-20, ignoring"); } return ServeRange::default(); } - filter_range(msg.filter, edge) + filter_range(filter, edge) } /// The Locations a single Location Filter selects, resolved against the live edge. @@ -16080,7 +16191,10 @@ mod range_tests { start: Location { group: 4, object: 0 }, end: Some(EndLocation { group: 9, object: None }), }); - assert_eq!(subscribe_range(&msg, EDGE, Version::Draft19), ServeRange::default()); + assert_eq!( + subscribe_range(msg.filter, EDGE, Version::Draft19), + ServeRange::default() + ); } /// An absent filter is "no restriction on what is forwarded", not a request for @@ -16088,7 +16202,10 @@ mod range_tests { #[test] fn an_unfiltered_subscription_stays_live() { let msg = subscribe(Filter::Unfiltered); - assert_eq!(subscribe_range(&msg, EDGE, Version::Draft20), ServeRange::default()); + assert_eq!( + subscribe_range(msg.filter, EDGE, Version::Draft20), + ServeRange::default() + ); } /// Next Object starts one past the Largest Object, mid-group. Everything below it, @@ -16097,7 +16214,7 @@ mod range_tests { fn next_object_starts_past_the_largest_object() { let msg = subscribe(Filter::NextObject); assert_eq!( - subscribe_range(&msg, EDGE, Version::Draft20), + subscribe_range(msg.filter, EDGE, Version::Draft20), ServeRange { start: Some(Location { group: 100, object: 5 }), end: None, @@ -16116,7 +16233,7 @@ mod range_tests { }; let msg = subscribe(Filter::NextObject); assert_eq!( - subscribe_range(&msg, edge, Version::Draft20), + subscribe_range(msg.filter, edge, Version::Draft20), ServeRange { start: Some(Location { group: 101, object: 0 }), end: None, @@ -16131,7 +16248,7 @@ mod range_tests { for (groups, expected) in [(0, 101), (1, 100), (2, 99), (5, 96)] { let msg = subscribe(Filter::Relative(groups)); assert_eq!( - subscribe_range(&msg, EDGE, Version::Draft20), + subscribe_range(msg.filter, EDGE, Version::Draft20), ServeRange { start: Some(Location { group: expected, @@ -16155,7 +16272,7 @@ mod range_tests { }; let msg = subscribe(Filter::Relative(1)); assert_eq!( - subscribe_range(&msg, edge, Version::Draft20), + subscribe_range(msg.filter, edge, Version::Draft20), ServeRange { start: Some(Location { group: 0, object: 0 }), end: None, @@ -16168,7 +16285,7 @@ mod range_tests { fn relative_saturates_at_the_start() { let msg = subscribe(Filter::Relative(500)); assert_eq!( - subscribe_range(&msg, EDGE, Version::Draft20), + subscribe_range(msg.filter, EDGE, Version::Draft20), ServeRange { start: Some(Location { group: 0, object: 0 }), end: None, @@ -16181,7 +16298,7 @@ mod range_tests { fn relative_without_an_edge_stays_live() { let msg = subscribe(Filter::Relative(3)); assert_eq!( - subscribe_range(&msg, LiveEdge::default(), Version::Draft20), + subscribe_range(msg.filter, LiveEdge::default(), Version::Draft20), ServeRange::default() ); } @@ -16255,7 +16372,7 @@ mod range_tests { }), }); assert_eq!( - subscribe_range(&msg, EDGE, Version::Draft20), + subscribe_range(msg.filter, EDGE, Version::Draft20), ServeRange { start: Some(Location { group: 4, object: 3 }), end: Some(EndLocation { diff --git a/rs/moq-net/src/ietf/publisher/tracks.rs b/rs/moq-net/src/ietf/publisher/tracks.rs new file mode 100644 index 00000000000..888637e605e --- /dev/null +++ b/rs/moq-net/src/ietf/publisher/tracks.rs @@ -0,0 +1,2285 @@ +//! Answers a moq-transport SUBSCRIBE_TRACKS (draft-18+) through the TRACKS hook. +//! +//! Every track of every broadcast under the request's prefix is listed, later tracks and +//! broadcasts included, but no track's data is pushed unasked. A FORWARD=0 request gets a +//! paused PUBLISH per track, which carries the track's properties and an error PUBLISH_DONE +//! once the track is no longer listed; the subscriber resumes one with REQUEST_UPDATE to get its +//! data, served from the cache as a SUBSCRIBE would be. A FORWARD=1 request asks for every +//! track's data, which no draft lets us downgrade, so from draft-19 each track is answered +//! with PUBLISH_SKIPPED and the subscriber SUBSCRIBEs to what it wants. Draft-18 has no such +//! skip, so there the request is refused. + +use std::collections::HashSet; + +use hashlink::LinkedHashMap; + +use super::*; +use crate::util::TaskSet; + +/// The session's SUBSCRIBE_TRACKS bookkeeping, shared by its requests. +#[derive(Default)] +pub(super) struct Listings { + /// The prefix of every established SUBSCRIBE_TRACKS, by a key its guard removes. + prefixes: HashMap, + next: u64, + /// On draft-18, the tracks a PUBLISH of ours subscribes the peer to, and how many + /// SUBSCRIBEs of the peer's each track has: draft-18 allows the peer one subscription + /// per track (section 5.1), so a PUBLISH joins neither kind. + published: HashMap<(crate::PathOwned, Arc), usize>, + subscribed: HashMap<(crate::PathOwned, Arc), usize>, +} + +/// Holds a SUBSCRIBE_TRACKS prefix as established until dropped. +struct Established { + listings: kio::Shared, + key: u64, +} + +impl Established { + /// Establish `prefix`, unless it overlaps an established request (section 10.19): two + /// would match the same namespaces, and keeping them apart is also what lists each + /// broadcast at most once per session. + fn new(listings: &kio::Shared, prefix: &crate::PathOwned) -> Option { + let mut state = listings.lock(); + if state + .prefixes + .values() + .any(|other| other.has_prefix(prefix) || prefix.has_prefix(other)) + { + return None; + } + let key = state.next; + state.next += 1; + state.prefixes.insert(key, prefix.clone()); + Some(Self { + listings: listings.clone(), + key, + }) + } +} + +impl Drop for Established { + fn drop(&mut self) { + self.listings.lock().prefixes.remove(&self.key); + } +} + +/// Counts one subscription to a track, a PUBLISH of ours or a SUBSCRIBE of the peer's, +/// until dropped. +pub(super) struct Subscribed { + listings: kio::Shared, + key: (crate::PathOwned, Arc), + /// A SUBSCRIBE of the peer's, rather than a PUBLISH of ours. + direct: bool, +} + +impl Subscribed { + /// Claim `namespace`'s `track` for a PUBLISH of ours, unless the peer is subscribed to + /// it already, by its SUBSCRIBE or another PUBLISH. + fn publish(listings: &kio::Shared, namespace: crate::PathOwned, track: Arc) -> Option { + Self::claim(listings, (namespace, track), false) + } + + /// Count the peer's SUBSCRIBE to `namespace`'s `track`, unless a PUBLISH of ours + /// subscribes it already. + pub(super) fn subscribe(listings: &kio::Shared, namespace: &crate::Path, track: &str) -> Option { + Self::claim(listings, (namespace.to_owned(), Arc::from(track)), true) + } + + fn claim(listings: &kio::Shared, key: (crate::PathOwned, Arc), direct: bool) -> Option { + let mut locked = listings.lock(); + let Listings { + published, subscribed, .. + } = &mut *locked; + let (mine, other) = if direct { + (subscribed, published) + } else { + (published, subscribed) + }; + if other.contains_key(&key) || (!direct && mine.contains_key(&key)) { + return None; + } + *mine.entry(key.clone()).or_default() += 1; + Some(Self { + listings: listings.clone(), + key, + direct, + }) + } +} + +impl Drop for Subscribed { + fn drop(&mut self) { + let mut listings = self.listings.lock(); + let counts = if self.direct { + &mut listings.subscribed + } else { + &mut listings.published + }; + if let Some(count) = counts.get_mut(&self.key) { + *count -= 1; + if *count == 0 { + counts.remove(&self.key); + } + } + } +} + +/// The subscription parameters a PUBLISH subscription has, starting from those of the +/// SUBSCRIBE_TRACKS that made it (draft-19 section 10.19.1) and moved by each update. +#[derive(Clone, Copy)] +struct Params { + priority: u8, + order: Option, + filter: Filter, + max_age: Option, + /// NEW_GROUP_REQUEST, applied on the first resume. + new_group: Option, +} + +/// The listed tracks a request answers with PUBLISH_SKIPPED and has not sent yet, keyed by +/// (suffix, name) in listing order. A track removed before its skip went out cancels it, +/// so a peer that stops reading holds at most one per listed track. +type Skips = kio::Shared), ()>>; + +/// What a request's listing shares with the tasks listing each broadcast. +#[derive(Clone)] +struct Request { + origin: origin::Consumer, + /// The request's prefix, which every PUBLISH namespace starts with. + prefix: crate::PathOwned, + /// Whether the request asked for every track's objects, which we answer with skips. + forward: bool, + params: Params, + properties_wanted: bool, + skips: Skips, + /// Closes when the request is cancelled: nothing more is PUBLISHed or skipped, but the + /// PUBLISHes already sent live on. + live: kio::Consumer<()>, +} + +/// One listed track, handed to its PUBLISH task. +struct Listed { + track: track::Consumer, + /// The track's full namespace, as the PUBLISH names it. + namespace: crate::PathOwned, + name: Arc, + properties: ietf::Properties, + /// Closes once the track is no longer listed. + listed: kio::Consumer<()>, + /// Closes once the request is cancelled, which a PUBLISH not yet sent heeds. + live: kio::Consumer<()>, + /// The subscription slot the PUBLISH holds for as long as it lives. + slot: crate::session::Slot, + /// On draft-18, claims the track's one subscription for as long as the PUBLISH lives. + subscribed: Option, + params: Params, +} + +/// How a paused PUBLISH subscription resumes forwarding. +struct Resume { + /// FILL_PARAMETERS (draft-20+) for the objects before the join. + fill: Option, + /// NEW_GROUP_REQUEST, counted as a join as on a SUBSCRIBE. + new_group: Option, + /// Whether a REQUEST_UPDATE asked for it, which the join's REQUEST_OK answers. + answer: bool, +} + +/// What ended a paused PUBLISH subscription's wait. +enum Wait { + /// The subscriber's REQUEST_UPDATE. + Update(super::super::request_stream::Update), + /// The track is no longer listed. + Removed, + /// Our grant stopped covering the track. + Revoked, + /// The subscriber cancelled, or the session ended. + Cancelled, + /// The request stream failed. + Failed(Error), +} + +impl Publisher +where + S: crate::transport::poll::Boxable, +{ + /// Answer a SUBSCRIBE_TRACKS: list every track under its prefix until the subscriber + /// cancels or the session ends, then let the PUBLISHes it made run to their own end. + pub(super) async fn serve_subscribe_tracks_stream( + self, + mut stream: Stream, + msg: ietf::SubscribeTracks<'_>, + ) -> Result<(), Error> { + if let Some(error) = super::super::namespace::refusal(&msg.prefix, self.version) { + return super::super::request_stream::reject(stream, error).await; + } + let refuse = |error_code, reason: &'static str| ietf::RequestError { + request_id: None, + error_code, + reason_phrase: reason.into(), + retry_interval: 0, + }; + // Filters we do not implement are refused rather than ignored, which would hand the + // subscriber tracks or objects it excluded. + if msg.range_filters { + let error = refuse(request::INVALID_FILTER, "Range Filters exceed MAX_FILTER_RANGES=0"); + return super::super::request_stream::reject(stream, error).await; + } + if msg.track_filter { + let error = refuse(request::NOT_SUPPORTED, "TRACK_PROPERTY_FILTER is not supported"); + return super::super::request_stream::reject(stream, error).await; + } + // We never push a track's objects unasked, and draft-18 has no PUBLISH_SKIPPED to + // list a track without them: its PUBLISH_BLOCKED only reports running out of streams. + if msg.forward && self.version == Version::Draft18 { + let error = refuse(request::NOT_SUPPORTED, "SUBSCRIBE_TRACKS needs FORWARD=0"); + return super::super::request_stream::reject(stream, error).await; + } + let prefix = msg.prefix.to_owned(); + let Some(established) = Established::new(&self.listings, &prefix) else { + let error = refuse(request::PREFIX_OVERLAP, "overlaps another SUBSCRIBE_TRACKS"); + return super::super::request_stream::reject(stream, error).await; + }; + + tracing::debug!(prefix = %self.origin.absolute(&prefix), forward = msg.forward, "subscribe_tracks stream"); + + // The view SUBSCRIBE_NAMESPACE discovers through: rooted at the prefix so every + // broadcast arrives named by its suffix, never handing back what flowed through the + // subscriber, and without hidden broadcasts unless the peer opted in. + let scope = crate::Pattern::subtree(prefix.as_str()) + .map(|subtree| subtree.rebase(prefix.as_str())) + .unwrap_or_default(); + let peer = self.peer().await; + let declared = self.peer_setup.get().await; + // With MoQ Auth, list nothing until the credential we presented at setup is answered, + // so no track is listed before our grant says which ones the peer may see. The wait + // lives in the listing loop so a cancel or a closed session still frees the prefix. + let mut answered = !declared.auth; + let origin = self + .origin + .scope(&prefix, &scope) + .unwrap_or_else(|_| self.origin.empty()) + .excluding(self.exclude(&peer)) + .discovery(!declared.hidden || self.origin.includes_hidden()); + let mut announced = origin.announced(); + + stream.writer.buffer_varint(ietf::RequestOk::ID)?; + stream + .writer + .encode(&ietf::RequestOk { + request_id: None, + active: None, + largest: None, + }) + .await?; + + // Dropped when the request is cancelled, which tells every broadcast to stop. + let live = kio::Producer::new(()); + let request = Request { + origin, + prefix: prefix.clone(), + forward: msg.forward, + params: Params { + priority: msg.subscriber_priority.unwrap_or(128), + order: msg.group_order, + filter: msg.filter.unwrap_or(Filter::Unfiltered), + max_age: None, + new_group: msg.new_group, + }, + properties_wanted: msg.properties_wanted, + skips: Skips::default(), + live: live.consume(), + }; + // Each listed broadcast's route: dropping it ends that broadcast's listing. + let mut routes: HashMap> = HashMap::new(); + let mut broadcasts = TaskSet::owned(); + let mut session = self.session.clone(); + let mut finished = false; + let mut goaways = super::super::request_stream::Messages::new(self.session.clone(), self.control.client()); + + enum Event { + Announce(crate::announce::Event), + /// The subscriber ended the request, or the stream failed. + Cancelled(Result<(), Error>), + Closed, + } + + let cancelled = loop { + let event = broadcasts + .drive(|waiter| { + let mut cx = waiter.context(); + if session.poll_closed(&mut cx).is_ready() { + return Poll::Ready(Event::Closed); + } + if let Poll::Ready(res) = + self.poll_listing_cancel(&mut stream, &mut finished, &mut goaways, &mut cx) + { + return Poll::Ready(Event::Cancelled(res)); + } + if let Err(err) = Self::poll_write_skips(&mut stream, &request.skips, waiter, &mut cx) { + return Poll::Ready(Event::Cancelled(Err(err))); + } + if !answered { + ready!(self.auth.poll_setup_answered(waiter)); + answered = true; + } + match announced.poll_next(waiter) { + Poll::Ready(Some(event)) => Poll::Ready(Event::Announce(event)), + _ => Poll::Pending, + } + }) + .await; + + let announce = match event { + Event::Closed => return Ok(()), + Event::Cancelled(res) => break res, + Event::Announce( + crate::announce::Event::Start(announce) | crate::announce::Event::Restart(announce), + ) => announce, + Event::Announce(crate::announce::Event::End(announce)) => { + routes.remove(&announce.prefix); + continue; + } + // A new hop or cost changes nothing listed. + Event::Announce(crate::announce::Event::Update(_)) => continue, + }; + // A restart is a new instance: the old one's listing ends with its route. + let route = kio::Producer::new(()); + let task = self.clone().run_listed_broadcast( + request.clone(), + announce.prefix.clone(), + announce.route.epoch.clone(), + route.consume(), + ); + routes.insert(announce.prefix, route); + broadcasts.push(task); + }; + + tracing::debug!(prefix = %self.origin.absolute(&prefix), ?cancelled, "subscribe_tracks ended"); + // The request no longer stands: another may take its prefix, nothing more is + // PUBLISHed, and what was never sent is dropped. Its routes stay, so a PUBLISH + // already sent still ends when its track does. + drop(established); + drop(live); + request.skips.lock().clear(); + // A refused update left its REQUEST_ERROR buffered, so a cancel flushes before the FIN. + let mut closing = match cancelled { + Ok(()) => Some(stream.writer), + Err(err) => { + stream.writer.abort(&err); + None + } + }; + let mut fin = false; + + // The PUBLISHes already sent are subscriptions of their own, on their own streams, + // each ending once its broadcast does, even while the peer is slow to take the close. + loop { + let event = kio::wait(|waiter| { + let mut cx = waiter.context(); + if let Some(writer) = closing.as_mut() { + let closed = 'close: { + if !fin { + match writer.poll_flush(&mut cx) { + Poll::Pending => break 'close Poll::Pending, + Poll::Ready(res) => { + if res.and_then(|()| writer.finish()).is_err() { + break 'close Poll::Ready(()); + } + } + } + fin = true; + } + writer.poll_close(&mut cx).map(|_| ()) + }; + if closed.is_ready() { + closing = None; + } + } + if session.poll_closed(&mut cx).is_ready() { + return Poll::Ready(None); + } + if broadcasts.poll(waiter).is_ready() && closing.is_none() { + return Poll::Ready(None); + } + announced.poll_next(waiter) + }) + .await; + match event { + Some(crate::announce::Event::End(announce) | crate::announce::Event::Restart(announce)) => { + routes.remove(&announce.prefix); + } + Some(_) => {} + None => return Ok(()), + } + } + } + + /// Poll for the subscriber ending a SUBSCRIBE_TRACKS: `Ok` for a cancel, or the error + /// ending it, such as a REQUEST_UPDATE we refuse. + fn poll_listing_cancel( + &self, + stream: &mut Stream, + finished: &mut bool, + goaways: &mut super::super::request_stream::Messages, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + use super::super::request_stream::{NamespaceUpdate, Next}; + if stream.writer.poll_closed(cx).is_ready() { + return Poll::Ready(Ok(())); + } + while !*finished { + match ready!(stream.reader.poll_decode_maybe::>>(cx)) { + Ok(Some(Next::Message(update))) => { + // Its Request ID is spent like any other: a bad one is the session's error, + // and one past our GOAWAY is refused as such. + let going_away = match self.control.accept(update.request_id) { + Err(Error::GoingAway) => true, + Err(err) => { + self.session.clone().close( + super::super::error::to_session_code(&crate::SessionError::from(&err)), + "invalid request ID", + ); + return Poll::Ready(Err(err)); + } + Ok(_) => false, + }; + // A new prefix or FORWARD would change which later tracks are listed and + // how, which we do not support. Refusing it ends the request (section 10.9.1). + let (err, reason) = match going_away { + true => (Error::GoingAway, "going away"), + false => ( + update.refused.unwrap_or(Error::Unsupported), + "REQUEST_UPDATE on SUBSCRIBE_TRACKS is not supported", + ), + }; + let code = request::to_code(&err, request::Kind::SubscribeNamespace, self.version); + stream.writer.buffer_varint(ietf::RequestError::ID)?; + stream.writer.buffer(&ietf::RequestError { + request_id: None, + error_code: code, + reason_phrase: reason.into(), + retry_interval: 0, + })?; + return Poll::Ready(Ok(())); + } + Ok(Some(Next::GoAway { redirect })) => goaways.receive(redirect)?, + Ok(None) if !super::super::request_stream::fin_cancels(self.version) => *finished = true, + Ok(None) => return Poll::Ready(Ok(())), + Err(err) => return Poll::Ready(Err(goaways.failed(err))), + } + } + Poll::Pending + } + + /// Write the pending PUBLISH_SKIPPEDs, buffering the next only once the last is out, so + /// a peer that stops reading leaves the rest collapsing in `skips`. + fn poll_write_skips( + stream: &mut Stream, + skips: &Skips, + waiter: &kio::Waiter, + cx: &mut std::task::Context<'_>, + ) -> Result<(), Error> { + while stream.writer.poll_flush(cx)?.is_ready() { + let Poll::Ready(mut pending) = skips.poll(waiter, |skips| match skips.is_empty() { + true => Poll::Pending, + false => Poll::Ready(()), + }) else { + break; + }; + let Some(((suffix, name), ())) = pending.pop_front() else { + break; + }; + drop(pending); + stream.writer.buffer_varint(ietf::PublishSkipped::ID)?; + stream.writer.buffer(&ietf::PublishSkipped { + suffix, + track_name: name.as_ref().into(), + })?; + } + Ok(()) + } + + /// List one broadcast under a SUBSCRIBE_TRACKS: PUBLISH or skip each of its tracks until + /// its route goes or the broadcast ends, then let its PUBLISHes run to their end. + async fn run_listed_broadcast( + self, + request: Request, + suffix: crate::PathOwned, + epoch: Option, + route: kio::Consumer<()>, + ) { + let namespace = request.prefix.join(&suffix).to_owned(); + let absolute = self.origin.absolute(&namespace).to_owned(); + + // Resolving may wait on a route for the broadcast, which its going ends. + let resolved = { + let mut requesting = std::pin::pin!(request.origin.request_broadcast(&suffix, epoch)); + kio::wait(|waiter| { + if route.poll_closed(waiter).is_ready() || request.live.poll_closed(waiter).is_ready() { + return Poll::Ready(None); + } + waiter.poll_future(requesting.as_mut()).map(Some) + }) + .await + }; + let broadcast = match resolved { + Some(Ok(broadcast)) => broadcast, + Some(Err(err)) => { + tracing::debug!(broadcast = %absolute, %err, "listed broadcast did not resolve"); + return; + } + None => return, + }; + // Each PUBLISH watches its track's entry here: dropping it says the track is no + // longer listed. + let mut listed: HashMap, kio::Producer<()>> = HashMap::new(); + // The tracks this broadcast queued a skip for, so its end touches only its own. + let mut skipped: HashSet> = HashSet::new(); + let mut publishes = TaskSet::owned(); + // The listing ended, or the request did: nothing more to add. + let mut adding = true; + // The broadcast's listing, held only while our publish permit covers it: a track + // it does not cover is not revealed at all, and one it comes to cover is listed + // from scratch. + let (permit, mut auth_epoch) = self.permit_now(None); + let mut tracks = None; + if permit.matches(namespace.as_str()) { + match broadcast.tracks() { + Ok(listing) => tracks = Some(listing), + Err(err) => { + tracing::debug!(broadcast = %absolute, %err, "listed broadcast ended"); + return; + } + } + } + + enum Event { + Track(crate::broadcast::TracksEvent), + /// Our publish permit may have changed. + Regrant(crate::auth::Permit), + /// The broadcast ended or its route went: every track it listed is gone. + Ended, + /// The request was cancelled. + Cancelled, + } + + loop { + let event = kio::wait(|waiter| { + let drained = publishes.poll(waiter).is_ready(); + if !adding && drained { + return Poll::Ready(None); + } + if adding && request.live.poll_closed(waiter).is_ready() { + return Poll::Ready(Some(Event::Cancelled)); + } + if route.poll_closed(waiter).is_ready() { + return Poll::Ready(Some(Event::Ended)); + } + if let Poll::Ready(permit) = + self.auth + .poll_permit(crate::auth::Direction::Publish, &mut auth_epoch, waiter) + { + return Poll::Ready(Some(Event::Regrant(permit))); + } + let Some(tracks) = tracks.as_mut() else { + return Poll::Pending; + }; + match tracks.poll_next(waiter) { + Poll::Ready(Ok(Some(event))) => Poll::Ready(Some(Event::Track(event))), + Poll::Ready(Ok(None)) => Poll::Ready(Some(Event::Ended)), + Poll::Ready(Err(err)) => { + tracing::debug!(broadcast = %absolute, %err, "listing failed"); + Poll::Ready(Some(Event::Ended)) + } + Poll::Pending => Poll::Pending, + } + }) + .await; + + let Some(event) = event else { + return; + }; + let unlist = |listed: &mut HashMap, kio::Producer<()>>, skipped: &mut HashSet>| { + listed.clear(); + let mut skips = request.skips.lock(); + for name in skipped.drain() { + skips.remove(&(suffix.clone(), name)); + } + }; + match event { + Event::Ended => { + unlist(&mut listed, &mut skipped); + // What it already PUBLISHed ends now; wait for that to go out. + kio::wait(|waiter| publishes.poll(waiter)).await; + return; + } + Event::Regrant(permit) => match (permit.matches(namespace.as_str()), tracks.is_some()) { + // Each PUBLISH ends on its own gate as well; dropping the listing also + // cancels the skips not yet sent. + (false, true) => { + tracks = None; + unlist(&mut listed, &mut skipped); + } + (true, false) if adding => match broadcast.tracks() { + Ok(listing) => tracks = Some(listing), + Err(err) => { + tracing::debug!(broadcast = %absolute, %err, "listed broadcast ended"); + unlist(&mut listed, &mut skipped); + kio::wait(|waiter| publishes.poll(waiter)).await; + return; + } + }, + _ => {} + }, + // The PUBLISHes already sent still end when their track is unlisted, so keep + // reading the listing for them, adding nothing. + Event::Cancelled => adding = false, + Event::Track(crate::broadcast::TracksEvent::Synced) => {} + Event::Track(crate::broadcast::TracksEvent::Removed { name }) => { + if skipped.remove(&name) { + request.skips.lock().remove(&(suffix.clone(), name.clone())); + } + listed.remove(&name); + } + Event::Track(crate::broadcast::TracksEvent::Added { .. }) if !adding => {} + Event::Track(crate::broadcast::TracksEvent::Added { name, info }) => { + let mut skip = |name: Arc| { + request.skips.lock().insert((suffix.clone(), name.clone()), ()); + skipped.insert(name); + }; + if request.forward { + skip(name); + continue; + } + // A track this draft cannot describe is skipped rather than PUBLISHed with + // its properties rewritten. + let Ok(properties) = track_properties(&info, request.properties_wanted, self.version) else { + skip(name); + continue; + }; + let Ok(track) = broadcast.track(&name) else { + continue; + }; + // Each paused PUBLISH holds a subscription slot; past the cap the peer is + // told the track exists rather than left without it. + let Ok(slot) = self.subscriptions.acquire() else { + skip(name); + continue; + }; + // Draft-18 allows one subscription per track (section 5.1), so a track the + // peer is subscribed to, by its SUBSCRIBE or a PUBLISH still alive, is + // skipped. Held from now, so a SUBSCRIBE cannot slip in before the PUBLISH + // goes out. + let subscribed = match self.version { + Version::Draft18 => { + match Subscribed::publish(&self.listings, namespace.clone(), name.clone()) { + Some(subscribed) => Some(subscribed), + None => { + skip(name); + continue; + } + } + } + _ => None, + }; + let entry = kio::Producer::new(()); + let task = Listed { + track, + namespace: namespace.clone(), + name: name.clone(), + properties, + listed: entry.consume(), + live: request.live.clone(), + slot, + subscribed, + params: request.params, + }; + listed.insert(name.clone(), entry); + let this = self.clone(); + publishes.push(async move { + if let Err(err) = this.run_publish(task).await { + tracing::debug!(%err, "publish stream error"); + } + }); + } + } + } + } + + /// Run one paused PUBLISH subscription until it ends: the track is unlisted, the + /// subscriber cancels or refuses it, or, once resumed, the track ends. + async fn run_publish(self, task: Listed) -> Result<(), Error> { + let Listed { + track, + namespace, + name, + properties, + listed, + live, + slot, + subscribed, + mut params, + } = task; + let _slot = slot; + let _subscribed = subscribed; + // An update may ask for a new group only if the PUBLISH sent DYNAMIC_GROUPS, which + // INCLUDE_PROPERTIES=0 leaves out. + let dynamic = properties.dynamic_groups; + let absolute = self.origin.absolute(&namespace).to_owned(); + + // Every wait on the peer's credit, for the stream, a Request ID, or the PUBLISH's + // bytes, lasts only while the track is listed and the request stands; a peer that + // grants none holds only this task and its slot. A Request ID abandoned unsent leaves + // a gap, which the drafts with SUBSCRIBE_TRACKS allow: none has MAX_REQUEST_ID. + let mut session = self.session.clone(); + let opened = { + let mut opening = std::pin::pin!(Stream::open(&mut session, self.version)); + kio::wait(|waiter| { + if listed.poll_closed(waiter).is_ready() || live.poll_closed(waiter).is_ready() { + return Poll::Ready(None); + } + waiter.poll_future(opening.as_mut()).map(Some) + }) + .await + }; + let Some(opened) = opened else { + return Ok(()); + }; + let mut stream = opened?; + let allocated = kio::wait(|waiter| { + if listed.poll_closed(waiter).is_ready() || live.poll_closed(waiter).is_ready() { + return Poll::Ready(None); + } + self.control.poll_next_request_id(waiter).map(Some) + }) + .await; + // Dropped unwritten, the stream is reset before its header, which the peer ignores. + let Some(request_id) = allocated else { + return Ok(()); + }; + + // A joining FETCH needs the subscription forwarding. + let join = (!Filter::is_draft20(self.version)).then(|| self.register_join(request_id)); + self.pause_join(join.as_ref()); + + tracing::debug!(id = %request_id, broadcast = %absolute, track = %name, "publish started"); + stream.writer.buffer_varint(ietf::Publish::ID)?; + let publish = ietf::Publish { + request_id, + track_namespace: namespace.clone(), + track_name: name.as_ref().into(), + track_alias: request_id.0, + largest_location: live_edge(&track).largest, + forward: false, + properties, + // Draft-20 and later state the listing's subscription parameters on each PUBLISH; + // earlier drafts carry them on PUBLISH_OK. + subscriber_priority: Some(params.priority), + group_order: params.order, + filter: Some(params.filter), + }; + let written = { + let mut writing = std::pin::pin!(stream.writer.encode(&publish)); + kio::wait(|waiter| { + if listed.poll_closed(waiter).is_ready() || live.poll_closed(waiter).is_ready() { + return Poll::Ready(None); + } + waiter.poll_future(writing.as_mut()).map(Some) + }) + .await + }; + // Cut short, the stream is reset before its header is whole, which the peer ignores. + let Some(written) = written else { + return Ok(()); + }; + written?; + + let mut gate = crate::auth::Gate::new(self.auth.clone(), namespace.clone(), crate::auth::Direction::Publish); + let mut finished = false; + let mut goaways = super::super::request_stream::Messages::new(self.session.clone(), self.control.client()); + + // PUBLISH_OK or REQUEST_ERROR. A track unlisted before the answer has nothing to + // end yet, so the request is reset. + let answer = kio::wait(|waiter| { + let mut cx = waiter.context(); + if session.poll_closed(&mut cx).is_ready() || listed.poll_closed(waiter).is_ready() { + return Poll::Ready(None); + } + goaways.poll_next(&mut stream.reader, &mut cx).map(Some) + }) + .await; + // A NEW_GROUP_REQUEST on a PUBLISH_OK that leaves the subscription paused waits for + // the resume. + let mut new_group: Option; + // A PUBLISH_OK breaking a rule ends the PUBLISH, as a refused update does. + let mut refused = None; + let resume = match answer { + None => { + stream.writer.abort(&Error::Cancel); + return Ok(()); + } + Some(Err(err)) => return Err(err), + Some(Ok(None)) => return Err(goaways.failed(Error::ProtocolViolation)), + Some(Ok(Some(super::super::request_stream::Framed(id, body)))) => match id { + ietf::RequestError::ID => { + let refused = body + .message::(self.version) + .map_err(|err| goaways.failed(err.into()))?; + tracing::debug!(id = %request_id, track = %name, code = refused.error_code, reason = %refused.reason_phrase, "publish refused"); + return Ok(()); + } + // PUBLISH_OK is a REQUEST_OK from draft-18, which still lists 0x1E for it. + _ if id == ietf::RequestOk::ID || (id == ietf::PublishOk::ID && self.version == Version::Draft18) => { + let ok = body + .message::(self.version) + .map_err(|err| goaways.failed(err.into()))?; + // Through draft-19 the answer sets the Forward State and the parameters it + // carries, an absent FORWARD meaning 1; an absent parameter keeps the one the + // SUBSCRIBE_TRACKS set. Draft-20 moved them off PUBLISH_OK, leaving it paused. + if let Some(priority) = ok.subscriber_priority { + params.priority = priority; + } + params.order = ok.group_order.or(params.order); + params.filter = ok.filter.unwrap_or(params.filter); + // The answer may ask for a new group only if the PUBLISH sent DYNAMIC_GROUPS + // (draft-18 section 10.2.13). The listing's own asked without knowing, as a + // SUBSCRIBE may, so it is ignored where it does not apply. + if ok.new_group.is_some() && !dynamic { + refused = Some(Served::UpdateFailed(Error::Unsupported)); + } + new_group = ok.new_group.or(params.new_group.filter(|_| dynamic)); + (ok.forward && !Filter::is_draft20(self.version)).then(|| Resume { + fill: None, + new_group: new_group.take(), + answer: false, + }) + } + _ => return Err(goaways.failed(Error::UnexpectedMessage)), + }, + }; + + if let Some(done) = refused { + drop(join); + return self.write_publish_done(stream, request_id, done, 0).await; + } + + // Group streams opened over every time the subscription forwarded, which + // PUBLISH_DONE counts. + let mut opened = 0; + // What to resume with, and whether an update asked for it: a forwarding PUBLISH_OK + // resumes at once, with no update to answer. + let mut resume: Option = resume; + let done = loop { + if let Some(how) = resume.take() { + match self + .resume_publish( + &mut stream, + &track, + &namespace, + request_id, + &mut params, + how, + dynamic, + join.as_ref(), + &listed, + &mut gate, + &mut finished, + &mut goaways, + &mut opened, + ) + .await + { + Ok(Served::Paused) => { + self.pause_join(join.as_ref()); + self.write_request_ok(&mut stream.writer, request_id, None).await?; + // Unlisted while it forwarded: it ends now that it is idle. + if listed.is_closed() { + break unlisted(); + } + continue; + } + Ok(served) => break served, + // Cancelled by the subscriber, or the session ended. + Err(()) => return Ok(()), + } + } + + let wait = kio::wait(|waiter| { + let mut cx = waiter.context(); + if session.poll_closed(&mut cx).is_ready() || stream.writer.poll_closed(&mut cx).is_ready() { + return Poll::Ready(Wait::Cancelled); + } + if gate.poll_denied(waiter).is_ready() { + return Poll::Ready(Wait::Revoked); + } + if listed.poll_closed(waiter).is_ready() { + return Poll::Ready(Wait::Removed); + } + match ready!(self.poll_update(&mut stream, &mut finished, &mut goaways, &mut cx)) { + UpdateRead::Update(update) => Poll::Ready(Wait::Update(update)), + UpdateRead::Cancelled => Poll::Ready(Wait::Cancelled), + UpdateRead::Malformed(err) => { + if super::super::subscriber::is_protocol_violation(&err) { + self.session.clone().close( + super::super::error::to_session_code(&crate::SessionError::from(&err)), + &err.to_string(), + ); + } + Poll::Ready(Wait::Failed(err)) + } + UpdateRead::Failed(err) => Poll::Ready(Wait::Failed(err)), + } + }) + .await; + + let update = match wait { + Wait::Update(update) => update, + Wait::Removed => break unlisted(), + Wait::Revoked => { + tracing::info!(broadcast = %absolute, track = %name, "publish no longer authorized"); + break Served::Track(Err(Error::Unauthorized)); + } + Wait::Cancelled => return Ok(()), + Wait::Failed(err) => break Served::Track(Err(err)), + }; + // Refused like an update to any subscription: it fails, ending this one. + let refusal = match update.refused { + Some(Error::Decode(err)) => Some((super::super::token::reason(&err), Error::Decode(err))), + Some(Error::GoingAway) => Some(("going away", Error::GoingAway)), + Some(err) => Some(("REQUEST_UPDATE parameters not supported", err)), + None if update.authorization_token.is_some() + && self.auth.token_decides(crate::auth::Direction::Publish) => + { + Some(( + "AUTHORIZATION TOKEN on a request admitted without one", + Error::Unsupported, + )) + } + None if update.new_group.is_some() && !dynamic => Some(( + "NEW_GROUP_REQUEST on a track without DYNAMIC_GROUPS", + Error::Unsupported, + )), + None if update.fill.is_some() && update.forward != Some(true) => { + Some(("FILL_PARAMETERS without resuming", Error::Unsupported)) + } + // A paused subscription opens no groups, so a new one is asked for only as it + // resumes. + None if update.new_group.is_some() && update.forward != Some(true) => { + Some(("NEW_GROUP_REQUEST without resuming", Error::Unsupported)) + } + None => None, + }; + if let Some((reason, err)) = refusal { + self.write_subscribe_error(&mut stream.writer, update.request_id, &err, reason) + .await?; + break Served::UpdateFailed(Error::Unsupported); + } + if let Some(priority) = update.priority { + params.priority = priority; + } + if let Some(filter) = update.filter { + params.filter = filter; + } + if let Some(max_age) = update.max_age { + params.max_age = Some(max_age); + } + match update.forward { + Some(true) => { + resume = Some(Resume { + fill: update.fill, + new_group: update.new_group.or(new_group.take()), + answer: true, + }) + } + _ => self.write_request_ok(&mut stream.writer, request_id, None).await?, + } + }; + + drop(join); + self.write_publish_done(stream, request_id, done, opened).await + } + + /// Mark a paused subscription for joining FETCHes: draft-18 section 5.1 refuses one + /// unless the subscription forwards. + fn pause_join(&self, join: Option<&Join>>) { + if let Some(join) = join { + join.joins.lock().insert(join.request_id, Some(Joined::Empty)); + } + } + + /// End a PUBLISH whose resume failed with `err` before it joined. A REQUEST_UPDATE that + /// asked for it is owed an answer, so it is refused, which fails the update (draft-22 + /// section 9.5). `Err(())` when the subscriber cancelled. + async fn resume_failed( + &self, + stream: &mut Stream, + request_id: RequestId, + answer: bool, + err: Error, + ) -> Result { + if !answer { + return Ok(Served::Track(Err(err))); + } + self.write_subscribe_error(&mut stream.writer, request_id, &err, "resume failed") + .await + .map_err(|_| ())?; + Ok(Served::UpdateFailed(err)) + } + + /// Forward a paused PUBLISH subscription from the cache with `params`, as a SUBSCRIBE + /// with them would be served, until it pauses again or ends. Answers the resuming update + /// with where the subscription now joins, when `answer` says one asked. `Err(())` when the + /// subscriber cancelled. + #[allow(clippy::too_many_arguments)] + async fn resume_publish( + &self, + stream: &mut Stream, + track: &track::Consumer, + namespace: &crate::PathOwned, + request_id: RequestId, + params: &mut Params, + how: Resume, + dynamic: bool, + join: Option<&Join>>, + listed: &kio::Consumer<()>, + gate: &mut crate::auth::Gate, + finished: &mut bool, + goaways: &mut super::super::request_stream::Messages, + opened: &mut u64, + ) -> Result { + let mut subscription = serving_subscription(params.priority); + if let Some(order) = params.order { + subscription = subscription.with_order(order.to_order()); + } + if let Some(max_age) = params.max_age { + subscription.max_delay = Duration::from_millis(max_age); + } + // A start the live edge does not decide is no join; see `serve_subscribe_stream`. + let fixed = subscribe_range(params.filter, LiveEdge::default(), self.version); + if let Some(start) = fixed.start { + ServeRange { + start: Some(Location { object: 0, ..start }), + ..fixed + } + .apply(&mut subscription); + } + // Setting up lasts only while the track is listed and the subscriber stays: a cold + // route may take a while to say where its live edge is. + let mut subscribing = std::pin::pin!(track.subscribe(subscription.clone())); + let subscribed = kio::wait(|waiter| { + if stream.writer.poll_closed(&mut waiter.context()).is_ready() { + return Poll::Ready(Err(())); + } + if listed.poll_closed(waiter).is_ready() { + return Poll::Ready(Ok(None)); + } + waiter + .poll_future(subscribing.as_mut()) + .map(|subscribed| Ok(Some(subscribed))) + }) + .await?; + let mut subscriber = match subscribed { + Some(Ok(subscriber)) => subscriber, + Some(Err(err)) => return self.resume_failed(stream, request_id, how.answer, err).await, + None => { + return self + .resume_failed(stream, request_id, how.answer, Error::NotFound) + .await; + } + }; + let live = kio::wait(|waiter| { + if stream.writer.poll_closed(&mut waiter.context()).is_ready() { + return Poll::Ready(Err(())); + } + if listed.poll_closed(waiter).is_ready() { + return Poll::Ready(Ok(false)); + } + subscriber.poll_live(waiter).map(|_| Ok(true)) + }) + .await?; + if !live { + return self + .resume_failed(stream, request_id, how.answer, Error::NotFound) + .await; + } + + let edge = live_edge(track); + let range = subscribe_range(params.filter, edge, self.version); + range.apply(&mut subscription); + let _ = subscriber.update(subscription); + let timescale = subscriber + .info() + .timescale + .filter(|_| ietf::Properties::sends_timescale(self.version)); + let mut serve = TrackServe::new( + self.session.clone(), + subscriber, + request_id, + self.version, + range, + timescale, + ) + .ranked(self.priority.clone()); + serve.pausable = true; + serve.dynamic = dynamic; + if let Some(value) = how.new_group { + serve.request_new_group(value); + } + + if let Some(join) = join { + let joined = match (params.filter, edge.largest) { + (Filter::NextObject, Some(largest)) => Joined::Group { + end: Location { + group: largest.group, + object: largest.object + 1, + }, + cache: track.clone(), + namespace: namespace.clone(), + token: false, + ranking: serve.ranking.clone(), + }, + (Filter::NextObject, None) => Joined::Empty, + _ => Joined::Unsupported, + }; + join.joins.lock().insert(join.request_id, Some(joined)); + } + let fill = how + .fill + .filter(|_| Filter::is_draft20(self.version)) + .map(|fill| fill_range(fill, params.filter, edge.largest)); + + if how.answer + && self + .write_request_ok(&mut stream.writer, request_id, edge.largest) + .await + .is_err() + { + return Err(()); + } + + let served = { + let ranking = serve.ranking.clone(); + let streams = serve.opened.clone(); + let fill = async { + if let Some(fill) = fill { + self.run_fill(fill, &ranking, track.clone(), timescale, &streams).await; + } + }; + let serving = self.run_subscription(stream, &mut serve, finished, goaways, fill, None); + // A track no longer listed ends its PUBLISH even while the track itself goes on, + // as when its broadcast's route is withdrawn. + crate::auth::until(serving, |waiter| { + if gate.poll_denied(waiter).is_ready() { + return Poll::Ready(Error::Unauthorized.into()); + } + match listed.poll_closed(waiter) { + Poll::Ready(()) => Poll::Ready(Error::Cancel.into()), + Poll::Pending => Poll::Pending, + } + }) + .await + }; + // What an update changed while forwarding outlives a pause. + let applied = serve.track.subscription(); + params.priority = crate::ietf::priority::to_wire(applied.priority); + params.max_age = applied.max_delay.as_millis().try_into().ok(); + let served = match served { + Ok(Some(Served::Paused)) => Served::Paused, + Ok(Some(served)) => { + if matches!(served, Served::Track(Ok(()))) { + self.write_end_of_track(stream, &mut serve, finished, goaways).await; + } + served + } + Ok(None) => { + serve.close_groups(); + *opened += serve.opened(); + return Err(()); + } + Err(refusal) if matches!(refusal.error, Error::Cancel) => unlisted(), + Err(refusal) => Served::Track(Err(refusal.error)), + }; + // Forwarding stops here: PUBLISH_DONE, or the answer to the pause, follows the close + // of every data stream. + serve.close_groups(); + *opened += serve.opened(); + Ok(served) + } +} + +/// How a PUBLISH whose track is no longer listed ends: always in error, never TRACK_ENDED. +/// A track that leaves the listing may go on elsewhere, as when its broadcast's route is +/// withdrawn, and a clean end would tell the subscriber it is over. A finished track is +/// unlisted the same way, so it deliberately ends in error too. +fn unlisted() -> Served { + Served::Track(Err(Error::NotFound)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ProduceTest as _; + use crate::transport::memory; + use crate::transport::poll::Session as _; + + const TIMEOUT: Duration = Duration::from_secs(10); + + const DRAFTS: [Version; 5] = [ + Version::Draft18, + Version::Draft19, + Version::Draft20, + Version::Draft21, + Version::Draft22, + ]; + + /// A publisher whose origin holds broadcast `room/cam` listing `video` and `audio`, and + /// the peer's end of its session. + struct Harness { + version: Version, + publisher: Publisher, + session: memory::Session, + peer: memory::Session, + broadcast: crate::broadcast::Producer, + tracks: HashMap<&'static str, track::Producer>, + _origin: origin::Producer, + } + + fn harness(version: Version) -> Harness { + harness_with(version, memory::Config::default()) + } + + fn harness_with(version: Version, config: memory::Config) -> Harness { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.publish("room/cam", crate::origin::Route::default()).unwrap(); + let tracks = ["video", "audio"] + .into_iter() + .map(|name| (name, broadcast.create_track(name, crate::track::Info::timed()).unwrap())) + .collect(); + let (session, peer) = memory::pair(config); + let setup = peer::PeerSetup::default(); + setup.set(peer::Peer::default()); + let publisher = Publisher::new( + crate::time::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + setup, + version, + ); + Harness { + version, + publisher, + session, + peer, + broadcast, + tracks, + _origin: origin, + } + } + + fn listing(forward: bool) -> ietf::SubscribeTracks<'static> { + ietf::SubscribeTracks { + request_id: RequestId(0), + prefix: crate::Path::new("room"), + forward, + subscriber_priority: None, + group_order: None, + filter: None, + fill: None, + properties_wanted: true, + new_group: None, + range_filters: false, + track_filter: false, + authorization_token: None, + } + } + + impl Harness { + /// Send `msg` on a new request stream, dispatched as the session would. + async fn request(&mut self, msg: &M) -> Stream { + let mut stream = Stream::open(&mut self.peer, self.version).await.unwrap(); + stream.writer.varint(M::ID).await.unwrap(); + stream.writer.encode(msg).await.unwrap(); + + let mut accepted = timeout(Stream::accept(&mut self.session, self.version)).await.unwrap(); + let id = accepted.reader.varint().await.unwrap(); + let body: ietf::Body = accepted.reader.decode().await.unwrap(); + let task = self.publisher.handle_stream(id, body, accepted, false).unwrap(); + moq_clock::sim::spawn(task); + stream + } + + /// The next PUBLISH the publisher opens: its stream, namespace, track and FORWARD. + async fn publish(&mut self) -> (Stream, String, String, bool) { + let mut stream = timeout(Stream::accept(&mut self.peer, self.version)).await.unwrap(); + assert_eq!(stream.reader.varint().await.unwrap(), ietf::Publish::ID); + let body: ietf::Body = stream.reader.decode().await.unwrap(); + let publish: ietf::Publish = body.message(self.version).unwrap(); + let namespace = publish.track_namespace.as_str().to_string(); + let name = publish.track_name.to_string(); + (stream, namespace, name, publish.forward) + } + } + + async fn timeout(future: F) -> F::Output { + moq_clock::timeout(TIMEOUT, future).await.expect("timed out") + } + + /// The next message on a request stream: its type and body. + async fn next(stream: &mut Stream) -> (u64, ietf::Body) { + let id = timeout(stream.reader.varint()).await.unwrap(); + let body = stream.reader.decode().await.unwrap(); + (id, body) + } + + async fn send(stream: &mut Stream, msg: &M) { + stream.writer.varint(M::ID).await.unwrap(); + stream.writer.encode(msg).await.unwrap(); + } + + fn request_error(body: &ietf::Body, version: Version) -> u64 { + body.message::(version).unwrap().error_code + } + + /// Answer a PUBLISH with a PUBLISH_OK leaving it paused, which draft-20 on cannot + /// change. From draft-18 it is sent as REQUEST_OK. + async fn publish_ok(stream: &mut Stream) { + stream.writer.varint(ietf::RequestOk::ID).await.unwrap(); + stream + .writer + .encode(&ietf::PublishOk { + request_id: None, + forward: false, + subscriber_priority: Some(128), + group_order: Some(GroupOrder::Ascending), + filter: Some(Filter::NextObject), + new_group: None, + }) + .await + .unwrap(); + } + + fn update(request_id: u64, forward: bool) -> ietf::SubscribeUpdate { + ietf::SubscribeUpdate { + request_id: RequestId(request_id), + subscription_request_id: None, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: None, + forward: Some(forward), + // Join at the live edge: draft-20 moved the filter off PUBLISH_OK. + filter: forward.then_some(Filter::NextObject), + authorization_token: None, + new_group: None, + max_age: None, + } + } + + /// A paused listing PUBLISHes every track under the prefix, later ones included, each + /// paused, and ends a track's PUBLISH with PUBLISH_DONE once it is no longer listed. + #[moq_clock::test] + async fn a_paused_listing_publishes_every_track() { + for version in DRAFTS { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + + let mut published = HashMap::new(); + for _ in 0..2 { + let (stream, namespace, name, forward) = h.publish().await; + assert_eq!(namespace, "room/cam", "{version}"); + assert!(!forward, "{version}: {name} forwards"); + published.insert(name, stream); + } + assert!( + published.contains_key("video") && published.contains_key("audio"), + "{version}" + ); + + let _late = h + .broadcast + .create_track("captions", crate::track::Info::timed()) + .unwrap(); + let (_stream, _, name, _) = h.publish().await; + assert_eq!(name, "captions", "{version}"); + + let mic = h._origin.publish("room/mic", crate::origin::Route::default()).unwrap(); + let _audio = mic.create_track("audio", crate::track::Info::timed()).unwrap(); + let (_stream, namespace, name, _) = h.publish().await; + assert_eq!((namespace.as_str(), name.as_str()), ("room/mic", "audio"), "{version}"); + + let mut audio = published.remove("audio").unwrap(); + publish_ok(&mut audio).await; + drop(h.tracks.remove("audio")); + let (id, body) = next(&mut audio).await; + assert_eq!(id, ietf::PublishDone::ID, "{version}"); + let done: ietf::PublishDone = body.message(version).unwrap(); + assert_eq!(done.stream_count, 0, "{version}"); + } + } + + /// A REQUEST_UPDATE with FORWARD=1 resumes a paused PUBLISH: its answer names where the + /// subscription joins, and new groups flow. FORWARD=0 pauses it again. + #[moq_clock::test] + async fn an_update_resumes_and_pauses_a_publish() { + for version in DRAFTS { + let mut h = harness(version); + let video = h.tracks.remove("video").unwrap(); + let mut group = video.append_group().unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"cached".as_slice()).unwrap(); + group.finish().unwrap(); + + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "video" { + break stream; + } + }; + publish_ok(&mut stream).await; + send(&mut stream, &update(2, true)).await; + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::RequestOk::ID, "{version}"); + let ok: ietf::RequestOk = body.message(version).unwrap(); + assert_eq!(ok.largest, Some(Location { group: 0, object: 0 }), "{version}"); + + let mut group = video.append_group().unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"live".as_slice()).unwrap(); + group.finish().unwrap(); + // The joined group's remainder, then the live group. + let mut opened = 0; + loop { + let recv = timeout(std::future::poll_fn(|cx| h.peer.poll_accept_uni(cx))) + .await + .unwrap(); + opened += 1; + let mut reader = crate::coding::Reader::new(recv, version); + let mut bytes = Vec::new(); + while let Some(chunk) = timeout(std::future::poll_fn(|cx| reader.poll_read_chunk(cx, 1024))) + .await + .unwrap() + { + bytes.extend_from_slice(&chunk); + } + assert!(!bytes.windows(6).any(|w| w == b"cached"), "{version}: resent the join"); + if bytes.windows(4).any(|w| w == b"live") { + break; + } + } + + send(&mut stream, &update(4, false)).await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID, "{version}"); + let mut group = video.append_group().unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"paused".as_slice()).unwrap(); + group.finish().unwrap(); + moq_clock::sleep(Duration::from_millis(10)).await; + let more = futures::poll!(std::future::poll_fn(|cx| h.peer.poll_accept_uni(cx))); + assert!(more.is_pending(), "{version}: a paused PUBLISH forwarded"); + + // Ending the track while paused ends the PUBLISH, counting the streams it opened. + drop(video); + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::PublishDone::ID, "{version}"); + let done: ietf::PublishDone = body.message(version).unwrap(); + assert_eq!(done.stream_count, opened, "{version}"); + } + } + + /// FORWARD=1 asks for every track's objects, which we never push unasked: from + /// draft-19 each track is skipped, and draft-18, with no way to skip, refuses it. + #[moq_clock::test] + async fn a_forwarding_listing_skips_every_track() { + for version in DRAFTS { + let mut h = harness(version); + let mut request = h.request(&listing(true)).await; + let (id, body) = next(&mut request).await; + if version == Version::Draft18 { + assert_eq!(id, ietf::RequestError::ID); + assert_eq!(request_error(&body, version), request::NOT_SUPPORTED); + continue; + } + assert_eq!(id, ietf::RequestOk::ID, "{version}"); + let mut skipped = Vec::new(); + for _ in 0..2 { + let (id, body) = next(&mut request).await; + assert_eq!(id, ietf::PublishSkipped::ID, "{version}"); + let skip: ietf::PublishSkipped = body.message(version).unwrap(); + assert_eq!(skip.suffix.as_str(), "cam", "{version}"); + skipped.push(skip.track_name.to_string()); + } + skipped.sort(); + assert_eq!(skipped, ["audio", "video"], "{version}"); + let opened = futures::poll!(std::pin::pin!(Stream::accept(&mut h.peer, version))); + assert!(opened.is_pending(), "{version}: a skipped track was PUBLISHed"); + } + } + + /// A prefix overlapping an established listing is refused, and freed by its cancel. + #[moq_clock::test] + async fn an_overlapping_prefix_is_refused() { + for version in DRAFTS { + let mut h = harness(version); + let mut first = h.request(&listing(false)).await; + assert_eq!(next(&mut first).await.0, ietf::RequestOk::ID, "{version}"); + + let mut nested = listing(false); + nested.request_id = RequestId(2); + nested.prefix = crate::Path::new("room/cam"); + let mut second = h.request(&nested).await; + let (id, body) = next(&mut second).await; + assert_eq!(id, ietf::RequestError::ID, "{version}"); + assert_eq!(request_error(&body, version), request::PREFIX_OVERLAP, "{version}"); + + first.writer.abort(&Error::Cancel); + moq_clock::sleep(Duration::from_millis(10)).await; + nested.request_id = RequestId(4); + let mut third = h.request(&nested).await; + assert_eq!(next(&mut third).await.0, ietf::RequestOk::ID, "{version}"); + } + } + + /// Draft-18 allows one subscription per track: a SUBSCRIBE to a PUBLISHed track is a + /// duplicate. Later drafts serve it. + #[moq_clock::test] + async fn a_subscribe_to_a_published_track() { + for version in DRAFTS { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let _publishes = [h.publish().await, h.publish().await]; + + let subscribe = ietf::Subscribe { + request_id: RequestId(2), + track_namespace: crate::Path::new("room/cam"), + track_name: "video".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + forward: true, + rendezvous: false, + range_filters: false, + authorization_token: None, + epoch: None, + max_age: None, + new_group: None, + }; + let mut stream = h.request(&subscribe).await; + let (id, body) = next(&mut stream).await; + match version { + Version::Draft18 => { + assert_eq!(id, ietf::RequestError::ID); + assert_eq!(request_error(&body, version), request::DUPLICATE_SUBSCRIPTION); + } + _ => assert_eq!(id, ietf::SubscribeOk::ID, "{version}"), + } + } + } + + /// Each paused PUBLISH holds a subscription slot; past the cap the track is skipped + /// (PUBLISH_BLOCKED, the same message, on draft-18) rather than held. + #[moq_clock::test] + async fn past_the_subscription_cap_a_track_is_skipped() { + for version in DRAFTS { + let mut h = harness(version); + h.publisher.subscriptions = crate::session::Slots::new(1); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let (_stream, _, published, _) = h.publish().await; + let (id, body) = next(&mut request).await; + assert_eq!(id, ietf::PublishSkipped::ID, "{version}"); + let skip: ietf::PublishSkipped = body.message(version).unwrap(); + assert_ne!(skip.track_name, published, "{version}"); + } + } + + /// Cancelling the listing stops new PUBLISHes, while those already sent live on until + /// their broadcast ends, which ends each with PUBLISH_DONE. + #[moq_clock::test] + async fn a_cancelled_listing_keeps_its_publishes() { + for version in DRAFTS { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let (mut first, ..) = h.publish().await; + let (mut second, ..) = h.publish().await; + publish_ok(&mut first).await; + publish_ok(&mut second).await; + + request.writer.abort(&Error::Cancel); + moq_clock::sleep(Duration::from_millis(10)).await; + let _late = h + .broadcast + .create_track("captions", crate::track::Info::timed()) + .unwrap(); + moq_clock::sleep(Duration::from_millis(10)).await; + let opened = futures::poll!(std::pin::pin!(Stream::accept(&mut h.peer, version))); + assert!(opened.is_pending(), "{version}: PUBLISHed after the cancel"); + + drop(h.broadcast); + for stream in [&mut first, &mut second] { + assert_eq!(next(stream).await.0, ietf::PublishDone::ID, "{version}"); + } + } + } + + /// On draft-18 a replacement listing skips the tracks whose PUBLISHes outlive the one it + /// replaced, since each is still the peer's one subscription; later drafts PUBLISH them + /// again. + #[moq_clock::test] + async fn a_relisting_skips_tracks_still_published_on_draft_18() { + for version in [Version::Draft18, Version::Draft19] { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let (mut first, ..) = h.publish().await; + let (mut second, ..) = h.publish().await; + publish_ok(&mut first).await; + publish_ok(&mut second).await; + request.writer.abort(&Error::Cancel); + moq_clock::sleep(Duration::from_millis(10)).await; + + let relisting = ietf::SubscribeTracks { + request_id: RequestId(2), + ..listing(false) + }; + let mut relisting = h.request(&relisting).await; + assert_eq!(next(&mut relisting).await.0, ietf::RequestOk::ID, "{version}"); + if version == Version::Draft18 { + for _ in 0..2 { + assert_eq!(next(&mut relisting).await.0, ietf::PublishSkipped::ID); + } + let opened = futures::poll!(std::pin::pin!(Stream::accept(&mut h.peer, version))); + assert!(opened.is_pending(), "PUBLISHed a track twice"); + } else { + let _publishes = [h.publish().await, h.publish().await]; + } + } + } + + /// On drafts 18 and 19 a PUBLISH_OK may ask for a new group only if the PUBLISH sent + /// DYNAMIC_GROUPS; one that asks anyway ends the PUBLISH in error. + #[moq_clock::test] + async fn a_publish_ok_new_group_needs_dynamic_groups() { + for version in [Version::Draft18, Version::Draft19] { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let (mut stream, ..) = h.publish().await; + stream.writer.varint(ietf::RequestOk::ID).await.unwrap(); + stream + .writer + .encode(&ietf::PublishOk { + request_id: None, + forward: true, + subscriber_priority: None, + group_order: None, + filter: Some(Filter::NextObject), + new_group: Some(0), + }) + .await + .unwrap(); + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::PublishDone::ID, "{version}"); + let done: ietf::PublishDone = body.message(version).unwrap(); + assert!(done.end(version).is_err(), "{version}: ended cleanly"); + } + } + + /// Draft-18 and 19 let PUBLISH_OK forward at once, with no update to answer. + #[moq_clock::test] + async fn a_forwarding_publish_ok_resumes() { + for version in [Version::Draft18, Version::Draft19] { + let mut h = harness(version); + let video = h.tracks.remove("video").unwrap(); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "video" { + break stream; + } + }; + stream.writer.varint(ietf::RequestOk::ID).await.unwrap(); + stream + .writer + .encode(&ietf::PublishOk { + request_id: None, + forward: true, + subscriber_priority: Some(128), + group_order: Some(GroupOrder::Ascending), + filter: Some(Filter::NextObject), + new_group: None, + }) + .await + .unwrap(); + + let mut group = video.append_group().unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"live".as_slice()).unwrap(); + group.finish().unwrap(); + timeout(std::future::poll_fn(|cx| h.peer.poll_accept_uni(cx))) + .await + .unwrap(); + drop(video); + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::PublishDone::ID, "{version}: answered no update"); + let done: ietf::PublishDone = body.message(version).unwrap(); + assert_eq!(done.stream_count, 1, "{version}"); + } + } + + /// A REQUEST_UPDATE on the listing admits its Request ID like any other: one of the + /// wrong parity closes the session rather than only refusing the update. + #[moq_clock::test] + async fn a_listing_update_admits_its_request_id() { + for version in DRAFTS { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + // An odd Request ID is ours to allocate, never the client peer's. + let bare = ietf::SubscribeUpdate { + forward: None, + filter: None, + ..update(1, false) + }; + send(&mut request, &bare).await; + let closed = timeout(std::future::poll_fn(|cx| h.peer.poll_closed(cx))).await; + assert!( + matches!(&closed, memory::Error::Session { reason, .. } if reason == "invalid request ID"), + "{version}: {closed:?}" + ); + } + } + + /// A REQUEST_UPDATE on the listing is answered with REQUEST_ERROR. From draft-20 it may + /// carry FORWARD, which is refused the same way rather than closing the session. + #[moq_clock::test] + async fn a_listing_update_is_refused() { + for version in DRAFTS { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let refused = ietf::SubscribeUpdate { + filter: None, + forward: Filter::is_draft20(version).then_some(false), + ..update(2, false) + }; + send(&mut request, &refused).await; + let (id, body) = next(&mut request).await; + assert_eq!(id, ietf::RequestError::ID, "{version}"); + assert_eq!(request_error(&body, version), request::NOT_SUPPORTED, "{version}"); + } + } + + /// A listing update past our GOAWAY is refused as GOING_AWAY, not as unsupported. + #[moq_clock::test] + async fn a_listing_update_after_goaway_is_going_away() { + for version in DRAFTS { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + h.publisher.control.goaway().unwrap(); + let refused = ietf::SubscribeUpdate { + filter: None, + forward: None, + ..update(2, false) + }; + send(&mut request, &refused).await; + let (id, body) = next(&mut request).await; + assert_eq!(id, ietf::RequestError::ID, "{version}"); + let going_away = request::to_code(&Error::GoingAway, request::Kind::SubscribeNamespace, version); + assert_eq!(request_error(&body, version), going_away, "{version}"); + } + } + + /// NEW_GROUP_REQUEST resuming a paused PUBLISH is a join under the track's refresh + /// ratio, as on a SUBSCRIBE, even when the filter's fixed start is not. The listing's + /// own is each PUBLISH's default. + #[moq_clock::test] + async fn a_resume_asks_for_a_new_group() { + for on_listing in [false, true] { + resume_asks_for_a_new_group(on_listing).await; + } + } + + async fn resume_asks_for_a_new_group(on_listing: bool) { + let version = Version::Draft22; + let mut h = harness(version); + let live = h + .broadcast + .create_track("live", crate::track::Info::timed().with_refresh(Some(1))) + .unwrap(); + let mut group = live.create_group(group::Info { sequence: 4 }).unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"key".as_slice()).unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"delta".as_slice()).unwrap(); + + let request = ietf::SubscribeTracks { + new_group: on_listing.then_some(0), + ..listing(false) + }; + let mut request = h.request(&request).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "live" { + break stream; + } + }; + publish_ok(&mut stream).await; + send( + &mut stream, + &ietf::SubscribeUpdate { + filter: Some(Filter::Absolute { + start: Location { group: 4, object: 0 }, + end: None, + }), + new_group: (!on_listing).then_some(0), + ..update(2, true) + }, + ) + .await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID); + assert_eq!( + live.demand().wanted(), + Some(5), + "on_listing={on_listing}: the join never reached the track" + ); + } + + /// From draft-20 an update may ask for a new group only if its PUBLISH sent + /// DYNAMIC_GROUPS, which INCLUDE_PROPERTIES=0 leaves out even for a dynamic track. + #[moq_clock::test] + async fn a_new_group_needs_the_advertised_dynamic_groups() { + for version in [Version::Draft20, Version::Draft21, Version::Draft22] { + let mut h = harness(version); + let live = h + .broadcast + .create_track("live", crate::track::Info::timed().with_refresh(Some(1))) + .unwrap(); + let request = ietf::SubscribeTracks { + properties_wanted: false, + ..listing(false) + }; + let mut request = h.request(&request).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "live" { + break stream; + } + }; + publish_ok(&mut stream).await; + send( + &mut stream, + &ietf::SubscribeUpdate { + new_group: Some(0), + ..update(2, true) + }, + ) + .await; + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::RequestError::ID, "{version}"); + assert_eq!(request_error(&body, version), request::NOT_SUPPORTED, "{version}"); + assert_eq!( + live.demand().wanted(), + None, + "{version}: the refused join reached the track" + ); + } + } + + /// While forwarding, an update's NEW_GROUP_REQUEST also needs the advertised + /// DYNAMIC_GROUPS, which INCLUDE_PROPERTIES=0 leaves out. + #[moq_clock::test] + async fn a_forwarding_new_group_needs_the_advertised_dynamic_groups() { + let version = Version::Draft22; + let mut h = harness(version); + let _live = h + .broadcast + .create_track("live", crate::track::Info::timed().with_refresh(Some(1))) + .unwrap(); + let request = ietf::SubscribeTracks { + properties_wanted: false, + ..listing(false) + }; + let mut request = h.request(&request).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "live" { + break stream; + } + }; + publish_ok(&mut stream).await; + send(&mut stream, &update(2, true)).await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID); + let ask = ietf::SubscribeUpdate { + forward: None, + filter: None, + new_group: Some(0), + ..update(4, true) + }; + send(&mut stream, &ask).await; + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::RequestError::ID); + assert_eq!(request_error(&body, version), request::NOT_SUPPORTED); + } + + /// A paused subscription opens no groups, so an update that pauses may not ask for one, + /// while forwarding as while paused. + #[moq_clock::test] + async fn a_pausing_new_group_is_refused() { + for version in [Version::Draft18, Version::Draft22] { + let mut h = harness(version); + let _live = h + .broadcast + .create_track("live", crate::track::Info::timed().with_refresh(Some(1))) + .unwrap(); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "live" { + break stream; + } + }; + publish_ok(&mut stream).await; + send(&mut stream, &update(2, true)).await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID, "{version}"); + let ask = ietf::SubscribeUpdate { + new_group: Some(0), + ..update(4, false) + }; + send(&mut stream, &ask).await; + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::RequestError::ID, "{version}"); + assert_eq!(request_error(&body, version), request::NOT_SUPPORTED, "{version}"); + } + } + + /// An update taken while forwarding outlives a pause: the next resume keeps it. + #[moq_clock::test] + async fn a_forwarding_update_outlives_a_pause() { + for version in [Version::Draft18, Version::Draft22] { + let mut h = harness(version); + let video = h.tracks.remove("video").unwrap(); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "video" { + break stream; + } + }; + publish_ok(&mut stream).await; + send(&mut stream, &update(2, true)).await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID, "{version}"); + let reprice = ietf::SubscribeUpdate { + subscriber_priority: Some(7), + filter: None, + ..update(4, false) + }; + send(&mut stream, &reprice).await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID, "{version}"); + send(&mut stream, &update(6, true)).await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID, "{version}"); + let priority = video.subscription().map(|subscription| subscription.priority); + assert_eq!(priority, Some(crate::ietf::priority::from_wire(7)), "{version}"); + } + } + + /// A paused listing's FILL_PARAMETERS (draft-20+) open no fill, even once a PUBLISH + /// resumes without its own (draft-22 section 3.4.1). + #[moq_clock::test] + async fn a_listing_fill_opens_nothing() { + for version in [Version::Draft20, Version::Draft22] { + let mut h = harness(version); + let video = h.tracks.remove("video").unwrap(); + let mut group = video.append_group().unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"cached".as_slice()).unwrap(); + + let request = ietf::SubscribeTracks { + filter: Some(Filter::NextObject), + fill: Some(ietf::Fill { + filter: Some(Filter::Relative(1)), + range_filters: false, + }), + ..listing(false) + }; + let mut request = h.request(&request).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "video" { + break stream; + } + }; + publish_ok(&mut stream).await; + let resume = ietf::SubscribeUpdate { + filter: None, + ..update(2, true) + }; + send(&mut stream, &resume).await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID, "{version}"); + + // The joined group's own stream carries only what follows the join; a fill would + // carry the cached object. + group.finish().unwrap(); + moq_clock::sleep(Duration::from_millis(10)).await; + while let Poll::Ready(recv) = futures::poll!(std::future::poll_fn(|cx| h.peer.poll_accept_uni(cx))) { + let mut reader = crate::coding::Reader::new(recv.unwrap(), version); + let mut bytes = Vec::new(); + while let Some(chunk) = timeout(std::future::poll_fn(|cx| reader.poll_read_chunk(cx, 1024))) + .await + .unwrap() + { + bytes.extend_from_slice(&chunk); + } + assert!( + !bytes.windows(6).any(|w| w == b"cached"), + "{version}: the listing's fill opened" + ); + } + } + } + + /// On draft-18 a track the peer already SUBSCRIBEd to is skipped rather than PUBLISHed + /// a second time; later drafts PUBLISH it. + #[moq_clock::test] + async fn a_subscribed_track_is_skipped_on_draft_18() { + for version in [Version::Draft18, Version::Draft19] { + let mut h = harness(version); + let subscribe = ietf::Subscribe { + request_id: RequestId(0), + track_namespace: crate::Path::new("room/cam"), + track_name: "video".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + forward: true, + rendezvous: false, + range_filters: false, + authorization_token: None, + epoch: None, + max_age: None, + new_group: None, + }; + let mut subscription = h.request(&subscribe).await; + assert_eq!(next(&mut subscription).await.0, ietf::SubscribeOk::ID, "{version}"); + + let request = ietf::SubscribeTracks { + request_id: RequestId(2), + ..listing(false) + }; + let mut request = h.request(&request).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let (_audio, _, name, _) = h.publish().await; + if version == Version::Draft18 { + assert_eq!(name, "audio"); + let (id, body) = next(&mut request).await; + assert_eq!(id, ietf::PublishSkipped::ID); + let skip: ietf::PublishSkipped = body.message(version).unwrap(); + assert_eq!(skip.track_name, "video"); + } else { + let (_, _, other, _) = h.publish().await; + assert!([name, other].contains(&"video".to_string()), "{version}"); + } + } + } + + /// From draft-20 each PUBLISH states the listing's subscription parameters, which the + /// subscriber otherwise takes as the defaults; earlier drafts carry them on PUBLISH_OK. + #[moq_clock::test] + async fn a_listing_states_its_parameters_on_each_publish() { + for version in DRAFTS { + let mut h = harness(version); + let request = ietf::SubscribeTracks { + subscriber_priority: Some(7), + group_order: Some(GroupOrder::Ascending), + filter: Some(Filter::NextObject), + ..listing(false) + }; + let mut request = h.request(&request).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let mut stream = timeout(Stream::accept(&mut h.peer, version)).await.unwrap(); + assert_eq!(stream.reader.varint().await.unwrap(), ietf::Publish::ID); + let body: ietf::Body = stream.reader.decode().await.unwrap(); + let publish: ietf::Publish = body.message(version).unwrap(); + let stated = Filter::is_draft20(version); + assert_eq!(publish.subscriber_priority, stated.then_some(7), "{version}"); + assert_eq!( + publish.group_order, + stated.then_some(GroupOrder::Ascending), + "{version}" + ); + assert_eq!(publish.filter, stated.then_some(Filter::NextObject), "{version}"); + } + } + + /// A forwarding PUBLISH ends with PUBLISH_DONE once its track is no longer listed, here + /// because its broadcast's route went, even while the track itself goes on. The track + /// has not ended, so neither does the PUBLISH cleanly. + #[moq_clock::test] + async fn an_unlisted_forwarding_publish_ends() { + for version in [Version::Draft18, Version::Draft22] { + let mut h = harness(version); + let video = h.tracks.remove("video").unwrap(); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let mut stream = loop { + let (stream, _, name, _) = h.publish().await; + if name == "video" { + break stream; + } + }; + publish_ok(&mut stream).await; + send(&mut stream, &update(2, true)).await; + assert_eq!(next(&mut stream).await.0, ietf::RequestOk::ID, "{version}"); + + drop(h.broadcast); + let mut group = video.append_group().unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"after".as_slice()).unwrap(); + group.finish().unwrap(); + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::PublishDone::ID, "{version}"); + let done: ietf::PublishDone = body.message(version).unwrap(); + assert!(done.end(version).is_err(), "{version}: ended cleanly"); + } + } + + /// A paused PUBLISH ends in error once its track is unlisted, here because its + /// broadcast's route went, never with the clean end that says the track is over. + #[moq_clock::test] + async fn an_unlisted_paused_publish_ends_in_error() { + for version in DRAFTS { + let mut h = harness(version); + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + let (mut stream, ..) = h.publish().await; + publish_ok(&mut stream).await; + + drop(h.broadcast); + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::PublishDone::ID, "{version}"); + let done: ietf::PublishDone = body.message(version).unwrap(); + assert!(done.end(version).is_err(), "{version}: ended cleanly"); + } + } + + /// A listing ending through a refused update still unlists its tracks while the peer + /// withholds the credit its REQUEST_ERROR needs. + #[moq_clock::test] + async fn a_closing_listing_still_unlists() { + for version in [Version::Draft18, Version::Draft22] { + let config = memory::Config { + stream_buffer: 32, + ..Default::default() + }; + let mut h = harness_with(version, config); + // The REQUEST_OK stays unread, leaving no room for the REQUEST_ERROR. + let mut request = h.request(&listing(false)).await; + let (mut stream, ..) = h.publish().await; + publish_ok(&mut stream).await; + let refused = ietf::SubscribeUpdate { + filter: None, + forward: None, + ..update(2, false) + }; + send(&mut request, &refused).await; + moq_clock::sleep(Duration::from_millis(10)).await; + + // A new instance takes the path while the old one goes on: only its route ends. + let _restarted = h._origin.publish("room/cam", crate::origin::Route::default()).unwrap(); + let (id, body) = next(&mut stream).await; + assert_eq!(id, ietf::PublishDone::ID, "{version}"); + let done: ietf::PublishDone = body.message(version).unwrap(); + assert!(done.end(version).is_err(), "{version}: ended cleanly"); + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + assert_eq!(next(&mut request).await.0, ietf::RequestError::ID, "{version}"); + } + } + + /// With MoQ Auth, no track is listed until the peer answers our setup credential, whose + /// grant may narrow what the listing shows, and a listing cancelled while waiting frees + /// its prefix. + #[moq_clock::test] + async fn a_listing_waits_for_the_setup_answer() { + for version in [Version::Draft18, Version::Draft22] { + let mut h = harness(version); + let auth = crate::auth::Handle::new(true); + let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + let setup = peer::PeerSetup::default(); + setup.set(peer::Peer { + auth: true, + ..Default::default() + }); + h.publisher = Publisher::new( + crate::time::sim(), + h.session.clone(), + h._origin.consume(), + Control::new(None, false), + None, + setup, + version, + ) + .with_auth(auth.clone()); + + let first = h.request(&listing(false)).await; + moq_clock::sleep(Duration::from_millis(10)).await; + let opened = futures::poll!(std::pin::pin!(Stream::accept(&mut h.peer, version))); + assert!(opened.is_pending(), "{version}: listed a track before the setup answer"); + + first.writer.abort(&Error::Cancel); + moq_clock::sleep(Duration::from_millis(10)).await; + let mut again = listing(false); + again.request_id = RequestId(2); + let mut request = h.request(&again).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + + auth.granted(0, crate::auth::Grant::all()); + let (mut stream, ..) = h.publish().await; + publish_ok(&mut stream).await; + } + } + + /// Tracks under a namespace our grant does not cover are not listed until it does, and + /// a grant that stops covering them ends their PUBLISHes. + #[moq_clock::test] + async fn a_listing_follows_the_publish_grant() { + let nothing = || crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Patterns::new(), + expires: None, + }; + for version in [Version::Draft18, Version::Draft22] { + let mut h = harness(version); + let auth = crate::auth::Handle::new(false); + auth.authorize(¬hing()); + h.publisher = h.publisher.clone().with_auth(auth.clone()); + + let mut request = h.request(&listing(false)).await; + assert_eq!(next(&mut request).await.0, ietf::RequestOk::ID, "{version}"); + moq_clock::sleep(Duration::from_millis(10)).await; + let opened = futures::poll!(std::pin::pin!(Stream::accept(&mut h.peer, version))); + assert!(opened.is_pending(), "{version}: listed a track the grant denies"); + + auth.authorize(&crate::auth::Grant::all()); + let (mut first, ..) = h.publish().await; + let (mut second, ..) = h.publish().await; + publish_ok(&mut first).await; + publish_ok(&mut second).await; + + auth.authorize(¬hing()); + for stream in [&mut first, &mut second] { + assert_eq!(next(stream).await.0, ietf::PublishDone::ID, "{version}"); + } + } + } +} diff --git a/rs/moq-net/src/ietf/request.rs b/rs/moq-net/src/ietf/request.rs index b5d461869dd..c5df80a756a 100644 --- a/rs/moq-net/src/ietf/request.rs +++ b/rs/moq-net/src/ietf/request.rs @@ -89,6 +89,9 @@ pub struct RequestOk { /// MoQ Active Count: how many NAMESPACE messages follow before the subscription is /// synced. Only on the answer to a SUBSCRIBE_NAMESPACE, when negotiated. pub active: Option, + /// LARGEST_OBJECT: on the answer to a REQUEST_UPDATE that resumed forwarding, where the + /// subscription now joins (draft-18 section 5.1). Absent before the track has objects. + pub largest: Option, } impl Message for RequestOk { @@ -102,7 +105,10 @@ impl Message for RequestOk { } else { assert!(self.request_id.is_none(), "request_id must be None for draft17+"); } - encode_params!(w, version, ACTIVE_COUNT_PARAM => self.active); + encode_params!(w, version, + 0x09 => self.largest, + ACTIVE_COUNT_PARAM => self.active, + ); Ok(()) } @@ -114,14 +120,17 @@ impl Message for RequestOk { }; // A REQUEST_UPDATE_OK may refresh EXPIRES, which is ignored like SUBSCRIBE_OK's. // LARGEST_OBJECT is a MUST on a REQUEST_UPDATE_OK or TRACK_STATUS_OK once the track - // has objects. We send neither request, so nothing reads it, but rejecting it would - // close the session over a parameter a compliant peer is required to send. + // has objects. decode_params!(r, version, 0x08 => _expires: Option, - 0x09 => _largest: Option, + 0x09 => largest: Option, ACTIVE_COUNT_PARAM => active: Option, ); - Ok(Self { request_id, active }) + Ok(Self { + request_id, + active, + largest, + }) } } @@ -196,6 +205,7 @@ mod tests { #[test] fn test_request_ok_round_trip() { let msg = RequestOk { + largest: None, request_id: Some(RequestId(42)), active: None, }; @@ -245,6 +255,7 @@ mod tests { for (version, request_id) in [(Version::Draft16, Some(RequestId(3))), (Version::Draft22, None)] { for count in [None, Some(0), Some(7)] { let msg = RequestOk { + largest: None, request_id, active: count, }; @@ -293,6 +304,7 @@ mod tests { #[test] fn test_request_ok_v17_round_trip() { let msg = RequestOk { + largest: None, request_id: None, active: None, }; @@ -324,6 +336,7 @@ mod tests { #[test] fn test_request_ok_v18_round_trip() { let msg = RequestOk { + largest: None, request_id: None, active: None, }; @@ -339,6 +352,7 @@ mod tests { #[test] fn test_request_ok_v18_wire_matches_v17() { let msg = RequestOk { + largest: None, request_id: None, active: None, }; diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index 1e58348b9a4..fbf2f3134b4 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -349,6 +349,13 @@ pub(super) struct Update { pub(super) authorization_token: Option, /// NEW_GROUP_REQUEST (draft-15+), which only a track advertising DYNAMIC_GROUPS takes. pub(super) new_group: Option, + /// FORWARD, `None` when unchanged. Only a subscription we initiated with PUBLISH may + /// pause; the rest serve forwarding subscriptions only. + pub(super) forward: Option, + /// The new Location Filter, which only a paused PUBLISH subscription resuming takes. + pub(super) filter: Option, + /// FILL_PARAMETERS (draft-20+), which only a paused PUBLISH subscription resuming takes. + pub(super) fill: Option, } impl Update { @@ -383,7 +390,7 @@ impl Update { if !data.is_empty() { return Err(DecodeError::InvalidValue); } - let (refused, authorization_token) = Self::refused(tokens, !forward); + let (refused, authorization_token) = Self::refused(tokens, false); return Ok(Self { request_id, priority: Some(priority), @@ -391,6 +398,9 @@ impl Update { refused, authorization_token, new_group: None, + forward: Some(forward), + filter: None, + fill: None, }); } // The request it updates, which the adapter already routed by. @@ -425,10 +435,7 @@ impl Update { if !track_filter.is_empty() { return Err(DecodeError::InvalidValue); } - let unsupported = forward == Some(false) - || filter.is_some() - || fill.is_some() - || !subgroup_filter.is_empty() + let unsupported = !subgroup_filter.is_empty() || !object_filter.is_empty() || !priority_filter.is_empty() || !property_filter.is_empty(); @@ -440,6 +447,9 @@ impl Update { refused, authorization_token, new_group, + forward, + filter, + fill, }) } @@ -469,9 +479,11 @@ impl Decode for Update { } } -/// The namespace-only parameters of a modern REQUEST_UPDATE. +/// The namespace-only parameters of a modern REQUEST_UPDATE. `TRACKS` marks one on a +/// SUBSCRIBE_TRACKS, which from draft-19 may also carry TRACK_PROPERTY_FILTER and from +/// draft-20 FORWARD. #[derive(Debug)] -pub(super) struct NamespaceUpdate { +pub(super) struct NamespaceUpdate { pub(super) request_id: super::RequestId, pub(super) prefix: Option, pub(super) refused: Option, @@ -489,7 +501,7 @@ impl super::parameters::Param for NamespacePrefix { } } -impl Decode for NamespaceUpdate { +impl Decode for NamespaceUpdate { fn decode(r: &mut Decoder<'_>, version: Version) -> Result { if r.varint()? != super::SubscribeUpdate::ID { return Err(DecodeError::InvalidValue); @@ -500,6 +512,8 @@ impl Decode for NamespaceUpdate { let request_id = super::RequestId::decode(&mut data, version)?; decode_params!(&mut data, version, 0x03 => token: Vec, + 0x10 => _forward: Option where TRACKS && super::Filter::is_draft20(version), + 0x29 => _track_filter: Vec where TRACKS && super::subscribe::has_range_filters(version), 0x34 => prefix: Option, ); if !data.is_empty() { @@ -652,6 +666,23 @@ mod tests { } } + /// From draft-19 a SUBSCRIBE_TRACKS update may carry TRACK_PROPERTY_FILTER (0x29), so it + /// decodes there, to be refused per request, while a SUBSCRIBE_NAMESPACE's still closes. + #[test] + fn track_property_filter_on_listing_update_decodes() { + // REQUEST_UPDATE, 6 bytes: Request ID 2, one parameter: 0x29 with a two-byte value. + const FRAME: &[u8] = &[0x02, 0x00, 0x06, 2, 1, 0x29, 2, 0, 0]; + for version in [Version::Draft19, Version::Draft21, Version::Draft22] { + let mut buf = bytes::Bytes::from_static(FRAME); + let update = crate::coding::decode_buf(&mut buf, version, NamespaceUpdate::::decode).unwrap(); + assert!(update.refused.is_none(), "{version}: {:?}", update.refused); + let mut buf = bytes::Bytes::from_static(FRAME); + assert!(crate::coding::decode_buf(&mut buf, version, NamespaceUpdate::::decode).is_err()); + } + let mut buf = bytes::Bytes::from_static(FRAME); + assert!(crate::coding::decode_buf(&mut buf, Version::Draft18, NamespaceUpdate::::decode).is_err()); + } + /// TRACK_NAMESPACE_PREFIX (0x34) updates a SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS only, /// so on a subscription's REQUEST_UPDATE it closes the session instead of being acked. #[test] diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 32eeef71a22..20816b9fd00 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1307,7 +1307,7 @@ impl Bidi { | ietf::Fetch::ID | ietf::SubscribeNamespace::ID | ietf::SubscribeNamespaceLegacy::ID - | ietf::SUBSCRIBE_TRACKS_ID + | ietf::SubscribeTracks::ID | ietf::TrackStatus::ID | ietf::Publish::ID | ietf::PublishNamespace::ID @@ -1338,7 +1338,7 @@ impl Bidi { | ietf::Fetch::ID | ietf::SubscribeNamespace::ID | ietf::SubscribeNamespaceLegacy::ID - | ietf::SUBSCRIBE_TRACKS_ID + | ietf::SubscribeTracks::ID | ietf::TrackStatus::ID => { publisher.handle_stream(id, data, stream, epoch)?.await; } @@ -1716,6 +1716,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) @@ -3524,7 +3525,7 @@ mod tests { "a 4097 byte full track name", ), ( - frame(version, ietf::SUBSCRIBE_TRACKS_ID, &oversized_prefix), + frame(version, ietf::SubscribeTracks::ID, &oversized_prefix), SessionError::ProtocolViolation, "a 4097 byte SUBSCRIBE_TRACKS prefix", ), diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 45a317b22a2..12e6faeddfe 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -11,13 +11,6 @@ use super::publish_namespace::{AdvertParams, Negotiated}; use super::Version; -/// SUBSCRIBE_TRACKS message ID (0x51) introduced in draft-18 (#1542). -/// -/// moq-lite does not implement PUBLISH replication through a CDN, which is the -/// only thing that SUBSCRIBE_TRACKS enables (subscribing to all tracks under a -/// prefix), so each one is refused with REQUEST_ERROR NOT_SUPPORTED. -pub const SUBSCRIBE_TRACKS_ID: u64 = 0x51; - /// True for the drafts that use the legacy 0x11 SUBSCRIBE_NAMESPACE message. /// Draft-18+ uses the renumbered 0x50 message instead. fn is_legacy_version(version: Version) -> bool { @@ -316,37 +309,6 @@ impl Message for Namespace<'_> { } } -/// PUBLISH_BLOCKED message (0x0F): draft-17 only -/// Indicates a track within a namespace is blocked from publishing. -// The session does not dispatch this draft-17 message; keep its codec for fuzz coverage. -#[cfg(any(test, feature = "fuzz"))] -#[derive(Clone, Debug)] -pub struct PublishBlocked<'a> { - pub suffix: Path<'a>, - pub track_name: Cow<'a, str>, -} - -#[cfg(any(test, feature = "fuzz"))] -impl Message for PublishBlocked<'_> { - const ID: u64 = 0x0F; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - assert!(version == Version::Draft17, "PublishBlocked is draft17 only"); - encode_namespace(w, &self.suffix)?; - w.string(&self.track_name)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - if version != Version::Draft17 { - return Err(DecodeError::Unsupported); - } - let suffix = decode_namespace(r)?; - let track_name = Cow::Owned(r.string()?); - Ok(Self { suffix, track_name }) - } -} - /// NAMESPACE_DONE message (0x0E): v16 only, sent on SUBSCRIBE_NAMESPACE bidi stream /// Indicates a namespace suffix matching the subscribed prefix is no longer active. #[derive(Clone, Debug)] diff --git a/rs/moq-net/src/ietf/subscribe_tracks.rs b/rs/moq-net/src/ietf/subscribe_tracks.rs new file mode 100644 index 00000000000..64c7b2a26d0 --- /dev/null +++ b/rs/moq-net/src/ietf/subscribe_tracks.rs @@ -0,0 +1,295 @@ +//! IETF moq-transport SUBSCRIBE_TRACKS (draft-18+) and the PUBLISH_SKIPPED answering it. + +use std::borrow::Cow; + +use crate::{ + Path, + coding::*, + ietf::{Filter, GroupOrder, Opaque, RequestId}, +}; + +use super::Message; +use super::Version; +use super::namespace::{decode_namespace, encode_namespace}; +use super::subscribe::has_range_filters; +use super::token::RequestToken; + +/// SUBSCRIBE_TRACKS (0x51, draft-18+): list every track under a namespace prefix, each +/// answered with a PUBLISH (or a [`PublishSkipped`]) as it appears. +/// +/// Its subscription parameters are the defaults of each PUBLISH it produces. +#[derive(Clone, Debug)] +pub struct SubscribeTracks<'a> { + pub request_id: RequestId, + pub prefix: Path<'a>, + /// FORWARD: absent means 1, which asks each PUBLISH to push its objects at once. + pub forward: bool, + /// The default subscriber priority of each PUBLISH subscription. + pub subscriber_priority: Option, + /// The default group order of each PUBLISH subscription. + pub group_order: Option, + /// The default Location Filter of each PUBLISH subscription. + pub filter: Option, + /// FILL_PARAMETERS (draft-20+). Each PUBLISH starts paused, where a fill opens nothing, + /// even once resumed (draft-22 section 3.4.1), so the publisher ignores it. + pub fill: Option, + /// INCLUDE_PROPERTIES (draft-20+): whether each PUBLISH carries Track Properties. + pub properties_wanted: bool, + /// NEW_GROUP_REQUEST, which each PUBLISH applies as it first forwards. + pub new_group: Option, + /// Whether a Range Filter (0x25-0x28) narrows each subscription. We advertise no + /// MAX_FILTER_RANGES, so the request is refused. Never encoded. + pub range_filters: bool, + /// Whether TRACK_PROPERTY_FILTER (0x29) narrows which tracks are listed, which we + /// refuse rather than list tracks the subscriber excluded. Never encoded. + pub track_filter: bool, + /// The `AUTHORIZATION TOKEN` (0x03) this request presents. + pub authorization_token: Option, +} + +impl Message for SubscribeTracks<'_> { + const ID: u64 = 0x51; + + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + if !has_subscribe_tracks(version) { + return Err(EncodeError::Version); + } + self.request_id.encode(w, version)?; + encode_namespace(w, &self.prefix)?; + // Absent means 1, so only a paused listing writes it. + let include_properties = (!self.properties_wanted && Filter::is_draft20(version)).then_some(false); + // Draft-18 allows only AUTHORIZATION_TOKEN and FORWARD here. + let defaults = version != Version::Draft18; + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + 0x10 => (!self.forward).then_some(false), + 0x20 => self.subscriber_priority.filter(|_| defaults), + 0x21 => self.filter.filter(|_| defaults), + 0x22 => self.group_order.filter(|_| defaults), + 0x23 => self.fill.filter(|_| Filter::is_draft20(version)), + 0x32 => self.new_group.filter(|_| defaults), + 0x35 => include_properties, + ); + Ok(()) + } + + fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { + if !has_subscribe_tracks(version) { + return Err(DecodeError::Version); + } + let request_id = RequestId::decode(r, version)?; + let prefix = decode_namespace(r)?; + // From draft-19 any parameter a SUBSCRIBE takes is legal here (section 10.19.1); + // draft-18 allows only AUTHORIZATION_TOKEN and FORWARD. Those that only make sense + // on one subscription, such as RENDEZVOUS_TIMEOUT or a delivery timeout, are read + // and dropped. + let defaults = version != Version::Draft18; + decode_params!(r, version, + 0x02 => _object_delivery_timeout: Option where defaults, + 0x03 => authorization_token: Vec, + 0x04 => _rendezvous_timeout: Option where defaults, + 0x06 => _subgroup_delivery_timeout: Option where defaults, + 0x10 => forward: Option, + 0x20 => subscriber_priority: Option where defaults, + 0x21 => filter: Option where defaults, + 0x22 => group_order: Option where defaults, + 0x23 => fill: Option where Filter::is_draft20(version), + 0x25 => subgroup_filter: Vec where has_range_filters(version), + 0x26 => object_id_filter: Vec where has_range_filters(version), + 0x27 => priority_filter: Vec where has_range_filters(version), + 0x28 => object_property_filter: Vec where has_range_filters(version), + 0x29 => track_property_filter: Vec where has_range_filters(version), + 0x32 => new_group: Option where defaults, + 0x35 => include_properties: Option where Filter::is_draft20(version), + ); + let range_filters = [ + subgroup_filter, + object_id_filter, + priority_filter, + object_property_filter, + ] + .iter() + .any(|filter| !filter.is_empty()); + // Last, so a malformed field closes the session before a token refusal. + let authorization_token = super::token::single(authorization_token)?; + + Ok(Self { + request_id, + prefix, + forward: forward.unwrap_or(true), + subscriber_priority, + group_order, + filter, + fill, + properties_wanted: include_properties.unwrap_or(true), + new_group, + range_filters, + track_filter: !track_property_filter.is_empty(), + authorization_token, + }) + } +} + +/// Whether `version` defines SUBSCRIBE_TRACKS: draft-18 split it off SUBSCRIBE_NAMESPACE. +pub(crate) fn has_subscribe_tracks(version: Version) -> bool { + !matches!( + version, + Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 + ) +} + +/// PUBLISH_SKIPPED (0x0F): a track under a SUBSCRIBE_TRACKS prefix the publisher will +/// not PUBLISH, sent on that request's stream. The subscriber may SUBSCRIBE to it instead. +/// +/// Drafts 17 and 18 name it PUBLISH_BLOCKED and send it only when the publisher has run +/// out of streams for PUBLISH; draft-19 renamed it and allows it for any reason. +#[derive(Clone, Debug)] +pub struct PublishSkipped<'a> { + /// The track's namespace below the request's prefix. + pub suffix: Path<'a>, + pub track_name: Cow<'a, str>, +} + +impl Message for PublishSkipped<'_> { + const ID: u64 = 0x0F; + + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + if !has_publish_skipped(version) { + return Err(EncodeError::Version); + } + encode_namespace(w, &self.suffix)?; + w.string(&self.track_name)?; + Ok(()) + } + + fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { + if !has_publish_skipped(version) { + return Err(DecodeError::Version); + } + let suffix = decode_namespace(r)?; + let track_name = Cow::Owned(r.string()?); + Ok(Self { suffix, track_name }) + } +} + +/// Whether `version` defines [`PublishSkipped`], under either name. Draft-16 and older +/// had no message 0x0F. +fn has_publish_skipped(version: Version) -> bool { + !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encode(msg: &M, version: Version) -> Vec { + let mut buf = Vec::new(); + msg.encode_msg(&mut Encoder::new(&mut buf, version.into()), version) + .unwrap(); + buf + } + + fn decode(bytes: &[u8], version: Version) -> Result { + let mut buf = bytes::Bytes::from(bytes.to_vec()); + crate::coding::decode_buf(&mut buf, version, M::decode_msg) + } + + fn paused() -> SubscribeTracks<'static> { + SubscribeTracks { + request_id: RequestId(4), + prefix: Path::new("room"), + forward: false, + subscriber_priority: None, + group_order: None, + filter: None, + fill: None, + properties_wanted: true, + new_group: None, + range_filters: false, + track_filter: false, + authorization_token: None, + } + } + + #[test] + fn a_paused_listing_round_trips() { + for version in [ + Version::Draft18, + Version::Draft19, + Version::Draft20, + Version::Draft21, + Version::Draft22, + ] { + let decoded: SubscribeTracks = decode(&encode(&paused(), version), version).unwrap(); + assert_eq!(decoded.request_id, RequestId(4), "{version}"); + assert_eq!(decoded.prefix.as_str(), "room", "{version}"); + assert!(!decoded.forward, "{version}"); + } + } + + /// FORWARD defaults to 1, so a listing that omits it asks for every track's objects. + #[test] + fn an_omitted_forward_means_one() { + // Request ID 4, prefix "room", no parameters. + let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x00]; + let decoded: SubscribeTracks = decode(&bytes, Version::Draft22).unwrap(); + assert!(decoded.forward); + } + + /// TRACK_PROPERTY_FILTER (draft-19+) is read so the request can be refused, not served + /// with tracks the subscriber excluded. + #[test] + fn a_track_property_filter_is_noted() { + // Request ID 4, prefix "room", one parameter: 0x29 with a two-byte opaque value. + let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x01, 0x29, 0x02, 0xaa, 0xbb]; + let decoded: SubscribeTracks = decode(&bytes, Version::Draft19).unwrap(); + assert!(decoded.track_filter); + // Draft-18 has no such parameter, and an unknown even type is malformed there. + assert!(decode::(&bytes, Version::Draft18).is_err()); + } + + /// Draft-18 carries no subscription parameters on SUBSCRIBE_TRACKS: one is not sent, + /// and one received is malformed. + #[test] + fn draft_18_carries_no_subscription_parameters() { + let listing = SubscribeTracks { + subscriber_priority: Some(7), + group_order: Some(GroupOrder::Ascending), + filter: Some(Filter::NextObject), + new_group: Some(0), + ..paused() + }; + assert_eq!(encode(&listing, Version::Draft18), encode(&paused(), Version::Draft18)); + let decoded: SubscribeTracks = decode(&encode(&listing, Version::Draft19), Version::Draft19).unwrap(); + assert_eq!(decoded.subscriber_priority, Some(7)); + assert_eq!(decoded.new_group, Some(0)); + // Request ID 4, prefix "room", one parameter: SUBSCRIBER_PRIORITY 7. + let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x01, 0x20, 0x07]; + assert!(decode::(&bytes, Version::Draft19).is_ok()); + assert!(decode::(&bytes, Version::Draft18).is_err()); + } + + #[test] + fn subscribe_tracks_starts_at_draft_18() { + let mut buf = Vec::new(); + let version = Version::Draft17; + assert!( + paused() + .encode_msg(&mut Encoder::new(&mut buf, version.into()), version) + .is_err() + ); + } + + #[test] + fn publish_skipped_round_trips() { + for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + let msg = PublishSkipped { + suffix: Path::new("cam"), + track_name: "video".into(), + }; + let decoded: PublishSkipped = decode(&encode(&msg, version), version).unwrap(); + assert_eq!(decoded.suffix.as_str(), "cam", "{version}"); + assert_eq!(decoded.track_name, "video", "{version}"); + } + } +} diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 9e8d5460a53..e8152bccb75 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2111,6 +2111,7 @@ where stream .writer .encode(&ietf::RequestOk { + largest: None, request_id: Some(request_id), active: None, }) @@ -2121,6 +2122,7 @@ where stream .writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) @@ -6054,6 +6056,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) @@ -6149,6 +6152,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) @@ -6231,6 +6235,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: count, }) @@ -6283,6 +6288,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active, }) @@ -8464,6 +8470,7 @@ mod tests { writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) @@ -9818,6 +9825,9 @@ mod tests { largest_location: None, forward: true, properties: ietf::Properties::default(), + subscriber_priority: None, + group_order: None, + filter: None, }; // Errors are surfaced to the peer on the stream, not raised as a session error. @@ -16136,6 +16146,7 @@ mod new_group_tests { stream .writer .encode(&ietf::RequestOk { + largest: None, request_id: None, active: None, }) diff --git a/rs/moq-net/src/ietf/version.rs b/rs/moq-net/src/ietf/version.rs index 0e29f184548..45ad79d496f 100644 --- a/rs/moq-net/src/ietf/version.rs +++ b/rs/moq-net/src/ietf/version.rs @@ -125,6 +125,9 @@ mod tests { largest_location: Some(Location { group: 9, object: 0 }), forward: true, properties, + subscriber_priority: None, + group_order: None, + filter: None, }; let fetch = Fetch { diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 97a8e2e26fe..61e31a6bffc 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -792,7 +792,8 @@ impl Slots { /// Take one slot until the returned guard drops, or [`Error::TooManyRequests`] at the cap. /// - /// The caller closes the session on that error: a peer past its limits loses the session. + /// A peer whose own request runs past its limits loses the session. A subscription we + /// would initiate, such as a listed track's PUBLISH, is skipped instead. pub(crate) fn acquire(&self) -> Result { use std::sync::atomic::Ordering; let held = self diff --git a/rs/moq-net/tests/integration/subscribe_tracks.rs b/rs/moq-net/tests/integration/subscribe_tracks.rs index a6f9514b01c..fec79209e35 100644 --- a/rs/moq-net/tests/integration/subscribe_tracks.rs +++ b/rs/moq-net/tests/integration/subscribe_tracks.rs @@ -1,6 +1,7 @@ #![cfg(feature = "async")] -//! A SUBSCRIBE_TRACKS is refused on its own stream, and the session keeps delivering. +//! A SUBSCRIBE_TRACKS asking for every track's objects is answered on its own stream, and +//! the session keeps delivering. //! //! Deterministic: paused time and a single-threaded runtime over the mock transport. @@ -103,9 +104,9 @@ async fn deliver(setup: &mut Setup, payload: &'static [u8]) { assert_eq!(got, payload); } -/// Send SUBSCRIBE_TRACKS from the server to the publishing client, returning every byte -/// of the reply once the client ends the stream. -async fn subscribe_tracks(pair: &MockPair) -> Vec { +/// Send SUBSCRIBE_TRACKS from the server to the publishing client, returning the first +/// `len` bytes of the reply, or all of it if the client ends the stream first. +async fn subscribe_tracks(pair: &MockPair, len: usize) -> Vec { let (mut send, mut recv) = pair .server_transport .clone() @@ -116,22 +117,23 @@ async fn subscribe_tracks(pair: &MockPair) -> Vec { let mut reply = Vec::new(); let mut buf = [0u8; 64]; - while let Some(n) = moq_clock::timeout(TIMEOUT, recv.read(&mut buf)) - .await - .expect("reply timed out") - .expect("read") + while reply.len() < len + && let Some(n) = moq_clock::timeout(TIMEOUT, recv.read(&mut buf)) + .await + .expect("reply timed out") + .expect("read") { reply.extend_from_slice(&buf[..n]); } reply } -/// Draft-18 defines SUBSCRIBE_TRACKS, so a limited endpoint answers NOT_SUPPORTED and -/// the subscription already on the session keeps going. +/// An omitted FORWARD asks for every track's objects, which we never push unasked: from +/// draft-19 each track is answered with PUBLISH_SKIPPED, and the subscription already on +/// the session keeps going. #[moq_clock::test] -async fn subscribe_tracks_is_refused_per_request() { +async fn subscribe_tracks_skips_every_track() { for version in [ - "moq-transport-18", "moq-transport-19", "moq-transport-20", "moq-transport-21", @@ -140,15 +142,35 @@ async fn subscribe_tracks_is_refused_per_request() { let mut setup = setup(version.parse().unwrap()).await; deliver(&mut setup, b"before").await; - let reply = subscribe_tracks(&setup.pair).await; - // REQUEST_ERROR (0x05), a two-byte length, then the error code: NOT_SUPPORTED (0x3). - assert_eq!(reply.first(), Some(&0x05), "{version}: not a REQUEST_ERROR: {reply:?}"); - assert_eq!(reply.get(3), Some(&0x03), "{version}: not NOT_SUPPORTED: {reply:?}"); + #[rustfmt::skip] + let expected = [ + 0x07, 0x00, 0x01, 0x00, // REQUEST_OK, no parameters + 0x0f, 0x00, 0x07, // PUBLISH_SKIPPED + 0x00, // The broadcast is the prefix itself: an empty suffix. + 0x05, b'v', b'i', b'd', b'e', b'o', + ]; + let reply = subscribe_tracks(&setup.pair, expected.len()).await; + assert_eq!(reply, expected, "{version}"); deliver(&mut setup, b"after").await; } } +/// Draft-18 has no PUBLISH_SKIPPED, so a listing that must forward is refused on its own +/// stream with NOT_SUPPORTED, and the session keeps delivering. +#[moq_clock::test] +async fn subscribe_tracks_needs_forward_0_on_draft_18() { + let mut setup = setup("moq-transport-18".parse().unwrap()).await; + deliver(&mut setup, b"before").await; + + let reply = subscribe_tracks(&setup.pair, usize::MAX).await; + // REQUEST_ERROR (0x05), a two-byte length, then the error code: NOT_SUPPORTED (0x3). + assert_eq!(reply.first(), Some(&0x05), "not a REQUEST_ERROR: {reply:?}"); + assert_eq!(reply.get(3), Some(&0x03), "not NOT_SUPPORTED: {reply:?}"); + + deliver(&mut setup, b"after").await; +} + /// Before draft-18, 0x51 is not a message at all, so it is still a protocol violation. #[moq_clock::test] async fn subscribe_tracks_before_draft_18_closes_the_session() {