diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index ce55160b206..749273fc8c4 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -115,7 +115,7 @@ jobs: nix develop --command just test interop "${args[@]}" shell: bash -leo pipefail {0} env: - INTEROP_ARGS: ${{ github.event_name == 'pull_request' && '--publishers rust --subscribers rust,js-native-node,js-native-bun --versions moq-transport-14,moq-transport-22' || '--all --versions ietf' }} + INTEROP_ARGS: ${{ github.event_name == 'pull_request' && '--publishers rust --subscribers rust,js-native-node,js-native-bun --versions moq-transport-18,moq-transport-22' || '--all --versions ietf' }} HARNESS_RUNS: ${{ runner.temp }}/moq-test-runs HARNESS_RESULTS: ${{ runner.temp }}/moq-test-runs/results.jsonl diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 234e1dc734d..52c40a01e67 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -375,7 +375,7 @@ rtsp` one per RTSP session, and `import ts --program all` one per program. A restarted process takes the name at once: viewers see the broadcast restart, and their next subscribe reaches the new run instead of waiting for its group numbers to catch up. Epochs cross a connection only on moq-lite 07, which is -opt-in, and on moq-transport draft 17 and later, and order by the host's clock +opt-in, and on moq-transport, and order by the host's clock there, so a host whose clock runs behind the old run's waits for it to close. Other versions carry no epoch, so a restart from another session is the newest route at an equal cost, or a better one. @@ -484,7 +484,7 @@ the break. A stitch waits for the replacement's first catalog snapshot until it arrives or the replacement goes, keeping the old publisher playing meanwhile, so even the default `--follow 0s` switches to a replacement that is still starting. Without `--stitch` the export stays on the old publisher while it is -up. Only lite-07 and moq-transport draft 17+ sessions carry epochs, so on the +up. Only lite-07 and moq-transport sessions carry epochs, so on the default version every return is a replacement. A failure other than the publisher leaving, such as a codec TS cannot carry, exits 1 at once. Only `ts` can mark a break, so the other formats refuse `--linger`, the released spelling diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index edb94e52407..475a64c1892 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -79,7 +79,7 @@ unknown versions fail startup. QUIC durations are milliseconds, with zero disabling the local idle timeout or keep-alive. Each run from `READY` publishes under a fresh [publisher epoch](/concept/moq-lite#publisher-epochs), so on moq-lite 07 (opt-in) -or moq-transport draft 17 and later, viewers switch to a restarted pipeline at once instead of stalling on the old +or moq-transport, viewers switch to a restarted pipeline at once instead of stalling on the old one; reconnects within a run keep it. The first catalog waits for a timed buffer to place the shared wall clock and for every requested pad to negotiate its rendition, including any codec config carried in its first buffers. A linked pad diff --git a/doc/bin/obs.md b/doc/bin/obs.md index 3e344487924..f2de02b73b3 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -25,7 +25,7 @@ Enter a relay URL explicitly. On the shared anonymous relay, use a unique path such as `https://cdn.moq.dev/anon/your-stream`. A URL with `?jwt=` fills the token field. Editing the token while live presents the new one without reconnecting, on a session that carries in-band authorization: a -`moq-transport` draft-17 or later version pinned under Advanced settings, with +`moq-transport` draft-18 or later version pinned under Advanced settings, with a relay that negotiates the MoQ Auth extension. The default protocol does not yet, so there the dock reports the new token as refused and it applies on the next reconnect or Go Live. diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 0e12edbb098..a968cf0d1ed 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -152,7 +152,7 @@ key can itself be **scoped** at generation (`--root`, `--publish`, ### In-band renewal -On moq-lite-07-wip or moq-transport draft-17 and later with MoQ Auth negotiated, +On moq-lite-07-wip or moq-transport draft-18 and later with MoQ Auth negotiated, a client can present another JWT without reconnecting. Present its replacement before the current grant expires and keep the returned token handle alive. The auth handle a reconnecting connection owns (native `Connection::auth()`, diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 752e51e6004..80a9fdcd264 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -14,12 +14,7 @@ Each broadcast carries the list of relays it passed through. That hop list catches loops and picks the shortest route, and every relay breaks ties the same way so the cluster converges instead of flapping. Both wire protocols carry it: natively on moq-lite, and via the [cluster extension](/draft/moq-cluster) -on moq-transport 17+. - -moq-transport drafts 14 and 15 cannot ask for every broadcast (an empty -namespace prefix is illegal before draft 16), so a link to a peer without the -MoQ Solicit extension, such as moxygen, learns only what that peer announces -unasked. moxygen announces nothing unasked on draft 14. +on moq-transport. A track from a moq-transport publisher keeps its Object IDs, object properties, and END\_OF\_GROUP status objects across a moq-lite-07 link, since relays @@ -44,7 +39,7 @@ resuming it; the RTMP, SRT, and WHIP ingests mint one per connection. Epochs order by creation time, so a reconnect to a different gateway assumes the two gateways' clocks roughly agree. Seamless failover needs a publisher that announces an epoch and, on every link the route crosses, moq-lite 07 or -moq-transport draft 17 or later with the [epoch extension](/draft/moq-epoch). A route +moq-transport with the [epoch extension](/draft/moq-epoch). A route without an epoch, including every route on moq-lite 06 and older or a moq-transport link without the extension, keeps its subscriptions until it goes or a track fails upstream, and any other route winning it is announced as a restart. Viewers @@ -100,7 +95,7 @@ max_hops = 6 ``` The same setting is `--cluster-max-hops` or `MOQ_CLUSTER_MAX_HOPS`. It covers -moq-lite-03 through moq-lite-07-wip and moq-transport drafts 17 through 22 when +moq-lite-03 through moq-lite-07-wip and moq-transport drafts 18 through 22 when the MoQ Cluster extension is negotiated. A forwarded path counts this relay's own hop even when its peer appends it on receipt. Older versions and peers without hop paths hide their depth, which this setting cannot bound. diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 7659f433533..da3e71ef842 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -40,9 +40,9 @@ allow.uid = [1001] [listen.extensions] # moq-transport SETUP extensions to offer, each on by default. # The relay always offers ietf, for moq-lite-07 relay links. -auth = false # Decline MoQ Auth (draft 17+). +auth = false # Decline MoQ Auth. solicit = false # Decline MoQ Solicit, so peers may announce unasked. -epoch = false # Decline MoQ Epoch, so routes over moq-transport carry no epoch (draft 17+). +epoch = false # Decline MoQ Epoch, so routes over moq-transport carry no epoch. ``` `timeout` bounds the whole handshake, transport through MoQ SETUP and auth @@ -127,11 +127,9 @@ certificate, route hop, or cluster-looking URL alone does not select peer limits ```toml [limits.client] announces = 1000 # Simultaneous broadcasts or namespaces. -requests = 10000 # Other simultaneous moq-transport requests. [limits.peer] announces = 100000 -requests = 20000 [limits.peer.quic] max_streams = 100000 # Concurrent QUIC streams per direction. @@ -147,13 +145,10 @@ not throttled by `[quic]` limits sized for one viewer, for example `--limits-peer-quic-max-streams=200000`. Peers over WebSocket, TCP, or iroh keep their transport's own limits. -These are the defaults. Each subscription and FETCH holds a stream on moq-lite -and on moq-transport draft 17 and later, as does each PUBLISH a peer's track -listing gets, so `[quic]` or `[limits.peer.quic]` `max_streams` bounds them -there, and a peer past it waits rather than fails. -On drafts 14 to 16, `announces` and `requests` together size the request -window instead, which every request counts against, so very small values can -prevent other requests from progressing. Size peer limits +These are the defaults. Each subscription and FETCH holds a stream, as does each +PUBLISH a peer's track listing gets, so `[quic]` or `[limits.peer.quic]` +`max_streams` bounds them, and a peer past it waits rather than fails. The +removed `requests` limit is refused at startup. Size peer limits for the combined traffic they carry, and use session peak counters to check announcement and subscription headroom. Exceeding `announces` closes the session with `TOO_MANY_REQUESTS`; reconnecting with the same load will exceed it again. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 6fe5dc22e4c..49bed2e6454 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -12,7 +12,7 @@ against any moq-transport relay (including implement in an afternoon. The wire spec is [draft-lcurley-moq-lite](/draft/moq-lite). -Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts 14 +Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts 18 through 22. Clients offer `moq-lite-06` first. moq-lite 07 is still in progress (`moq-lite-07-wip`, and only when both sides enable it). It is the version that carries [publisher epochs](#publisher-epochs). @@ -132,11 +132,11 @@ authorization and hidden names work exactly as without one. of the old instance, so a new subscriber never sees its cached groups. The epoch and the restart message travel on moq-lite 07, which is opt-in. -moq-transport carries the epoch from draft 17 when both sides offer the [epoch +moq-transport carries the epoch when both sides offer the [epoch extension](/draft/moq-epoch), on by default. A restart onto another epoch is advertised before the old one is withdrawn, so the path is never absent, and one -that gains or loses an epoch is an end then a start. Older moq-lite versions, -older moq-transport drafts, and a side that declines the extension carry no +that gains or loses an epoch is an end then a start. Older moq-lite versions +and a side that declines the extension carry no epoch. Without an epoch, any other route winning is a restart, even a reconnect over the same path, since nothing says it serves the same bytes. A peer that ignores the end and start may keep cached objects of the old run under the same @@ -158,7 +158,7 @@ compared. A subscription uses the preferred link that has a route and moves to t next when that route goes; a publisher pushes the same broadcast to every CDN. The move resumes without a seam only between routes with the same epoch, which -needs moq-lite 07, or moq-transport draft 17 or later with the epoch extension, +needs moq-lite 07, or moq-transport with the epoch extension, on both links. A CDN link that carries no epoch makes moving onto it, or back off it, a restart, and the player requests the path again. Both links must also speak the same protocol and draft: a copy's track info records the @@ -231,13 +231,12 @@ code and the session stays up. A client that publishes outside its grant closes the session with `UNAUTHORIZED`, naming the path. A session whose last credential expires closes with `EXPIRED_AUTH_TOKEN` instead: refresh the token and reconnect, rather than treating the credential as invalid. moq-transport carries the same -exchange on draft-17+ through the [MoQ Auth extension](/draft/moq-auth), with +exchange through the [MoQ Auth extension](/draft/moq-auth), with the same patterns. Older versions have no grant; the URL token keeps working everywhere. On moq-transport an added token is kept even without the extension, so an announcement can name it: it rides that `PUBLISH_NAMESPACE` as its authorization token. Naming a fresh token renews the announcement in place with -`REQUEST_UPDATE` from draft 16, and re-announces it on drafts 14 and 15, so it -can outlive the token it started with. A token added to a reconnecting +`REQUEST_UPDATE`, so it can outlive the token it started with. A token added to a reconnecting connection's auth handle is presented on every session it makes, so the announcement keeps it across a reconnect with no re-add or re-announce; one added to a single session's handle ends with that session. moq-lite has no @@ -292,9 +291,9 @@ moq-lite-07 carries range by range and older wire versions as the single span covering them. A relative or time anchor resolves on the hop that publishes the track, and over moq-lite-07, which carries a relative or time start upstream and reports the live edge and media time, so a copy resolves the ends itself. A Rust -moq-transport copy on draft 17 and later accepts anchors its cache can already +moq-transport copy accepts anchors its cache can already resolve and sends group bounds upstream; it refuses an unknown time end. Older -wires accept absolute anchors but refuse time anchors and relative anchors other +moq-lite versions accept absolute anchors but refuse time anchors and relative anchors other than the live and default starts. An untimed track refuses a time anchor. At the live edge, a time end may let one group through before its first frame shows it starts past the end: @@ -320,7 +319,7 @@ across its subscribers. A track is timed or untimed: it declares a timescale or none, and a frame that doesn't match is refused. No receiver fills in an arrival time, so an untimed track is judged by the wall clock alone. Tracks from moq-lite before 05, or from -moq-transport without a `TIMESCALE` (every track on drafts 14-16), arrive +moq-transport without a `TIMESCALE`, arrive untimed. moq-lite 05 and 06 cannot declare an untimed track, so there its frames go out stamped with their send time; the work-in-progress 07 carries it untimed. diff --git a/doc/concept/standard.md b/doc/concept/standard.md index f1a056b9838..780465acb3b 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -11,7 +11,7 @@ with it, while shipping a simpler profile you can use today. | Spec | Scope | Here | | --- | --- | --- | -| [moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) | The IETF pub/sub protocol | Drafts 14 through 18, and 22, negotiated by ALPN; [moq-lite](/concept/moq-lite) is a forward-compatible subset | +| [moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) | The IETF pub/sub protocol | Drafts 18 and 22 negotiated by ALPN; [moq-lite](/concept/moq-lite) is a forward-compatible subset | | [MSF](https://datatracker.ietf.org/doc/draft-ietf-moq-msf/) | The IETF catalog format | Read and written; broadcasts ending in `.msf` select it | | [LOC](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) | The IETF low-overhead container | Supported as a hang container kind | | [moq-lite](/draft/moq-lite), [hang](/draft/moq-hang), [e2ee](/draft/moq-e2ee), and friends | This project's own drafts | Normative for the implementation, published to the datatracker from [`drafts/`](https://github.com/moq-dev/moq/tree/main/drafts) | @@ -25,18 +25,18 @@ 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, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a 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.** 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 a `PUBLISH_DONE` once the track is no longer listed: `TRACK_ENDED` if it finished, even just before its broadcast ended, and an error otherwise, 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` opens only as the peer's stream credit admits it, so a peer that grants none holds none. A listing that omits `FORWARD` or sets it to 1 asks for every track's objects: on draft 22 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. Rust sends it upstream for a listing (below); JavaScript does not. -- **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer on draft 18 and later sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Before draft 18 the listing is refused as unsupported. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end, including the error our own answerer sends for a track that left unfinished, fails the listing, and readers list again. -- **History is fetched in ranges.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns a range of groups from the cache, which a relay fills upstream with one `FETCH` per run of missing groups, or on drafts 14 through 18 the saved prefix of the group a new subscription just joined. The range ends at Largest Object's group and reads it whole, so a `FETCH` of a group still being written waits for it to finish. That departs from draft 22's cap at Largest Object: a cache cannot know an open group's real Largest Object, and a cut group would be cached downstream as complete. On drafts 14 through 18, `FETCH_OK` End Object 0 covers the entire End Group, matching the request; Rust also accepts the legacy answer naming the next group with Object 0. On draft 22, `FETCH_OK`'s End Location is the requested end, capped at Largest Object's group: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A Rust relay retains the upstream Largest Object from `TRACK_STATUS`, so even a fetch-only copy can resolve a draft 22 `LOCATION_FILTER` relative to it; without a publisher report or live feed, it refuses that filter as `NOT_SUPPORTED`. 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`. -- **Compression.** Rust peers on draft 17 and later exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Older peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. -- **Timing.** A track whose `SUBSCRIBE_OK` 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. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. +- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We send `PUBLISH` only to answer a `SUBSCRIBE_TRACKS` (below). +- **`SUBSCRIBE_TRACKS` lists, it never pushes.** 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 a `PUBLISH_DONE` once the track is no longer listed: `TRACK_ENDED` if it finished, even just before its broadcast ended, and an error otherwise, 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` opens only as the peer's stream credit admits it, so a peer that grants none holds none. A listing that omits `FORWARD` or sets it to 1 asks for every track's objects: on draft 22 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. Rust sends it upstream for a listing (below); JavaScript does not. +- **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end, including the error our own answerer sends for a track that left unfinished, fails the listing, and readers list again. +- **History is fetched in ranges.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns a range of groups from the cache, which a relay fills upstream with one `FETCH` per run of missing groups, or on draft 18 the saved prefix of the group a new subscription just joined. The range ends at Largest Object's group and reads it whole, so a `FETCH` of a group still being written waits for it to finish. That departs from draft 22's cap at Largest Object: a cache cannot know an open group's real Largest Object, and a cut group would be cached downstream as complete. On draft 18, `FETCH_OK` End Object 0 covers the entire End Group, matching the request; Rust also accepts the legacy answer naming the next group with Object 0. On draft 22, `FETCH_OK`'s End Location is the requested end, capped at Largest Object's group: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A Rust relay retains the upstream Largest Object from `TRACK_STATUS`, so even a fetch-only copy can resolve a draft 22 `LOCATION_FILTER` relative to it; without a publisher report or live feed, it refuses that filter as `NOT_SUPPORTED`. 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; a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED` still subscribes. +- **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. +- **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed. A Rust reader that never subscribed takes the units from `TRACK_STATUS`. +- **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. A `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. An unknown message parameter, or one defined only for another message, closes the session. 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, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE`; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. The negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. - **Pause delivery.** `FORWARD=0` on a `SUBSCRIBE` or its update drops the subscription, closing its data streams and holding nothing upstream, while the request and its slot stay. `FORWARD=1` subscribes again as a new `SUBSCRIBE` with the same filter would, so nothing carries across the pause: its `REQUEST_OK` names the live edge it joined at. A paused update may change only priority and max age. -- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 22 because the advertised filter limit is zero. On draft 22, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts. Rust also carries range changes from draft 15 onward, keeps bounded subscriptions updatable, renews request tokens, and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). On draft 22, widening fills cached history on a separate fetch stream, completing earlier groups and handing the newest head to its live subgroup tail; fills follow the requested group order. 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 between drafts 18 and 22, or refused when immutable, because draft 22 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. +- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 22 because the advertised filter limit is zero. On draft 22, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts. Rust also carries range changes, keeps bounded subscriptions updatable, renews request tokens, and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). On draft 22, widening fills cached history on a separate fetch stream, completing earlier groups and handing the newest head to its live subgroup tail; fills follow the requested group order. Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. A namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. +- **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers 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, as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped between drafts 18 and 22, or refused when immutable, because draft 22 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. A status object carrying properties is refused. A `FETCH` has no status objects, so its response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. +- **Dynamic groups.** A track advertises `DYNAMIC_GROUPS` when its publisher sets a refresh ratio, and a track received with `DYNAMIC_GROUPS` gets a ratio of 0. A `NEW_GROUP_REQUEST` counts as one more subscriber joining, which a `SUBSCRIBE` already is: it asks for a new group only when the newest group's bytes after its first frame exceed the ratio times the first frame. Joins coalesce until the asked-for group arrives. A Rust relay sends `NEW_GROUP_REQUEST` upstream, on the `SUBSCRIBE` or in a `REQUEST_UPDATE`, once a join wants a group past the newest it holds; a relay holding nothing yet asks for none. On a track without a ratio, an update carrying one ends the subscription with `UPDATE_FAILED`. A moq-lite-07 hop (`moq-lite-07-wip`, opt-in) carries the same demand natively: the ratio in `TRACK_INFO` and the asked-for group in `SUBSCRIBE_UPDATE`. Earlier moq-lite versions carry neither, so a track received over one never refreshes. - **Priority.** Higher is served first. This stack's priorities run from -128 to 127 with 0 unset, and go out on the IETF wire, which ranks lower first, as `127 - priority`: an unset priority is IETF 127. An omitted subscriber or publisher priority reads as the drafts' default of IETF 128, one step below unset, so a relay forwards it unchanged. A relay's upstream subscription carries the highest priority its subscribers ask for and updates it as they come and go; its group order stays as the `SUBSCRIBE` asked, since no draft lets an update change it. - **Size.** An object extension block larger than 64 KiB ends that subgroup stream. The session stays up. This cap is ours, not the draft's. @@ -52,7 +52,7 @@ without changing a path, and [probe](/draft/moq-probe) for bandwidth estimation. Rust sessions request PROBE only while an owned `recv_bandwidth()` consumer is held. Read estimates through its `peek()` or `changed()` methods; session statistics do not request PROBE. -Both libraries offer solicit, auth, and epoch (draft 17 and later) unless a side's +Both libraries offer solicit, auth, and epoch unless a side's `extensions` setting declines one, which connects it as a peer that does not speak that extension. The pooled JavaScript `Connection` has no such setting yet. [ietf](/draft/moq-ietf) goes the other way: it extends moq-lite-07 to carry this @@ -80,15 +80,14 @@ choices. They follow the product model. [Draft 18 §2.5](https://www.ietf.org/archive/id/draft-ietf-moq-transport-18.html#section-2.5) says a relay that does not understand a property still forwards and caches it. Rust moq-transport relay paths do so, for object properties and for the - track properties in `SUBSCRIBE_OK` and `FETCH_OK` from draft 16, and in - `TRACK_STATUS_OK` from draft 18; the public API, plain moq-lite, and + track properties in `SUBSCRIBE_OK`, `FETCH_OK`, and `TRACK_STATUS_OK`; the public API, plain moq-lite, and JavaScript [leave other per-object metadata out](/concept/moq-lite#what-moq-lite-leaves-out). Immutable property blocks retain their bytes. Forwarding is refused when the negotiated drafts use incompatible key or integer encodings, or when the block holds a Timestamp in units the response does not declare. Property 0x06 crosses between drafts 18 and 22 as it does on an object. A track carrying a Mandatory Track Property (0x4000-0x7FFF) is refused with `UNSUPPORTED_EXTENSION` - instead, or `NOT_SUPPORTED` before draft 18. + instead. - **`SUBSCRIBE_OK` before an old source answers.** moq-lite 01 through 04 have no track stream, so the relay cannot learn from that source whether the track diff --git a/doc/concept/stats.md b/doc/concept/stats.md index 9c03f08540c..a7954b0bfd8 100644 --- a/doc/concept/stats.md +++ b/doc/concept/stats.md @@ -175,7 +175,7 @@ carries a fresh [publisher epoch](/concept/moq-lite#publisher-epochs) on its route, so a restarted node is a new broadcast at the same path: it replaces the old one, and no relay serves it groups cached under the old epoch. A reader treats each epoch as its own set of counters. The epoch rides moq-lite 07 -announcements, and moq-transport draft 17 and later with the +announcements, and moq-transport with the [epoch extension](/draft/moq-epoch); over anything else a reader sees the same change as an end and a start at the path. An aggregating reader pins each node's subscription to its epoch, and when one diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index e9d34822be1..584bad6877d 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -211,7 +211,7 @@ Obtain a dynamic track request’s `demand()` before accepting it to watch subsc ## Authorization -On `moq-lite-07-wip` (opt in with `ClientConfig::versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session->grants()` returns a `GrantConsumer`: `grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects, and `changed()` waits for the next one until `cancel()`. +On `moq-lite-07-wip` (opt in with `ClientConfig::versions`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `session->grants()` returns a `GrantConsumer`: `grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects, and `changed()` waits for the next one until `cancel()`. Set `ClientConfig::tokens` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `session->tokens()->set(...)` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `answered()` resolves once the relay accepts them, or to an `Error` carrying its refusal code. A server-accepted session's `tokens()` is null. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 8743963cb28..f06be0cfbf1 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -148,7 +148,7 @@ shows which module holds each. - **Closing.** `await moq.close()` drains for up to one second and reports its result. `session.shutdown()` requests the drain synchronously; `await session.closed()` waits for its result. `session.cancel(code: 0)` closes at once. Finish or abort live tracks first. Awaiting cancellation of an announcement, grant, or request subscription releases its pending native read. Announcements and grants also release their consumer; server listeners leave their shared server usable. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. - **Tokens.** Pass `ConnectOptions(tokens: [...])` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `moq.tokens().set_(tokens: [...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `await tokens().answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index c2fe8c0a968..96e277bd27e 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -135,7 +135,7 @@ shows which module holds each. - **Closing.** `client.Close()` drains for up to one second and returns an error on failure. `session.Shutdown()` requests that drain immediately; `session.Closed(ctx)` waits for its result, and `session.Cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `Finish()` on its `media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.Close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`). A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol. `Dial` returns a connecting session; read `Session().State()` and wait with `StateChanged(ctx, state.Revision)` to observe first-dial failures and reconnects. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Rate()` reads the current estimate in bits per second and `RateChanged(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig.Versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Grants()` returns a `GrantConsumer`: `Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects, and `Changed(ctx)` waits for the next one. `Close()` it when done. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig.Versions`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Grants()` returns a `GrantConsumer`: `Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects, and `Changed(ctx)` waits for the next one. `Close()` it when done. - **Tokens.** Set `ClientConfig.Tokens` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `Session().Tokens().Set(...)` replaces a client session's tokens before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `Answered(ctx)` returns nil once the relay accepts them, or its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `Reason` and structured protocol failures from `Details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `Preference`; `0` is the default and `-1` marks a compatibility transcode. diff --git a/doc/lib/index.md b/doc/lib/index.md index 41f44d3bd7e..ac7205fb2f2 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -75,7 +75,7 @@ how it looks in that language: - **Catalog extensions**: write your own section next to `video` and `audio`, and read others' back. - **Routes**: see which relays a broadcast came through, and advertise a cost as a standby publisher. - **Connection stats** and **errors** that tell an auth rejection (don't retry) from a shutdown or a transport failure. -- **Authorization in band**: read the grant the relay sent back (the patterns this side may publish and subscribe to, and when it lapses), and present a fresh token before the current one expires, without reconnecting. It needs `moq-lite-07-wip`, which is opt-in, or moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth). +- **Authorization in band**: read the grant the relay sent back (the patterns this side may publish and subscribe to, and when it lapses), and present a fresh token before the current one expires, without reconnecting. It needs `moq-lite-07-wip`, which is opt-in, or moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth). C's audio decoder neither takes nor reports a playout delay, and C has no in-band authorization. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index e587877952c..af8f55ea520 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -51,7 +51,7 @@ publication.announce(); - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Credential` it returns (`publication.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. +- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Credential` it returns (`publication.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 676b5f76a36..71be8c08b83 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -28,7 +28,7 @@ WebCodecs, writes the catalog, and publishes a hang broadcast. | Attribute | | | --- | --- | | `url`, `name` | Relay URL (with `?jwt=` if needed) and broadcast name. | -| `versions` | Protocol versions to offer, most preferred first, comma-separated like `moq-transport-14,moq-lite-06`. Every stable version by default. | +| `versions` | Protocol versions to offer, most preferred first, comma-separated like `moq-transport-18,moq-lite-06`. Every stable version by default. | | `source` | `camera`, `screen`, or `file`. | | `muted`, `invisible` | Disable audio or video capture. | | `preview` | What the nested element shows: the raw `source` (default), a decoded copy of the `encoded` stream to see what viewers get, or `none`. | diff --git a/doc/lib/js/watch.md b/doc/lib/js/watch.md index 973de1f9170..78cb8af69c8 100644 --- a/doc/lib/js/watch.md +++ b/doc/lib/js/watch.md @@ -29,7 +29,7 @@ in sync at the latency you ask for. | Attribute | | | --- | --- | | `url`, `name` | Relay URL (with `?jwt=` if needed) and broadcast name. | -| `versions` | Protocol versions to offer, most preferred first, comma-separated like `moq-transport-14,moq-lite-06`. Every stable version by default. | +| `versions` | Protocol versions to offer, most preferred first, comma-separated like `moq-transport-18,moq-lite-06`. Every stable version by default. | | `paused`, `muted`, `volume` | The usual player controls. | | `delay` | How far playback trails the live edge: `"auto"` (the default, sized from how late frames arrive; see [audio jitter](/concept/audio-jitter)), a duration like `"300ms"`, or `"instant"` to paint video as it decodes, with no audio. | | `buffer` | How far ahead of the live edge media may run; see [buffered playback](#buffered-playback). | diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 21c2258c4c1..c10d9c3fad0 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -105,7 +105,7 @@ shows which module holds each. - **Closing.** `moq.shutdown()` requests a drain immediately; suspending `moq.session.closed()` waits up to one second and reports its result. `Moq.close()`, and so `use { }`, cancels at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.end()` takes it offline. How long to keep it up is yours to decide. A track still open at the end, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. Ending the collection cancels the consumer. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. Ending the collection cancels the consumer. - **Tokens.** Pass `ClientConfig(tokens = listOf(...))` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `moq.tokens().set(listOf(...))` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. Suspending `tokens().answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index e876ba9a07a..45667a358cc 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -119,7 +119,7 @@ shows which module holds each. - **Closing.** `session.shutdown()` requests a drain immediately; `await session.closed()` waits up to one second and reports its result. `cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `moq.media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rate_changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `async for grant in session.grants()` yields the `Grant` the relay sent back and each change, `None` until it answers and while a client session reconnects. `grants().grant()` reads it once; cancelling or leaving an `async with` releases the wait. +- **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `async for grant in session.grants()` yields the `Grant` the relay sent back and each change, `None` until it answers and while a client session reconnects. `grants().grant()` reads it once; cancelling or leaving an `async with` releases the wait. - **Tokens.** Pass `tokens=[...]` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `session.tokens().set([...])` replaces a client session's tokens before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `await tokens.answered()` returns once the relay accepts them and raises its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 2856c9b74f6..b3afcc93296 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -25,9 +25,9 @@ What you use it for, beyond what the concept page already describes: - **Publish only while someone is watching.** `demand()` on a track, group, or broadcast says whether a subscriber is attached, which is how capture and transcode skip work nobody asked for. Holding a broadcast or track consumer is not demand: a track consumer requests nothing until it queries, subscribes, or fetches, and those count until dropped. A shared fetch stays up until its last reader leaves. - **Use the model without a runtime.** Disable default features on `moq-net` to use its `poll_*` APIs and origin driver with caller-supplied time. The default `async` feature adds async helpers, `time::run`, and both wire sessions. - **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio or in the browser. `moq-tokio` and `moq-wasm` hide that. A custom transport implements `moq_net::transport::poll`, and `moq_net::transport::memory::pair` links two sessions in one process with no sockets, for tests or in-process links. -- **A session bounds what its peer can hold.** Each moq-lite request, and each moq-transport request from draft 17 on, holds a stream, so the transport's stream credit bounds subscriptions and FETCHes. `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds the rest per session: `announces`, and `requests`, which with `announces` sizes the request-ID window on moq-transport drafts 14 to 16. The defaults suit a relay mesh, so lower them for untrusted peers. Past a limit, the session closes with `TOO_MANY_REQUESTS`; very low limits can starve the window, which every request counts against. Peer-declared lengths are capped before they are buffered. -- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. `add(token)` resolves with the token and how the peer took it: `Answer::Granted` on its AUTH stream, or `Answer::Requests` on moq-transport without AUTH, where only a request that names it carries it. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. -- **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. `abort` ends immediately. IETF drafts 14 through 16 send withdrawals without waiting. +- **A session bounds what its peer can hold.** Each request holds a stream, so the transport's stream credit bounds subscriptions and FETCHes. `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds the announcements per session. The default suits a relay mesh, so lower it for untrusted peers. Past it, the session closes with `TOO_MANY_REQUESTS`. Peer-declared lengths are capped before they are buffered. +- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. `add(token)` resolves with the token and how the peer took it: `Answer::Granted` on its AUTH stream, or `Answer::Requests` on moq-transport without AUTH, where only a request that names it carries it. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. +- **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. `abort` ends immediately. ```bash cargo add moq-net moq-tokio diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 24a98d20ca3..a35909e9833 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -105,7 +105,7 @@ shows which module holds each. - **Closing.** `session.shutdown()` requests a drain immediately; `try await session.closed()` waits up to one second and reports its result. `session.cancel(code:)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `try finish()` on its `Media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `for try await grant in session.grants()` yields the `Grant` the relay sent back and each change, `nil` until it answers and while a client session reconnects. Cancelling the iterating task cancels the native wait. +- **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `for try await grant in session.grants()` yields the `Grant` the relay sent back and each change, `nil` until it answers and while a client session reconnects. Cancelling the iterating task cancels the native wait. - **Tokens.** Pass `Client(tokens: [...])` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `try session.tokens()?.set([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `try await tokens.answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Diagnostic cases use the `reason:` argument label; match their associated value to inspect it. Protocol errors carry `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. diff --git a/doc/setup/redundancy.md b/doc/setup/redundancy.md index afefd572dc3..d9c7d6604d6 100644 --- a/doc/setup/redundancy.md +++ b/doc/setup/redundancy.md @@ -30,7 +30,7 @@ promise. 2. Publish the same broadcast name under the same auth path. Each publisher keeps its own epoch; `moq` creates one automatically. 3. Use epoch-capable connections on every hop: moq-lite 07, which is opt-in, - or moq-transport draft 17 or later with the [epoch extension](/draft/moq-epoch) + or moq-transport with the [epoch extension](/draft/moq-epoch) enabled. Keep the publisher hosts' clocks synchronized, since epochs order by their UUIDv7 creation time. @@ -40,12 +40,12 @@ relays. Run each command on its publisher host, with its own source URL: ```bash # Publisher A moq --connect https://ingest-a.example.com/anon \ - --connect-version moq-transport-17 --broadcast event.hang \ + --connect-version moq-transport-18 --broadcast event.hang \ import hls https://source-a.example.com/live/index.m3u8 # Publisher B moq --connect https://ingest-b.example.com/anon \ - --connect-version moq-transport-17 --broadcast event.hang \ + --connect-version moq-transport-18 --broadcast event.hang \ import hls https://source-b.example.com/live/index.m3u8 ``` @@ -69,7 +69,7 @@ For MPEG-TS output, opt into following replacements: ```bash moq --connect https://edge.example.com/anon \ - --connect-version moq-transport-17 --broadcast event.hang \ + --connect-version moq-transport-18 --broadcast event.hang \ export ts --stitch --follow 10s > event.ts ``` diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index f04d064eb2d..9e46ec68334 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -42,6 +42,11 @@ These land with the next breaking release. hang's media priorities now sit above that default rather than below it. Mixed versions only differ in how an unset priority ranks against a set one, so upgrade publishers and players in either order. +- **moq-transport drafts 14 through 17 are dropped.** Rust and JavaScript + speak moq-transport drafts 18 through 22 only, and the `moql` SETUP + negotiates moq-lite only. A peer that offers nothing newer fails + to connect instead of falling back, so move such peers to draft 18 or a + moq-lite version before upgrading what they connect to. - **WebSocket needs a QMux subprotocol** (#6231). Relays answer a WebSocket upgrade that offers no `qmux-NN` subprotocol with 400 instead of falling back to the legacy `webtransport` format, so upgrade such clients before relays. @@ -63,8 +68,8 @@ These land with the next breaking release. their readers upgraded together. - **Announce restarts.** moq-lite 07 adds `ANNOUNCE_RESTART` (0x3): another publisher instance now serves the prefix. lite-06's `ANNOUNCE_RESTART` is - named `ANNOUNCE_UPDATE`, as it always meant. moq-transport draft 17 and later - with the epoch extension advertise another epoch before withdrawing the old + named `ANNOUNCE_UPDATE`, as it always meant. moq-transport with the epoch + extension advertise another epoch before withdrawing the old one. Elsewhere, a restart is still an end then a start. - **Publisher epochs ride the route** (#4942). moq-lite 07 carries the epoch after the path in `ANNOUNCE_START`, `TRACK`, `SUBSCRIBE`, and `FETCH`. @@ -77,7 +82,7 @@ These land with the next breaking release. tie-break and ends once the fleet is upgraded. - **A track is timed or untimed, and nothing fills in a timestamp on receive.** Tracks from moq-lite before 05, and moq-transport tracks without - `TIMESCALE` (every track on drafts 14 to 16), arrive untimed instead of + `TIMESCALE` arrive untimed instead of stamped with their arrival time. moq-lite 05 and 06 can't encode absence and carry a send time instead, so their receivers drop every timestamp and those tracks arrive untimed too: read media time from the payload. moq-transport diff --git a/drafts/draft-lcurley-moq-auth.md b/drafts/draft-lcurley-moq-auth.md index c48e7439032..8acb49e65f8 100644 --- a/drafts/draft-lcurley-moq-auth.md +++ b/drafts/draft-lcurley-moq-auth.md @@ -69,7 +69,7 @@ AUTH Setup Option { The extension is negotiated when both endpoints sent the option with a value of 1. An endpoint MUST NOT send AUTH on a session that did not negotiate it, and an endpoint that receives AUTH anyway MUST close the session with PROTOCOL_VIOLATION, as it would for any unknown message type. -This extension is defined for the versions of {{moqt}} with the unified SETUP message, draft-17 and later. +This extension is defined for the versions of {{moqt}} with the unified SETUP message, draft-18 and later. # Auth Requests {#requests} @@ -243,6 +243,7 @@ This document requests three registrations in the "MOQT Message Types" registry - AUTH_ERROR can carry GOING_AWAY, refusing an AUTH past the acceptor's GOAWAY without refusing the token. - AUTH_OK carries moq-lite Path Patterns instead of Track Namespace prefixes, and a grant is refused only when too large for one AUTH_OK. - The AUTH Setup Option moves from 0x40B60 to 0x40B70. +- Defined for {{moqt}} draft-18 and later, instead of draft-17 and later. # Acknowledgments {:numbered="false"} diff --git a/drafts/draft-lcurley-moq-epoch.md b/drafts/draft-lcurley-moq-epoch.md index a21f4c2d37a..a401b24b094 100644 --- a/drafts/draft-lcurley-moq-epoch.md +++ b/drafts/draft-lcurley-moq-epoch.md @@ -68,7 +68,7 @@ EPOCH Setup Option { A receiver MUST ignore the value. The extension is negotiated when both endpoints sent the option. -It relies on Setup Options and Key-Value-Pair parameters, so an endpoint MUST NOT send the option, and MUST ignore it, on a version of {{moqt}} earlier than draft-17. +It relies on Setup Options and Key-Value-Pair parameters, so an endpoint MUST NOT send the option, and MUST ignore it, on a version of {{moqt}} earlier than draft-18. An endpoint MUST NOT send the EPOCH parameter ({{parameter}}) on a session that did not negotiate this extension. A receiver treats one received on such a session as any unknown parameter, which closes the session. @@ -191,7 +191,7 @@ The Setup Option is even, so its value is a bare varint; the parameter is odd, s # Appendix A: Changelog ## moq-epoch-00 -- The EPOCH Setup Option (0x40B68) negotiates the extension on draft-17 and later. +- The EPOCH Setup Option (0x40B68) negotiates the extension on draft-18 and later. - The EPOCH parameter (0x40B69), a UUIDv7, names the publisher instance on PUBLISH_NAMESPACE, NAMESPACE, SUBSCRIBE, TRACK_STATUS, and a FETCH that names its track. - An advertisement's EPOCH is fixed. Another instance named by an EPOCH is advertised before the old one is withdrawn, so the namespace is never absent; one gaining or losing an EPOCH is withdrawn and advertised again. - A request that carries EPOCH is served only by that instance, and refused as a namespace that does not exist otherwise. diff --git a/drafts/draft-lcurley-moq-flate.md b/drafts/draft-lcurley-moq-flate.md index 87a9087d007..106119889d8 100644 --- a/drafts/draft-lcurley-moq-flate.md +++ b/drafts/draft-lcurley-moq-flate.md @@ -89,11 +89,11 @@ A peer is never sent compressed bytes it was not told about. # MoQ Transport Signaling {#signaling} -This extension applies to MoQ Transport draft-17 and later. +This extension applies to MoQ Transport draft-18 and later. A peer offers it with the FLATE Setup Option, key `0x40B6A`, an integer with value 1. Other values are invalid. Both endpoints MUST offer FLATE before either sends a compressed track. -Draft-14 through draft-16, and a peer that did not offer FLATE receive inflated payloads without these declarations, subject to the sealed-property refusal in {{declare}}. +Earlier drafts, and a peer that did not offer FLATE, receive inflated payloads without these declarations, subject to the sealed-property refusal in {{declare}}. ## Compression Track Property {#property} COMPRESSION, property type `0x40B6C`, has Track scope and integer value 1 for the format in {{stream}}. @@ -149,7 +149,7 @@ It also requests these entries in the MOQ Properties registry, outside the appli ## Since draft-lcurley-moq-flate-00 (in progress) -- Negotiated FLATE signaling adds COMPRESSION and INFLATED_SIZE properties to MoQ Transport draft-17 and later; sealed declarations are forwarded unchanged or refused when incompatible. +- Negotiated FLATE signaling adds COMPRESSION and INFLATED_SIZE properties to MoQ Transport draft-18 and later; sealed declarations are forwarded unchanged or refused when incompatible. - Compression is declared by the transport instead of the application: {{moql}} carries a Compression codec in TRACK_INFO and each object's Inflated Size on FRAME and datagrams. - A relay inflates for a peer whose transport does not declare compression. - A datagram is compressed alone, as a stream of its own. diff --git a/js/e2ee/src/transport.test.ts b/js/e2ee/src/transport.test.ts index a62643fd9ac..f356986ca1a 100644 --- a/js/e2ee/src/transport.test.ts +++ b/js/e2ee/src/transport.test.ts @@ -52,8 +52,7 @@ async function active(origin: Origin.Producer, path: Path.Valid): Promise { const { publisher, subscriber, close } = await session(protocol); diff --git a/js/justfile b/js/justfile index c8716b13a80..1a4fc861eb1 100644 --- a/js/justfile +++ b/js/justfile @@ -57,7 +57,7 @@ test-changed list: coverage package="": python3 ../sh/js/coverage.py '{{ package }}' -# Run one net benchmark: broadcasts, forward, requests, fills, reader, frames, track, resume, observed, drift, ledger, varint, lite-varint, priority, request-ids, subscribe-drop, subscribe-tracks, refresh, or compression. +# Run one net benchmark: broadcasts, forward, requests, fills, reader, frames, track, resume, observed, drift, ledger, varint, lite-varint, priority, subscribe-drop, subscribe-tracks, refresh, or compression. bench name: bun install --frozen-lockfile bun net/bench/{{ name }}.ts diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 84e2ac0ac8f..a3ac5bd6ba4 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### moq-transport drafts 14 through 17 are removed ([#6753](https://github.com/moq-dev/moq/pull/6753)) + +The `"moq-transport-14"` through `"moq-transport-17"` version names now throw a `TypeError`, and a peer offering only those versions fails the handshake. +The moql SETUP (no ALPN, or `moql`) negotiates only moq-lite versions. +Peers must speak moq-transport draft 18 or later, or moq-lite. + ### Request handlers are `requests()` ([#6752](https://github.com/moq-dev/moq/pull/6752)) Rename `Origin.Producer.dynamic(prefix)` to `requests(prefix)`, and the `Origin.Dynamic` handle it returns to `Origin.Requests`; its methods are unchanged. diff --git a/js/net/bench/fills.ts b/js/net/bench/fills.ts index 60fd9654aee..b93ab017bac 100644 --- a/js/net/bench/fills.ts +++ b/js/net/bench/fills.ts @@ -1,11 +1,11 @@ /** Sweep cached groups against independent peers requesting draft-22 fills. */ import { Milli } from "@moq/clock"; -import { NativeSession } from "../src/ietf/adapter"; import { FetchHeader } from "../src/ietf/fetch"; import * as Filter from "../src/ietf/filter"; import { Parameters } from "../src/ietf/parameters"; import { Publisher } from "../src/ietf/publisher"; import { RequestOk } from "../src/ietf/request"; +import { NativeSession } from "../src/ietf/session"; import { Subscribe, SubscribeOk, SubscribeUpdate } from "../src/ietf/subscribe"; import { ALPN, Version } from "../src/ietf/version"; import { createMockTransportPair } from "../src/mock"; diff --git a/js/net/bench/frames.ts b/js/net/bench/frames.ts index 510b113206f..76443e319cf 100644 --- a/js/net/bench/frames.ts +++ b/js/net/bench/frames.ts @@ -1,8 +1,8 @@ /** Sweep frame and chunk size for a group stream decoded into a group and drained by a reader. */ import type { Consumer as GroupConsumer } from "../src/group.ts"; import { Producer } from "../src/group.ts"; -import { NativeSession } from "../src/ietf/adapter.ts"; import { Group as IetfGroup } from "../src/ietf/object.ts"; +import { NativeSession } from "../src/ietf/session.ts"; import { Subscribe, SubscribeOk } from "../src/ietf/subscribe.ts"; import { Subscriber as IetfSubscriber } from "../src/ietf/subscriber.ts"; import { ALPN, Version as IetfVersion } from "../src/ietf/version.ts"; @@ -91,12 +91,9 @@ const ietf: Protocol = { const peer = await Stream.accept(pair.client, IETF_VERSION); if (!peer) throw new Error("the subscriber never subscribed"); await peer.reader.u53(); - const request = await Subscribe.decode(peer.reader, IETF_VERSION); + await Subscribe.decode(peer.reader, IETF_VERSION); await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: IETF_ALIAS }).encode( - peer.writer, - IETF_VERSION, - ); + await new SubscribeOk({ trackAlias: IETF_ALIAS }).encode(peer.writer, IETF_VERSION); const ordered = track.ordered(); let groupId = 0; diff --git a/js/net/bench/request-ids.ts b/js/net/bench/request-ids.ts deleted file mode 100644 index b59e7c18c35..00000000000 --- a/js/net/bench/request-ids.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Request-ID admission cost across independent sessions and reordered windows. -import { ControlStreamAdapter, SpentIds } from "../src/ietf/adapter.ts"; -import { Version } from "../src/ietf/version.ts"; -import { Stream } from "../src/stream.ts"; - -/** - * Every ID in the window: in order, reversed (one skip to the top, then each fill - * shortens it), or scattered (the top first, then every other ID descending, so each - * splits the lowest skipped run and the remembered runs grow to half the window). - */ -function order(mode: string, window: number): bigint[] { - const all = Array.from({ length: window }, (_, id) => BigInt(id * 2)); - if (mode === "ordered") return all; - if (mode === "reordered") return all.reverse(); - const top = all[window - 1]; - const odd = all.filter((id, index) => index % 2 === 1 && id !== top).reverse(); - const even = all.filter((id, index) => index % 2 === 0 && id !== top).reverse(); - return [top, ...odd, ...even]; -} - -const debug = console.debug; -console.debug = () => undefined; -try { - console.log("mode,sessions,window,admit_us,accepted"); - // Up to the production window, REQUEST_WINDOW. - for (const mode of ["ordered", "reordered", "scattered"]) { - for (const sessions of [1, 8, 32]) { - for (const window of [8, 128, 220_000]) { - const ids = order(mode, window); - // Fewer rounds for the large window, keeping each sweep to a similar budget. - const rounds = Math.max(1, Math.floor(8192 / window)); - let elapsed = 0; - let accepted = 0; - for (let round = 0; round < rounds; round++) { - const fixtures = Array.from({ length: sessions }, () => { - const stream = new Stream({ - readable: new ReadableStream(), - writable: new WritableStream(), - version: Version.DRAFT_16, - }); - const quic = { close() {} } as unknown as WebTransport; - return { - stream, - adapter: new ControlStreamAdapter( - quic, - stream, - Version.DRAFT_16, - 0n, - false, - BigInt(window), - ), - }; - }); - const start = performance.now(); - for (const { adapter } of fixtures) { - for (const id of ids) { - try { - adapter.admitRequest(id); - accepted++; - } catch { - /* Count refusals in baseline comparisons. */ - } - } - } - elapsed += performance.now() - start; - for (const { stream, adapter } of fixtures) { - adapter.close(); - stream.close(); - } - await Promise.resolve(); - } - console.log( - `${mode},${sessions},${window},${((elapsed * 1000) / rounds).toFixed(2)},${accepted / rounds}`, - ); - } - } - } -} finally { - console.debug = debug; -} - -// Draft 17+ has no MAX_REQUEST_ID window, yet still remembers skipped runs to refuse reuse. -console.log("modern_mode,sessions,requests,admit_us,accepted"); -for (const mode of ["ordered", "reordered", "scattered"]) { - for (const sessions of [1, 8, 32]) { - for (const requests of [8, 128, 220_000]) { - const ids = order(mode, requests); - const rounds = Math.max(1, Math.floor(8192 / requests)); - let elapsed = 0; - let accepted = 0; - for (let round = 0; round < rounds; round++) { - const receivers = Array.from({ length: sessions }, () => new SpentIds(0n)); - const start = performance.now(); - for (const receiver of receivers) { - for (const id of ids) { - receiver.spend(id); - accepted++; - } - } - elapsed += performance.now() - start; - } - console.log( - `modern-${mode},${sessions},${requests},${((elapsed * 1000) / rounds).toFixed(2)},${accepted / rounds}`, - ); - } - } -} diff --git a/js/net/bench/requests.ts b/js/net/bench/requests.ts index 8eef8d04a73..57de2aeff2b 100644 --- a/js/net/bench/requests.ts +++ b/js/net/bench/requests.ts @@ -1,9 +1,9 @@ /** Sweep request restart and cancellation over independent origin and waiter dimensions. */ import { Producer as Broadcast } from "../src/broadcast"; import * as Epoch from "../src/epoch"; -import { NativeSession } from "../src/ietf/adapter"; import { Publisher as IetfPublisher } from "../src/ietf/publisher"; import { RequestOk } from "../src/ietf/request"; +import { NativeSession } from "../src/ietf/session"; import { SubscribeNamespace, SubscribeNamespaceEntry, diff --git a/js/net/bench/serve-browser-entry.ts b/js/net/bench/serve-browser-entry.ts index c5c6aec586b..c449fc56333 100644 --- a/js/net/bench/serve-browser-entry.ts +++ b/js/net/bench/serve-browser-entry.ts @@ -2,8 +2,8 @@ import { Milli } from "@moq/clock"; import { randomHop } from "../src/hop.ts"; -import { NativeSession } from "../src/ietf/adapter.ts"; import { Publisher as IetfPublisher } from "../src/ietf/publisher.ts"; +import { NativeSession } from "../src/ietf/session.ts"; import { Subscribe as IetfSubscribe } from "../src/ietf/subscribe.ts"; import { ALPN, Version as IetfVersion } from "../src/ietf/version.ts"; import { Publisher as LitePublisher } from "../src/lite/publisher.ts"; diff --git a/js/net/bench/subscribe-tracks.ts b/js/net/bench/subscribe-tracks.ts index cd7e735ebbb..ea7bfe87cfd 100644 --- a/js/net/bench/subscribe-tracks.ts +++ b/js/net/bench/subscribe-tracks.ts @@ -1,8 +1,8 @@ /** Sweep origin size and subscribers while listing one newly added track. */ import { StreamCode, StreamError } from "../src/error.ts"; -import { NativeSession } from "../src/ietf/adapter.ts"; import { Publisher } from "../src/ietf/publisher.ts"; import { RequestOk } from "../src/ietf/request.ts"; +import { NativeSession } from "../src/ietf/session.ts"; import { PublishSkipped, SubscribeTracks } from "../src/ietf/subscribe_tracks.ts"; import { ALPN, Version } from "../src/ietf/version.ts"; import { createMockTransportPair } from "../src/mock.ts"; diff --git a/js/net/bench/varint.ts b/js/net/bench/varint.ts index 1cc8812e852..17d87cd5bc4 100644 --- a/js/net/bench/varint.ts +++ b/js/net/bench/varint.ts @@ -1,5 +1,6 @@ /** Time one varint encode and one decode, for both wire formats, at their 1, 2, 4, and 8-byte sizes. */ -import { Version } from "../src/ietf/version.ts"; +import { Version as Ietf } from "../src/ietf/version.ts"; +import { Version as Lite } from "../src/lite/version.ts"; import { Cursor } from "../src/stream.ts"; import * as Varint from "../src/varint.ts"; @@ -10,18 +11,18 @@ const reps = 9; const scratch = new ArrayBuffer(9); let checksum = 0; -// QUIC varints (lite, and moq-transport before draft-17), and leading-ones (draft-17+), each at the +// QUIC varints (lite before 07) and leading-ones (lite-07 and moq-transport), each at the // largest value of its 1, 2, 4, and 8-byte forms that a `number` holds. const formats = [ { name: "quic", - version: Version.DRAFT_16, + version: Lite.DRAFT_06, encode: Varint.encodeTo, values: [2 ** 6 - 1, 2 ** 14 - 1, 2 ** 30 - 1, Number.MAX_SAFE_INTEGER], }, { name: "leading-ones", - version: Version.DRAFT_17, + version: Ietf.DRAFT_18, encode: Varint.encodeLeadingOnesTo, values: [2 ** 7 - 1, 2 ** 14 - 1, 2 ** 28 - 1, Number.MAX_SAFE_INTEGER], }, diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 15f5df5e87f..06ca66a4a98 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -7,8 +7,6 @@ import { SessionCode, SessionError, StreamCode, StreamError, toStreamCode } from import { AuthMessage as IetfAuthMessage, IetfAuthWire } from "./ietf/auth.ts"; import * as Ietf from "./ietf/index.ts"; import * as IetfMessage from "./ietf/message.ts"; -import { TrackStatusRequest } from "./ietf/track.ts"; -import { Version as IetfVersion } from "./ietf/version.ts"; import { AuthMessage as LiteAuthMessage, LiteAuthWire } from "./lite/auth.ts"; import * as Lite from "./lite/index.ts"; import * as LiteMessage from "./lite/message.ts"; @@ -94,7 +92,7 @@ function answer(requests: Requests, answer: (token: string) => Grant): Issued[] } // Every case runs on moq-lite-07-wip and on moq-transport with the MoQ Auth extension. -describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { +describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { test("a shared handle presents its token on every session", async () => { const handle = new Handle(); // Added before any session: it waits for the first. @@ -1143,7 +1141,7 @@ test.each(Array.from({ length: 17 }, (_, gap) => gap))( }, ); -test.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])( +test.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])( "%s carries literal and wildcard grants exactly", async (protocol) => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); @@ -1183,7 +1181,6 @@ test.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])( test.each([ [Lite.ALPN_05, false], [Lite.ALPN_06, false], - [Ietf.ALPN.DRAFT_16, true], ])("%s has no grant, keeping a token for requests: %p", async (protocol, kept) => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); expect(client.auth.grant.peek()).toBeUndefined(); @@ -1368,7 +1365,7 @@ test("withdrawn queued tokens are pruned before the next application decision", // Capture the acceptor's actual AUTH writer after a valid setup grant. The following // bytes travel through the same connection dispatcher and presenter as a real peer. -describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("AUTH violations %s", (protocol) => { +describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("AUTH violations %s", (protocol) => { test("a second presenter message closes the session", async () => { const streams: Stream[] = []; const prototype = protocol === Lite.ALPN_07_WIP ? LiteAuthWire.prototype : IetfAuthWire.prototype; @@ -1489,78 +1486,44 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("AUTH } }); -// AUTH shares its ID space with every other request from the same peer. -describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("AUTH request IDs %s", (protocol) => { +// AUTH takes the peer's request ID parity. +describe.each([Ietf.ALPN.DRAFT_22])("AUTH request IDs %s", (protocol) => { for (const client of [true, false]) { - for (const fault of ["reused", "wrong parity"] as const) { - test(`${client ? "client" : "server"} ${fault} closes the session`, async () => { - const pair = await connect({ protocol }); - await waitFor(pair.client.auth.grant, (g) => g !== undefined); - await waitFor(pair.server.auth.grant, (g) => g !== undefined); - const original = IetfAuthMessage.prototype.encode; - const spy = spyOn(IetfAuthMessage.prototype, "encode").mockImplementation(async function ( - this: IetfAuthMessage, - writer, - version, - ) { - this.requestId = fault === "reused" ? (client ? 0n : 1n) : client ? 1n : 0n; - await original.call(this, writer, version); - }); - const added = (client ? pair.client : pair.server).auth.add("bad ID").catch(() => undefined); - try { - expect( - ( - await withTimeout( - pair.transport.closed, - Nano.fromMilli(Milli(100)), - "invalid AUTH ID left session open", - ) - ).closeCode, - ).toBe(SessionCode.InvalidRequestId); - } finally { - spy.mockRestore(); - pair.client.abort(); - pair.server.abort(); - await added; - } + test(`${client ? "client" : "server"} wrong parity closes the session`, async () => { + const pair = await connect({ protocol }); + await waitFor(pair.client.auth.grant, (g) => g !== undefined); + await waitFor(pair.server.auth.grant, (g) => g !== undefined); + const original = IetfAuthMessage.prototype.encode; + const spy = spyOn(IetfAuthMessage.prototype, "encode").mockImplementation(async function ( + this: IetfAuthMessage, + writer, + version, + ) { + this.requestId = client ? 1n : 0n; + await original.call(this, writer, version); }); - } + const added = (client ? pair.client : pair.server).auth.add("bad ID").catch(() => undefined); + try { + expect( + ( + await withTimeout( + pair.transport.closed, + Nano.fromMilli(Milli(100)), + "invalid AUTH ID left session open", + ) + ).closeCode, + ).toBe(SessionCode.InvalidRequestId); + } finally { + spy.mockRestore(); + pair.client.abort(); + pair.server.abort(); + await added; + } + }); } }); -for (const [protocol, version] of [ - [Ietf.ALPN.DRAFT_17, IetfVersion.DRAFT_17], - [Ietf.ALPN.DRAFT_22, IetfVersion.DRAFT_22], -] as const) { - test(`TRACK_STATUS cannot reuse setup AUTH ID on ${protocol}`, async () => { - const pair = await connect({ protocol }); - await waitFor(pair.client.auth.grant, (g) => g !== undefined); - try { - const raw = await pair.transport.createBidirectionalStream(); - const writer = new Writer(raw.writable, version); - await writer.u53(TrackStatusRequest.id); - await new TrackStatusRequest({ - requestId: 0n, - trackNamespace: Path.from("test"), - trackName: "video", - }).encode(writer, version); - expect( - ( - await withTimeout( - pair.transport.closed, - Nano.fromMilli(Milli(100)), - "cross-request reuse left session open", - ) - ).closeCode, - ).toBe(SessionCode.InvalidRequestId); - } finally { - pair.client.abort(); - pair.server.abort(); - } - }); -} - -for (const protocol of [Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22]) { +for (const protocol of [Ietf.ALPN.DRAFT_22]) { test(`AUTH accepts reordered IDs on ${protocol}`, async () => { const pair = await connect({ protocol }); await waitFor(pair.client.auth.grant, (g) => g !== undefined); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 622271afe13..b823668d440 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -1,7 +1,7 @@ /** * In-band authorization: present tokens to the peer and learn what they grant. * - * Each side of a moq-lite-07 session, and of a moq-transport draft-17+ session when both + * Each side of a moq-lite-07 session, and of a moq-transport draft-18+ session when both * sides negotiate MoQ Auth, presents the credential its connection already carried (the * URL, or nothing) right after setup, and learns the {@link Grant} it earned. Older * versions, and peers that do not negotiate it, carry no AUTH exchange: the grant stays diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 168ec215775..68945df8b92 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -2,7 +2,6 @@ import { Milli, Nano } from "@moq/clock"; import { race } from "@moq/signals"; import { ConnectTimeout, error, fromClose, ProtocolViolation } from "../error.ts"; import * as Ietf from "../ietf/index.ts"; -import * as Message from "../ietf/message.ts"; import { IMPLEMENTATION } from "../implementation.ts"; import * as Lite from "../lite/index.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; @@ -12,7 +11,7 @@ import { after } from "../util/timer.ts"; import type { Established } from "./established.ts"; import * as Extensions from "./extensions.ts"; import { DISCOVERY_REMOVED, forwardAnnounced } from "./forward.ts"; -import { DEFAULT_TIMEOUT, exchangeSetup, handsOff, legacyPeerImplementation } from "./handshake.ts"; +import { DEFAULT_TIMEOUT, exchangeSetup, handsOff } from "./handshake.ts"; /** Options for {@link accept}. */ export interface AcceptProps { @@ -26,7 +25,7 @@ export interface AcceptProps { /** Abort the handshake and release the transport; ignored after acceptance completes. */ signal?: AbortSignal; - /** Version to select during SETUP negotiation (for non-ALPN paths). */ + /** The moq-lite version to select in a `moql` SETUP, instead of the client's first. */ version?: number; /** @@ -129,12 +128,6 @@ async function acceptInner( } if (protocol === Ietf.ALPN.DRAFT_18) { return acceptAlpn(transport, url, Ietf.Version.DRAFT_18, wiring); - } else if (protocol === Ietf.ALPN.DRAFT_17) { - return acceptAlpn(transport, url, Ietf.Version.DRAFT_17, wiring); - } else if (protocol === Ietf.ALPN.DRAFT_16) { - return acceptSetup(transport, url, Ietf.Version.DRAFT_16, wiring); - } else if (protocol === Ietf.ALPN.DRAFT_15) { - return acceptSetup(transport, url, Ietf.Version.DRAFT_15, wiring); } else if (protocol === Lite.ALPN_07_WIP) { return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { @@ -153,7 +146,7 @@ async function acceptInner( } /** - * Draft-17+ accept: ALPN already pinned the version. SETUP is exchanged over + * Moq-transport accept: ALPN already pinned the version. SETUP is exchanged over * a pair of uni streams using stream type 0x2F00. */ async function acceptAlpn( @@ -176,142 +169,33 @@ async function acceptAlpn( url, quic: transport, ...setup, - // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. - maxRequestId: 0n, version, }); } -/** - * Legacy accept (draft-15/16): ALPN pinned the version, but the SETUP message - * is still exchanged over a bidi stream wrapped in the moq-lite compat envelope. - */ -async function acceptSetup( - transport: WebTransport, - url: URL, - version: Ietf.IetfVersion, - wiring: SessionProps, -): Promise { - // Accept bidi, read ClientSetup, write ServerSetup - const stream = await Stream.accept(transport, version); - if (!stream) throw new Error("no incoming bidi stream for SETUP"); - - const clientCompat = await stream.reader.u53(); - if (clientCompat !== Lite.StreamId.ClientCompat) { - throw new Error(`unexpected client message type: 0x${clientCompat.toString(16)}`); - } - - const client = await Ietf.ClientSetup.decode(stream.reader, version); - const peerImplementation = legacyPeerImplementation(transport, client.parameters); - - const params = new Ietf.SetupOptions(); - params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(true)); - Ietf.implementationIntoSetup(params, wiring.implementation); - Extensions.intoSetup(params, wiring.extensions, version); - Ietf.hiddenIntoSetup(params); - Ietf.maxAgeIntoSetup(params); - Ietf.activeCountIntoSetup(params, version); - - const server = new Ietf.ServerSetup({ version, parameters: params }); - await Message.write(stream.writer, Lite.StreamId.ServerCompat, server, version); - - const maxRequestId = client.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n; - - // The IETF session code loads on demand, since most sessions negotiate lite. - const { Connection } = await import("../ietf/connection.ts"); - return new Connection({ - ...wiring, - client: false, - url, - quic: transport, - control: stream, - maxRequestId, - version, - solicit: Ietf.solicitFromSetup(client.parameters), - hidden: Ietf.hiddenFromSetup(client.parameters), - maxAge: Ietf.maxAgeFromSetup(client.parameters), - activeCount: Ietf.activeCountFromSetup(client.parameters, version), - peerImplementation, - }); -} - +/** `moql`, or no subprotocol: negotiate moq-lite in SETUP. */ async function acceptNegotiated( transport: WebTransport, url: URL, wiring: SessionProps, version?: number, ): Promise { - const setupVersion = Ietf.Version.DRAFT_14; - - const stream = await Stream.accept(transport, setupVersion); + // The SETUP keeps lite-02's framing whichever version it offers. + const stream = await Stream.accept(transport, Lite.Version.DRAFT_02); if (!stream) throw new Error("no incoming bidi stream for SETUP"); - const clientCompat = await stream.reader.u53(); - if (clientCompat !== Lite.StreamId.ClientCompat) { - throw new Error(`unexpected client message type: 0x${clientCompat.toString(16)}`); - } - - const client = await Ietf.ClientSetup.decode(stream.reader, setupVersion); - - // Pick the requested version, or the first from the client's list that SETUP can negotiate: - // every moq-lite version and moq-transport-14, since later drafts carry no version list. - const negotiable = [...Object.values(Lite.Version), Ietf.Version.DRAFT_14] as number[]; - let selectedVersion: number; - if (version !== undefined) { - selectedVersion = version; - } else { - const match = client.versions.find((v) => negotiable.includes(v)); - if (match === undefined) { - throw new Error( - `no common version found; client offered: ${client.versions.map((v) => v.toString(16)).join(", ")}`, - ); - } - selectedVersion = match; + const client = await Lite.ClientSetup.decode(stream.reader); + const selected = version ?? client.versions.find(Lite.isNegotiated); + if (selected === undefined || !Lite.isNegotiated(selected)) { + throw new Error( + `no common version found; client offered: ${client.versions.map((v) => v.toString(16)).join(", ")}`, + ); } - const params = new Ietf.SetupOptions(); - params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(true)); - Ietf.implementationIntoSetup(params, wiring.implementation); - Extensions.intoSetup(params, wiring.extensions, setupVersion); - Ietf.hiddenIntoSetup(params); - Ietf.maxAgeIntoSetup(params); - Ietf.activeCountIntoSetup(params, setupVersion); - - const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params }); - await Message.write(stream.writer, Lite.StreamId.ServerCompat, server, setupVersion); - - if (handsOff(selectedVersion)) { + await new Lite.ServerSetup(selected).encode(stream.writer); + if (handsOff(selected)) { stream.close(); - return new Lite.Connection({ url, quic: transport, version: selectedVersion, ...wiring }); - } - - if (Object.values(Lite.Version).includes(selectedVersion as Lite.Version)) { - return new Lite.Connection({ - url, - quic: transport, - version: selectedVersion as Lite.Version, - session: stream, - ...wiring, - }); - } else if (Object.values(Ietf.Version).includes(selectedVersion as Ietf.Version)) { - const maxRequestId = client.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n; - // The IETF session code loads on demand, since most sessions negotiate lite. - const { Connection } = await import("../ietf/connection.ts"); - return new Connection({ - ...wiring, - client: false, - url, - quic: transport, - control: stream, - maxRequestId, - version: selectedVersion as Ietf.IetfVersion, - solicit: Ietf.solicitFromSetup(client.parameters), - hidden: Ietf.hiddenFromSetup(client.parameters), - maxAge: Ietf.maxAgeFromSetup(client.parameters), - activeCount: Ietf.activeCountFromSetup(client.parameters, selectedVersion as Ietf.IetfVersion), - peerImplementation: legacyPeerImplementation(transport, client.parameters), - }); - } else { - throw new Error(`unsupported version: ${selectedVersion.toString(16)}`); + return new Lite.Connection({ url, quic: transport, version: selected, ...wiring }); } + return new Lite.Connection({ url, quic: transport, version: selected, session: stream, ...wiring }); } diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index 3f797c43c65..aeef7bb808a 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -2,8 +2,10 @@ import { expect, jest, spyOn, test } from "bun:test"; import { Manual, Milli, Nano, sleep } from "@moq/clock"; import { ConnectTimeout, SessionCode, SessionError } from "../error.ts"; import * as Ietf from "../ietf/index.ts"; -import { ALPN, ALPN_05, ALPN_06, ALPN_07_WIP, Version as LiteVersion } from "../lite/version.ts"; +import { ClientSetup, ServerSetup } from "../lite/compat.ts"; +import { ALPN, ALPN_05, ALPN_06, ALPN_07_WIP, Version as Lite } from "../lite/version.ts"; import { createMockTransportPair } from "../mock.ts"; +import { Stream } from "../stream.ts"; import { accept } from "./accept.ts"; import { type ConnectProps, connect as connectSession } from "./connect.ts"; @@ -334,7 +336,7 @@ test("SETUP preserves a delayed session close code", async () => { }); test("versions map to WebTransport ALPNs in order", async () => { - const versions = ["moq-lite-07-wip", "moq-lite-06", "moq-transport-22", "moq-lite-02", "moq-transport-14"]; + const versions = ["moq-lite-07-wip", "moq-lite-06", "moq-transport-22", "moq-lite-02", "moq-lite-01"]; expect((await offered(ALPN_07_WIP, { versions })).protocols).toEqual([ ALPN_07_WIP, ALPN_06, @@ -344,7 +346,15 @@ test("versions map to WebTransport ALPNs in order", async () => { }); test("versions reject empty, duplicate, unsupported and ALPN spellings", async () => { - for (const versions of [[], ["moq-lite-06", "moq-lite-06"], ["moq-lite-99"], ["toString"], [Ietf.ALPN.DRAFT_22]]) { + for (const versions of [ + [], + ["moq-lite-06", "moq-lite-06"], + ["moq-lite-99"], + ["moq-transport-14"], + ["moq-transport-17"], + ["toString"], + [Ietf.ALPN.DRAFT_22], + ]) { expect(await connect(url, { versions }).catch((err: unknown) => err)).toBeInstanceOf(TypeError); } }); @@ -388,8 +398,8 @@ for (const protocol of ["", undefined]) { test(`SETUP negotiates the named versions with a peer whose protocol is ${JSON.stringify(protocol)}`, async () => { for (const [versions, want] of [ [["moq-lite-07-wip", "moq-lite-06"], "moq-lite-07-wip"], - [["moq-lite-06", "moq-transport-14"], "moq-lite-06"], - [["moq-transport-14", "moq-lite-06"], "moq-transport-14"], + [["moq-lite-06", "moq-lite-02"], "moq-lite-06"], + [["moq-lite-01", "moq-lite-06"], "moq-lite-01"], [["moq-lite-04"], "moq-lite-04"], ] as const) { const pair = createMockTransportPair(""); @@ -415,10 +425,10 @@ test("SETUP refuses a version the client did not name", async () => { const pair = createMockTransportPair(""); const restore = stubWebTransport(pair.client); try { - const server = accept({ url, transport: pair.server, version: LiteVersion.DRAFT_05 }).catch(() => undefined); + const server = accept({ url, transport: pair.server, version: Lite.DRAFT_05 }).catch(() => undefined); await expect( connect(url, { versions: ["moq-lite-07-wip", "moq-lite-06"], websocket: { enabled: false } }), - ).rejects.toThrow("server selected an unoffered version: 0xff0dad05"); + ).rejects.toThrow("unsupported server version: 0xff0dad05"); (await server)?.abort(); } finally { restore(); @@ -437,6 +447,32 @@ test("a peer without an ALPN is refused when no named version negotiates in SETU } }); +// The moq-transport draft-14 version code, which the `moql` SETUP once also negotiated. +const DRAFT_14 = 0xff00000e; + +test("a client refuses an IETF version selected in the moql SETUP", async () => { + const pair = createMockTransportPair(""); + const restore = stubWebTransport(pair.client); + try { + const connecting = connect(url, { websocket: { enabled: false } }); + const stream = await Stream.accept(pair.server, Lite.DRAFT_02); + if (!stream) throw new Error("no SETUP stream"); + await ClientSetup.decode(stream.reader); + await new ServerSetup(DRAFT_14).encode(stream.writer); + await expect(connecting).rejects.toThrow("unsupported server version: 0xff00000e"); + } finally { + restore(); + } +}); + +test("a server refuses a client offering only IETF versions in the moql SETUP", async () => { + const pair = createMockTransportPair(""); + const accepting = accept({ url, transport: pair.server }); + const stream = await Stream.open(pair.client, { version: Lite.DRAFT_02 }); + await new ClientSetup([DRAFT_14]).encode(stream.writer); + await expect(accepting).rejects.toThrow("no common version found; client offered: ff00000e"); +}); + // The WebSocket subprotocols one connect() attempt asks a server for. async function websocketOffer(props: Omit): Promise { const original = globalThis.WebTransport; diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 0d4abd1b4a8..a050c82709c 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -5,7 +5,6 @@ import type * as Auth from "../auth.ts"; import { vacant } from "../auth_session.ts"; import { ConnectTimeout, error, fromClose, ProtocolViolation, sessionCause } from "../error.ts"; import * as Ietf from "../ietf/index.ts"; -import * as Message from "../ietf/message.ts"; import { IMPLEMENTATION } from "../implementation.ts"; import * as Lite from "../lite/index.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; @@ -17,7 +16,7 @@ import { isWebTransportSupported } from "./browser.ts"; import type { Established } from "./established.ts"; import * as Extensions from "./extensions.ts"; import { DISCOVERY_REMOVED, forwardAnnounced } from "./forward.ts"; -import { DEFAULT_TIMEOUT, exchangeSetup, handsOff, legacyPeerImplementation } from "./handshake.ts"; +import { DEFAULT_TIMEOUT, exchangeSetup, handsOff } from "./handshake.ts"; import { type Credential, credential, fallback, hold } from "./tokens.ts"; // Default head start for WebTransport before attempting the WebSocket fallback. @@ -85,12 +84,10 @@ export interface WebTransportProps extends Omit = { +// moq-transport has no version list in SETUP. +const VERSIONS: Record = { "moq-lite-07-wip": { alpn: Lite.ALPN_07_WIP, setup: Lite.Version.DRAFT_07 }, "moq-lite-06": { alpn: Lite.ALPN_06, setup: Lite.Version.DRAFT_06 }, "moq-lite-05": { alpn: Lite.ALPN_05, setup: Lite.Version.DRAFT_05 }, @@ -100,16 +97,12 @@ const VERSIONS: Record = { "moq-lite-01": { alpn: Lite.ALPN, setup: Lite.Version.DRAFT_01 }, "moq-transport-22": { alpn: Ietf.ALPN.DRAFT_22 }, "moq-transport-18": { alpn: Ietf.ALPN.DRAFT_18 }, - "moq-transport-17": { alpn: Ietf.ALPN.DRAFT_17 }, - "moq-transport-16": { alpn: Ietf.ALPN.DRAFT_16 }, - "moq-transport-15": { alpn: Ietf.ALPN.DRAFT_15 }, - "moq-transport-14": { alpn: Lite.ALPN, setup: Ietf.Version.DRAFT_14 }, }; // Experimental lite-07 is offered only when named. const DEFAULT_VERSIONS = Object.keys(VERSIONS).filter((name) => name !== "moq-lite-07-wip"); -// moq-transport-18 and newer are pinned to qmux-01. Every other ALPN is currently +// moq-transport is pinned to qmux-01. Every other ALPN is currently // negotiated as `qmux-00.{alpn}`, but `null` lets the polyfill advertise every QMux draft // it knows and the server pick one. const QMUX: Record = { @@ -120,7 +113,7 @@ const QMUX: Record = { /** What one version list offers: the ALPNs in preference order, and the versions offered in SETUP. */ interface Offer { alpns: string[]; - setup: SetupVersion[]; + setup: Lite.Version[]; /** Whether the caller named the versions, so the peer must pick one of them. */ explicit: boolean; /** Whether every offered version carries the connection credential in SETUP. */ @@ -135,7 +128,7 @@ function hasSetupToken(name: string): boolean { function offer(versions?: readonly string[]): Offer { const names = versions ?? DEFAULT_VERSIONS; const alpns = new Set(); - const setup: SetupVersion[] = []; + const setup: Lite.Version[] = []; for (const name of names) { const version = VERSIONS[name]; if (!version) throw new TypeError(`unsupported version: ${name}`); @@ -180,7 +173,7 @@ export interface ConnectProps { * `moq-lite-06`. Defaults to every supported version except the work-in-progress * `moq-lite-07-wip`; list every fallback you still want. With a peer that negotiates no * subprotocol (such as WebTransport in Firefox before 155), the version is negotiated in - * SETUP instead, which moq-transport 15 and newer cannot do. + * SETUP instead, which moq-transport cannot do. */ versions?: readonly string[]; @@ -455,7 +448,7 @@ async function connectTransport( url: URL, session: WebTransport, wiring: SessionProps, - setup: SetupVersion[], + setup: Lite.Version[], ): Promise { const closed = session.closed.then(fromClose, error); const terminal = closed.then((cause) => { @@ -493,29 +486,21 @@ async function negotiate( url: URL, session: WebTransport, wiring: SessionProps, - setup: SetupVersion[], + setup: Lite.Version[], ): Promise { // The DOM lib has no `protocol` property yet. It is "" when none was negotiated, and // undefined in a browser that predates subprotocols (Firefox before 155). const protocol: string | undefined = (session as { protocol?: string }).protocol || undefined; console.debug(redact(url), "negotiated ALPN:", protocol ?? "(none)"); - // Choose setup encoding based on negotiated WebTransport protocol (if any). - let setupVersion: Ietf.Version; - const modernVersion = + const ietf = protocol === Ietf.ALPN.DRAFT_22 ? Ietf.Version.DRAFT_22 : protocol === Ietf.ALPN.DRAFT_18 ? Ietf.Version.DRAFT_18 - : protocol === Ietf.ALPN.DRAFT_17 - ? Ietf.Version.DRAFT_17 - : undefined; - if (modernVersion !== undefined) { - return await handshakeAlpn(url, session, modernVersion, wiring); - } else if (protocol === Ietf.ALPN.DRAFT_16) { - setupVersion = Ietf.Version.DRAFT_16; - } else if (protocol === Ietf.ALPN.DRAFT_15) { - setupVersion = Ietf.Version.DRAFT_15; + : undefined; + if (ietf !== undefined) { + return await handshakeAlpn(url, session, ietf, wiring); } else if (protocol === Lite.ALPN_07_WIP) { return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { @@ -526,84 +511,29 @@ async function negotiate( return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_04, ...wiring }); } else if (protocol === Lite.ALPN_03) { return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_03, ...wiring }); - } else if (protocol === Lite.ALPN || protocol === "" || protocol === undefined) { - setupVersion = Ietf.Version.DRAFT_14; - } else { + } else if (protocol !== Lite.ALPN && protocol !== undefined) { throw new Error(`unsupported WebTransport protocol: ${protocol}`); } - const stream = await Stream.open(session, { version: setupVersion }); - - const params = new Ietf.SetupOptions(); - params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(false)); - Ietf.implementationIntoSetup(params, wiring.implementation); - Extensions.intoSetup(params, wiring.extensions, setupVersion); - Ietf.hiddenIntoSetup(params); - Ietf.maxAgeIntoSetup(params); - Ietf.activeCountIntoSetup(params, setupVersion); - if (wiring.token) Ietf.tokenIntoSetup(params, { kind: Ietf.TOKEN_OUT_OF_BAND, value: wiring.token }, setupVersion); - - const client = new Ietf.ClientSetup({ - versions: - setupVersion === Ietf.Version.DRAFT_16 - ? [Ietf.Version.DRAFT_16] - : setupVersion === Ietf.Version.DRAFT_15 - ? [Ietf.Version.DRAFT_15] - : setup, - parameters: params, - }); - await Message.write(stream.writer, Lite.StreamId.ClientCompat, client, setupVersion); - - const serverCompat = await stream.reader.u53(); - if (serverCompat !== Lite.StreamId.ServerCompat) { - throw new Error(`unsupported server message type: ${serverCompat.toString()}`); - } - - const server = await Ietf.ServerSetup.decode(stream.reader, setupVersion); - if (!client.versions.includes(server.version)) { - throw new Error(`server selected an unoffered version: 0x${server.version.toString(16)}`); - } - - if (handsOff(server.version)) { + // `moql`, or no subprotocol: negotiate moq-lite in SETUP, which keeps lite-02's framing + // whichever version it offers. + if (setup.length === 0) throw new Error("no version negotiates in SETUP"); + const stream = await Stream.open(session, { version: Lite.Version.DRAFT_02 }); + await new Lite.ClientSetup(setup).encode(stream.writer); + const server = await Lite.ServerSetup.decode(stream.reader); + const version = setup.find((v) => v === server.version); + if (version === undefined) throw new Error(`unsupported server version: 0x${server.version.toString(16)}`); + if (handsOff(version)) { // The server ends the stream after its SETUP; nothing else may follow. if (!(await stream.reader.done())) throw new Error("unexpected data after SERVER_SETUP"); stream.close(); - return new Lite.Connection({ url, quic: session, version: server.version, ...wiring }); - } - - if (isLite(server.version)) { - return new Lite.Connection({ - url, - quic: session, - version: server.version, - session: stream, - ...wiring, - }); - } else if (Object.values(Ietf.Version).includes(server.version as Ietf.Version)) { - const maxRequestId = server.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n; - // The IETF session code loads on demand, since most sessions negotiate lite. - const { Connection } = await import("../ietf/connection.ts"); - return new Connection({ - ...wiring, - client: true, - url, - quic: session, - control: stream, - maxRequestId, - version: server.version as Ietf.IetfVersion, - solicit: Ietf.solicitFromSetup(server.parameters), - hidden: Ietf.hiddenFromSetup(server.parameters), - maxAge: Ietf.maxAgeFromSetup(server.parameters), - activeCount: Ietf.activeCountFromSetup(server.parameters, server.version as Ietf.IetfVersion), - peerImplementation: legacyPeerImplementation(session, server.parameters), - }); - } else { - throw new Error(`unsupported server version: ${server.version.toString()}`); + return new Lite.Connection({ url, quic: session, version, ...wiring }); } + return new Lite.Connection({ url, quic: session, version, session: stream, ...wiring }); } /** - * Draft-17+ client handshake. ALPN already pinned the version; SETUP is + * Moq-transport client handshake. ALPN already pinned the version; SETUP is * exchanged over a pair of uni streams using stream type 0x2F00. */ async function handshakeAlpn( @@ -626,8 +556,6 @@ async function handshakeAlpn( url, quic: session, ...setup, - // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. - maxRequestId: 0n, version, }); } @@ -855,7 +783,3 @@ async function connectWebSocket( } } } - -function isLite(version: number): version is Lite.Version { - return Object.values(Lite.Version).includes(version as Lite.Version); -} diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index 1e25985de45..991dcb1d920 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -30,7 +30,7 @@ export interface Established { /** * The tokens this side presented and the grant they earned, plus the tokens the peer - * presents. On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate + * presents. On moq-lite-07-wip, and on moq-transport draft-18+ when both sides negotiate * MoQ Auth, each side presents its connection's credential right after setup. * Otherwise the grant stays undefined. * diff --git a/js/net/src/connection/extensions.ts b/js/net/src/connection/extensions.ts index f53e2fc1504..142887d0d26 100644 --- a/js/net/src/connection/extensions.ts +++ b/js/net/src/connection/extensions.ts @@ -6,11 +6,11 @@ import * as Ietf from "../ietf/index.ts"; * core, so this only changes moq-transport sessions. */ export interface Extensions { - /** The MoQ Auth extension: tokens presented and granted on their own stream (draft-17+). */ + /** The MoQ Auth extension: tokens presented and granted on their own stream (draft-18+). */ auth?: boolean; /** The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than announcing unasked. */ solicit?: boolean; - /** The MoQ Epoch extension: announcements and requests carry the publisher's epoch, as moq-lite does (draft-17+). */ + /** The MoQ Epoch extension: announcements and requests carry the publisher's epoch, as moq-lite does (draft-18+). */ epoch?: boolean; } @@ -27,8 +27,8 @@ export function offered(extensions?: Extensions): Offered { } /** Write the options for the offered extensions into a SETUP. */ -export function intoSetup(params: Ietf.SetupOptions, extensions: Offered, version: Ietf.IetfVersion) { +export function intoSetup(params: Ietf.SetupOptions, extensions: Offered) { if (extensions.solicit) Ietf.solicitIntoSetup(params); - if (extensions.auth) Ietf.Auth.intoSetup(params, version); - if (extensions.epoch) Ietf.Epoch.intoSetup(params, version); + if (extensions.auth) Ietf.Auth.intoSetup(params); + if (extensions.epoch) Ietf.Epoch.intoSetup(params); } diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index 40a4b279ab2..e7b9171a303 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -11,7 +11,7 @@ import * as Extensions from "./extensions.ts"; export const DEFAULT_TIMEOUT = Milli(30_000); /** - * Draft-17+ SETUP exchange. Each side opens a uni stream, writes its Setup + * Draft-18+ SETUP exchange. Each side opens a uni stream, writes its Setup * message, and reads the peer's Setup off an incoming uni stream. The two * halves run in parallel and the protocol is symmetric, so both `connect` * (client) and `accept` (server) use this same function. @@ -44,17 +44,17 @@ export async function exchangeSetup( }> { const params = new Ietf.SetupOptions(); Ietf.implementationIntoSetup(params, implementation); - Extensions.intoSetup(params, extensions, version); + Extensions.intoSetup(params, extensions); Ietf.hiddenIntoSetup(params); Ietf.maxAgeIntoSetup(params); - Ietf.activeCountIntoSetup(params, version); + Ietf.activeCountIntoSetup(params); // Only a client presents a connection credential. - if (client && token) Ietf.tokenIntoSetup(params, { kind: Ietf.TOKEN_OUT_OF_BAND, value: token }, version); + if (client && token) Ietf.tokenIntoSetup(params, { kind: Ietf.TOKEN_OUT_OF_BAND, value: token }); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. const self = randomHop(); - Ietf.Cluster.intoSetup(params, self, version); + Ietf.Cluster.intoSetup(params, self); const setupMsg = new Ietf.Setup({ parameters: params }); @@ -160,7 +160,7 @@ async function receiveSetup( } // Nothing here uses the peer's token, but one that cannot be decoded still closes the // session (draft-22 section 8.9). - Ietf.tokenFromSetup(setup.parameters, version); + Ietf.tokenFromSetup(setup.parameters); return { reader, @@ -168,33 +168,15 @@ async function receiveSetup( solicit: Ietf.solicitFromSetup(setup.parameters), hidden: Ietf.hiddenFromSetup(setup.parameters), maxAge: Ietf.maxAgeFromSetup(setup.parameters), - activeCount: Ietf.activeCountFromSetup(setup.parameters, version), - cluster: Ietf.Cluster.fromSetup(setup.parameters, version), - auth: Ietf.Auth.fromSetup(setup.parameters, version) === true, - epoch: Ietf.Epoch.fromSetup(setup.parameters, version), + activeCount: Ietf.activeCountFromSetup(setup.parameters), + cluster: Ietf.Cluster.fromSetup(setup.parameters), + auth: Ietf.Auth.fromSetup(setup.parameters) === true, + epoch: Ietf.Epoch.fromSetup(setup.parameters), implementation: Ietf.implementationFromSetup(setup.parameters), }; } -/** - * The implementation a moq-transport draft-14 to 16 peer advertised in its bidi SETUP. A malformed one - * closes the session as a protocol violation, the way {@link exchangeSetup} refuses one. - */ -export function legacyPeerImplementation(transport: WebTransport, params: Ietf.SetupOptions): string | undefined { - try { - return Ietf.implementationFromSetup(params); - } catch (err) { - if (err instanceof ProtocolViolation) - transport.close({ closeCode: err.code, reason: closeReason(err.message) }); - throw err; - } -} - /** Whether SETUP selecting `version` ends the SETUP stream, the session continuing as its ALPN would. */ -export function handsOff(version: number): version is Lite.Version { - return ( - Object.values(Lite.Version).includes(version as Lite.Version) && - version !== Lite.Version.DRAFT_01 && - version !== Lite.Version.DRAFT_02 - ); +export function handsOff(version: Lite.Version): boolean { + return version !== Lite.Version.DRAFT_01 && version !== Lite.Version.DRAFT_02; } diff --git a/js/net/src/connection/implementation.test.ts b/js/net/src/connection/implementation.test.ts index 8e302805f44..f27eb533c71 100644 --- a/js/net/src/connection/implementation.test.ts +++ b/js/net/src/connection/implementation.test.ts @@ -1,11 +1,8 @@ import { expect, test } from "bun:test"; -import { ProtocolViolation, SessionCode } from "../error.ts"; import * as Ietf from "../ietf/index.ts"; -import * as Message from "../ietf/message.ts"; import { IMPLEMENTATION } from "../implementation.ts"; import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; -import { Stream } from "../stream.ts"; import { accept } from "./accept.ts"; import { connect } from "./connect.ts"; @@ -25,8 +22,8 @@ async function session(protocol: string, client: string | undefined, server: str return { client: peers[0], server: peers[1] }; } -// The lite Setup Stream, a draft-17+ uni SETUP, and a draft-14 to 16 bidi SETUP each carry it on their own path. -for (const protocol of [Lite.ALPN_06, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_16]) { +// The lite Setup Stream and the moq-transport uni SETUP each carry it on their own path. +for (const protocol of [Lite.ALPN_06, Ietf.ALPN.DRAFT_18]) { test(`each side reads the other's implementation over ${protocol}`, async () => { expect(await session(protocol, "client/1", "server/2")).toEqual({ client: "server/2", server: "client/1" }); }); @@ -49,23 +46,6 @@ for (const [protocol, version] of [ }); } -test("a bidi SETUP with a malformed implementation closes the session", async () => { - const pair = createMockTransportPair(Ietf.ALPN.DRAFT_16); - const version = Ietf.Version.DRAFT_16; - const params = new Ietf.SetupOptions(); - params.setBytes(Ietf.SetupOption.Implementation, new Uint8Array([0xc3, 0x28])); - const stream = await Stream.open(pair.client, { version }); - await Message.write( - stream.writer, - Lite.StreamId.ClientCompat, - new Ietf.ClientSetup({ versions: [], parameters: params }), - version, - ); - - await expect(accept({ url, transport: pair.server })).rejects.toThrow(ProtocolViolation); - expect((await pair.client.closed).closeCode).toBe(SessionCode.ProtocolViolation); -}); - test("a session that closes before the peer's SETUP reads no implementation", async () => { const pair = createMockTransportPair(Lite.ALPN_06); const accepted = await accept({ url, transport: pair.server }); diff --git a/js/net/src/connection/tokens.test.ts b/js/net/src/connection/tokens.test.ts index 43a11b8be52..eab3da94fba 100644 --- a/js/net/src/connection/tokens.test.ts +++ b/js/net/src/connection/tokens.test.ts @@ -114,7 +114,7 @@ test("sessions sharing an auth handle present each configured token once", async expect(closed).toEqual([]); }); -test("a draft-17+ client presents its token in SETUP", async () => { +test("an IETF client presents its token in SETUP", async () => { const version = Ietf.Version.DRAFT_18; const pair = createMockTransportPair(Ietf.ALPN.DRAFT_18); const token = new TextEncoder().encode("credential"); @@ -127,7 +127,7 @@ test("a draft-17+ client presents its token in SETUP", async () => { const reader = new Reader(next.value, undefined, version); expect(await reader.u53()).toBe(Ietf.Setup.id); const setup = await Ietf.Setup.decode(reader, version); - const presented = Ietf.tokenFromSetup(setup.parameters, version); + const presented = Ietf.tokenFromSetup(setup.parameters); expect(presented?.kind).toBe(Ietf.TOKEN_OUT_OF_BAND); expect(new TextDecoder().decode(presented?.value)).toBe("credential"); pair.client.close(); diff --git a/js/net/src/error.test.ts b/js/net/src/error.test.ts index 1a68ae07347..aa0d5820a9e 100644 --- a/js/net/src/error.test.ts +++ b/js/net/src/error.test.ts @@ -375,14 +375,7 @@ test("toStreamCode: a control timeout keeps its own code", () => { expect(toStreamCode(timeout)).toBe(StreamCode.ControlTimeout); // The moq-transport registry has no value for it, so every draft reads it as INTERNAL_ERROR. - for (const version of [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, - ]) { + for (const version of [Version.DRAFT_18, Version.DRAFT_22]) { expect(toStreamCode(timeout, { version })).toBe(StreamCode.Internal); } diff --git a/js/net/src/error.ts b/js/net/src/error.ts index 31f61ea73d3..1d1a46de8cc 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -388,7 +388,7 @@ interface TransportErrorOptions { export function toStreamCode(err: unknown, options?: TransportErrorOptions): StreamCode { const code = localStreamCode(err); if (options?.version === undefined) return code; - return sharedStreamCode(code, options.version) ? code : StreamCode.Internal; + return sharedStreamCode(code) ? code : StreamCode.Internal; } /** The moq-lite code for a local failure, before any draft has a say. */ @@ -453,9 +453,7 @@ export function unauthorized(broadcast: string): Stream { * the same `instanceof` as one raised here. * * On an IETF stream a code keeps its value unless moq-lite claims that number for something - * the draft does not: `0x4` is GOING_AWAY here but UNKNOWN_OBJECT_STATUS on draft-16 and 17, - * and reading one back as the other would retire a session that is not going anywhere. Those - * read as {@link StreamCode.Internal} with the wire value kept in the message, since + * the draft does not, such as its own 48-63 range. Those read as {@link StreamCode.Internal} with the wire value kept in the message, since * {@link StreamCode} is one numeric space and cannot hold a foreign code without lying about * it. A code moq-lite names nothing for survives intact: nothing can misread it. * @@ -464,7 +462,7 @@ export function unauthorized(broadcast: string): Stream { export function fromTransport(err: unknown, options?: TransportErrorOptions): Error { const code = streamCode(err); if (code === undefined) return error(err); - if (options?.version !== undefined && !sharedStreamCode(code, options.version) && claimedLocally(code)) { + if (options?.version !== undefined && !sharedStreamCode(code) && claimedLocally(code)) { return new Stream(StreamCode.Internal, { cause: err, message: `remote error: ${code}` }); } if (code === StreamCode.TooFarBehind) return new TooFarBehind({ cause: err }); diff --git a/js/net/src/goaway-requests.test.ts b/js/net/src/goaway-requests.test.ts index 3589df5bab0..ec8e25a5df5 100644 --- a/js/net/src/goaway-requests.test.ts +++ b/js/net/src/goaway-requests.test.ts @@ -4,8 +4,8 @@ import { Once } from "@moq/signals"; import { accept } from "./connection/accept.ts"; import { connect } from "./connection/connect.ts"; import type { Drain } from "./connection/goaway.ts"; -import type { Session } from "./ietf/adapter.ts"; import { GoAway } from "./ietf/goaway.ts"; +import type { Session } from "./ietf/session.ts"; import { Subscriber } from "./ietf/subscriber.ts"; import { ALPN, Version as IetfVersion } from "./ietf/version.ts"; import { Goaway } from "./lite/goaway.ts"; @@ -37,7 +37,7 @@ async function waitUntil(pred: () => boolean): Promise { } } -/** Bytes of one GOAWAY, framed the way the draft-17 setup stream carries it. */ +/** Bytes of one GOAWAY, framed the way the setup stream carries it. */ async function encodeIetfGoaway(): Promise { const chunks: Uint8Array[] = []; const writable = new WritableStream({ @@ -45,9 +45,9 @@ async function encodeIetfGoaway(): Promise { chunks.push(Uint8Array.from(chunk)); }, }); - const writer = new Writer(writable, IetfVersion.DRAFT_17); + const writer = new Writer(writable, IetfVersion.DRAFT_22); await writer.u53(GoAway.id); - await new GoAway({ newSessionUri: "" }).encode(writer, IetfVersion.DRAFT_17); + await new GoAway({ newSessionUri: "" }).encode(writer, IetfVersion.DRAFT_22); const size = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); const bytes = new Uint8Array(size); let offset = 0; @@ -100,7 +100,7 @@ async function sendLiteGoaway(server: WebTransport): Promise { * replacement is another instance: the request already open stays on the draining session. */ async function handover(kind: "lite" | "ietf"): Promise { - const protocol = kind === "lite" ? ALPN_06 : ALPN.DRAFT_17; + const protocol = kind === "lite" ? ALPN_06 : ALPN.DRAFT_22; const pair = createMockTransportPair(protocol); const inject = kind === "ietf" ? captureFirstUni(pair.server) : undefined; const goawayBytes = kind === "ietf" ? await encodeIetfGoaway() : undefined; @@ -244,8 +244,8 @@ test("ietf: requests open on the draining session until the replacement wins", a await handover("ietf"); }); -test("announcing and subscribing after GOAWAY still open streams, on the adapter too", async () => { - for (const version of [IetfVersion.DRAFT_14, IetfVersion.DRAFT_16, IetfVersion.DRAFT_18]) { +test("announcing and subscribing after GOAWAY still open streams", async () => { + for (const version of [IetfVersion.DRAFT_18, IetfVersion.DRAFT_22]) { const goaway = new Once(); goaway.set({ uri: "" }); let opened = false; @@ -256,24 +256,19 @@ test("announcing and subscribing after GOAWAY still open streams, on the adapter opened = true; throw new Error("openBi"); }, - openNativeBi() { - opened = true; - throw new Error("openNativeBi"); - }, acceptBi() { return Promise.resolve(undefined); }, nextRequestId() { return Promise.resolve(0n); }, - spendRequest() { + checkRequestId() { throw new Error("unexpected incoming request"); }, close() {}, }; - // A soliciting peer, so draft-14 still asks it for the empty namespace. - const subscriber = new Subscriber({ session, goaway, solicit: true }); + const subscriber = new Subscriber({ session, goaway }); const track = subscriber.consume(Path.from("room")).track("video").subscribe(); await track.closed; expect(opened).toBe(true); @@ -296,7 +291,7 @@ async function routeDeath(kind: "lite" | "ietf", after: boolean): Promise const clock = new Manual(); const installed = clock.install(); using _clock = { [Symbol.dispose]: () => installed.close() }; - const protocol = kind === "lite" ? ALPN_06 : ALPN.DRAFT_17; + const protocol = kind === "lite" ? ALPN_06 : ALPN.DRAFT_22; const pair = createMockTransportPair(protocol); const inject = kind === "ietf" ? captureFirstUni(pair.server) : undefined; const goawayBytes = kind === "ietf" ? await encodeIetfGoaway() : undefined; diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index f48c4e5e64d..713adb69cac 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -111,10 +111,8 @@ export interface Route { * The token the request advertising this route carries, from `auth.add()`, or undefined * for the session's own credential. Only the session that issued it carries it, as the * token on its moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE - * answering a solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and 15 answer - * with a PUBLISH_NAMESPACE, which carries it), and moq-lite advertise the route unnamed. - * Swapping it renews the request with REQUEST_UPDATE from draft 16, or ends and makes it - * again before. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. + * answering a solicited SUBSCRIBE_NAMESPACE, and moq-lite advertise the route unnamed. + * Swapping it renews the request with REQUEST_UPDATE. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. */ auth?: Credential; } diff --git a/js/net/src/ietf/active_count.ts b/js/net/src/ietf/active_count.ts index 02882a8730c..9831a265509 100644 --- a/js/net/src/ietf/active_count.ts +++ b/js/net/src/ietf/active_count.ts @@ -1,5 +1,4 @@ import { SetupOption, type SetupOptions } from "./setup_options.ts"; -import { type IetfVersion, Version } from "./version.ts"; /** * The MoQ Active Count extension (draft-lcurley-moq-active-count-00). @@ -14,18 +13,13 @@ import { type IetfVersion, Version } from "./version.ts"; * @internal */ -// Draft-14/15 answer SUBSCRIBE_NAMESPACE with PUBLISH_NAMESPACE requests, which the stream cannot count. -function supported(version: IetfVersion): boolean { - return version !== Version.DRAFT_14 && version !== Version.DRAFT_15; -} - /** * Whether the extension is negotiated. We always declare it, so the peer's SETUP decides. * * @internal */ -export function activeCountFromSetup(params: SetupOptions, version: IetfVersion): boolean { - return supported(version) && params.getVarint(SetupOption.ActiveCount) !== undefined; +export function activeCountFromSetup(params: SetupOptions): boolean { + return params.getVarint(SetupOption.ActiveCount) !== undefined; } /** @@ -33,6 +27,6 @@ export function activeCountFromSetup(params: SetupOptions, version: IetfVersion) * * @internal */ -export function activeCountIntoSetup(params: SetupOptions, version: IetfVersion) { - if (supported(version)) params.setVarint(SetupOption.ActiveCount, 1n); +export function activeCountIntoSetup(params: SetupOptions) { + params.setVarint(SetupOption.ActiveCount, 1n); } diff --git a/js/net/src/ietf/adapter.test.ts b/js/net/src/ietf/adapter.test.ts deleted file mode 100644 index 8eda62ac499..00000000000 --- a/js/net/src/ietf/adapter.test.ts +++ /dev/null @@ -1,813 +0,0 @@ -import { expect, spyOn, test } from "bun:test"; -import { SessionCode } from "../error.ts"; -import { createMockTransportPair } from "../mock.ts"; -import * as Path from "../path.ts"; -import { Stream } from "../stream.ts"; -import { ControlStreamAdapter } from "./adapter.ts"; -import { toRequestCode } from "./error.ts"; -import { GoAway } from "./goaway.ts"; -import { - PublishNamespace, - PublishNamespaceCancel, - PublishNamespaceDone, - PublishNamespaceUpdate, -} from "./publish_namespace.ts"; -import { MaxRequestId, RequestError, RequestOk } from "./request.ts"; -import { initialMaxRequestId, REQUEST_WINDOW } from "./setup_options.ts"; -import { Subscribe, SubscribeError, SubscribeUpdate } from "./subscribe.ts"; -import { SubscribeNamespaceLegacy } from "./subscribe_namespace.ts"; -import { TrackStatusOk, TrackStatusRequest } from "./track.ts"; -import { ALPN, type IetfVersion, Version } from "./version.ts"; - -test("draft-14 status answers route by request ID and a refusal leaves the session usable", async () => { - const version = Version.DRAFT_14; - const pair = createMockTransportPair(ALPN.DRAFT_14); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, true); - void adapter.run().catch(() => {}); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - try { - for (const refused of [false, true, false]) { - const requestId = await adapter.nextRequestId(); - if (requestId === undefined) throw new Error("no request ID"); - const stream = await adapter.openBi(); - await stream.writer.u53(TrackStatusRequest.id); - await new TrackStatusRequest({ requestId, trackNamespace: Path.from("room"), trackName: "video" }).encode( - stream.writer, - version, - ); - expect(await peer.reader.u53()).toBe(TrackStatusRequest.id); - expect((await TrackStatusRequest.decode(peer.reader, version)).requestId).toBe(requestId); - if (refused) { - await peer.writer.u53(0x0f); - await new SubscribeError({ requestId, errorCode: 4, reasonPhrase: "missing" }).encode( - peer.writer, - version, - ); - expect(await stream.reader.u53()).toBe(0x0f); - expect((await SubscribeError.decode(stream.reader, version)).requestId).toBe(requestId); - } else { - await peer.writer.u53(TrackStatusOk.id(version)); - await new TrackStatusOk({ requestId, largest: { groupId: 4n, objectId: 1n } }).encode( - peer.writer, - version, - ); - expect(await stream.reader.u53()).toBe(TrackStatusOk.id(version)); - const status = await TrackStatusOk.decode(stream.reader, version); - expect(status.requestId).toBe(requestId); - expect(status.largest).toEqual({ groupId: 4n, objectId: 1n }); - } - stream.close(); - } - } finally { - adapter.close(); - } -}); - -// Draft-15 is the interesting one: it names its namespace withdrawals instead of -// numbering them, so the adapter has to resolve them through a map it keeps itself. -const VERSION = Version.DRAFT_15; - -/** How long to wait for something the adapter should have done by now. */ -const WAIT = 250; - -/** Stand up an adapter over a mock transport, plus the peer's view of the control stream. */ -async function connect(): Promise<{ adapter: ControlStreamAdapter; peer: Stream }> { - const pair = createMockTransportPair(ALPN.DRAFT_15); - - const control = await Stream.open(pair.server, { version: VERSION }); - const adapter = new ControlStreamAdapter(pair.server, control, VERSION, 100n, true); - void adapter.run().catch(() => void 0); - - const peer = await Stream.accept(pair.client, VERSION); - if (!peer) throw new Error("no control stream"); - - return { adapter, peer }; -} - -/** Announce a namespace from the peer, on its own request. */ -async function announce(peer: Stream, requestId: bigint, namespace: Path.Valid): Promise { - await peer.writer.u53(PublishNamespace.id); - await new PublishNamespace({ requestId, trackNamespace: namespace }).encode(peer.writer, VERSION); -} - -/** Announce a namespace through the adapter to its peer. */ -async function announceOutgoing( - adapter: ControlStreamAdapter, - requestId: bigint, - namespace: Path.Valid, -): Promise { - const stream = adapter.openBi(); - await stream.writer.u53(PublishNamespace.id); - await new PublishNamespace({ requestId, trackNamespace: namespace }).encode(stream.writer, VERSION); - return stream; -} - -/** Withdraw a namespace from the peer, by name as draft-14/15 do. */ -async function withdraw(peer: Stream, namespace: Path.Valid): Promise { - await peer.writer.u53(PublishNamespaceDone.id); - await new PublishNamespaceDone({ trackNamespace: namespace }).encode(peer.writer, VERSION); -} - -/** Reject an outgoing namespace announcement by name. */ -async function cancel(peer: Stream, namespace: Path.Valid): Promise { - await peer.writer.u53(PublishNamespaceCancel.id); - await new PublishNamespaceCancel({ trackNamespace: namespace, errorCode: 0, reasonPhrase: "" }).encode( - peer.writer, - VERSION, - ); -} - -/** Accept the virtual stream an announcement opened and consume the announcement itself. */ -async function accept(adapter: ControlStreamAdapter): Promise { - const stream = await Promise.race([ - adapter.acceptBi(), - new Promise((resolve) => setTimeout(() => resolve(undefined), WAIT)), - ]); - if (!stream) throw new Error("no virtual stream"); - - expect(await stream.reader.u53()).toBe(PublishNamespace.id); - await PublishNamespace.decode(stream.reader, VERSION); - - return stream; -} - -/** Whether a virtual stream's recv side closed, rather than staying open forever. */ -async function closed(stream: Stream): Promise { - return await Promise.race([ - stream.reader.done(), - new Promise((resolve) => setTimeout(() => resolve(false), WAIT)), - ]); -} - -/** - * Draft-14/15 withdrawals name a namespace, so the adapter resolves them through a map it - * keeps while decoding. A duplicate announcement is refused, but the mapping is written - * before the subscriber ever sees it: overwriting there would point the first request's DONE - * at the refused one, which has no stream left, and the announcement would stay up for the - * rest of the session. - */ -test("a refused duplicate does not strand the first announcement", async () => { - const { adapter, peer } = await connect(); - const namespace = Path.from("twice"); - - await announce(peer, 1n, namespace); - const first = await accept(adapter); - - // The same namespace again, on its own request. - await announce(peer, 3n, namespace); - const second = await accept(adapter); - - // Refused, the way the subscriber refuses a namespace it already has. - await second.writer.u53(RequestError.id); - await new RequestError({ - requestId: 3n, - errorCode: toRequestCode("internal", "publish_namespace", VERSION), - reasonPhrase: "duplicate namespace", - retryInterval: 0n, - }).encode(second.writer, VERSION); - second.close(); - - // The first request is still the one that owns the name, so its DONE withdraws it. - await withdraw(peer, namespace); - expect(await closed(first)).toBe(true); -}); - -/** - * A withdrawal the adapter cannot resolve is the peer tidying up after a refusal, or a - * request that is already gone. Throwing there tears down the control stream, which takes - * every healthy request on the session with it. - */ -test("an unresolvable withdrawal leaves the session open", async () => { - const { adapter, peer } = await connect(); - - await withdraw(peer, Path.from("ghost")); - - // The adapter is still routing: a real announcement arrives after the dropped one. - await announce(peer, 1n, Path.from("real")); - const stream = await accept(adapter); - - await withdraw(peer, Path.from("real")); - expect(await closed(stream)).toBe(true); -}); - -/** - * Closing a request has to release both halves of the mapping. A namespace left behind - * would refuse its own re-announcement for the rest of the session, since the first - * announcement wins. - */ -test("a cancel releases the namespace for the next announcement", async () => { - const { adapter, peer } = await connect(); - const namespace = Path.from("recycled"); - - const first = await announceOutgoing(adapter, 0n, namespace); - await cancel(peer, namespace); - expect(await closed(first)).toBe(true); - - // The name is free again, so a later request can take it and be canceled. - const second = await announceOutgoing(adapter, 2n, namespace); - await cancel(peer, namespace); - expect(await closed(second)).toBe(true); -}); - -/** - * A relay may advertise the same namespace in both directions. DONE withdraws the - * peer's incoming announcement, while CANCEL rejects the local outgoing one. - */ -test("withdrawals distinguish the same namespace by direction", async () => { - const { adapter, peer } = await connect(); - const namespace = Path.from("mesh"); - - const outgoing = await announceOutgoing(adapter, 0n, namespace); - await announce(peer, 1n, namespace); - const incoming = await accept(adapter); - - await withdraw(peer, namespace); - expect(await closed(incoming)).toBe(true); - - await cancel(peer, namespace); - expect(await closed(outgoing)).toBe(true); -}); - -/** - * Draft-14 to -16 carry GOAWAY on the shared control stream. The adapter decodes it and keeps - * routing, so the session serves its groups in flight while the caller migrates. - */ -test("the control stream adapter decodes a GOAWAY and keeps running", async () => { - const { adapter, peer } = await connect(); - - await peer.writer.u53(GoAway.id); - await new GoAway({ newSessionUri: "https://relay.example/next" }).encode(peer.writer, VERSION); - - const drain = await adapter.goaway; - expect(drain.uri).toBe("https://relay.example/next"); - // These drafts carry no timeout: absence means the caller's cap, never a zero handover. - expect(drain.timeout).toBeUndefined(); - - // Still routing: a later announcement opens its virtual stream. - await announce(peer, 1n, Path.from("still")); - await accept(adapter); -}); - -test("a server adapter rejects a client GOAWAY that names a redirect", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_15); - const control = await Stream.open(pair.server, { version: VERSION }); - const adapter = new ControlStreamAdapter(pair.server, control, VERSION, 100n, false); - const running = adapter.run(); - // A writer may yield a task before the rejection assertion below. - void running.catch(() => {}); - const peer = await Stream.accept(pair.client, VERSION); - if (!peer) throw new Error("no control stream"); - - await peer.writer.u53(GoAway.id); - await new GoAway({ newSessionUri: "https://other.example/" }).encode(peer.writer, VERSION); - await expect(running).rejects.toThrow("client GOAWAY must not name a redirect"); -}); - -test("the control stream adapter refuses draft 17 and later", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_17); - const control = await Stream.open(pair.server, { version: Version.DRAFT_17 }); - expect(() => new ControlStreamAdapter(pair.server, control, Version.DRAFT_17, 100n, true)).toThrow( - "drafts 14 to 16", - ); -}); - -test("a second GOAWAY on the control stream closes the session", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_15); - const control = await Stream.open(pair.server, { version: VERSION }); - const adapter = new ControlStreamAdapter(pair.server, control, VERSION, 100n, true); - const running = adapter.run(); - // A writer may yield a task before the rejection assertion below. - void running.catch(() => {}); - const peer = await Stream.accept(pair.client, VERSION); - if (!peer) throw new Error("no control stream"); - - for (let i = 0; i < 2; i++) { - await peer.writer.u53(GoAway.id); - await new GoAway({ newSessionUri: "" }).encode(peer.writer, VERSION); - } - await expect(running).rejects.toThrow("duplicate GOAWAY"); -}); - -// Past the advertised maximum, or more open requests than the window, is TOO_MANY_REQUESTS. -// A parity or duplicate error is draft-14 to -16's INVALID_REQUEST_ID. -const INVALID_REQUEST_ID = 0x4; - -const WINDOW_DRAFTS = [ - [Version.DRAFT_14, ALPN.DRAFT_14], - [Version.DRAFT_15, ALPN.DRAFT_15], - [Version.DRAFT_16, ALPN.DRAFT_16], -] as const; - -/** - * Adapter admitting `window` open requests, plus the peer's control stream. - * `client` is this side, so the peer uses the other parity. - */ -async function windowed( - version: IetfVersion, - alpn: string, - window: bigint, - client: boolean, -): Promise<{ - pair: ReturnType; - adapter: ControlStreamAdapter; - peer: Stream; - running: Promise; -}> { - const pair = createMockTransportPair(alpn); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, client, window); - const running = adapter.run(); - // A peer write may yield a browser task before the assertion awaits this rejection. - void running.catch(() => {}); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - return { pair, adapter, peer, running }; -} - -/** A new request of the peer's parity. TRACK_STATUS spends an id and holds no namespace. */ -async function trackStatus(peer: Stream, version: IetfVersion, requestId: bigint): Promise { - await peer.writer.u53(TrackStatusRequest.id); - await new TrackStatusRequest({ - requestId, - trackNamespace: Path.from("t"), - trackName: "a", - }).encode(peer.writer, version); -} - -/** A SUBSCRIBE as `requestId`, returning its virtual stream past the initial message. */ -async function subscribe(adapter: ControlStreamAdapter, peer: Stream, version: IetfVersion, requestId: bigint) { - await peer.writer.u53(Subscribe.id); - await new Subscribe({ - requestId, - trackNamespace: Path.from("update"), - trackName: "video", - subscriberPriority: 128, - }).encode(peer.writer, version); - - const stream = await adapter.acceptBi(); - if (!stream) throw new Error(`no stream for ${requestId}`); - expect(await stream.reader.u53()).toBe(Subscribe.id); - await Subscribe.decode(stream.reader, version); - return stream; -} - -/** An update spending `own` that changes `target`. */ -async function update(peer: Stream, version: IetfVersion, own: bigint, target: bigint): Promise { - await peer.writer.u53(SubscribeUpdate.id); - await new SubscribeUpdate({ requestId: target, ownRequestId: own }).encode(peer.writer, version); -} - -/** The request named by the next update delivered to `stream`. */ -async function updated(stream: Stream, version: IetfVersion): Promise { - const typeId = await Promise.race([ - stream.reader.u53(), - new Promise((resolve) => setTimeout(() => resolve(undefined), WAIT)), - ]); - if (typeId === undefined) throw new Error("update did not reach its target"); - expect(typeId).toBe(SubscribeUpdate.id); - return (await SubscribeUpdate.decode(stream.reader, version)).requestId; -} - -/** The next MAX_REQUEST_ID on the peer's control stream, skipping anything else. */ -async function nextGrant(peer: Stream, version: IetfVersion): Promise { - for (;;) { - const type = await peer.reader.u53(); - if (type === MaxRequestId.id) return (await MaxRequestId.decode(peer.reader, version)).requestId; - const size = await peer.reader.u16(); - await peer.reader.read(size); - } -} - -/** Resolves with the run loop's error, or undefined while it is still running. */ -function settled(running: Promise): () => Promise { - const failed = running.then( - () => undefined, - (err: unknown) => err, - ); - return () => Promise.race([failed, Promise.resolve(undefined)]); -} - -test("an id at the advertised maximum closes the session", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - for (const client of [false, true]) { - // A window of 2 admits ids below 4 from a client peer and below 5 from a server peer. - // A wrong-parity id that is also past the maximum is still too many: the maximum is checked first. - const past = client ? 5n : 4n; - const pastWrong = client ? 6n : 5n; - for (const requestId of [past, pastWrong]) { - const { pair, peer, running } = await windowed(version, alpn, 2n, client); - await trackStatus(peer, version, requestId); - await expect(running).rejects.toThrow("request id exceeds max"); - expect((await pair.client.closed).closeCode).toBe(SessionCode.TooManyRequests); - } - } - } -}); - -test("a request id with the wrong parity closes the session", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - for (const client of [false, true]) { - const wrong = client ? 0n : 1n; - const { pair, peer, running } = await windowed(version, alpn, 2n, client); - await trackStatus(peer, version, wrong); - await expect(running).rejects.toThrow("wrong parity"); - expect((await pair.client.closed).closeCode).toBe(INVALID_REQUEST_ID); - } - } -}); - -test("a second use of an open request id closes the session", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - const { pair, peer, running } = await windowed(version, alpn, 1n, false); - await trackStatus(peer, version, 0n); - await trackStatus(peer, version, 0n); - await expect(running).rejects.toThrow("request id was already spent or out of sequence"); - expect((await pair.client.closed).closeCode).toBe(INVALID_REQUEST_ID); - } -}); - -test("once half the window closes, one MAX_REQUEST_ID grants the ids it spent", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - for (const client of [false, true]) { - const ids = client ? [1n, 3n] : [0n, 2n]; - const { adapter, peer, running } = await windowed(version, alpn, 4n, client); - const state = settled(running); - for (const requestId of ids) { - await trackStatus(peer, version, requestId); - const stream = await adapter.acceptBi(); - if (!stream) throw new Error(`no stream for ${requestId}`); - stream.close(); - } - // One close is below half the window, so the first grant covers both. - expect(await nextGrant(peer, version)).toBe(initialMaxRequestId(!client, 4n) + 4n); - expect(await state()).toBeUndefined(); - } - } -}); - -test("a request the peer closes is granted back", async () => { - const { adapter, peer, running } = await windowed(VERSION, ALPN.DRAFT_15, 1n, true); - const state = settled(running); - await announce(peer, 1n, Path.from("room")); - const stream = await adapter.acceptBi(); - if (!stream) throw new Error("no stream"); - await withdraw(peer, Path.from("room")); - expect(await nextGrant(peer, VERSION)).toBe(initialMaxRequestId(false, 1n) + 2n); - expect(await state()).toBeUndefined(); -}); - -// A single-id update cannot tell its own Request ID from the target, so these carry both. -test("drafts 14 to 16 route an update to its second request id", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - const { adapter, peer, running } = await windowed(version, alpn, 4n, false); - const state = settled(running); - const target = await subscribe(adapter, peer, version, 0n); - await update(peer, version, 2n, 0n); - expect(await updated(target, version)).toBe(0n); - expect(await state()).toBeUndefined(); - } -}); - -// The update's own id is retired, not the target's, so half a window of updates earns a grant. -test("updates to an open request earn a grant and still reach it", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - const { adapter, peer, running } = await windowed(version, alpn, 4n, false); - const state = settled(running); - const target = await subscribe(adapter, peer, version, 0n); - for (const own of [2n, 4n]) { - await update(peer, version, own, 0n); - expect(await updated(target, version)).toBe(0n); - } - expect(await nextGrant(peer, version)).toBe(initialMaxRequestId(true, 4n) + 4n); - expect(await state()).toBeUndefined(); - } -}); - -test("a request update spends an id and retires it immediately", async () => { - const warn = spyOn(console, "warn").mockImplementation(() => undefined); - try { - for (const [version, alpn] of WINDOW_DRAFTS) { - for (const client of [false, true]) { - const updateId = client ? 1n : 0n; - const nextId = client ? 3n : 2n; - const { adapter, peer, running } = await windowed(version, alpn, 1n, client); - const state = settled(running); - // The target is gone, which drops the update but still spends its id. - await update(peer, version, updateId, 100n); - // The update's own id is granted back, so the next id is admitted. - await trackStatus(peer, version, nextId); - const stream = await adapter.acceptBi(); - if (!stream) throw new Error("update held the only slot"); - expect(await nextGrant(peer, version)).toBe(initialMaxRequestId(!client, 1n) + 2n); - expect(await state()).toBeUndefined(); - } - } - } finally { - warn.mockRestore(); - } -}); - -test("an update spending an open request id closes the session", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - for (const client of [false, true]) { - const heldId = client ? 1n : 0n; - const { pair, adapter, peer, running } = await windowed(version, alpn, 2n, client); - await trackStatus(peer, version, heldId); - if (!(await adapter.acceptBi())) throw new Error("no stream"); - await update(peer, version, heldId, heldId); - await expect(running).rejects.toThrow("request id was already spent or out of sequence"); - expect((await pair.client.closed).closeCode).toBe(INVALID_REQUEST_ID); - } - } -}); - -/** Request IDs stay spent after their stream or update retires. */ -test("retired and lower request ids close the session", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - for (const client of [false, true]) { - const parity = client ? 1n : 0n; - for (const [first, later] of [ - [parity, parity], - [parity + 4n, parity + 2n], - ] as const) { - const { pair, adapter, peer, running } = await windowed(version, alpn, 8n, client); - for (let id = parity; id <= first; id += 2n) { - await trackStatus(peer, version, id); - const stream = await adapter.acceptBi(); - if (!stream) throw new Error("no stream"); - stream.close(); - } - await trackStatus(peer, version, later); - await expect(running).rejects.toThrow("request id was already spent or out of sequence"); - expect((await pair.client.closed).closeCode).toBe(INVALID_REQUEST_ID); - } - } - } -}); - -test("an update id stays spent after the update retires", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - const { pair, adapter, peer, running } = await windowed(version, alpn, 4n, false); - await trackStatus(peer, version, 0n); - if (!(await adapter.acceptBi())) throw new Error("no stream"); - await update(peer, version, 2n, 0n); - await update(peer, version, 2n, 0n); - await expect(running).rejects.toThrow("request id was already spent or out of sequence"); - expect((await pair.client.closed).closeCode).toBe(INVALID_REQUEST_ID); - } -}); - -/** Three windows of requests, one at a time, only complete when grants keep raising the maximum. */ -test("more requests than the setup window complete on one session", async () => { - const version = Version.DRAFT_15; - const window = 64n; - const initial = initialMaxRequestId(true, window); - const { pair, adapter, peer, running } = await windowed(version, ALPN.DRAFT_15, window, false); - const state = settled(running); - - let limit = initial; - const reading = (async () => { - try { - for (;;) limit = await nextGrant(peer, version); - } catch { - // The session closed under the reader. - } - })(); - - try { - for (let id = 0n; id < initial * 3n; id += 2n) { - await trackStatus(peer, version, id); - const stream = await adapter.acceptBi(); - if (!stream) throw new Error(`session closed at request ${id}`); - stream.close(); - } - expect(await state()).toBeUndefined(); - while (limit < initial * 3n) await new Promise((resolve) => setTimeout(resolve, 0)); - expect(limit).toBeGreaterThanOrEqual(initial * 3n); - } finally { - pair.server.close(); - await running.catch(() => undefined); - await reading; - } -}); - -// Both requests and updates allocate IDs, even when an update's target is gone. -test("skipped request and update ids close the session", async () => { - for (const [version, alpn] of WINDOW_DRAFTS.filter(([version]) => version !== Version.DRAFT_16)) { - for (const client of [false, true]) { - for (const prefix of [0n, 1n]) { - for (const isUpdate of [false, true]) { - const { pair, peer, running } = await windowed(version, alpn, 8n, client); - const first = client ? 1n : 0n; - if (prefix) await trackStatus(peer, version, first); - const skipped = first + 2n * prefix + 2n; - if (isUpdate) await update(peer, version, skipped, 100n); - else await trackStatus(peer, version, skipped); - await expect(running).rejects.toThrow("request id was already spent or out of sequence"); - expect((await pair.client.closed).closeCode).toBe(SessionCode.InvalidRequestId); - } - } - } - } -}); - -// SUBSCRIBE_NAMESPACE uses an independent bidi stream in draft 16, sharing the ID space. -test("draft 16 accepts reordered native and control IDs but never reuses them", async () => { - for (const client of [false, true]) { - for (const nativeFirst of [false, true]) { - const version = Version.DRAFT_16; - const { pair, adapter, peer, running } = await windowed(version, ALPN.DRAFT_16, 8n, client); - void running.catch(() => undefined); - const first = client ? 1n : 0n; - const native = nativeFirst ? first + 2n : first; - const control = nativeFirst ? first : first + 2n; - try { - if (nativeFirst) adapter.admitRequest(native); - await trackStatus(peer, version, control); - const stream = await adapter.acceptBi(); - if (!stream) throw new Error("no reordered control request"); - if (!nativeFirst) adapter.admitRequest(native); - stream.close(); - adapter.releaseRequest(native); - await trackStatus(peer, version, first); - await expect(running).rejects.toThrow("request id was already spent"); - expect((await pair.client.closed).closeCode).toBe(SessionCode.InvalidRequestId); - } finally { - peer.close(); - adapter.close(); - } - } - } -}); - -// Draft 16 remembers a skipped stretch as one run, so a jump costs nothing per skipped ID. -test("draft 16 tracks a jump across a production window as one run", async () => { - const window = REQUEST_WINDOW; - const { adapter, peer } = await windowed(Version.DRAFT_16, ALPN.DRAFT_16, window, false); - const top = 2n * (window - 1n); - try { - for (const id of [top, 100n, 0n, top - 2n, 2n, 98n]) adapter.admitRequest(id); - for (const id of [0n, 2n, 98n, 100n, top - 2n, top]) { - expect(() => adapter.admitRequest(id)).toThrow("request id was already spent"); - } - } finally { - peer.close(); - adapter.close(); - } -}); - -// Splitting runs in descending order spans many chunks; every ID is admitted exactly once. -test("draft 16 admits a scattered window once across run chunks", async () => { - const window = 4096n; - const { adapter, peer } = await windowed(Version.DRAFT_16, ALPN.DRAFT_16, window, false); - const top = 2n * (window - 1n); - const below = Array.from({ length: Number(window - 1n) }, (_, i) => 2n * (window - 2n - BigInt(i))); - const ids = [top, ...below.filter((id) => (id / 2n) % 2n === 1n), ...below.filter((id) => (id / 2n) % 2n === 0n)]; - try { - for (const id of ids) adapter.admitRequest(id); - for (const id of [0n, 2n, 1024n, top - 2n, top]) { - expect(() => adapter.admitRequest(id)).toThrow("request id was already spent"); - } - } finally { - peer.close(); - adapter.close(); - } -}); - -// Allocation precedes an asynchronous encode, so a shorter later request can finish first. -test("drafts 14 and 15 send concurrent requests in request id order", async () => { - for (const [version, alpn] of [ - [Version.DRAFT_14, ALPN.DRAFT_14], - [Version.DRAFT_15, ALPN.DRAFT_15], - ] as const) { - const pair = createMockTransportPair(alpn); - const control = await Stream.open(pair.server, { version }); - const sender = new ControlStreamAdapter(pair.server, control, version, 100n, true); - void sender.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const receiver = new ControlStreamAdapter(pair.client, peer, version, 100n, false, 8n); - void receiver.run().catch(() => undefined); - try { - const request = async (namespace: Path.Valid) => { - const requestId = await sender.nextRequestId(); - if (requestId === undefined) throw new Error("no request id"); - const stream = sender.openBi(); - await stream.writer.u53(SubscribeNamespaceLegacy.id); - await new SubscribeNamespaceLegacy({ namespace, requestId }).encode(stream.writer, version); - }; - await Promise.all([request(Path.from("a/b/c/d/e/f/g/h")), request(Path.from("a"))]); - - const received: bigint[] = []; - for (let i = 0; i < 2; i++) { - const stream = await receiver.acceptBi(); - if (!stream) throw new Error("the receiver refused a request"); - expect(await stream.reader.u53()).toBe(SubscribeNamespaceLegacy.id); - received.push((await SubscribeNamespaceLegacy.decode(stream.reader, version)).requestId); - } - expect(received).toEqual([0n, 2n]); - } finally { - sender.close(); - receiver.close(); - } - } -}); - -// Canceling the wait must remove the allocation itself, not just stop awaiting it. -test("a canceled request ID wait never spends a later grant", async () => { - for (const [version, alpn] of WINDOW_DRAFTS) { - const pair = createMockTransportPair(alpn); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 0n, true); - const running = adapter.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - try { - const signal = new AbortController(); - const canceled = adapter.nextRequestId(signal.signal); - signal.abort(new Error("retired")); - await expect(canceled).rejects.toThrow("retired"); - await peer.writer.u53(MaxRequestId.id); - await new MaxRequestId({ requestId: 4n }).encode(peer.writer, version); - expect(await adapter.nextRequestId()).toBe(0n); - await expect(adapter.nextRequestId(signal.signal)).rejects.toThrow("retired"); - expect(await adapter.nextRequestId()).toBe(2n); - } finally { - peer.close(); - adapter.close(); - void running; - } - } -}); - -// Draft 16 answers our REQUEST_UPDATE by the update's own request ID. The answer reaches the -// request it updated, and a refusal leaves that request open. Draft 15's reaches nothing. -test("an answer to our update reaches the request it updated on draft 16 only", async () => { - for (const [version, alpn] of [ - [Version.DRAFT_15, ALPN.DRAFT_15], - [Version.DRAFT_16, ALPN.DRAFT_16], - ] as const) { - const { adapter, peer, running } = await windowed(version, alpn, 8n, true); - try { - const stream = adapter.openBi(); - await stream.writer.u53(PublishNamespace.id); - await new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("ns") }).encode( - stream.writer, - version, - ); - // The mock transport schedules nothing on timers, so settled promise chains are final. - const next = async () => { - let id: number | undefined; - const read = stream.reader.u53().then((value) => { - id = value; - }); - for (let i = 0; i < 20 && id === undefined; i++) await new Promise((resolve) => setImmediate(resolve)); - void read.catch(() => undefined); - return id; - }; - const renew = async (own: bigint) => { - await stream.writer.u53(SubscribeUpdate.id); - const update = - version === Version.DRAFT_16 - ? new PublishNamespaceUpdate({ requestId: own, namespaceRequestId: 0n, update: {} }) - : new SubscribeUpdate({ requestId: 0n, ownRequestId: own }); - await update.encode(stream.writer, version); - }; - const ok = async (requestId: bigint) => { - await peer.writer.u53(RequestOk.id); - await new RequestOk({ requestId }).encode(peer.writer, version); - }; - - await ok(0n); - expect(await next()).toBe(RequestOk.id); - await RequestOk.decode(stream.reader, version); - - await renew(2n); - await ok(2n); - await renew(4n); - await peer.writer.u53(RequestError.id); - await new RequestError({ requestId: 4n, errorCode: 0, reasonPhrase: "refused" }).encode( - peer.writer, - version, - ); - if (version === Version.DRAFT_15) { - expect(await next()).toBeUndefined(); - continue; - } - expect(await next()).toBe(RequestOk.id); - await RequestOk.decode(stream.reader, version); - expect(await next()).toBe(RequestError.id); - await RequestError.decode(stream.reader, version); - - // Still open: a later message for the request reaches it after the refusal. - await ok(0n); - expect(await next()).toBe(RequestOk.id); - } finally { - peer.close(); - adapter.close(); - void running; - } - } -}); diff --git a/js/net/src/ietf/adapter.ts b/js/net/src/ietf/adapter.ts deleted file mode 100644 index e3133fc3745..00000000000 --- a/js/net/src/ietf/adapter.ts +++ /dev/null @@ -1,1211 +0,0 @@ -import { Once, race } from "@moq/signals"; -import { Mutex } from "async-mutex"; -import type { Drain } from "../connection/goaway.ts"; -import { error, ProtocolViolation, SessionCode, SessionError, sessionClosed } from "../error.ts"; -import { type OpenOptions, Reader, Stream, type Writer } from "../stream.ts"; -import { untilAborted } from "../util/abort.ts"; -import * as Varint from "../varint.ts"; -import { GoAway } from "./goaway.ts"; -import * as Message from "./message.ts"; -import * as Namespace from "./namespace.ts"; -import { MaxRequestId } from "./request.ts"; -import { refuseUpdate } from "./request_stream.ts"; -import { initialMaxRequestId, REQUEST_WINDOW } from "./setup_options.ts"; -import { type IetfVersion, Version } from "./version.ts"; - -/** The peer used a request ID the draft closes the session for. */ -export class RequestWindowError extends Error { - readonly code: number; - - constructor(code: number, message: string) { - super(message); - this.name = "RequestWindowError"; - this.code = code; - } -} - -/** - * Interface for opening outgoing bidi streams and allocating request IDs. - * Implemented by both ControlStreamAdapter (v14-v16) and NativeSession (v17). - */ -export interface Session { - openBi(options?: Partial): Stream | Promise; - openNativeBi?(options?: Partial): Promise; - acceptBi(): Promise; - nextRequestId(signal?: AbortSignal): Promise; - /** Spend a request ID the peer used without holding a request open, closing the session if it cannot. */ - spendRequest(id: bigint): void; - close(): void; - readonly version: IetfVersion; - /** Whether we opened the session. */ - readonly client: boolean; -} - -/** - * v17 native session — thin wrapper around WebTransport. - * Each request gets its own real bidi stream; no control stream multiplexing. - */ -export class NativeSession implements Session { - #quic: WebTransport; - // moq-transport reserves even request IDs for the client and odd for the server, - // so the two peers' ID spaces never overlap. - #requestId: bigint; - // The peer's IDs, of the other parity. Draft 17 has no window, yet an ID stays spent. - #spent: SpentIds; - readonly version: IetfVersion; - readonly client: boolean; - - constructor(quic: WebTransport, version: IetfVersion, client: boolean) { - this.#quic = quic; - this.version = version; - this.client = client; - this.#requestId = client ? 0n : 1n; - this.#spent = new SpentIds(client ? 1n : 0n); - } - - spendRequest(id: bigint): void { - try { - this.#spent.spend(id); - } catch (err) { - if (err instanceof SessionError) this.#quic.close({ closeCode: err.code, reason: err.message }); - throw err; - } - } - - async openBi(options?: Partial): Promise { - return Stream.open(this.#quic, { ...options, version: this.version }); - } - - async acceptBi(): Promise { - return Stream.accept(this.#quic, this.version); - } - - async nextRequestId(signal?: AbortSignal): Promise { - signal?.throwIfAborted(); - const id = this.#requestId; - this.#requestId += 2n; - return id; - } - - /** Closes the underlying WebTransport session. */ - close() { - this.#quic.close(); - } -} - -// Route classification for control stream messages. -const Route = { - NewRequest: 0, // Create virtual bidi stream, push initial message - Response: 1, // Push message to existing stream (keep open) - ErrorResponse: 2, // Push a final message to existing stream, then close - CloseStream: 3, // Close stream recv (no bytes pushed) - FollowUp: 4, // Push follow-up message to existing stream - MaxRequestId: 5, // Update flow control - Ignore: 6, // Connection-level, no routing - GoAway: 7, // Terminal - Update: 8, // Spend the update's own request ID, then follow up on its target -} as const; -type Route = (typeof Route)[keyof typeof Route]; - -interface StreamEntry { - controller: ReadableStreamDefaultController; -} - -/** - * Which request a v14/v15 namespace-keyed message names: an announcement, by the side that - * made it, or the peer's namespace subscription (draft 14's UNSUBSCRIBE_NAMESPACE). - */ -type NamespaceDirection = "incoming" | "outgoing" | "subscribed"; - -/** Runs per chunk before it splits, so a split or removal shifts at most one chunk. */ -const RUN_CHUNK = 256; - -/** - * Skipped request IDs as sorted, disjoint `[start, end)` runs of one parity. - * - * A jump costs one run however far it skips, and only an ID landing inside a run splits it, - * so the runs never outnumber the requests received. They are kept in chunks so a split - * costs the chunk it lands in rather than shifting the whole table. - */ -class MissingRuns { - #chunks: [bigint, bigint][][] = []; - #size = 0; - - /** How many runs are remembered. */ - get size(): number { - return this.#size; - } - - /** Whether `id` was skipped and not yet received. */ - has(id: bigint): boolean { - return this.#find(id) !== undefined; - } - - /** Whether taking `id` leaves one more run, landing strictly inside its run. */ - splits(id: bigint): boolean { - const found = this.#find(id); - if (!found) return false; - const [start, end] = this.#chunks[found[0]][found[1]]; - return start < id && id + 2n < end; - } - - /** Mark a skipped `id` received, splitting its run. */ - take(id: bigint): void { - const found = this.#find(id); - if (!found) return; - const [index, run] = found; - const chunk = this.#chunks[index]; - const [start, end] = chunk[run]; - const rest: [bigint, bigint][] = []; - if (start < id) rest.push([start, id]); - if (id + 2n < end) rest.push([id + 2n, end]); - this.#size += rest.length - 1; - chunk.splice(run, 1, ...rest); - if (chunk.length === 0) this.#chunks.splice(index, 1); - else if (chunk.length > 2 * RUN_CHUNK) this.#chunks.splice(index + 1, 0, chunk.splice(RUN_CHUNK)); - } - - /** Record `[start, end)` as skipped, above every existing run. */ - push(start: bigint, end: bigint): void { - this.#size++; - const last = this.#chunks.at(-1); - if (last && last.length < 2 * RUN_CHUNK) last.push([start, end]); - else this.#chunks.push([[start, end]]); - } - - /** The chunk and run holding `id`. */ - #find(id: bigint): [number, number] | undefined { - const index = lastAtOrBelow(this.#chunks, id, (chunk) => chunk[0][0]); - if (index < 0) return undefined; - const chunk = this.#chunks[index]; - const run = lastAtOrBelow(chunk, id, (run) => run[0]); - return run >= 0 && id < chunk[run][1] ? [index, run] : undefined; - } -} - -/** - * The peer's spent request IDs on draft 17 and later: every ID of its parity below `#next`, - * except the skipped runs. Mirrors Rust's `Spent`. - */ -export class SpentIds { - #next: bigint; - #missing = new MissingRuns(); - // No window bounds the runs here, and forgetting one would make its IDs usable again. - #limit: number; - - constructor(first: bigint, limit = Number(REQUEST_WINDOW)) { - this.#next = first; - this.#limit = limit; - } - - /** Spend `id`, throwing the session error a reused, wrong-parity, or unrecordable ID closes with. */ - spend(id: bigint): void { - // No peer reaches 2^62 requests; refusing past it matches Rust, whose `next` would overflow. - const unused = id < 1n << 62n && (id >= this.#next || this.#missing.has(id)); - if (id % 2n !== this.#next % 2n || !unused) throw new SessionError(SessionCode.InvalidRequestId); - const splits = id < this.#next ? this.#missing.splits(id) : id > this.#next; - if (splits && this.#missing.size >= this.#limit) { - throw new SessionError(SessionCode.Internal, { reason: "request ID history exhausted" }); - } - if (id < this.#next) { - this.#missing.take(id); - return; - } - if (id > this.#next) this.#missing.push(this.#next, id); - this.#next = id + 2n; - } -} - -/** The last index whose key is at or below `id`, or -1, in an array sorted by key. */ -function lastAtOrBelow(items: T[], id: bigint, key: (item: T) => bigint): number { - let lo = 0; - let hi = items.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if (key(items[mid]) <= id) lo = mid + 1; - else hi = mid; - } - return lo - 1; -} - -/** - * Converts v14-v16 control stream multiplexing into virtual bidi streams. - * - * Reads control messages, classifies them, and routes to virtual Stream objects. - * Each request/response pair gets its own virtual Stream, making all versions - * look like v17's stream-per-request model. - */ -export class ControlStreamAdapter implements Session { - // WebTransport session (for opening real bidi streams in v16) - #quic: WebTransport; - - // Control stream - #reader: Reader; - #writer: Writer; - #writeMutex = new Mutex(); - readonly version: IetfVersion; - - /** The peer's GOAWAY, which draft-14 to -16 carry on the shared control stream. */ - readonly goaway = new Once(); - - // Virtual streams keyed by requestId - #streams = new Map(); - - // Namespace → requestId reverse lookups for v14/v15 namespace-keyed messages. - // DONE names an incoming announcement and CANCEL names an outgoing one, so a relay - // may legitimately hold the same namespace in both maps at once. - #incomingNamespaces = new Map(); - #outgoingNamespaces = new Map(); - // The peer's draft-14 SUBSCRIBE_NAMESPACE prefixes, which its UNSUBSCRIBE_NAMESPACE names. - #subscribedNamespaces = new Map(); - - // requestId → namespace reverse lookup, one entry per request including a refused - // duplicate, so #forget can clean up whichever request goes away. - #namespacesByRequestId = new Map(); - - // SubscribeNamespace requestIds — for routing 0x08/0x0E entries that lack requestId (v14/v15) - #subscribeNamespaces = new Set(); - - // Our updates' own request IDs on drafts 15 and 16 → the request each updated, since the - // answer names only the update. Draft 16's reaches that request's stream; draft 15's is - // consumed here, since nothing there reads one. An entry goes with its answer or its request. - #updates = new Map(); - - // Incoming stream queue (for acceptBi) - #incomingQueue: Stream[] = []; - #incomingWaiters: ((stream: Stream | undefined) => void)[] = []; - - // Request ID flow control. moq-transport reserves even request IDs for the - // client and odd for the server, so the two peers' ID spaces never overlap. - // This matters here because a single `#streams` map routes every request by - // ID: overlapping spaces would let an inbound request clobber the routing - // entry of an outbound one with the same number (e.g. an inbound - // PUBLISH_NAMESPACE stealing a pending SUBSCRIBE's slot, so SUBSCRIBE_OK is - // delivered to the wrong virtual stream). - #requestId: bigint; - #maxRequestId: bigint; - #maxRequestIdResolves = new Set<() => void>(); - - // Drafts 14/15 require our new request IDs in order on the control stream. Callers - // allocate an ID and then encode asynchronously, so a shorter later request can finish - // first: a message spending a new ID waits here until every lower one has gone out. - // Draft 16 sends SUBSCRIBE_NAMESPACE on its own stream and the peer accepts any order. - #sendNext: bigint | undefined; - #sendTurns = new Map void>(); - - // The peer's requests, flow controlled as moq-net does: at most `#peerWindow` open at - // once, every ID below `#peerMax`, and once half the window has closed the bound rises - // by the IDs they spent and goes out as one MAX_REQUEST_ID. - #peerWindow: bigint; - #peerMax: bigint; - #peerNext: bigint; - // Draft 16 reorders across streams, so it remembers the IDs skipped below #peerNext. - #peerMissing: MissingRuns | undefined; - #open = new Set(); - #retired = 0n; - #granting = false; - #grantDirty = false; - - #closed = false; - - // Whether this side opened the session. Only a server may name a redirect. - readonly client: boolean; - - constructor( - quic: WebTransport, - controlStream: Stream, - version: IetfVersion, - maxRequestId: bigint, - client: boolean, - window: bigint = REQUEST_WINDOW, - ) { - // Routing assumes the shared control stream's layout, such as an update's two ids. - if (version >= Version.DRAFT_17) - throw new Error(`control stream adapter is for drafts 14 to 16, not ${version}`); - this.#quic = quic; - this.#reader = controlStream.reader; - this.#reader.version = version; - this.#writer = controlStream.writer; - this.#writer.version = version; - this.version = version; - this.#maxRequestId = maxRequestId; - this.#peerWindow = window; - this.#peerMax = initialMaxRequestId(!client, window); - this.#peerNext = client ? 1n : 0n; - this.#peerMissing = version === Version.DRAFT_16 ? new MissingRuns() : undefined; - this.#requestId = client ? 0n : 1n; - this.#sendNext = version === Version.DRAFT_16 ? undefined : this.#requestId; - this.client = client; - } - - /** - * Accept the next incoming virtual bidi stream. - * Blocks until a new request arrives on the control stream. - */ - async acceptBi(): Promise { - if (this.#closed) return undefined; - - const queued = this.#incomingQueue.shift(); - if (queued) return queued; - - return new Promise((resolve) => { - this.#incomingWaiters.push(resolve); - }); - } - - /** - * Open an outgoing virtual bidi stream. - * Buffers writes until the first full message is available, parses the - * requestId (and namespace for PublishNamespace), self-registers, then - * flushes. Subsequent writes go directly to the control stream. - */ - openBi(): Stream { - let controller!: ReadableStreamDefaultController; - let registeredRequestId: bigint | undefined; - - const readable = new ReadableStream({ - start(c) { - controller = c; - }, - cancel: () => { - if (registeredRequestId !== undefined) { - this.#forget(registeredRequestId); - } - }, - }); - - let buffer = new Uint8Array(0); - let registered = false; - - const sendWritable = new WritableStream({ - write: async (chunk) => { - // Always accumulate bytes and flush only complete messages - // to prevent interleaving of partial messages from concurrent virtual streams. - const newBuf = new Uint8Array(buffer.length + chunk.length); - newBuf.set(buffer); - newBuf.set(chunk, buffer.length); - buffer = newBuf; - - // Try to flush complete messages from the buffer - for (;;) { - const boundary = this.#messageSize(buffer); - if (boundary === undefined) break; - - const toFlush = buffer.subarray(0, boundary); - buffer = buffer.subarray(boundary); - - let spent: bigint | undefined; - if (!registered) { - // First message: extract requestId and register before flushing - const parsed = await this.#tryParseOutgoing(toFlush); - if (parsed) { - registeredRequestId = parsed.requestId; - this.#streams.set(parsed.requestId, { controller }); - registered = true; - spent = parsed.requestId; - } - } else { - spent = this.#updateId(toFlush); - if ( - spent !== undefined && - registeredRequestId !== undefined && - this.version !== Version.DRAFT_14 - ) - this.#updates.set(spent, registeredRequestId); - } - - await this.#send(toFlush, spent); - } - }, - }); - - const stream = new Stream({ readable, writable: sendWritable, version: this.version }); - return stream; - } - - /** - * Open a real WebTransport bidi stream (for v16 SubscribeNamespace). - */ - async openNativeBi(options?: Partial): Promise { - return Stream.open(this.#quic, { ...options, version: this.version }); - } - - /** - * Take a slot for a request that arrived on its own stream (draft-16 SUBSCRIBE_NAMESPACE). - * Control-stream requests are admitted while they are classified. - */ - admitRequest(requestId: bigint): void { - this.#admitHeld(requestId); - } - - /** Give back a slot taken by {@link admitRequest}. */ - releaseRequest(requestId: bigint): void { - if (this.#open.delete(requestId)) this.#retire(); - } - - /** - * Allocate the next request ID, canceling before allocation while waiting for credit. - * A returned legacy ID is spent and its request must be sent, even if then canceled. - */ - async nextRequestId(signal?: AbortSignal): Promise { - for (;;) { - signal?.throwIfAborted(); - if (this.#closed) return undefined; - const id = this.#requestId; - if (id < this.#maxRequestId) { - this.#requestId += 2n; - return id; - } - const { promise, resolve } = Promise.withResolvers(); - this.#maxRequestIdResolves.add(resolve); - try { - await untilAborted(promise, signal); - } finally { - this.#maxRequestIdResolves.delete(resolve); - } - } - } - - /** - * Main run loop — reads control stream messages and routes to virtual streams. - * Must be called after construction. Runs until the control stream closes. - */ - async run(): Promise { - // Why the virtual streams end: undefined only for a GOAWAY, which is not a failure. - let cause: Error | undefined; - try { - // v16: also accept real bidi streams (for SubscribeNamespace) - if (this.version === Version.DRAFT_16) { - void this.#acceptNativeBidis(); - } - - const read = async () => { - for (;;) { - const done = await this.#reader.done(); - if (done) { - throw new ProtocolViolation("control stream closed"); - } - - const typeId = await this.#reader.u53(); - const size = await this.#reader.u16(); - const body = await this.#reader.read(size); - - const classified = await this.#classify(typeId, body); - - if (classified.route === Route.GoAway) { - // The session keeps serving: a GOAWAY asks us to migrate, not to stop reading. - const msg = await GoAway.decodeBody(body, this.version); - if (this.goaway.peek() !== undefined) throw new ProtocolViolation("duplicate GOAWAY"); - // A client may leave, but only the server may name where to go. - if (!this.client && msg.newSessionUri !== "") { - throw new ProtocolViolation("client GOAWAY must not name a redirect"); - } - this.goaway.set(msg.drain()); - continue; - } - - if (classified.route === Route.Update) { - this.spendRequest(classified.ownRequestId); - // An update whose request is gone, or rides a stream of its own like draft 16's - // SUBSCRIBE_NAMESPACE, is still owed its one answer. Draft 14 answers no update. - if ( - !this.#pushMessage(classified.requestId, typeId, size, body) && - this.version !== Version.DRAFT_14 - ) { - void this.#refuseUpdate(classified.ownRequestId); - } - continue; - } - - const { route, requestId } = classified; - const updated = this.#updates.get(requestId); - if ((route === Route.Response || route === Route.ErrorResponse) && updated !== undefined) { - // A refused update leaves its request open: the answer is all it ends. - this.#updates.delete(requestId); - if (this.version === Version.DRAFT_16) this.#pushMessage(updated, typeId, size, body); - continue; - } - - switch (route) { - case Route.NewRequest: - this.#admitHeld(requestId); - this.#newRequest(typeId, size, body, requestId); - break; - case Route.Response: - this.#pushMessage(requestId, typeId, size, body); - break; - case Route.ErrorResponse: - this.#pushMessage(requestId, typeId, size, body); - this.#closeStream(requestId); - break; - case Route.CloseStream: - this.#closeStream(requestId); - break; - case Route.FollowUp: - this.#pushMessage(requestId, typeId, size, body); - break; - case Route.MaxRequestId: - this.#maxRequestId = requestId; - for (const resolve of this.#maxRequestIdResolves) resolve(); - this.#maxRequestIdResolves.clear(); - break; - } - } - }; - const stopped = this.#writer.closed.then(() => { - throw new ProtocolViolation("control stream stopped"); - }); - await race([read(), stopped]); - } catch (err: unknown) { - // A closing session fails this stream too, which reports the session's error. - cause = - err instanceof ProtocolViolation || err instanceof RequestWindowError || sessionClosed(err) - ? error(err) - : new ProtocolViolation(error(err).message, { cause: err }); - throw cause; - } finally { - this.close(cause); - } - } - - /** Accept real WebTransport bidi streams and queue them for acceptBi (v16). */ - async #acceptNativeBidis(): Promise { - try { - for (;;) { - const stream = await Stream.accept(this.#quic, this.version); - if (!stream) break; - - const waiter = this.#incomingWaiters.shift(); - if (waiter) { - waiter(stream); - } else { - this.#incomingQueue.push(stream); - } - } - } catch { - // Session closed - } - } - - #newRequest(typeId: number, size: number, body: Uint8Array, requestId: bigint) { - let controller!: ReadableStreamDefaultController; - const readable = new ReadableStream({ - start(c) { - controller = c; - }, - cancel: () => { - this.#forget(requestId); - }, - }); - - const sendWritable = this.#createSendWritable(); - - const stream = new Stream({ readable, writable: sendWritable, version: this.version }); - - this.#streams.set(requestId, { controller }); - - // Push initial message bytes so the dispatcher can read typeId + decode - controller.enqueue(this.#encodeRaw(typeId, size, body)); - - // Queue for acceptBi - const waiter = this.#incomingWaiters.shift(); - if (waiter) { - waiter(stream); - } else { - this.#incomingQueue.push(stream); - } - } - - /** Deliver a message to its request's stream, returning whether one was open to take it. */ - #pushMessage(requestId: bigint, typeId: number, size: number, body: Uint8Array): boolean { - const entry = this.#streams.get(requestId); - if (!entry) { - console.warn(`adapter: no stream for requestId=${requestId} typeId=0x${typeId.toString(16)}`); - return false; - } - try { - entry.controller.enqueue(this.#encodeRaw(typeId, size, body)); - return true; - } catch { - // Stream already closed - return false; - } - } - - /** Refuse an update no request here can apply, naming its own Request ID. */ - async #refuseUpdate(requestId: bigint): Promise { - try { - await this.#writeMutex.runExclusive(async () => { - if (this.#closed) return; - await refuseUpdate(this.#writer, this.version, "subscribe", { - requestId, - condition: "not_supported", - reasonPhrase: "update not supported", - }); - }); - } catch { - // The control stream closed under the refusal. - } - } - - #closeStream(requestId: bigint) { - const entry = this.#streams.get(requestId); - this.#forget(requestId); - if (!entry) return; - console.debug(`adapter: closing stream requestId=${requestId}`); - try { - entry.controller.close(); - } catch { - // Already closed - } - } - - /** - * Drop every routing entry a request owns, keeping the two namespace maps in step. - * - * Runs even when the virtual stream is already gone, so a request that was torn down - * locally still gives its namespace back. - */ - #forget(requestId: bigint) { - const held = this.#open.delete(requestId); - this.#streams.delete(requestId); - this.#subscribeNamespaces.delete(requestId); - for (const [update, updated] of this.#updates) { - if (updated === requestId) this.#updates.delete(update); - } - - const registered = this.#namespacesByRequestId.get(requestId); - if (registered !== undefined) { - this.#namespacesByRequestId.delete(requestId); - const namespaces = this.#namespaceRequests(registered.direction); - - // Only the request holding the live announcement may retract it. A refused duplicate - // carries the same name, and letting its close withdraw the name would strand the - // original: its own DONE would then resolve to nothing. - if (namespaces.get(registered.namespace) === requestId) namespaces.delete(registered.namespace); - } - - if (held) this.#retire(); - } - - /** Reject a request ID the peer is not allowed to use. */ - #checkPeer(requestId: bigint): void { - if (requestId >= this.#peerMax) { - // Section 9.1 also names INVALID_REQUEST_ID here, but the session registry and - // section 9.5 assign an ID past the maximum to TOO_MANY_REQUESTS. - this.#refuse(SessionCode.TooManyRequests, "request id exceeds max"); - } - // Even IDs are the client's. `client` is this side, so the peer's IDs are the other parity. - const even = (requestId & 1n) === 0n; - if (even === this.client) { - this.#refuse(SessionCode.InvalidRequestId, "request id has the wrong parity"); - } - } - - #admitHeld(requestId: bigint): void { - this.#checkPeer(requestId); - const unused = - requestId < this.#peerNext - ? this.#peerMissing?.has(requestId) === true - : requestId === this.#peerNext || this.#peerMissing !== undefined; - if (!unused) { - this.#refuse(SessionCode.InvalidRequestId, "request id was already spent or out of sequence"); - } - this.#checkLive(); - if (requestId < this.#peerNext) this.#peerMissing?.take(requestId); - else if (requestId > this.#peerNext) this.#peerMissing?.push(this.#peerNext, requestId); - if (requestId >= this.#peerNext) this.#peerNext = requestId + 2n; - this.#open.add(requestId); - } - - /** - * An update spends its own request ID like any request but keeps no stream, so the - * slot is retired at once. The ID is spent even when the target is already gone. - */ - spendRequest(requestId: bigint): void { - this.#admitHeld(requestId); - this.#open.delete(requestId); - this.#retire(); - } - - /** Enforce the live-request cap independently of the ID bound. */ - #checkLive(): void { - if (BigInt(this.#open.size) >= this.#peerWindow) { - this.#refuse(SessionCode.TooManyRequests, "too many open requests"); - } - } - - #refuse(code: number, message: string): never { - try { - this.#quic.close({ closeCode: code, reason: message }); - } catch { - // The session is already closed. - } - throw new RequestWindowError(code, message); - } - - /** - * Count a request that ended. Once half the window has, raise the bound by the IDs - * they spent and send it, so grants cost a fraction of the requests they admit. - */ - #retire(): void { - this.#retired += 1n; - if (this.#retired * 2n < this.#peerWindow) return; - this.#peerMax += this.#retired * 2n; - this.#retired = 0n; - this.#grantDirty = true; - if (this.#granting || this.#closed) return; - this.#granting = true; - void this.#flushGrant(); - } - - async #flushGrant(): Promise { - try { - while (this.#grantDirty && !this.#closed) { - this.#grantDirty = false; - const max = this.#peerMax; - await this.#writeMutex.runExclusive(async () => { - if (this.#closed) return; - const grant = new MaxRequestId({ requestId: max }); - await Message.write(this.#writer, MaxRequestId.id, grant, this.version); - }); - } - } catch { - // The control stream closed under the grant. - } finally { - this.#granting = false; - } - if (this.#grantDirty && !this.#closed) { - this.#granting = true; - void this.#flushGrant(); - } - } - - /** - * Returns the total byte size of the first complete message in buffer, - * or undefined if the buffer doesn't contain a complete message yet. - * Message format: [typeId varint][size u16 BE][body of `size` bytes] - */ - #messageSize(buffer: Uint8Array): number | undefined { - if (buffer.length === 0) return undefined; - - const typeSize = 1 << ((buffer[0] & 0xc0) >> 6); - if (buffer.length < typeSize) return undefined; - - const [, afterType] = Varint.decode(buffer); - if (afterType.length < 2) return undefined; - - const size = (afterType[0] << 8) | afterType[1]; - const totalSize = buffer.length - afterType.length + 2 + size; - if (buffer.length < totalSize) return undefined; - - return totalSize; - } - - /** - * Try to parse the first outgoing message from accumulated bytes. - * Returns the requestId if enough data is available, undefined otherwise. - */ - async #tryParseOutgoing(buffer: Uint8Array): Promise<{ requestId: bigint } | undefined> { - if (buffer.length === 0) return undefined; - - // Check typeId varint size before decoding - const typeSize = 1 << ((buffer[0] & 0xc0) >> 6); - if (buffer.length < typeSize) return undefined; - - const [typeId, afterType] = Varint.decode(buffer); - - // Need 2 bytes for u16 size - if (afterType.length < 2) return undefined; - - const size = (afterType[0] << 8) | afterType[1]; - const bodyStart = afterType.subarray(2); - - // Need full body - if (bodyStart.length < size) return undefined; - - const body = bodyStart.subarray(0, size); - - // Decode requestId from body (QUIC varint) - const [reqId] = Varint.decode(body); - const requestId = BigInt(reqId); - - // PublishNamespace (0x06): also parse namespace for v14/v15 reverse lookup - if (typeId === 0x06) { - try { - await this.#registerNamespace(body, "outgoing"); - } catch { - // Non-critical: only needed for v14/v15 PublishNamespaceDone/Cancel - } - } - - // SubscribeNamespace (0x11): register for follow-up routing - if (typeId === 0x11) { - this.#subscribeNamespaces.add(requestId); - } - - return { requestId }; - } - - /** - * Decode a PublishNamespace body, or a draft-14 SubscribeNamespace one, and register its - * namespace for the v14/v15 namespace-keyed messages that end it. Returns the request - * ID it carried. - * - * The one decode site for every direction: outgoing announcements go through - * #tryParseOutgoing and incoming requests through #classify. The direction selects the - * map that the peer's DONE, CANCEL, or UNSUBSCRIBE_NAMESPACE will later consult. - */ - async #registerNamespace(body: Uint8Array, direction: NamespaceDirection): Promise { - const r = new Reader(undefined, body, this.version); - const requestId = await r.u62(); - const namespace = await Namespace.decode(r); - const namespaces = this.#namespaceRequests(direction); - - this.#namespacesByRequestId.set(requestId, { namespace, direction }); - - // First announcement in this direction wins. A duplicate is refused with 409, and - // overwriting here would point the first request's withdrawal at the refused one, - // which has no stream left, leaving the namespace announced forever. - if (!namespaces.has(namespace)) namespaces.set(namespace, requestId); - - return requestId; - } - - #namespaceRequests(direction: NamespaceDirection): Map { - switch (direction) { - case "incoming": - return this.#incomingNamespaces; - case "outgoing": - return this.#outgoingNamespaces; - case "subscribed": - return this.#subscribedNamespaces; - } - } - - /** Create a WritableStream that buffers and writes complete messages to the control stream under mutex. */ - #createSendWritable(): WritableStream { - let buffer = new Uint8Array(0); - return new WritableStream({ - write: async (chunk) => { - const newBuf = new Uint8Array(buffer.length + chunk.length); - newBuf.set(buffer); - newBuf.set(chunk, buffer.length); - buffer = newBuf; - - for (;;) { - const boundary = this.#messageSize(buffer); - if (boundary === undefined) break; - - const toFlush = buffer.subarray(0, boundary); - buffer = buffer.subarray(boundary); - await this.#send(toFlush, this.#updateId(toFlush)); - } - }, - }); - } - - /** The update's own request ID if this complete message is SUBSCRIBE_UPDATE or REQUEST_UPDATE. */ - #updateId(message: Uint8Array): bigint | undefined { - const [typeId, afterType] = Varint.decode(message); - if (typeId !== 0x02) return undefined; - return Varint.decodeBigInt(afterType.subarray(2))[0]; - } - - /** Write one complete message, after every lower request ID when it spends a new one. */ - async #send(message: Uint8Array, requestId: bigint | undefined): Promise { - if (requestId === undefined || this.#sendNext === undefined) { - await this.#writeMutex.runExclusive(() => this.#writer.write(message)); - return; - } - if (requestId < this.#sendNext || this.#sendTurns.has(requestId)) { - throw new Error(`request id ${requestId} was already sent`); - } - if (requestId !== this.#sendNext) { - const { promise, resolve } = Promise.withResolvers(); - this.#sendTurns.set(requestId, resolve); - await promise; - if (this.#closed) throw new Error("control stream closed"); - } - try { - await this.#writeMutex.runExclusive(() => this.#writer.write(message)); - } finally { - this.#sendNext = requestId + 2n; - const next = this.#sendTurns.get(this.#sendNext); - this.#sendTurns.delete(this.#sendNext); - next?.(); - } - } - - /** Encode raw message bytes: [typeId varint][size u16 BE][body] */ - #encodeRaw(typeId: number, size: number, body: Uint8Array): Uint8Array { - // v14-v16 always use QUIC varint - const typeIdBytes = Varint.encodeTo(new ArrayBuffer(9), typeId); - const result = new Uint8Array(typeIdBytes.byteLength + 2 + body.byteLength); - result.set(typeIdBytes, 0); - const sizeView = new DataView(result.buffer, typeIdBytes.byteLength, 2); - sizeView.setUint16(0, size); - result.set(body, typeIdBytes.byteLength + 2); - return result; - } - - /** - * Classify a control message and extract its requestId for routing. - */ - async #classify( - typeId: number, - body: Uint8Array, - ): Promise< - | { route: typeof Route.GoAway } - | { route: typeof Route.Update; ownRequestId: bigint; requestId: bigint } - | { route: Exclude; requestId: bigint } - > { - const readRequestId = async (): Promise => { - const r = new Reader(undefined, body, this.version); - return await r.u62(); - }; - - // v14/v15 name their withdrawals instead of numbering them. A name we have no - // announcement for is dropped, not fatal: it belongs to a duplicate we already - // refused, or to a request that is already gone, so there is nothing left to close. - // The mapping itself is released by #forget when the stream goes. - const readNamespaceRequestId = async (direction: NamespaceDirection): Promise => { - const r = new Reader(undefined, body, this.version); - const namespace = await Namespace.decode(r); - const namespaces = this.#namespaceRequests(direction); - const requestId = namespaces.get(namespace); - if (requestId === undefined) console.warn(`adapter: no request for namespace: ${namespace}`); - return requestId; - }; - - switch (typeId) { - // === FollowUp: route to existing stream === - case 0x02: { - // SUBSCRIBE_UPDATE / REQUEST_UPDATE: the update's own Request ID, then the request - // it changes (Subscription Request ID on 14 and 15, Existing Request ID on 16). - const r = new Reader(undefined, body, this.version); - const ownRequestId = await r.u62(); - const requestId = await r.u62(); - return { route: Route.Update, ownRequestId, requestId }; - } - - // === NewRequest: create virtual stream === - case 0x03: // Subscribe - case 0x16: // Fetch - case 0x1d: // Publish - case 0x0d: { - // TrackStatusRequest - const requestId = await readRequestId(); - return { route: Route.NewRequest, requestId }; - } - case 0x06: { - // PublishNamespace: also store the namespace for v14/v15 reverse lookup - const requestId = await this.#registerNamespace(body, "incoming"); - return { route: Route.NewRequest, requestId }; - } - case 0x11: { - // SubscribeNamespace (v14/v15 only on control stream) - if (this.version !== Version.DRAFT_14 && this.version !== Version.DRAFT_15) { - throw new Error("unexpected SubscribeNamespace on control stream"); - } - // Draft 14's UNSUBSCRIBE_NAMESPACE names the prefix rather than the request. - const requestId = - this.version === Version.DRAFT_14 - ? await this.#registerNamespace(body, "subscribed") - : await readRequestId(); - return { route: Route.NewRequest, requestId }; - } - - // === Response: push bytes, keep stream open === - case 0x04: { - // SubscribeOk - const requestId = await readRequestId(); - return { route: Route.Response, requestId }; - } - case 0x18: { - // FetchOk - const requestId = await readRequestId(); - return { route: Route.Response, requestId }; - } - case 0x1e: { - // PublishOk - const requestId = await readRequestId(); - return { route: Route.Response, requestId }; - } - case 0x07: { - // v14: PublishNamespaceOk, v15+: RequestOk - const requestId = await readRequestId(); - return { route: Route.Response, requestId }; - } - case 0x12: { - // SubscribeNamespaceOk (v14 only) - if (this.version !== Version.DRAFT_14) throw new Error("unexpected SubscribeNamespaceOk"); - const requestId = await readRequestId(); - return { route: Route.Response, requestId }; - } - - // === ErrorResponse: push bytes + close === - case 0x05: { - // SubscribeError (v14) / RequestError (v15+) - const requestId = await readRequestId(); - return { route: Route.ErrorResponse, requestId }; - } - case 0x19: { - // FetchError (v14 only) - if (this.version !== Version.DRAFT_14) throw new Error("unexpected FetchError"); - const requestId = await readRequestId(); - return { route: Route.ErrorResponse, requestId }; - } - case 0x1f: { - // PublishError (v14 only) - if (this.version !== Version.DRAFT_14) throw new Error("unexpected PublishError"); - const requestId = await readRequestId(); - return { route: Route.ErrorResponse, requestId }; - } - case 0x08: { - if (this.version === Version.DRAFT_14) { - // PublishNamespaceError - const requestId = await readRequestId(); - return { route: Route.ErrorResponse, requestId }; - } - // v15: Namespace entry (no requestId) — route to SubscribeNamespace stream - const subNs08 = this.#subscribeNamespaces.values().next().value; - if (subNs08 === undefined) throw new Error("unexpected message 0x08: no SubscribeNamespace stream"); - return { route: Route.FollowUp, requestId: subNs08 }; - } - case 0x0f: { - // TRACK_STATUS_ERROR has the SUBSCRIBE_ERROR body on draft 14. - if (this.version !== Version.DRAFT_14) throw new Error("unexpected TrackStatusError"); - return { route: Route.ErrorResponse, requestId: await readRequestId() }; - } - case 0x0e: { - if (this.version === Version.DRAFT_14) { - // TRACK_STATUS_OK names its request, unlike draft 16's namespace entry. - return { route: Route.Response, requestId: await readRequestId() }; - } - if (this.version === Version.DRAFT_15) { - throw new Error("unexpected message 0x0e"); - } - // v16+: NamespaceDone entry (no requestId) — route to SubscribeNamespace stream - const subNs0e = this.#subscribeNamespaces.values().next().value; - if (subNs0e === undefined) throw new Error("unexpected message 0x0e: no SubscribeNamespace stream"); - return { route: Route.FollowUp, requestId: subNs0e }; - } - case 0x13: { - // SubscribeNamespaceError (v14 only) - if (this.version !== Version.DRAFT_14) throw new Error("unexpected SubscribeNamespaceError"); - const requestId = await readRequestId(); - return { route: Route.ErrorResponse, requestId }; - } - - // === CloseStream: close recv (no bytes pushed) === - case 0x0a: { - // Unsubscribe - const requestId = await readRequestId(); - return { route: Route.CloseStream, requestId }; - } - case 0x0b: { - // PublishDone: the subscriber reads its status and stream count before the end. - const requestId = await readRequestId(); - return { route: Route.ErrorResponse, requestId }; - } - case 0x17: { - // FetchCancel - const requestId = await readRequestId(); - return { route: Route.CloseStream, requestId }; - } - case 0x09: { - // PublishNamespaceDone: v16 uses requestId, v14/v15 names the incoming - // announcement its sender is withdrawing. - if (this.version === Version.DRAFT_16) { - const requestId = await readRequestId(); - return { route: Route.CloseStream, requestId }; - } - const requestId = await readNamespaceRequestId("incoming"); - if (requestId === undefined) return { route: Route.Ignore, requestId: 0n }; - return { route: Route.CloseStream, requestId }; - } - case 0x0c: { - // PublishNamespaceCancel: v16 uses requestId, v14/v15 names the outgoing - // announcement its sender is rejecting. - if (this.version === Version.DRAFT_16) { - const requestId = await readRequestId(); - return { route: Route.CloseStream, requestId }; - } - const requestId = await readNamespaceRequestId("outgoing"); - if (requestId === undefined) return { route: Route.Ignore, requestId: 0n }; - return { route: Route.CloseStream, requestId }; - } - case 0x14: { - // UnsubscribeNamespace (v14/v15 only): v14 names the prefix, v15 the request. - if (this.version === Version.DRAFT_15) { - const requestId = await readRequestId(); - return { route: Route.CloseStream, requestId }; - } - if (this.version !== Version.DRAFT_14) throw new Error("unexpected UnsubscribeNamespace"); - const requestId = await readNamespaceRequestId("subscribed"); - if (requestId === undefined) return { route: Route.Ignore, requestId: 0n }; - return { route: Route.CloseStream, requestId }; - } - - // === Utility === - case 0x15: { - // MaxRequestId - const requestId = await readRequestId(); - return { route: Route.MaxRequestId, requestId }; - } - case 0x1a: { - // RequestsBlocked — connection-level, consume and ignore - await readRequestId(); - return { route: Route.Ignore, requestId: 0n }; - } - - // === Terminal === - case 0x10: // GoAway - return { route: Route.GoAway }; - - default: - throw new Error(`unknown control message type: 0x${typeId.toString(16)}`); - } - } - - /** - * Ends every virtual stream: cleanly for a deliberate close, or with `err` when the - * control stream died under them, since every request riding it was cut off. - */ - close(err?: Error) { - if (this.#closed) return; - this.#closed = true; - console.debug("adapter: close() called"); - - for (const entry of this.#streams.values()) { - try { - if (err) entry.controller.error(err); - else entry.controller.close(); - } catch { - // Already closed - } - } - this.#streams.clear(); - - // Resolve any waiting acceptBi callers - for (const waiter of this.#incomingWaiters) { - waiter(undefined); - } - this.#incomingWaiters = []; - - // Clear namespace mappings - this.#incomingNamespaces.clear(); - this.#outgoingNamespaces.clear(); - this.#subscribedNamespaces.clear(); - this.#namespacesByRequestId.clear(); - this.#subscribeNamespaces.clear(); - - // Unblock maxRequestId waiters - for (const resolve of this.#maxRequestIdResolves) resolve(); - this.#maxRequestIdResolves.clear(); - - for (const resolve of this.#sendTurns.values()) resolve(); - this.#sendTurns.clear(); - } -} diff --git a/js/net/src/ietf/auth.test.ts b/js/net/src/ietf/auth.test.ts index ce5d0f256c6..048272567ca 100644 --- a/js/net/src/ietf/auth.test.ts +++ b/js/net/src/ietf/auth.test.ts @@ -4,8 +4,6 @@ import { SessionCode, type SessionError, StreamCode, type StreamError } from ".. import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { AuthError, AuthMessage, AuthOk } from "./auth.ts"; -import { fromSetup, intoSetup } from "./auth_setup.ts"; -import { SetupOption, SetupOptions } from "./setup_options.ts"; import { Version } from "./version.ts"; function patterns(...texts: string[]): Path.Patterns { @@ -21,7 +19,7 @@ async function bytes(write: (w: Writer) => Promise): Promise { chunks.push(new Uint8Array(chunk)); }, }), - Version.DRAFT_17, + Version.DRAFT_18, ); await write(writer); writer.close(); @@ -37,31 +35,14 @@ async function bytes(write: (w: Writer) => Promise): Promise { /** A reader past the message type, the way the dispatcher leaves one. */ async function afterType(write: (w: Writer) => Promise): Promise { - const reader = new Reader(undefined, await bytes(write), Version.DRAFT_17); + const reader = new Reader(undefined, await bytes(write), Version.DRAFT_18); await reader.u53(); return reader; } -test("the setup option negotiates on draft-17+ only", () => { - for (const version of [Version.DRAFT_17, Version.DRAFT_22]) { - const params = new SetupOptions(); - expect(fromSetup(params, version)).toBeUndefined(); - intoSetup(params, version); - expect(fromSetup(params, version)).toBe(true); - } - const legacy = new SetupOptions(); - intoSetup(legacy, Version.DRAFT_16); - expect(legacy.getVarint(SetupOption.Auth)).toBeUndefined(); - - // An explicit value other than 1 is an implementation that declined. - const declined = new SetupOptions(); - declined.setVarint(SetupOption.Auth, 0n); - expect(fromSetup(declined, Version.DRAFT_17)).toBe(false); -}); - test("AUTH round-trips its request id and token", async () => { - const r = await afterType((w) => new AuthMessage(4n, new TextEncoder().encode("jwt")).encode(w, Version.DRAFT_17)); - const msg = await AuthMessage.decode(r, Version.DRAFT_17); + const r = await afterType((w) => new AuthMessage(4n, new TextEncoder().encode("jwt")).encode(w, Version.DRAFT_18)); + const msg = await AuthMessage.decode(r, Version.DRAFT_18); expect(msg.requestId).toBe(4n); expect(new TextDecoder().decode(msg.token)).toBe("jwt"); }); @@ -69,7 +50,7 @@ test("AUTH round-trips its request id and token", async () => { test("AUTH_OK carries each pattern as its canonical text", async () => { // The exact bytes `rs/moq-net/src/ietf/auth.rs` checks, so both encoders agree. const ok = new AuthOk(patterns("room/*/cam", "ch1"), patterns(""), undefined); - const wire = await bytes((w) => ok.encode(w, Version.DRAFT_17)); + const wire = await bytes((w) => ok.encode(w, Version.DRAFT_18)); // Type, 16-bit length, then: two publish patterns in canonical order, the empty // subscribe pattern, never expires. expect([...wire.slice(-19)]).toEqual([ @@ -85,8 +66,8 @@ test("AUTH_OK carries each pattern as its canonical text", async () => { // Literals and wildcards travel exactly, never widened to a prefix. const grant = new AuthOk(patterns("**"), patterns("live/*", "lobby/cam-*.hang", "ch1"), 60_000); - const r = await afterType((w) => grant.encode(w, Version.DRAFT_17)); - const got = await AuthOk.decode(r, Version.DRAFT_17); + const r = await afterType((w) => grant.encode(w, Version.DRAFT_18)); + const got = await AuthOk.decode(r, Version.DRAFT_18); expect(got.publish.equals(grant.publish)).toBe(true); expect(got.subscribe.equals(grant.subscribe)).toBe(true); expect(got.expires).toBe(60_000); @@ -104,17 +85,17 @@ test("a grant too large for one message is refused before anything is written", written += chunk.byteLength; }, }), - Version.DRAFT_17, + Version.DRAFT_18, ); - await expect(huge.encode(writer, Version.DRAFT_17)).rejects.toThrow("Message too large"); + await expect(huge.encode(writer, Version.DRAFT_18)).rejects.toThrow("Message too large"); writer.close(); await writer.closed; expect(written).toBe(0); }); test("AUTH_ERROR preserves actionable refusal kinds", async () => { - const r = await afterType((w) => new AuthError(0x3, "too large").encode(w, Version.DRAFT_17)); - expect((await AuthError.decode(r, Version.DRAFT_17)).toError()).toBeInstanceOf(Unsupported); + const r = await afterType((w) => new AuthError(0x3, "too large").encode(w, Version.DRAFT_18)); + expect((await AuthError.decode(r, Version.DRAFT_18)).toError()).toBeInstanceOf(Unsupported); const expired = new AuthError(0x5, "expired").toError() as SessionError; expect(expired.code).toBe(SessionCode.Expired); const malformed = new AuthError(0x4, "not a JWT").toError() as SessionError; @@ -125,7 +106,3 @@ test("AUTH_ERROR preserves actionable refusal kinds", async () => { const goingAway = new AuthError(0x6, "going away").toError() as StreamError; expect(goingAway.code).toBe(StreamCode.GoingAway); }); - -test("drafts before 17 carry no AUTH", async () => { - await expect(bytes((w) => new AuthMessage(0n, new Uint8Array()).encode(w, Version.DRAFT_16))).rejects.toThrow(); -}); diff --git a/js/net/src/ietf/auth.ts b/js/net/src/ietf/auth.ts index 29d55cf3960..773bc836949 100644 --- a/js/net/src/ietf/auth.ts +++ b/js/net/src/ietf/auth.ts @@ -3,10 +3,9 @@ import { type AuthWire, decodePatterns, encodePatterns, type WireGrant, type Wir import { ProtocolViolation, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; import type * as Path from "../path.ts"; import type { Reader, Stream, Writer } from "../stream.ts"; -import type { Session } from "./adapter.ts"; -import { supported } from "./auth_setup.ts"; import * as Message from "./message.ts"; import { nextMessage } from "./request_stream.ts"; +import type { Session } from "./session.ts"; import type { IetfVersion } from "./version.ts"; /** @@ -23,16 +22,11 @@ const UNAUTHORIZED = 0x1; const NOT_SUPPORTED = 0x3; const MALFORMED_AUTH_TOKEN = 0x4; const EXPIRED_AUTH_TOKEN = 0x5; -/** Draft-17 assigned it, the first draft this extension negotiates on. */ const GOING_AWAY = 0x6; /** Longest AUTH_ERROR reason, in bytes, matching the Rust decoder. */ const MAX_REASON = 8192; -function guard(version: IetfVersion) { - if (!supported(version)) throw new Error("auth not supported for this version"); -} - /** * AUTH: the first message on an Auth request stream, presenting a token. Each message * here encodes its own type, which the dispatcher consumes before decoding. @@ -50,8 +44,7 @@ export class AuthMessage { this.token = token; } - async encode(w: Writer, version: IetfVersion): Promise { - guard(version); + async encode(w: Writer, _version: IetfVersion): Promise { return Message.encode( w, async (wr) => { @@ -63,8 +56,7 @@ export class AuthMessage { ); } - static async decode(r: Reader, version: IetfVersion): Promise { - guard(version); + static async decode(r: Reader, _version: IetfVersion): Promise { return Message.decode(r, async (rd) => { const requestId = await rd.u62(); const size = await rd.u53(); @@ -88,8 +80,7 @@ export class AuthOk { this.expires = expires; } - async encode(w: Writer, version: IetfVersion): Promise { - guard(version); + async encode(w: Writer, _version: IetfVersion): Promise { return Message.encode( w, async (wr) => { @@ -106,8 +97,7 @@ export class AuthOk { ); } - static async decode(r: Reader, version: IetfVersion): Promise { - guard(version); + static async decode(r: Reader, _version: IetfVersion): Promise { return Message.decode(r, async (rd) => { const publish = await decodePatterns(rd); const subscribe = await decodePatterns(rd); @@ -130,8 +120,7 @@ export class AuthError { this.reason = reason; } - async encode(w: Writer, version: IetfVersion): Promise { - guard(version); + async encode(w: Writer, _version: IetfVersion): Promise { if (new TextEncoder().encode(this.reason).byteLength > MAX_REASON) { throw new Error("AUTH_ERROR reason exceeds 8,192 bytes"); } @@ -145,8 +134,7 @@ export class AuthError { ); } - static async decode(r: Reader, version: IetfVersion): Promise { - guard(version); + static async decode(r: Reader, _version: IetfVersion): Promise { return Message.decode(r, async (rd) => { const code = await rd.u53(); if (code > 0xffffffff) throw new Error("AUTH_ERROR code exceeds 32 bits"); @@ -208,7 +196,7 @@ export class IetfAuthWire implements AuthWire { /** The type id is already consumed by the dispatcher. */ async accept(stream: Stream): Promise { const msg = await AuthMessage.decode(stream.reader, this.#version); - this.#session.spendRequest(msg.requestId); + this.#session.checkRequestId(msg.requestId); return msg.token; } diff --git a/js/net/src/ietf/auth_setup.ts b/js/net/src/ietf/auth_setup.ts index 3d4fcf2bef8..500b3cc8631 100644 --- a/js/net/src/ietf/auth_setup.ts +++ b/js/net/src/ietf/auth_setup.ts @@ -1,5 +1,4 @@ import { SetupOption, type SetupOptions } from "./setup_options.ts"; -import { type IetfVersion, Version } from "./version.ts"; /** * Negotiation of the MoQ Auth extension (draft-lcurley-moq-auth-01) with the AUTH Setup Option. @@ -9,24 +8,18 @@ import { type IetfVersion, Version } from "./version.ts"; * @internal */ -/** Whether a version negotiates the extension. @internal */ -export function supported(version: IetfVersion): boolean { - return version >= Version.DRAFT_17; -} - /** * What the peer declared: undefined for no option, otherwise whether it offered the * extension. Only an explicit 1 negotiates it. * * @internal */ -export function fromSetup(params: SetupOptions, version: IetfVersion): boolean | undefined { - if (!supported(version)) return undefined; +export function fromSetup(params: SetupOptions): boolean | undefined { const value = params.getVarint(SetupOption.Auth); return value === undefined ? undefined : value === 1n; } -/** Offer the extension, on the versions that negotiate it. @internal */ -export function intoSetup(params: SetupOptions, version: IetfVersion) { - if (supported(version)) params.setVarint(SetupOption.Auth, 1n); +/** Offer the extension. @internal */ +export function intoSetup(params: SetupOptions) { + params.setVarint(SetupOption.Auth, 1n); } diff --git a/js/net/src/ietf/cluster.test.ts b/js/net/src/ietf/cluster.test.ts index 882c7217e44..355f7bfb99a 100644 --- a/js/net/src/ietf/cluster.test.ts +++ b/js/net/src/ietf/cluster.test.ts @@ -8,7 +8,7 @@ import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; import { Parameters } from "./parameters.ts"; import { PublishNamespace, PublishNamespaceUpdate } from "./publish_namespace.ts"; -import { SetupOption, SetupOptions } from "./setup_options.ts"; +import { SetupOptions } from "./setup_options.ts"; import { SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; import { MalformedAuthToken } from "./token.ts"; import { type IetfVersion, Version } from "./version.ts"; @@ -140,22 +140,22 @@ test("Cluster: a path that cannot have come from a conforming sender is rejected test("Cluster: the setup options round trip", async () => { for (const declared of [origin(42n), origin((1n << 64n) - 1n), UNKNOWN_HOP]) { const params = new SetupOptions(); - Cluster.intoSetup(params, declared, VERSION); + Cluster.intoSetup(params, declared); const encoded = await encode(params); - const decoded = await SetupOptions.decode(reader(encoded), VERSION); - expect(Cluster.fromSetup(decoded, VERSION)).toBe(declared); + const decoded = await SetupOptions.decode(reader(encoded)); + expect(Cluster.fromSetup(decoded)).toBe(declared); } // A peer that declared nothing did not negotiate. - expect(Cluster.fromSetup(new SetupOptions(), VERSION)).toBeUndefined(); + expect(Cluster.fromSetup(new SetupOptions())).toBeUndefined(); }); test("Cluster: HOP_ID is a bare varint", async () => { // The key sits at delta 0x40b54 from the start of the block and the value follows it // directly: a length byte here would make us unreadable to every -01 peer. const params = new SetupOptions(); - Cluster.intoSetup(params, origin(42n), VERSION); + Cluster.intoSetup(params, origin(42n)); expect(Array.from(await encode(params))).toEqual([ 0xc4, 0x0b, @@ -171,24 +171,8 @@ test("Cluster: a peer declaring only RELAY_HOPS is not negotiated", async () => const params = new SetupOptions(); params.setBytes(0x40b55n, new Uint8Array([0x2a])); - const decoded = await SetupOptions.decode(reader(await encode(params)), VERSION); - expect(Cluster.fromSetup(decoded, VERSION)).toBeUndefined(); -}); - -test("Cluster: the setup options are skipped before draft-17", () => { - // Draft-14..16 exchange SETUP over the legacy control stream, which this extension - // does not cover; we neither send nor read the options there. - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16]) { - expect(Cluster.supported(version)).toBe(false); - - const params = new SetupOptions(); - Cluster.intoSetup(params, origin(42n), version); - expect(params.getVarint(SetupOption.HopId)).toBeUndefined(); - - // Even one that arrived anyway reads as "not negotiated" there. - Cluster.intoSetup(params, origin(42n), Version.DRAFT_18); - expect(Cluster.fromSetup(params, version)).toBeUndefined(); - } + const decoded = await SetupOptions.decode(reader(await encode(params))); + expect(Cluster.fromSetup(decoded)).toBeUndefined(); }); test("Cluster: we advertise our own id, and only once negotiated", () => { @@ -272,44 +256,6 @@ test("Cluster: a repeated AUTHORIZATION_TOKEN refuses only the advertisement", a await expect(update).rejects.toThrow(MalformedAuthToken); }); -test("Cluster: every malformed PUBLISH_NAMESPACE update is a protocol violation", async () => { - const valid = await encode(new PublishNamespaceUpdate({ requestId: 1n, update: { cost: 2n } })); - const trailing = new Uint8Array(valid.byteLength + 1); - trailing.set(valid); - trailing[1] += 1; // Include the extra byte in the message body's declared size. - - const malformed = [ - valid.slice(0, 1), // Truncated size prefix. - valid.slice(0, -1), // Truncated body. - new Uint8Array([0, 1, 0xff]), // Truncated request ID. - trailing, - ]; - - for (const bytes of malformed) { - await expect(PublishNamespaceUpdate.decode(reader(bytes), VERSION)).rejects.toThrow(ProtocolViolation); - } - - // The message did not exist before draft-16. - await expect( - PublishNamespaceUpdate.decode(reader(new Uint8Array([0, 0]), Version.DRAFT_15), Version.DRAFT_15), - ).rejects.toThrow(ProtocolViolation); -}); - -test("Cluster: a draft-16 PUBLISH_NAMESPACE update names the advertisement", async () => { - const version = Version.DRAFT_16; - const update = new PublishNamespaceUpdate({ requestId: 2n, namespaceRequestId: 0n, update: {} }); - const decoded = await PublishNamespaceUpdate.decode(reader(await encode(update, version), version), version); - expect(decoded.requestId).toBe(2n); - expect(decoded.namespaceRequestId).toBe(0n); - - // A repeated token refuses the update, keyed by its own Request ID. - const tokens = [2, 0x03, 3, 0x03, 0x00, 0xaa, 0, 3, 0x03, 0x00, 0xaa]; - const body = [2, 0, ...tokens]; - const repeated = PublishNamespaceUpdate.decode(reader(new Uint8Array([0, body.length, ...body]), version), version); - await expect(repeated).rejects.toThrow(MalformedAuthToken); - await expect(repeated).rejects.toMatchObject({ requestId: 2n }); -}); - test("Cluster: a reset while decoding an update stays stream-local", async () => { const reset = Object.assign(new Error("reset"), { source: "stream" as const, diff --git a/js/net/src/ietf/cluster.ts b/js/net/src/ietf/cluster.ts index b785f0035d4..ac0814b9e54 100644 --- a/js/net/src/ietf/cluster.ts +++ b/js/net/src/ietf/cluster.ts @@ -11,7 +11,7 @@ * and a PUBLISH_NAMESPACE is repriced with the same parameters on a REQUEST_UPDATE. * * These are the semantics moq-lite carries natively on every announcement (see - * `lite/announce.ts`); this module is the moq-transport binding, negotiated on draft-17+ + * `lite/announce.ts`); this module is the moq-transport binding, negotiated on draft-18+ * only, where SETUP is a Key-Value-Pair block. * * A broadcast we produce carries a single-entry HOP_PATH and a cost of 0, and a route we @@ -30,20 +30,6 @@ import * as Varint from "../varint.ts"; // Type-only: SETUP loads eagerly and must not pull in the message parameter codec. import type { Parameters } from "./parameters.ts"; import { SetupOption, type SetupOptions } from "./setup_options.ts"; -import { type IetfVersion, Version } from "./version.ts"; - -/** - * Whether a version can negotiate the extension. - * - * Draft-17 is the first with the unified SETUP: a bare Key-Value-Pair block that a new - * option slots into without touching the message shape. Earlier drafts exchange SETUP over - * the legacy control stream, which this extension does not cover. - * - * @internal - */ -export function supported(version: IetfVersion): boolean { - return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; -} /** * The Hop IDs this session declared, one per direction. @@ -103,9 +89,7 @@ export interface Update { * * @internal */ -export function fromSetup(params: SetupOptions, version: IetfVersion): Hop | undefined { - if (!supported(version)) return undefined; - +export function fromSetup(params: SetupOptions): Hop | undefined { // 0 is legal: the peer speaks the extension but withholds its identity. const value = params.getVarint(SetupOption.HopId); if (value === undefined) return undefined; @@ -117,8 +101,7 @@ export function fromSetup(params: SetupOptions, version: IetfVersion): Hop | und * * @internal */ -export function intoSetup(params: SetupOptions, self: Hop, version: IetfVersion) { - if (!supported(version)) return; +export function intoSetup(params: SetupOptions, self: Hop) { params.setVarint(SetupOption.HopId, self); } diff --git a/js/net/src/ietf/connection.test.ts b/js/net/src/ietf/connection.test.ts index c615877ff1a..6c7e6385996 100644 --- a/js/net/src/ietf/connection.test.ts +++ b/js/net/src/ietf/connection.test.ts @@ -11,18 +11,11 @@ import { Connection } from "./connection.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; import { Group } from "./object.ts"; -import { Parameters } from "./parameters.ts"; -import { MaxRequestId, RequestError, RequestOk } from "./request.ts"; +import { RequestError, RequestOk } from "./request.ts"; import { Setup } from "./setup.ts"; -import { initialMaxRequestId, SetupOption, SetupOptions } from "./setup_options.ts"; -import { SubscribeError, SubscribeOk } from "./subscribe.ts"; -import { - SUBSCRIBE_TRACKS_ID, - SubscribeNamespace, - SubscribeNamespaceEntry, - SubscribeNamespaceLegacy, - SubscribeOptions, -} from "./subscribe_namespace.ts"; +import { SetupOption, SetupOptions } from "./setup_options.ts"; +import { SubscribeOk } from "./subscribe.ts"; +import { SUBSCRIBE_TRACKS_ID, SubscribeNamespace, SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; const PADDING = 0x132b3e28n; @@ -39,7 +32,6 @@ async function openUni( url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, }); @@ -79,8 +71,6 @@ test("an unknown uni stream type closes the session", async () => { [Version.DRAFT_18, ALPN.DRAFT_18, 0n], // Past 2^53, so it cannot be read as a number. [Version.DRAFT_18, ALPN.DRAFT_18, 2n ** 53n], - // Padding arrived in draft-18. - [Version.DRAFT_17, ALPN.DRAFT_17, PADDING], // A SUBGROUP_HEADER with the reserved SUBGROUP_ID_MODE (0b11). [Version.DRAFT_18, ALPN.DRAFT_18, 0x56n], ] as const) { @@ -99,7 +89,6 @@ test("an unknown uni stream type closes the session", async () => { /** Unknown bidi message types close the session, including full-width varints. */ for (const [version, alpn] of [ - [Version.DRAFT_17, ALPN.DRAFT_17], [Version.DRAFT_18, ALPN.DRAFT_18], [Version.DRAFT_22, ALPN.DRAFT_22], ] as const) { @@ -112,7 +101,6 @@ for (const [version, alpn] of [ url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, }); @@ -140,7 +128,6 @@ test("SUBSCRIBE_TRACKS accepts an empty origin", async () => { url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, }); @@ -164,108 +151,37 @@ test("SUBSCRIBE_TRACKS accepts an empty origin", async () => { } }); -/** - * A draft-16/17 SUBSCRIBE_NAMESPACE gets NAMESPACE whenever it asks for namespaces, and one - * asking for PUBLISH alone is refused, since we never send PUBLISH. Draft-18 has no - * Subscribe Options and always asks for namespaces. - */ -for (const [version, alpn, options, namespaces] of [ - [Version.DRAFT_16, ALPN.DRAFT_16, SubscribeOptions.PUBLISH, false], - [Version.DRAFT_16, ALPN.DRAFT_16, SubscribeOptions.NAMESPACE, true], - [Version.DRAFT_16, ALPN.DRAFT_16, SubscribeOptions.BOTH, true], - [Version.DRAFT_17, ALPN.DRAFT_17, SubscribeOptions.PUBLISH, false], - [Version.DRAFT_17, ALPN.DRAFT_17, SubscribeOptions.NAMESPACE, true], - [Version.DRAFT_17, ALPN.DRAFT_17, SubscribeOptions.BOTH, true], - [Version.DRAFT_18, ALPN.DRAFT_18, undefined, true], -] as const) { - test(`${alpn} SUBSCRIBE_NAMESPACE with Subscribe Options ${options ?? "absent"}`, async () => { - const pair = createMockTransportPair(alpn); - const control = await Stream.open(pair.server, { version }); - const origin = new OriginProducer(); - origin.createBroadcast(Path.from("room")).announce(); - const connection = new Connection({ - url: new URL("https://example.com"), - quic: pair.server, - control, - maxRequestId: 100n, - version, - client: false, - publish: origin.consume(), - solicit: true, - }); - - try { - const stream = await Stream.open(pair.client, { version }); - if (options === undefined) { - await stream.writer.u53(SubscribeNamespace.id); - await new SubscribeNamespace({ namespace: Path.empty(), requestId: 0n }).encode(stream.writer, version); - } else { - await stream.writer.u53(SubscribeNamespaceLegacy.id); - await new SubscribeNamespaceLegacy({ - namespace: Path.empty(), - requestId: 0n, - subscribeOptions: options, - }).encode(stream.writer, version); - } - - if (namespaces) { - expect(await stream.reader.u53()).toBe(RequestOk.id); - await RequestOk.decode(stream.reader, version); - expect(await stream.reader.u53()).toBe(SubscribeNamespaceEntry.id); - expect((await SubscribeNamespaceEntry.decode(stream.reader, version)).suffix).toBe(Path.from("room")); - } else { - expect(await stream.reader.u53()).toBe(RequestError.id); - const refused = await RequestError.decode(stream.reader, version); - expect(refused.errorCode).toBe(0x3); - expect(refused.requestId).toBe(version === Version.DRAFT_16 ? 0n : undefined); - expect(await stream.reader.done()).toBe(true); - } - } finally { - connection.abort(); - origin.close(); - } +/** A SUBSCRIBE_NAMESPACE always asks for namespaces, so it gets the ones we publish. */ +test("SUBSCRIBE_NAMESPACE gets the published namespaces", async () => { + const version = Version.DRAFT_18; + const pair = createMockTransportPair(ALPN.DRAFT_18); + const control = await Stream.open(pair.server, { version }); + const origin = new OriginProducer(); + origin.createBroadcast(Path.from("room")).announce(); + const connection = new Connection({ + url: new URL("https://example.com"), + quic: pair.server, + control, + version, + client: false, + publish: origin.consume(), + solicit: true, }); -} - -/** The drafts define only 0x00 through 0x02; any other value closes the session, even past 2^53. */ -for (const [version, alpn] of [ - [Version.DRAFT_16, ALPN.DRAFT_16], - [Version.DRAFT_17, ALPN.DRAFT_17], -] as const) { - for (const options of [3n, 2n ** 53n]) { - test(`${alpn} Subscribe Options ${options} closes the session`, async () => { - const logged = spyOn(console, "error").mockImplementation(() => void 0); - const pair = createMockTransportPair(alpn); - const control = await Stream.open(pair.server, { version }); - const connection = new Connection({ - url: new URL("https://example.com"), - quic: pair.server, - control, - maxRequestId: 100n, - version, - client: false, - }); - try { - const stream = await Stream.open(pair.client, { version }); - await stream.writer.u53(SubscribeNamespaceLegacy.id); - await Message.encode(stream.writer, async (w) => { - await w.u62(1n); // Request ID - if (version === Version.DRAFT_17) await w.u62(0n); // Required Request ID delta - await Namespace.encode(w, Path.empty()); - await w.u62(options); - await new Parameters().encode(w, version); - }); - - const info = await pair.client.closed; - expect(info.closeCode).toBe(SessionCode.ProtocolViolation); - } finally { - logged.mockRestore(); - connection.abort(); - } - }); + try { + const stream = await Stream.open(pair.client, { version }); + await stream.writer.u53(SubscribeNamespace.id); + await new SubscribeNamespace({ namespace: Path.empty(), requestId: 0n }).encode(stream.writer, version); + + expect(await stream.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(stream.reader, version); + expect(await stream.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(stream.reader, version)).suffix).toBe(Path.from("room")); + } finally { + connection.abort(); + origin.close(); } -} +}); /** * Padding and group streams that beat the peer's SETUP are held and classified once it @@ -322,7 +238,6 @@ test("uni streams before SETUP are held until it lands", async () => { solicit, hidden, cluster, - maxRequestId: 0n, version, client: false, }); @@ -338,33 +253,10 @@ test("uni streams before SETUP are held until it lands", async () => { } }); -/** An early stream that dies before its type is skipped, but a malformed type is still fatal. */ -test("malformed uni stream before SETUP fails the handshake", async () => { - const version = Version.DRAFT_17; - const pair = createMockTransportPair(ALPN.DRAFT_17); - - const empty = new Writer(await pair.client.createUnidirectionalStream(), version); - empty.close(); - - // 0b1111110x is a reserved varint prefix on draft-17. - const malformed = new Writer(await pair.client.createUnidirectionalStream(), version); - await malformed.write(new Uint8Array([0xfc, 0, 0, 0, 0, 0, 0, 0, 0])); - - const setup = new Writer(await pair.client.createUnidirectionalStream(), version); - await setup.u53(Setup.id); - const parameters = new SetupOptions(); - parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test")); - await new Setup({ parameters }).encode(setup, version); - - await expect(exchangeSetup(pair.server, version, "test", { client: false, extensions: offered() })).rejects.toThrow( - "leading-ones varint", - ); -}); - /** An early stream that ends partway through its type died; it is skipped like an empty one. */ test("truncated uni stream before SETUP is skipped", async () => { - const version = Version.DRAFT_17; - const pair = createMockTransportPair(ALPN.DRAFT_17); + const version = Version.DRAFT_18; + const pair = createMockTransportPair(ALPN.DRAFT_18); // A two-byte varint prefix, then FIN. const truncated = new Writer(await pair.client.createUnidirectionalStream(), version); @@ -381,91 +273,6 @@ test("truncated uni stream before SETUP is skipped", async () => { expect(early.length).toBe(0); }); -// A PUBLISH-only request is refused, but it still spends an id and must give it back. -for (const [name, options] of [ - ["namespaces", SubscribeOptions.NAMESPACE], - ["publish only", SubscribeOptions.PUBLISH], -] as const) { - /** Draft-16 carries SUBSCRIBE_NAMESPACE on its own stream, so the control adapter never sees the id. */ - test(`draft-16 subscribe namespace for ${name} past the request window closes the session`, async () => { - const logged = spyOn(console, "error").mockImplementation(() => undefined); - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPN.DRAFT_16); - const control = await Stream.open(pair.server, { version }); - const connection = new Connection({ - url: new URL("https://example.com"), - quic: pair.server, - control, - maxRequestId: 100n, - version, - client: false, - }); - - try { - const stream = await Stream.open(pair.client, { version }); - await stream.writer.u53(SubscribeNamespaceLegacy.id); - await new SubscribeNamespaceLegacy({ - requestId: initialMaxRequestId(true), - namespace: Path.from("room"), - subscribeOptions: options, - }).encode(stream.writer, version); - - const info = await pair.client.closed; - expect(info.closeCode).toBe(SessionCode.TooManyRequests); - expect(logged).not.toHaveBeenCalled(); - } finally { - logged.mockRestore(); - connection.abort(); - } - }); - - /** Ending that stream has to grant another id, or the next namespace request stalls at the same ceiling. */ - test(`draft-16 subscribe namespace for ${name} grants another request id when it ends`, async () => { - const logged = spyOn(console, "error").mockImplementation(() => undefined); - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPN.DRAFT_16); - const control = await Stream.open(pair.server, { version }); - const connection = new Connection({ - url: new URL("https://example.com"), - quic: pair.server, - control, - maxRequestId: 100n, - version, - client: false, - requestWindow: 1n, - }); - const peerControl = await Stream.accept(pair.client, version); - if (!peerControl) throw new Error("no control stream"); - - try { - const stream = await Stream.open(pair.client, { version }); - await stream.writer.u53(SubscribeNamespaceLegacy.id); - await new SubscribeNamespaceLegacy({ - requestId: 0n, - namespace: Path.from("room"), - subscribeOptions: options, - }).encode(stream.writer, version); - - if (options === SubscribeOptions.PUBLISH) { - expect(await stream.reader.u53()).toBe(RequestError.id); - expect((await RequestError.decode(stream.reader, version)).requestId).toBe(0n); - } else { - expect(await stream.reader.u53()).toBe(RequestOk.id); - expect((await RequestOk.decode(stream.reader, version)).requestId).toBe(0n); - } - stream.close(); - - expect(await peerControl.reader.u53()).toBe(MaxRequestId.id); - const grant = await MaxRequestId.decode(peerControl.reader, version); - expect(grant.requestId).toBe(initialMaxRequestId(true, 1n) + 2n); - expect(logged).not.toHaveBeenCalled(); - } finally { - logged.mockRestore(); - connection.abort(); - } - }); -} - for (const [version, alpn] of [ [Version.DRAFT_18, ALPN.DRAFT_18], [Version.DRAFT_22, ALPN.DRAFT_22], @@ -487,7 +294,6 @@ for (const [version, alpn] of [ url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, }); @@ -507,55 +313,36 @@ for (const [version, alpn] of [ } } -/** - * A parameter block of AUTHORIZATION TOKENs. Draft-16 on codes each key as a delta from the - * one before, so a repeat is 0. - */ -function tokenParams(version: IetfVersion, ...tokens: (readonly number[])[]): number[] { - const delta = version !== Version.DRAFT_14 && version !== Version.DRAFT_15; - return [tokens.length, ...tokens.flatMap((token, i) => [i > 0 && delta ? 0 : 0x03, token.length, ...token])]; +/** A parameter block of AUTHORIZATION TOKENs. Each key is a delta from the one before, so a repeat is 0. */ +function tokenParams(...tokens: (readonly number[])[]): number[] { + return [tokens.length, ...tokens.flatMap((token, i) => [i > 0 ? 0 : 0x03, token.length, ...token])]; } /** A SUBSCRIBE for `room/video` whose parameter block is `params`. */ -function subscribeWith(version: IetfVersion, params: readonly number[], requestId = 0): Uint8Array { +function subscribeWith(params: readonly number[], requestId = 0): Uint8Array { const ascii = (text: string) => [...new TextEncoder().encode(text)]; // Request ID, then the namespace and track name. - const head = [requestId, 1, 4, ...ascii("room"), 5, ...ascii("video")]; - // Draft-14 carries priority, group order, forward, and a Largest Object filter inline. - const fields = version === Version.DRAFT_14 ? [0x80, 0x02, 0x01, 0x02] : []; - const body = [...head, ...fields, ...params]; + const body = [requestId, 1, 4, ...ascii("room"), 5, ...ascii("video"), ...params]; return new Uint8Array([0x03, 0, body.length, ...body]); } /** A SUBSCRIBE for `room/video` whose only parameter is an AUTHORIZATION TOKEN holding `token`. */ -function subscribeWithToken(version: IetfVersion, token: readonly number[]): Uint8Array { - return subscribeWith(version, tokenParams(version, token)); +function subscribeWithToken(token: readonly number[]): Uint8Array { + return subscribeWith(tokenParams(token)); } /** An update to that SUBSCRIBE whose only parameters are AUTHORIZATION TOKENs holding `tokens`. */ -function updateWithTokens(version: IetfVersion, ...tokens: (readonly number[])[]): Uint8Array { - // Draft-14 names its own Request ID and the subscription's, then the range, priority, - // and forward inline. Drafts 15 and 16 name both IDs, and later drafts only its own, - // which must be fresh: the subscription already spent 0. - const head = - version === Version.DRAFT_14 - ? [2, 0, 0, 0, 0, 0x80, 0x01] - : version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? [2, 0] - : [2]; - const body = [...head, ...tokenParams(version, ...tokens)]; +function updateWithTokens(...tokens: (readonly number[])[]): Uint8Array { + // Its own Request ID, which must be fresh: the subscription already spent 0. + const body = [2, ...tokenParams(...tokens)]; return new Uint8Array([0x02, 0, body.length, ...body]); } /** * A request's AUTHORIZATION TOKEN decodes by the SETUP option's rules, so one this endpoint - * cannot use closes the session with the draft's code. Draft-14 carries the request on the - * control stream, a strict draft on its own stream. + * cannot use closes the session with the draft's code. */ -for (const [version, alpn] of [ - [Version.DRAFT_14, ALPN.DRAFT_14], - [Version.DRAFT_22, ALPN.DRAFT_22], -] as const) { +for (const [version, alpn] of [[Version.DRAFT_22, ALPN.DRAFT_22]] as const) { for (const [name, token, code] of [ ["REGISTER", [0x01, 0x07, 0x00], SessionCode.AuthTokenCacheOverflow], ["DELETE", [0x00, 0x07], SessionCode.UnknownAuthTokenAlias], @@ -571,17 +358,13 @@ for (const [version, alpn] of [ url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, }); try { - const stream = - version === Version.DRAFT_14 - ? await Stream.accept(pair.client, version) - : await Stream.open(pair.client, { version }); + const stream = await Stream.open(pair.client, { version }); if (!stream) throw new Error("no control stream"); - await stream.writer.write(subscribeWithToken(version, token)); + await stream.writer.write(subscribeWithToken(token)); const info = await pair.client.closed; expect(info.closeCode).toBe(code); @@ -597,10 +380,7 @@ for (const [version, alpn] of [ * An update's AUTHORIZATION TOKEN decodes like the request's, so one this endpoint cannot use * closes the session with the draft's code, not only the subscription it updates. */ -for (const [version, alpn] of [ - [Version.DRAFT_14, ALPN.DRAFT_14], - [Version.DRAFT_22, ALPN.DRAFT_22], -] as const) { +for (const [version, alpn] of [[Version.DRAFT_22, ALPN.DRAFT_22]] as const) { for (const [name, token, code] of [ ["REGISTER", [0x01, 0x07, 0x00], SessionCode.AuthTokenCacheOverflow], ["DELETE", [0x00, 0x07], SessionCode.UnknownAuthTokenAlias], @@ -620,26 +400,17 @@ for (const [version, alpn] of [ url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, publish: origin.consume(), }); try { - const stream = - version === Version.DRAFT_14 - ? await Stream.accept(pair.client, version) - : await Stream.open(pair.client, { version }); + const stream = await Stream.open(pair.client, { version }); if (!stream) throw new Error("no control stream"); - await stream.writer.write(subscribeWith(version, [0])); - // Draft-14's control stream also carries the PUBLISH_NAMESPACE for `room`. - for (;;) { - const type = await stream.reader.u53(); - if (type === SubscribeOk.id) break; - await stream.reader.read(await stream.reader.u16()); - } + await stream.writer.write(subscribeWith([0])); + expect(await stream.reader.u53()).toBe(SubscribeOk.id); await SubscribeOk.decode(stream.reader, version); - await stream.writer.write(updateWithTokens(version, token)); + await stream.writer.write(updateWithTokens(token)); const info = await pair.client.closed; expect(info.closeCode).toBe(code); @@ -652,14 +423,6 @@ for (const [version, alpn] of [ } } -/** Skip the messages ahead of the first `id`, such as draft-14's PUBLISH_NAMESPACE. */ -async function skipTo(reader: Stream["reader"], id: number) { - for (;;) { - if ((await reader.u53()) === id) return; - await reader.read(await reader.u16()); - } -} - /** A server publishing `room/video`, and the client's stream to send requests on. */ async function serving(version: IetfVersion, alpn: string) { const pair = createMockTransportPair(alpn); @@ -673,14 +436,11 @@ async function serving(version: IetfVersion, alpn: string) { url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, publish: origin.consume(), }); - // Through draft 16 every request rides the control stream. - const control16 = version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; - const stream = control16 ? await Stream.accept(pair.client, version) : await Stream.open(pair.client, { version }); + const stream = await Stream.open(pair.client, { version }); if (!stream) throw new Error("no control stream"); const close = () => { connection.abort(); @@ -693,28 +453,20 @@ async function serving(version: IetfVersion, alpn: string) { const TOKEN = [0x03, 0x00, 0xaa] as const; /** - * Section 8.9: a request whose tokens repeat is rejected MALFORMED_AUTH_TOKEN, which draft-15 - * moved from 0x10 to 0x4, and the session stays open for the next request. + * Section 8.9: a request whose tokens repeat is rejected MALFORMED_AUTH_TOKEN, and the session + * stays open for the next request. */ -for (const [version, alpn, malformed] of [ - [Version.DRAFT_14, ALPN.DRAFT_14, 0x10], - [Version.DRAFT_22, ALPN.DRAFT_22, 0x4], -] as const) { +for (const [version, alpn] of [[Version.DRAFT_22, ALPN.DRAFT_22]] as const) { test(`a repeated request token rejects only the request ${alpn}`, async () => { const { pair, stream, close } = await serving(version, alpn); try { - await stream.writer.write(subscribeWith(version, tokenParams(version, TOKEN, TOKEN))); - if (version === Version.DRAFT_14) { - await skipTo(stream.reader, SubscribeError.id); - expect((await SubscribeError.decode(stream.reader, version)).errorCode).toBe(malformed); - } else { - expect(await stream.reader.u53()).toBe(RequestError.id); - expect((await RequestError.decode(stream.reader, version)).errorCode).toBe(malformed); - } + await stream.writer.write(subscribeWith(tokenParams(TOKEN, TOKEN))); + expect(await stream.reader.u53()).toBe(RequestError.id); + expect((await RequestError.decode(stream.reader, version)).errorCode).toBe(0x4); - const next = version === Version.DRAFT_14 ? stream : await Stream.open(pair.client, { version }); - await next.writer.write(subscribeWith(version, tokenParams(version, TOKEN, [0x03, 0x00, 0xbb]), 2)); - await skipTo(next.reader, SubscribeOk.id); + const next = await Stream.open(pair.client, { version }); + await next.writer.write(subscribeWith(tokenParams(TOKEN, [0x03, 0x00, 0xbb]), 2)); + expect(await next.reader.u53()).toBe(SubscribeOk.id); await SubscribeOk.decode(next.reader, version); } finally { close(); @@ -722,25 +474,18 @@ for (const [version, alpn, malformed] of [ }); } -/** - * An update whose tokens repeat is answered MALFORMED_AUTH_TOKEN, not NOT_SUPPORTED, naming - * the update's own Request ID on draft 16. - */ -for (const [version, alpn, requestId] of [ - [Version.DRAFT_16, ALPN.DRAFT_16, 2n], - [Version.DRAFT_22, ALPN.DRAFT_22, undefined], -] as const) { +/** An update whose tokens repeat is answered MALFORMED_AUTH_TOKEN, not NOT_SUPPORTED. */ +for (const [version, alpn] of [[Version.DRAFT_22, ALPN.DRAFT_22]] as const) { test(`a repeated update token is malformed ${alpn}`, async () => { const { stream, close } = await serving(version, alpn); try { - await stream.writer.write(subscribeWith(version, [0])); - await skipTo(stream.reader, SubscribeOk.id); + await stream.writer.write(subscribeWith([0])); + expect(await stream.reader.u53()).toBe(SubscribeOk.id); await SubscribeOk.decode(stream.reader, version); - await stream.writer.write(updateWithTokens(version, TOKEN, TOKEN)); - await skipTo(stream.reader, RequestError.id); + await stream.writer.write(updateWithTokens(TOKEN, TOKEN)); + expect(await stream.reader.u53()).toBe(RequestError.id); const error = await RequestError.decode(stream.reader, version); expect(error.errorCode).toBe(0x4); - expect(error.requestId).toBe(requestId); } finally { close(); } diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 853915b6cc5..98bf82d7a4b 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -13,9 +13,7 @@ import * as Path from "../path.ts"; import { type Reader, Readers, type Stream } from "../stream.ts"; import { withTimeout } from "../util/timeout.ts"; import { registerWire } from "../wire.ts"; -import { ControlStreamAdapter, NativeSession, RequestWindowError, type Session } from "./adapter.ts"; import { AuthMessage, IetfAuthWire } from "./auth.ts"; -import { supported as authSupported } from "./auth_setup.ts"; import * as Cluster from "./cluster.ts"; import type { RequestKind } from "./error.ts"; import { Fetch, FetchHeader } from "./fetch.ts"; @@ -25,13 +23,9 @@ import { Publish } from "./publish.ts"; import { PublishNamespace } from "./publish_namespace.ts"; import { Publisher } from "./publisher.ts"; import { malformed, refuse } from "./request_stream.ts"; +import { NativeSession } from "./session.ts"; import { Subscribe, SubscribeUpdate } from "./subscribe.ts"; -import { - SUBSCRIBE_TRACKS_ID, - SubscribeNamespace, - SubscribeNamespaceLegacy, - SubscribeOptions, -} from "./subscribe_namespace.ts"; +import { SUBSCRIBE_TRACKS_ID, SubscribeNamespace } from "./subscribe_namespace.ts"; import { SubscribeTracks } from "./subscribe_tracks.ts"; import { Subscriber } from "./subscriber.ts"; import { MalformedAuthToken } from "./token.ts"; @@ -81,8 +75,8 @@ export class Connection implements Established { // the dialing side fails loud on a publication its grant does not cover. #client: boolean; - // Session abstraction: adapter for v14-v16, native for v17. - #session: Session; + // Opens request streams and allocates request IDs. + #session: NativeSession; // Module for contributing tracks. #publisher: Publisher; @@ -101,8 +95,8 @@ export class Connection implements Established { // Whether both SETUPs offered MoQ Epoch, so advertisements and requests may carry EPOCH. #epoch: boolean; - // The peer's GOAWAY: read here on v17+, by the control stream adapter before that. - #goaway: Once; + // The peer's GOAWAY, read off its SETUP stream. + #goaway = new Once(); // Just to avoid logging when `close()` is called. #closed = false; @@ -114,8 +108,7 @@ export class Connection implements Established { * Creates a new Connection instance. * @param url - The URL of the connection * @param quic - The WebTransport session - * @param control - The control/setup stream - * @param maxRequestId - The initial max request ID + * @param control - The SETUP stream, which then carries GOAWAY * @param version - The negotiated protocol version * @param solicit - What the peer's SETUP declared (undefined when it declared nothing) * @param cluster - The Hop IDs the SETUP exchange settled, on the versions that negotiate them @@ -127,7 +120,6 @@ export class Connection implements Established { url, quic, control, - maxRequestId, version, client, publish, @@ -140,14 +132,12 @@ export class Connection implements Established { handle, epoch = false, early = [], - requestWindow, extensions, peerImplementation, }: { url: URL; quic: WebTransport; control: Stream; - maxRequestId: bigint; version: IetfVersion; /** Whether this peer initiated the session, selecting the even request-ID space. */ client: boolean; @@ -169,16 +159,14 @@ export class Connection implements Established { * cannot negotiate the extension, as is a `peer` the peer never declared. */ cluster?: Cluster.Hops; - /** Whether MoQ Auth is negotiated (draft-17+): offered by both SETUPs. */ + /** Whether MoQ Auth is negotiated: offered by both SETUPs. */ auth?: boolean; /** The auth handle to build the session on, or undefined for one of its own. */ handle?: Auth.Handle; - /** Whether MoQ Epoch is negotiated (draft-17+): offered by both SETUPs. */ + /** Whether MoQ Epoch is negotiated: offered by both SETUPs. */ epoch?: boolean; - /** Uni streams that arrived before the peer's SETUP, type unread (v17+). */ + /** Uni streams that arrived before the peer's SETUP, type unread. */ early?: Reader[]; - /** Requests the peer may hold open on drafts 14 to 16, the window our SETUP advertised (default `REQUEST_WINDOW`). */ - requestWindow?: bigint; /** What our own SETUP offered; every extension when omitted. */ extensions?: { solicit: boolean }; /** The implementation the peer's SETUP advertised, read before the session starts. */ @@ -191,40 +179,15 @@ export class Connection implements Established { this.#client = client; this.peerImplementation = Promise.resolve(peerImplementation); - // Two-path dispatch: v14-v16 uses adapter, v17+ uses native bidi streams - if (version >= Version.DRAFT_17) { - this.#session = new NativeSession(quic, version, client); - this.#goaway = new Once(); - // v17+: control/setup stream only carries GoAway - void this.#runGoAway(control, version); - } else { - const adapter = new ControlStreamAdapter(quic, control, version, maxRequestId, client, requestWindow); - this.#session = adapter; - this.#goaway = adapter.goaway; - // Start the adapter read loop (routes control messages to virtual streams) - void adapter.run().catch((err: unknown) => { - if (this.#closed) return; - if (err instanceof RequestWindowError) { - this.#close({ closeCode: err.code, reason: err.message }); - return; - } - if (sessionClosed(err)) { - this.#close(); - return; - } - this.#violated( - err instanceof ProtocolViolation - ? err - : new ProtocolViolation("control stream failed", { cause: err }), - ); - }); - } + this.#session = new NativeSession(quic, version, client); + // The SETUP stream only carries GOAWAY from here on. + void this.#runGoAway(control, version); // What the peer's connection credential earns by default: publishing anything to us, // since we consume on demand, and subscribing to whatever we publish. const session = this.#session; this.#auth = new AuthSession({ - wire: auth && authSupported(version) ? new IetfAuthWire(session) : undefined, + wire: auth ? new IetfAuthWire(session) : undefined, requests: true, peerGrant: { publish: new Path.Patterns([Path.Pattern.all()]), @@ -268,7 +231,6 @@ export class Connection implements Established { epoch, hidden, activeCount, - solicit, goaway: this.#goaway, grant: this.#auth.scope, auth: this.#auth, @@ -335,8 +297,8 @@ export class Connection implements Established { async #run(early: Reader[]): Promise { try { // All run together. runPublishNamespaces is a no-op when the peer asked to be - // solicited; otherwise it pushes PUBLISH_NAMESPACE. On draft-16 and later a - // SUBSCRIBE_NAMESPACE stream is filled either way. A NAMESPACE is discovery, + // solicited; otherwise it pushes PUBLISH_NAMESPACE. A SUBSCRIBE_NAMESPACE stream + // is filled either way. A NAMESPACE is discovery, // not a second route. const tasks = [ this.#runBidis(), @@ -366,19 +328,13 @@ export class Connection implements Established { return this.#subscriber.announced(scope, options); } - /** - * Accepts bidi streams (virtual for v14-v16, real for v17) and dispatches. - */ + /** Accepts request streams and dispatches them. */ async #runBidis() { for (;;) { const stream = await this.#session.acceptBi(); if (!stream) break; void this.#runBidi(stream).catch((err: unknown) => { - if (err instanceof RequestWindowError) { - this.#close({ closeCode: err.code, reason: err.message }); - return; - } console.error("error processing bidi stream", err); stream.abort(new Error("bidi stream error")); if (err instanceof ProtocolViolation) this.#violated(err); @@ -395,56 +351,12 @@ export class Connection implements Established { const typeId = await stream.reader.u62(); switch (typeId) { - // Draft-18 SUBSCRIBE_NAMESPACE (0x50) and the legacy 0x11 message decode - // to the same request_id + namespace. We never send PUBLISH, so a legacy - // request for PUBLISH alone is refused, and one for both gets only NAMESPACE. case BigInt(SubscribeNamespace.id): { const decoded = SubscribeNamespace.decode(stream.reader, this.#session.version); const msg = await this.#request(stream, "subscribe_namespace", decoded); if (msg) await handed(this.#publisher.runSubscribeNamespace(msg, stream)); break; } - case BigInt(SubscribeNamespaceLegacy.id): { - // Draft-16 carries this on its own stream, so the control adapter never sees the ID. - // Draft-14 and 15 already admitted it off the control stream. A refused request - // still spends its ID, so it is admitted and released like any other. - const adapter = - this.#session instanceof ControlStreamAdapter && this.#session.version === Version.DRAFT_16 - ? this.#session - : undefined; - let legacy: SubscribeNamespaceLegacy; - try { - legacy = await SubscribeNamespaceLegacy.decode(stream.reader, this.#session.version); - } catch (err) { - if (!(err instanceof MalformedAuthToken) || err.requestId === undefined) - throw malformed(err, "subscribe_namespace"); - adapter?.admitRequest(err.requestId); - this.#spend(err.requestId); - try { - await this.#refuseToken(stream, "subscribe_namespace", err.requestId); - } finally { - adapter?.releaseRequest(err.requestId); - } - break; - } - if (adapter) adapter.admitRequest(legacy.requestId); - this.#spend(legacy.requestId); - try { - if (legacy.subscribeOptions === SubscribeOptions.PUBLISH) { - await handed(this.#publisher.refuseSubscribeNamespace(legacy.requestId, stream)); - } else { - const msg = new SubscribeNamespace({ - requestId: legacy.requestId, - namespace: legacy.namespace, - hidden: legacy.hidden, - }); - await handed(this.#publisher.runSubscribeNamespace(msg, stream)); - } - } finally { - adapter?.releaseRequest(legacy.requestId); - } - break; - } case BigInt(SubscribeUpdate.id): { // REQUEST_UPDATE (0x02) is a follow-up, not a valid initial message throw new ProtocolViolation("unexpected REQUEST_UPDATE as initial message"); @@ -457,10 +369,6 @@ export class Connection implements Established { break; } case BigInt(SUBSCRIBE_TRACKS_ID): { - // 0x51 is only a message from draft-18 on. - if (this.#session.version < Version.DRAFT_18) { - throw new ProtocolViolation("SUBSCRIBE_TRACKS before draft-18"); - } const decoded = SubscribeTracks.decode(stream.reader, this.#session.version); const msg = await this.#request(stream, "subscribe_namespace", decoded); if (msg) await handed(this.#publisher.runSubscribeTracks(msg, stream)); @@ -495,12 +403,7 @@ export class Connection implements Established { // advertising unasked is allowed. So this is a bug in the peer, and a // silent one on both sides if we tolerate it. Without our declaration an // unasked announce is what we invited. - // - // Draft-14/15 are exempt: they have no inline NAMESPACE, so a - // PUBLISH_NAMESPACE request is also how a peer answers our - // SUBSCRIBE_NAMESPACE there, and the message alone does not say which. - const legacy = this.#session.version === Version.DRAFT_14 || this.#session.version === Version.DRAFT_15; - if (this.#declaredSolicit && this.#solicit !== undefined && !legacy) { + if (this.#declaredSolicit && this.#solicit !== undefined) { throw new ProtocolViolation( `unsolicited publish_namespace from a peer that implements MoQ Solicit: broadcast=${msg.trackNamespace}`, ); @@ -544,22 +447,21 @@ export class Connection implements Established { } catch (err) { if (!(err instanceof MalformedAuthToken) || err.requestId === undefined) throw malformed(err, kind); this.#spend(err.requestId); - await this.#refuseToken(stream, kind, err.requestId); + await this.#refuseToken(stream, kind); return undefined; } this.#spend(msg.requestId); return msg; } - /** Spend a request's ID on draft 17+; the control stream adapter already spent it on earlier drafts. */ + /** Spend a request's ID, closing the session if it was already used. */ #spend(requestId: bigint) { - if (this.#session instanceof NativeSession) this.#session.spendRequest(requestId); + this.#session.checkRequestId(requestId); } /** Refuse a request MALFORMED_AUTH_TOKEN, ending its stream. Mirrors Rust's `token::reject`. */ - async #refuseToken(stream: Stream, kind: RequestKind, requestId: bigint) { + async #refuseToken(stream: Stream, kind: RequestKind) { await refuse(stream, this.#session.version, kind, { - requestId, condition: "malformed_auth_token", reasonPhrase: "repeated AUTHORIZATION TOKEN", }); @@ -620,7 +522,7 @@ export class Connection implements Established { // The receiver MUST discard padding. We read it to the end rather than cancel, // so a peer probing for bandwidth gets the throughput it is measuring. - if (type === PADDING && version >= Version.DRAFT_18) { + if (type === PADDING) { await stream.discard(); return; } @@ -633,7 +535,7 @@ export class Connection implements Established { } /** - * v17+ only: reads GoAway from the setup/control stream. + * Reads GOAWAY from the SETUP stream. * * The session keeps serving after a GOAWAY so its groups in flight can finish while the * caller migrates; only the stream ending, or a second GOAWAY, closes it here. diff --git a/js/net/src/ietf/datagram.test.ts b/js/net/src/ietf/datagram.test.ts index d8589d14dc9..2ad2b042669 100644 --- a/js/net/src/ietf/datagram.test.ts +++ b/js/net/src/ietf/datagram.test.ts @@ -4,16 +4,7 @@ import { encodeVarint } from "../stream.ts"; import { ObjectDatagram } from "./datagram.ts"; import { type IetfVersion, Version } from "./version.ts"; -const ALL: IetfVersion[] = [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, -]; - -const LEGACY: IetfVersion[] = [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16]; +const ALL: IetfVersion[] = [Version.DRAFT_18, Version.DRAFT_22]; function single(publisherPriority?: number): ObjectDatagram { return new ObjectDatagram({ @@ -39,15 +30,6 @@ test("roundtrips on every draft", async () => { } }); -test("a default priority needs draft-15", async () => { - expect(() => single().encode(Version.DRAFT_14)).toThrow(); - await rejects(Uint8Array.of(0x0c, 0x01, 0x02), Version.DRAFT_14); - - const bytes = single().encode(Version.DRAFT_16); - expect(bytes[0]).toBe(0x0f); - expect(await ObjectDatagram.decode(bytes, Version.DRAFT_16)).toEqual(single()); -}); - test("an explicit object id and a status", async () => { const datagram = new ObjectDatagram({ trackAlias: 1n, @@ -77,33 +59,3 @@ test("rejects invalid types", async () => { } } }); - -test("a status with properties needs a Normal Object past draft-16", async () => { - const datagram = new ObjectDatagram({ - trackAlias: 1n, - groupId: 2, - objectId: 0, - publisherPriority: 0, - endOfGroup: false, - properties: Uint8Array.of(0x10, 0x05), - body: { status: 3 }, - }); - for (const version of ALL) { - if (LEGACY.includes(version)) { - expect(await ObjectDatagram.decode(datagram.encode(version), version)).toEqual(datagram); - } else { - expect(() => datagram.encode(version)).toThrow(); - // The same bytes a legacy draft writes, which these drafts refuse. - await rejects(datagram.encode(Version.DRAFT_16), version); - } - } -}); - -test("rejects an empty properties block, a truncated header, and bytes after a status", async () => { - // PROPERTIES and ZERO_OBJECT_ID, alias 1, group 2, priority 0, empty block. - await rejects(Uint8Array.of(0x05, 0x01, 0x02, 0x00, 0x00, 0x78), Version.DRAFT_16); - // The group ID is missing. - await rejects(Uint8Array.of(0x04, 0x01), Version.DRAFT_16); - // A Normal status, then a stray byte. - await rejects(Uint8Array.of(0x24, 0x01, 0x02, 0x00, 0x00, 0x00), Version.DRAFT_16); -}); diff --git a/js/net/src/ietf/datagram.ts b/js/net/src/ietf/datagram.ts index 9f0641921ab..f3c8ab3ed86 100644 --- a/js/net/src/ietf/datagram.ts +++ b/js/net/src/ietf/datagram.ts @@ -14,7 +14,7 @@ import { error, ProtocolViolation, reason } from "../error.ts"; import { type Cursor, encodeVarint, Reader } from "../stream.ts"; import type { Timescale } from "../time.ts"; import { encodeObjectExtensions } from "./object.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; // The bits of an OBJECT_DATAGRAM Type. const PROPERTIES = 0x01; @@ -26,17 +26,11 @@ const STATUS = 0x20; const ALL = PROPERTIES | END_OF_GROUP | ZERO_OBJECT_ID | DEFAULT_PRIORITY | STATUS; /** Whether `kind` is a Type this draft defines. */ -function valid(kind: number, version: IetfVersion): boolean { - if (version === Version.DRAFT_14) return kind <= 0x07 || kind === 0x20 || kind === 0x21; +function valid(kind: number): boolean { // A status cannot also end the group. return (kind & ~ALL) === 0 && !((kind & STATUS) !== 0 && (kind & END_OF_GROUP) !== 0); } -/** Drafts 14-16 let any status carry Properties; later ones only a Normal Object. */ -function legacy(version: IetfVersion): boolean { - return version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; -} - /** What follows an OBJECT_DATAGRAM's header: the payload, or the status of an Object without one. */ export type DatagramBody = { payload: Uint8Array } | { status: number }; @@ -46,7 +40,7 @@ export class ObjectDatagram { groupId: number; /** The Object ID, or `undefined` when the ZERO_OBJECT_ID bit omits it (Object 0). */ objectId?: number; - /** The Publisher Priority, or `undefined` to inherit the subscription's (draft-15+). */ + /** The Publisher Priority, or `undefined` to inherit the subscription's. */ publisherPriority?: number; /** No Object past this one exists in the group. */ endOfGroup: boolean; @@ -80,7 +74,7 @@ export class ObjectDatagram { if (this.objectId === undefined) kind |= ZERO_OBJECT_ID; if (this.publisherPriority === undefined) kind |= DEFAULT_PRIORITY; if ("status" in this.body) kind |= STATUS; - if (!valid(kind, version)) throw new Error(`OBJECT_DATAGRAM type 0x${kind.toString(16)} is invalid`); + if (!valid(kind)) throw new Error(`OBJECT_DATAGRAM type 0x${kind.toString(16)} is invalid`); const parts = [encodeVarint(kind, version), encodeVarint(this.trackAlias, version)]; parts.push(encodeVarint(this.groupId, version)); @@ -92,7 +86,7 @@ export class ObjectDatagram { parts.push(encodeVarint(this.properties.byteLength, version), this.properties); } if ("status" in this.body) { - if (!legacy(version) && this.body.status !== 0 && this.properties !== undefined) { + if (this.body.status !== 0 && this.properties !== undefined) { throw new Error("only a Normal OBJECT_DATAGRAM may carry properties"); } parts.push(encodeVarint(this.body.status, version)); @@ -116,7 +110,7 @@ export class ObjectDatagram { */ static async decode(data: Uint8Array, version: IetfVersion): Promise { try { - return await new Reader(undefined, data, version).decode((c) => decodeFields(c, version)); + return await new Reader(undefined, data, version).decode(decodeFields); } catch (err: unknown) { if (err instanceof ProtocolViolation) throw err; throw new ProtocolViolation(`malformed OBJECT_DATAGRAM: ${reason(error(err))}`, { cause: err }); @@ -147,11 +141,11 @@ export async function encodeDatagram( }).encode(version); } -function decodeFields(c: Cursor, version: IetfVersion): ObjectDatagram { +function decodeFields(c: Cursor): ObjectDatagram { // Full width, so an out-of-range Type is refused rather than overflowing. const raw = c.u62(); const kind = raw <= 0xffn ? Number(raw) : -1; - if (kind < 0 || !valid(kind, version)) { + if (kind < 0 || !valid(kind)) { throw new ProtocolViolation(`invalid OBJECT_DATAGRAM type 0x${raw.toString(16)}`); } @@ -168,7 +162,7 @@ function decodeFields(c: Cursor, version: IetfVersion): ObjectDatagram { let body: DatagramBody; if (kind & STATUS) { const status = c.u53(); - if (!legacy(version) && status !== 0 && properties !== undefined) { + if (status !== 0 && properties !== undefined) { throw new ProtocolViolation("only a Normal OBJECT_DATAGRAM may carry properties"); } if (c.remaining > 0) throw new ProtocolViolation(`OBJECT_DATAGRAM has ${c.remaining} trailing bytes`); diff --git a/js/net/src/ietf/epoch.test.ts b/js/net/src/ietf/epoch.test.ts index 9f8eea34130..82e2523ed44 100644 --- a/js/net/src/ietf/epoch.test.ts +++ b/js/net/src/ietf/epoch.test.ts @@ -10,7 +10,7 @@ import { Fetch } from "./fetch.ts"; import * as Message from "./message.ts"; import { Parameters } from "./parameters.ts"; import { type AdvertParams, PublishNamespace } from "./publish_namespace.ts"; -import { SetupOption, SetupOptions } from "./setup_options.ts"; +import { SetupOptions } from "./setup_options.ts"; import { Subscribe } from "./subscribe.ts"; import { SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; import { TrackStatusRequest } from "./track.ts"; @@ -20,8 +20,7 @@ const VERSION = Version.DRAFT_18; const EPOCH_PARAM = 0x40b69n; const EPOCH = Epoch.mint(); -const LEGACY = [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16] as const; -const NEGOTIATING = [Version.DRAFT_17, Version.DRAFT_18, Version.DRAFT_22] as const; +const ALL = [Version.DRAFT_18, Version.DRAFT_22] as const; async function encode( message: { encode(w: Writer, v: IetfVersion): Promise }, @@ -60,23 +59,13 @@ function epochParam(value: Uint8Array): Parameters { return params; } -test("Epoch: SETUP declares it on draft-17 and later only", async () => { - for (const version of LEGACY) { +test("Epoch: SETUP declares it", async () => { + for (const version of ALL) { const params = new SetupOptions(); - EpochExt.intoSetup(params, version); - expect(params.getVarint(SetupOption.Epoch)).toBeUndefined(); - - // Even one that arrived anyway reads as "not negotiated" there. - params.setVarint(SetupOption.Epoch, 1n); - expect(EpochExt.fromSetup(params, version)).toBe(false); - } - - for (const version of NEGOTIATING) { - const params = new SetupOptions(); - expect(EpochExt.fromSetup(params, version)).toBe(false); - EpochExt.intoSetup(params, version); - const decoded = await SetupOptions.decode(reader(await encode(params, version), version), version); - expect(EpochExt.fromSetup(decoded, version)).toBe(true); + expect(EpochExt.fromSetup(params)).toBe(false); + EpochExt.intoSetup(params); + const decoded = await SetupOptions.decode(reader(await encode(params, version), version)); + expect(EpochExt.fromSetup(decoded)).toBe(true); } }); @@ -113,10 +102,10 @@ test("Epoch: a REQUEST_UPDATE never carries one", async () => { } }); -test("Epoch: SUBSCRIBE and TRACK_STATUS carry it on draft-17 and later only", async () => { +test("Epoch: SUBSCRIBE and TRACK_STATUS carry it", async () => { const trackNamespace = Path.from("alice"); - for (const version of [...LEGACY, ...NEGOTIATING]) { - const expected = EpochExt.supported(version) ? EPOCH : undefined; + for (const version of ALL) { + const expected = EPOCH; const subscribe = new Subscribe({ requestId: 0n, diff --git a/js/net/src/ietf/epoch.ts b/js/net/src/ietf/epoch.ts index 4e26bd45846..022dc3f0793 100644 --- a/js/net/src/ietf/epoch.ts +++ b/js/net/src/ietf/epoch.ts @@ -1,5 +1,4 @@ import { SetupOption, type SetupOptions } from "./setup_options.ts"; -import { type IetfVersion, Version } from "./version.ts"; /** * The MoQ Epoch extension (draft-lcurley-moq-epoch-00). @@ -11,24 +10,19 @@ import { type IetfVersion, Version } from "./version.ts"; * that resolves one (SUBSCRIBE, FETCH, and TRACK_STATUS). A namespace and an EPOCH name one * broadcast; a request carrying one is refused rather than served by another instance. * - * Negotiated on draft-17+ only, where SETUP is a Key-Value-Pair block, and only when both + * Negotiated on draft-18+ only, where SETUP is a Key-Value-Pair block, and only when both * endpoints declare the option. * * @module * @internal */ -/** Whether a version negotiates the extension: draft-17+, like MoQ Cluster. @internal */ -export function supported(version: IetfVersion): boolean { - return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; -} - /** Whether the peer declared the option. @internal */ -export function fromSetup(params: SetupOptions, version: IetfVersion): boolean { - return supported(version) && params.getVarint(SetupOption.Epoch) !== undefined; +export function fromSetup(params: SetupOptions): boolean { + return params.getVarint(SetupOption.Epoch) !== undefined; } -/** Declare that we understand the EPOCH parameter, on the versions that negotiate it. @internal */ -export function intoSetup(params: SetupOptions, version: IetfVersion) { - if (supported(version)) params.setVarint(SetupOption.Epoch, 1n); +/** Declare that we understand the EPOCH parameter. @internal */ +export function intoSetup(params: SetupOptions) { + params.setVarint(SetupOption.Epoch, 1n); } diff --git a/js/net/src/ietf/error.test.ts b/js/net/src/ietf/error.test.ts index 7a51e48efe2..180a68cf73b 100644 --- a/js/net/src/ietf/error.test.ts +++ b/js/net/src/ietf/error.test.ts @@ -1,16 +1,8 @@ import { expect, test } from "bun:test"; -import { StreamCode } from "../error.ts"; -import { fromRequestCode, type RequestCondition, type RequestKind, sharedStreamCode, toRequestCode } from "./error.ts"; +import { fromRequestCode, type RequestCondition, type RequestKind, toRequestCode } from "./error.ts"; import { type IetfVersion, Version } from "./version.ts"; -const ALL: IetfVersion[] = [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, -]; +const ALL: IetfVersion[] = [Version.DRAFT_18, Version.DRAFT_22]; const KINDS: RequestKind[] = [ "subscribe", @@ -38,43 +30,20 @@ const CONDITIONS: RequestCondition[] = [ ]; /** - * Every code we put in a rejection has to be one the negotiated draft registers for that - * request. The table is transcribed from the drafts (draft-14 section 13.1 through draft-22 - * section 16.11.2), not derived from the mapping, so a mistake in the mapping cannot talk - * the assertion into agreeing with it. + * Every code we put in a rejection has to be one the drafts register. The table is + * transcribed from draft-20 section 15.11.2, not derived from the mapping, so a mistake in + * the mapping cannot talk the assertion into agreeing with it. */ -function registered(kind: RequestKind, version: IetfVersion): number[] { - if (version === Version.DRAFT_14) { - switch (kind) { - case "subscribe": - return [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x12]; - case "fetch": - return [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x10, 0x12]; - case "publish": - return [0x0, 0x1, 0x2, 0x3, 0x4]; - case "publish_namespace": - return [0x0, 0x1, 0x2, 0x3, 0x4, 0x10, 0x12]; - case "subscribe_namespace": - case "track_status": - return [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x12]; - } - } - if (version === Version.DRAFT_15) return [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x11, 0x12, 0x20, 0x30, 0x32, 0x33]; - if (version === Version.DRAFT_16) return [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x11, 0x12, 0x19, 0x20, 0x30, 0x32]; - if (version === Version.DRAFT_17) { - return [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x9, 0x10, 0x11, 0x12, 0x19, 0x20, 0x30, 0x31, 0x32]; - } - return [ - 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x9, 0x10, 0x11, 0x12, 0x19, 0x20, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, - ]; -} +const REGISTERED = [ + 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x9, 0x10, 0x11, 0x12, 0x19, 0x20, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, +]; test("fetch refusal codes follow each draft registry", () => { for (const version of ALL) { - const range = version === Version.DRAFT_14 ? 0x5 : 0x11; + const range = 0x11; expect(toRequestCode("invalid_range", "fetch", version)).toBe(range); expect(fromRequestCode(range, "fetch", version)).toBe("invalid_range"); - const joining = version === Version.DRAFT_14 ? 0x7 : version <= Version.DRAFT_18 ? 0x32 : undefined; + const joining = version <= Version.DRAFT_18 ? 0x32 : undefined; expect(toRequestCode("invalid_joining_request_id", "fetch", version)).toBe(joining ?? 0); if (joining !== undefined) expect(fromRequestCode(joining, "fetch", version)).toBe("invalid_joining_request_id"); @@ -88,7 +57,7 @@ test("only registered codes reach the wire", () => { // Sent but never read back as itself, so it stays out of the round trip. for (const condition of [...CONDITIONS, "malformed_auth_token" as const]) { const code = toRequestCode(condition, kind, version); - expect(registered(kind, version)).toContain(code); + expect(REGISTERED).toContain(code); } } } @@ -106,114 +75,15 @@ test("every emitted code round trips", () => { } }); -/** - * Draft-15 swapped MALFORMED_AUTH_TOKEN and the missing-track code, and draft-14 gives - * PUBLISH_ERROR no value for it. - */ -test("a malformed token uses the draft's own number", () => { - for (const kind of KINDS) { - expect(toRequestCode("malformed_auth_token", kind, Version.DRAFT_14)).toBe(kind === "publish" ? 0x0 : 0x10); - for (const version of ALL.slice(1)) { - expect(toRequestCode("malformed_auth_token", kind, version)).toBe(0x4); - } - } -}); - -/** - * Draft-14 numbers a missing track 0x4 and draft-15 moved it to 0x10, which is draft-14's - * MALFORMED_AUTH_TOKEN. Getting this backwards tells a peer its token is broken when the - * broadcast simply is not here, so it re-authenticates instead of waiting. - */ -test("a missing broadcast uses the draft's own number", () => { - for (const kind of ["subscribe", "fetch"] as const) { - expect(toRequestCode("does_not_exist", kind, Version.DRAFT_14)).toBe(0x4); - expect(fromRequestCode(0x4, kind, Version.DRAFT_14)).toBe("does_not_exist"); - - for (const version of ALL.slice(1)) { - expect(toRequestCode("does_not_exist", kind, version)).toBe(0x10); - expect(fromRequestCode(0x10, kind, version)).toBe("does_not_exist"); - // 0x4 is MALFORMED_AUTH_TOKEN from draft-15 on, which is not ours to claim. - expect(fromRequestCode(0x4, kind, version)).toBeUndefined(); - } - } -}); - -/** - * Draft-14 gives 0x4 to UNINTERESTED on the requests that offer content and to - * TRACK_DOES_NOT_EXIST on the ones that ask for it, so the same integer on the same draft - * says two different things. - */ -test("draft-14 reads 0x4 per request", () => { - for (const kind of ["publish", "publish_namespace"] as const) { - expect(toRequestCode("uninterested", kind, Version.DRAFT_14)).toBe(0x4); - expect(fromRequestCode(0x4, kind, Version.DRAFT_14)).toBe("uninterested"); - // Those requests offer content, so "it is not here" is not a rejection they carry. - expect(toRequestCode("does_not_exist", kind, Version.DRAFT_14)).toBe(0x0); - } - - expect(fromRequestCode(0x4, "subscribe_namespace", Version.DRAFT_14)).toBeUndefined(); -}); - -/** GOING_AWAY arrived in draft-17; an earlier peer is told INTERNAL_ERROR instead. */ -test("going away only exists from draft-17", () => { +test("going away is 0x6 on every request", () => { for (const kind of KINDS) { - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16] as const) { - expect(toRequestCode("going_away", kind, version)).toBe(0x0); - expect(fromRequestCode(0x6, kind, version)).toBeUndefined(); - } - - for (const version of [Version.DRAFT_17, Version.DRAFT_18, Version.DRAFT_22] as const) { + for (const version of ALL) { expect(toRequestCode("going_away", kind, version)).toBe(0x6); expect(fromRequestCode(0x6, kind, version)).toBe("going_away"); } } }); -/** - * Draft-18 took 0x4 for GOING_AWAY from UNKNOWN_OBJECT_STATUS, so the same integer means - * different things on two drafts we both negotiate. Sending it to draft-17 would claim the - * next object's status is unknowable; reading theirs as GOING_AWAY would start draining a - * session that is not going anywhere. - */ -test("stream reset codes are shared only where the draft agrees", () => { - for (const version of ALL) { - for (const code of [ - StreamCode.Internal, - StreamCode.Cancel, - StreamCode.DeliveryTimeout, - StreamCode.SessionClosed, - ]) { - expect(sharedStreamCode(code, version)).toBe(true); - } - - // Lite-only: the codes assigned in moq-lite's own 48-63 range are not in this - // registry at all, so no lite value ever reaches a moq-transport peer. - for (const code of [ - StreamCode.ControlTimeout, - StreamCode.NotFound, - StreamCode.Old, - StreamCode.Evicted, - StreamCode.FrameTooLarge, - StreamCode.GroupTooLarge, - StreamCode.Unauthorized, - ]) { - expect(sharedStreamCode(code, version)).toBe(false); - } - } - - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_17] as const) { - expect(sharedStreamCode(StreamCode.GoingAway, version)).toBe(false); - } - for (const version of [Version.DRAFT_18, Version.DRAFT_22] as const) { - expect(sharedStreamCode(StreamCode.GoingAway, version)).toBe(true); - } - - expect(sharedStreamCode(StreamCode.TooFarBehind, Version.DRAFT_16)).toBe(false); - expect(sharedStreamCode(StreamCode.TooFarBehind, Version.DRAFT_17)).toBe(true); - expect(sharedStreamCode(StreamCode.MalformedTrack, Version.DRAFT_15)).toBe(false); - expect(sharedStreamCode(StreamCode.MalformedTrack, Version.DRAFT_16)).toBe(true); -}); - test("INVALID_FILTER is registered from draft 22", () => { for (const version of ALL) { expect(toRequestCode("invalid_filter", "subscribe", version)).toBe(version >= Version.DRAFT_22 ? 0x36 : 0); diff --git a/js/net/src/ietf/error.ts b/js/net/src/ietf/error.ts index be3985b418f..82700e1da80 100644 --- a/js/net/src/ietf/error.ts +++ b/js/net/src/ietf/error.ts @@ -1,9 +1,9 @@ /** * The moq-transport error code registries, per negotiated draft. * - * A code only means something once you know which draft carried it: the registries grew, - * moved values, and in draft-15 collapsed five per-message tables into one. Mirrors the - * Rust `moq_net::ietf::error` module, and the two have to agree value for value. + * Draft-22 added INVALID_FILTER and removed INVALID_JOINING_REQUEST_ID; the rest + * holds from draft-18 on. Mirrors the Rust `moq_net::ietf::error` module, and the two have + * to agree value for value. * * @module */ @@ -11,58 +11,23 @@ import { type IetfVersion, Version } from "./version.ts"; /** - * Whether `code` means the same thing in the negotiated draft's stream reset registry as it - * does in moq-lite's. + * Whether `code` means the same thing in the moq-transport stream reset registry as it does + * in moq-lite's. * - * The two registries agree on most of what they both assign, but this one grew across the - * drafts we negotiate and moved a value on the way: - * - * | Code | draft-14/15 | draft-16/17 | draft-18+ | - * |------|-------------|-------------|-----------| - * | 0x0 | INTERNAL_ERROR | INTERNAL_ERROR | INTERNAL_ERROR | - * | 0x1 | CANCELLED | CANCELLED | CANCELLED | - * | 0x2 | DELIVERY_TIMEOUT | DELIVERY_TIMEOUT | DELIVERY_TIMEOUT | - * | 0x3 | SESSION_CLOSED | SESSION_CLOSED | SESSION_CLOSED | - * | 0x4 | - | UNKNOWN_OBJECT_STATUS | GOING_AWAY | - * | 0x5 | - | TOO_FAR_BEHIND (17) | TOO_FAR_BEHIND | - * | 0x12 | - | MALFORMED_TRACK | MALFORMED_TRACK | - * - * So GOING_AWAY sent to a draft-17 peer reads as UNKNOWN_OBJECT_STATUS, and that peer's - * UNKNOWN_OBJECT_STATUS read as GOING_AWAY would retire a session that is not going - * anywhere. Anything this says `false` about is sent as, and read as, INTERNAL_ERROR: the - * codes moq-lite assigns in its own 48-63 range, and the ones the draft assigns that - * moq-lite has no name for (UNKNOWN_OBJECT_STATUS, EXPIRED_AUTH_TOKEN, EXCESSIVE_LOAD). + * The two agree on 0x0-0x5 (INTERNAL_ERROR, CANCELLED, DELIVERY_TIMEOUT, SESSION_CLOSED, + * GOING_AWAY, TOO_FAR_BEHIND) and 0x12 (MALFORMED_TRACK). Anything else is sent as, and read + * as, INTERNAL_ERROR: the codes moq-lite assigns in its own 48-63 range, and the ones the + * draft assigns that moq-lite has no name for (UNKNOWN_OBJECT_STATUS, EXPIRED_AUTH_TOKEN, + * EXCESSIVE_LOAD). * * @internal */ -export function sharedStreamCode(code: number, version: IetfVersion): boolean { - switch (code) { - case 0x0: - case 0x1: - case 0x2: - case 0x3: - return true; - // Draft-16 and 17 give 0x4 to UNKNOWN_OBJECT_STATUS, which draft-18 moved to 0x6 when - // it took 0x4 for GOING_AWAY. Draft-14 and 15 assign it nothing. - case 0x4: - return version >= Version.DRAFT_18; - // TOO_FAR_BEHIND arrived in draft-17. - case 0x5: - return version >= Version.DRAFT_17; - // MALFORMED_TRACK arrived in draft-16. - case 0x12: - return version >= Version.DRAFT_16; - default: - return false; - } +export function sharedStreamCode(code: number): boolean { + return (code >= 0x0 && code <= 0x5) || code === 0x12; } /** - * Which request a rejection answers, so draft-14 picks the right registry. - * - * Draft-14 gives each error message its own registry and they disagree about 0x4; draft-15 - * folded them all into REQUEST_ERROR with a single registry, so from there on the kind - * changes nothing. + * Which request a rejection answers: some codes are registered for only some requests. * * @internal */ @@ -113,79 +78,26 @@ const UNAUTHORIZED = 0x1; const TIMEOUT = 0x2; const NOT_SUPPORTED = 0x3; -/** A draining session. Draft-17 and later. */ const GOING_AWAY = 0x6; +const MALFORMED_AUTH_TOKEN = 0x4; +const EXPIRED_AUTH_TOKEN = 0x5; +const DOES_NOT_EXIST = 0x10; +const MALFORMED_TRACK = 0x12; +const UNINTERESTED = 0x20; /** FETCH-only errors. Draft-22 has no INVALID_JOINING_REQUEST_ID. */ -const INVALID_RANGE_14 = 0x5; const INVALID_RANGE = 0x11; -const INVALID_JOINING_REQUEST_ID_14 = 0x7; const INVALID_JOINING_REQUEST_ID = 0x32; -function invalidRange(kind: RequestKind, version: IetfVersion): number | undefined { - if (kind !== "fetch") return undefined; - return version === Version.DRAFT_14 ? INVALID_RANGE_14 : INVALID_RANGE; -} +/** Draft-22 added it. */ +const INVALID_FILTER = 0x36; -function invalidJoiningRequestId(kind: RequestKind, version: IetfVersion): number | undefined { - if (kind !== "fetch") return undefined; - if (version === Version.DRAFT_14) return INVALID_JOINING_REQUEST_ID_14; - return version <= Version.DRAFT_18 ? INVALID_JOINING_REQUEST_ID : undefined; +function invalidRange(kind: RequestKind): number | undefined { + return kind === "fetch" ? INVALID_RANGE : undefined; } -/** Draft-14 calls it TRACK_DOES_NOT_EXIST; draft-15 renamed it and moved it off 0x4. */ -const DOES_NOT_EXIST_14 = 0x4; -const DOES_NOT_EXIST = 0x10; - -/** Draft-14 assigns UNINTERESTED only to the two requests that offer content. */ -const UNINTERESTED_14 = 0x4; -const UNINTERESTED = 0x20; - -/** Draft-14 assigns MALFORMED_TRACK to FETCH_ERROR only. */ -const MALFORMED_TRACK_14 = 0x9; -const MALFORMED_TRACK = 0x12; - -/** The value for "the thing you asked for is not here", or undefined where none is assigned. */ -function doesNotExist(kind: RequestKind, version: IetfVersion): number | undefined { - if (version !== Version.DRAFT_14) return DOES_NOT_EXIST; - return kind === "subscribe" || kind === "fetch" || kind === "track_status" ? DOES_NOT_EXIST_14 : undefined; -} - -/** The value for "we do not want this", or undefined where none is assigned. */ -function uninterested(kind: RequestKind, version: IetfVersion): number | undefined { - if (version !== Version.DRAFT_14) return UNINTERESTED; - return kind === "publish" || kind === "publish_namespace" ? UNINTERESTED_14 : undefined; -} - -/** The value for a track we could not parse, or undefined where none is assigned. */ -function malformedTrack(kind: RequestKind, version: IetfVersion): number | undefined { - if (version !== Version.DRAFT_14) return MALFORMED_TRACK; - return kind === "fetch" ? MALFORMED_TRACK_14 : undefined; -} - -/** Draft-15 swapped MALFORMED_AUTH_TOKEN and the missing-track code. */ -const MALFORMED_AUTH_TOKEN_14 = 0x10; -const MALFORMED_AUTH_TOKEN = 0x4; - -/** The value for an unusable token, or undefined where none is assigned: draft-14 PUBLISH_ERROR. */ -function malformedAuthToken(kind: RequestKind, version: IetfVersion): number | undefined { - if (version !== Version.DRAFT_14) return MALFORMED_AUTH_TOKEN; - return kind === "publish" ? undefined : MALFORMED_AUTH_TOKEN_14; -} - -/** Draft-15 moved EXPIRED_AUTH_TOKEN off 0x12, which became MALFORMED_TRACK. */ -const EXPIRED_AUTH_TOKEN_14 = 0x12; -const EXPIRED_AUTH_TOKEN = 0x5; - -/** The value for an expired token, or undefined where none is assigned: draft-14 PUBLISH_ERROR. */ -function expiredAuthToken(kind: RequestKind, version: IetfVersion): number | undefined { - if (version !== Version.DRAFT_14) return EXPIRED_AUTH_TOKEN; - return kind === "publish" ? undefined : EXPIRED_AUTH_TOKEN_14; -} - -/** The value for a draining session, or undefined before draft-17 registered one. */ -function goingAway(version: IetfVersion): number | undefined { - return version >= Version.DRAFT_17 ? GOING_AWAY : undefined; +function invalidJoiningRequestId(kind: RequestKind, version: IetfVersion): number | undefined { + return kind === "fetch" && version <= Version.DRAFT_18 ? INVALID_JOINING_REQUEST_ID : undefined; } /** @@ -193,45 +105,41 @@ function goingAway(version: IetfVersion): number | undefined { * * A condition the draft does not register for this request falls back to INTERNAL_ERROR * rather than borrowing a number from another draft, which would say something the peer's - * registry gives a different meaning. The renumbering is the trap: draft-14 SUBSCRIBE_ERROR - * gives 0x4 to TRACK_DOES_NOT_EXIST and 0x10 to MALFORMED_AUTH_TOKEN, and draft-15 swaps - * the two. + * registry gives a different meaning. * * @internal */ export function toRequestCode(condition: RequestCondition, kind: RequestKind, version: IetfVersion): number { switch (condition) { case "invalid_filter": - return version >= Version.DRAFT_22 ? 0x36 : INTERNAL_ERROR; + return version >= Version.DRAFT_22 ? INVALID_FILTER : INTERNAL_ERROR; case "internal": return INTERNAL_ERROR; case "unauthorized": return UNAUTHORIZED; case "expired": - return expiredAuthToken(kind, version) ?? INTERNAL_ERROR; + return EXPIRED_AUTH_TOKEN; case "timeout": return TIMEOUT; case "not_supported": return NOT_SUPPORTED; case "invalid_range": - return invalidRange(kind, version) ?? INTERNAL_ERROR; + return invalidRange(kind) ?? INTERNAL_ERROR; case "invalid_joining_request_id": return invalidJoiningRequestId(kind, version) ?? INTERNAL_ERROR; case "does_not_exist": - return doesNotExist(kind, version) ?? INTERNAL_ERROR; + return DOES_NOT_EXIST; // A subscriber that asked for content cannot act on "we do not want it": what it needs // to know is that we do not have it, which is the same refusal from its side. Only the // requests that offer content say UNINTERESTED. case "uninterested": - return kind === "subscribe" || kind === "fetch" || kind === "track_status" - ? (doesNotExist(kind, version) ?? INTERNAL_ERROR) - : (uninterested(kind, version) ?? INTERNAL_ERROR); + return kind === "subscribe" || kind === "fetch" || kind === "track_status" ? DOES_NOT_EXIST : UNINTERESTED; case "malformed_track": - return malformedTrack(kind, version) ?? INTERNAL_ERROR; + return MALFORMED_TRACK; case "malformed_auth_token": - return malformedAuthToken(kind, version) ?? INTERNAL_ERROR; + return MALFORMED_AUTH_TOKEN; case "going_away": - return goingAway(version) ?? INTERNAL_ERROR; + return GOING_AWAY; } } @@ -253,14 +161,14 @@ export function fromRequestCode(code: number, kind: RequestKind, version: IetfVe return "not_supported"; } - if (code === 0x36 && version >= Version.DRAFT_22) return "invalid_filter"; - if (code === doesNotExist(kind, version)) return "does_not_exist"; - if (code === invalidRange(kind, version)) return "invalid_range"; + if (code === INVALID_FILTER && version >= Version.DRAFT_22) return "invalid_filter"; + if (code === DOES_NOT_EXIST) return "does_not_exist"; + if (code === invalidRange(kind)) return "invalid_range"; if (code === invalidJoiningRequestId(kind, version)) return "invalid_joining_request_id"; - if (code === expiredAuthToken(kind, version)) return "expired"; - if (code === uninterested(kind, version)) return "uninterested"; - if (code === malformedTrack(kind, version)) return "malformed_track"; - if (code === goingAway(version)) return "going_away"; + if (code === EXPIRED_AUTH_TOKEN) return "expired"; + if (code === UNINTERESTED) return "uninterested"; + if (code === MALFORMED_TRACK) return "malformed_track"; + if (code === GOING_AWAY) return "going_away"; return undefined; } diff --git a/js/net/src/ietf/fetch.ts b/js/net/src/ietf/fetch.ts index a6eadbf8058..416e2d2833c 100644 --- a/js/net/src/ietf/fetch.ts +++ b/js/net/src/ietf/fetch.ts @@ -5,7 +5,7 @@ import { hasRangeFilters, isDraft22 } from "./filter.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; import { Parameters } from "./parameters.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; /** * The header that begins a fetch stream (draft-22 section 11.4), naming the request it @@ -79,14 +79,6 @@ export class Fetch { static async #decode(r: Reader, version: IetfVersion): Promise { const requestId = await r.u62(); - if (version === Version.DRAFT_17) { - await r.u62(); // required_request_id_delta - } - - if (version === Version.DRAFT_14) { - await r.u8(); // subscriber_priority - await r.u8(); // group_order - } let trackNamespace: Path.Valid | undefined; if (isDraft22(version)) { @@ -125,37 +117,3 @@ export class Fetch { return new Fetch({ requestId, rangeFilters: params.rangeFilters, trackNamespace, epoch }); } } - -export class FetchError { - static id = 0x19; - - requestId: bigint; - errorCode: number; - reasonPhrase: string; - - constructor({ - requestId, - errorCode, - reasonPhrase, - }: { requestId: bigint; errorCode: number; reasonPhrase: string }) { - this.requestId = requestId; - this.errorCode = errorCode; - this.reasonPhrase = reasonPhrase; - } - - async #encode(_w: Writer): Promise { - throw new Error("FETCH_ERROR messages are not supported"); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, FetchError.#decode); - } - - static async #decode(_r: Reader): Promise { - throw new Error("FETCH_ERROR messages are not supported"); - } -} diff --git a/js/net/src/ietf/filter.test.ts b/js/net/src/ietf/filter.test.ts index 589b4642a4c..097e45e3082 100644 --- a/js/net/src/ietf/filter.test.ts +++ b/js/net/src/ietf/filter.test.ts @@ -9,7 +9,7 @@ const OLD = Version.DRAFT_18; const NEW = Version.DRAFT_22; test("legacy live joins do not exclude the prefix without a joining FETCH", () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_17, Version.DRAFT_18]) { + for (const version of [Version.DRAFT_18]) { expect([...Filter.encode(joinFilter(version), version)]).toEqual([3, 0, 0]); } expect([...Filter.encode(joinFilter(NEW), NEW)]).toEqual([0x01, 0x01]); diff --git a/js/net/src/ietf/filter.ts b/js/net/src/ietf/filter.ts index 4ffa5656362..a8d238bab91 100644 --- a/js/net/src/ietf/filter.ts +++ b/js/net/src/ietf/filter.ts @@ -40,13 +40,7 @@ const TAG_ABSOLUTE_RANGE = 0x4n; * that follow, so the value carries no Length, and added the parameters that ride with it. */ export function isDraft22(version: IetfVersion): boolean { - return ( - version !== Version.DRAFT_14 && - version !== Version.DRAFT_15 && - version !== Version.DRAFT_16 && - version !== Version.DRAFT_17 && - version !== Version.DRAFT_18 - ); + return version !== Version.DRAFT_18; } /** The Location Filter Type of draft-22. */ @@ -62,22 +56,8 @@ export function hasRangeFilters(version: IetfVersion): boolean { return isDraft22(version); } -/** - * Draft-17 replaced QUIC's two-bit-length varint with a leading-1-bits one. The two agree - * below 64 and diverge above it, so getting this wrong is invisible until group or object - * ids grow past that. - */ -function usesLeadingOnes(version: IetfVersion): boolean { - return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; -} - -function varint(v: bigint, version: IetfVersion): Uint8Array { - return usesLeadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v); -} - -function unvarint(buf: Uint8Array, version: IetfVersion): [bigint, Uint8Array] { - return usesLeadingOnes(version) ? Varint.decodeLeadingOnes(buf) : Varint.decodeBigInt(buf); -} +const varint = Varint.encodeLeadingOnes; +const unvarint = Varint.decodeLeadingOnes; function endDelta(startGroup: bigint, endGroup: bigint): bigint { if (endGroup < startGroup) { @@ -88,14 +68,14 @@ function endDelta(startGroup: bigint, endGroup: bigint): bigint { /** Encode a filter as its raw parameter value, without the Length that frames it before draft-22. */ export function encode(filter: Filter, version: IetfVersion): Uint8Array { - return isDraft22(version) ? encodeTyped(filter, version) : encodeTag(filter, version); + return isDraft22(version) ? encodeTyped(filter) : encodeTag(filter); } /** Encode LOCATION_FILTER's value as it follows the parameter type from draft-17 on. */ export function encodeParam(filter: Filter, version: IetfVersion): Uint8Array { const value = encode(filter, version); // Draft-22's Location Filter Type says where the value ends, so it needs no Length. - return isDraft22(version) ? value : encodeLengthPrefixed(value, version); + return isDraft22(version) ? value : encodeLengthPrefixed(value); } /** Read LOCATION_FILTER's value as it follows the parameter type from draft-17 on. */ @@ -111,14 +91,14 @@ export async function decodeParam(r: Reader, version: IetfVersion): Promise 0; i--) { - const [field, next] = unvarint(rest, version); + const [field, next] = unvarint(rest); fields.push(field); rest = next; } return [fromTyped(type, fields), rest]; } -function encodeTag(filter: Filter, version: IetfVersion): Uint8Array { +function encodeTag(filter: Filter): Uint8Array { switch (filter.kind) { case "unfiltered": // No tag means "everything", which only the absolute spelling can say. - return concat([varint(TAG_ABSOLUTE_START, version), varint(0n, version), varint(0n, version)]); + return concat([varint(TAG_ABSOLUTE_START), varint(0n), varint(0n)]); case "nextObject": - return varint(TAG_LARGEST_OBJECT, version); + return varint(TAG_LARGEST_OBJECT); case "relative": if (filter.groups !== 0n) { // Only draft-22 can name a start further back than the next group without // knowing Largest Object, so there is no honest tag to fall back to. throw new Error(`relative filter ${filter.groups} needs draft-22`); } - return varint(TAG_NEXT_GROUP, version); + return varint(TAG_NEXT_GROUP); case "absolute": { // Draft-18's AbsoluteRange ends on a group, so an object-bounded range has no // spelling. Refuse rather than widen the range the caller asked for. @@ -214,12 +190,12 @@ function encodeTag(filter: Filter, version: IetfVersion): Uint8Array { throw new Error("an object-bounded range needs draft-22"); } const parts = [ - varint(filter.endGroup === undefined ? TAG_ABSOLUTE_START : TAG_ABSOLUTE_RANGE, version), - varint(filter.startGroup, version), - varint(filter.startObject, version), + varint(filter.endGroup === undefined ? TAG_ABSOLUTE_START : TAG_ABSOLUTE_RANGE), + varint(filter.startGroup), + varint(filter.startObject), ]; if (filter.endGroup !== undefined) { - parts.push(varint(endDelta(filter.startGroup, filter.endGroup), version)); + parts.push(varint(endDelta(filter.startGroup, filter.endGroup))); } return concat(parts); } @@ -229,19 +205,19 @@ function encodeTag(filter: Filter, version: IetfVersion): Uint8Array { /** Decode a filter from its raw parameter value, which must be consumed whole. */ export function decode(data: Uint8Array, version: IetfVersion): Filter { if (isDraft22(version)) { - const [filter, rest] = readTyped(data, version); + const [filter, rest] = readTyped(data); expectEmpty(rest); return filter; } - return decodeTag(data, version); + return decodeTag(data); } -function decodeTag(data: Uint8Array, version: IetfVersion): Filter { - const [tag, afterTag] = unvarint(data, version); +function decodeTag(data: Uint8Array): Filter { + const [tag, afterTag] = unvarint(data); const readLocation = (buf: Uint8Array): [bigint, bigint, Uint8Array] => { - const [group, afterGroup] = unvarint(buf, version); - const [object, rest] = unvarint(afterGroup, version); + const [group, afterGroup] = unvarint(buf); + const [object, rest] = unvarint(afterGroup); return [group, object, rest]; }; @@ -259,7 +235,7 @@ function decodeTag(data: Uint8Array, version: IetfVersion): Filter { } case TAG_ABSOLUTE_RANGE: { const [startGroup, startObject, afterLocation] = readLocation(afterTag); - const [delta, rest] = unvarint(afterLocation, version); + const [delta, rest] = unvarint(afterLocation); expectEmpty(rest); return { kind: "absolute", startGroup, startObject, endGroup: startGroup + delta }; } @@ -358,14 +334,14 @@ const FILL_ALLOWED = new Map([ * The value is a nested parameter scope, encoded like a message's parameters. */ export function encodeFill(fill: Fill, version: IetfVersion): Uint8Array { - const parts = [varint(BigInt(Number(fill.filter !== undefined) + Number(fill.groupOrder !== undefined)), version)]; + const parts = [varint(BigInt(Number(fill.filter !== undefined) + Number(fill.groupOrder !== undefined)))]; if (fill.filter !== undefined) { - parts.push(varint(FILL_LOCATION_FILTER, version), encodeParam(fill.filter, version)); + parts.push(varint(FILL_LOCATION_FILTER), encodeParam(fill.filter, version)); } if (fill.groupOrder !== undefined) { // Parameters sort by id; GROUP_ORDER follows LOCATION_FILTER by one. parts.push( - varint(fill.filter !== undefined ? 1n : 0x22n, version), + varint(fill.filter !== undefined ? 1n : 0x22n), new Uint8Array([fill.groupOrder === "ascending" ? 1 : 2]), ); } @@ -374,7 +350,7 @@ export function encodeFill(fill: Fill, version: IetfVersion): Uint8Array { /** Decode FILL_PARAMETERS, including its inherited or explicit group order. */ export function decodeFill(data: Uint8Array, version: IetfVersion): Fill { - const [count, afterCount] = unvarint(data, version); + const [count, afterCount] = unvarint(data); if (count > 64n) { throw new Error("too many parameters in FILL_PARAMETERS"); } @@ -385,7 +361,7 @@ export function decodeFill(data: Uint8Array, version: IetfVersion): Fill { let rangeFilters = false; let prev = 0n; for (let i = 0n; i < count; i++) { - const [delta, afterType] = unvarint(rest, version); + const [delta, afterType] = unvarint(rest); const key = i === 0n ? delta : prev + delta; prev = key; rest = afterType; @@ -396,10 +372,10 @@ export function decodeFill(data: Uint8Array, version: IetfVersion): Fill { throw new Error("duplicate LOCATION_FILTER inside FILL_PARAMETERS"); } if (isDraft22(version)) { - [filter, rest] = readTyped(rest, version); + [filter, rest] = readTyped(rest); } else { let value: Uint8Array; - [value, rest] = readLengthPrefixed(rest, version); + [value, rest] = readLengthPrefixed(rest); filter = decode(value, version); } continue; @@ -416,7 +392,7 @@ export function decodeFill(data: Uint8Array, version: IetfVersion): Fill { // Each parameter must consume its own value or the remaining keys desync. if (framing === "varint") { - [, rest] = unvarint(rest, version); + [, rest] = unvarint(rest); continue; } if (framing === "byte") { @@ -432,7 +408,7 @@ export function decodeFill(data: Uint8Array, version: IetfVersion): Fill { continue; } - [, rest] = readLengthPrefixed(rest, version); + [, rest] = readLengthPrefixed(rest); } if (rest.length !== 0) { @@ -443,14 +419,14 @@ export function decodeFill(data: Uint8Array, version: IetfVersion): Fill { } /** Split a length-prefixed value off the front of `data`, returning it and what follows. */ -function readLengthPrefixed(data: Uint8Array, version: IetfVersion): [Uint8Array, Uint8Array] { - const [length, afterLength] = unvarint(data, version); +function readLengthPrefixed(data: Uint8Array): [Uint8Array, Uint8Array] { + const [length, afterLength] = unvarint(data); if (BigInt(afterLength.length) < length) { throw new Error("truncated value inside FILL_PARAMETERS"); } return [afterLength.slice(0, Number(length)), afterLength.slice(Number(length))]; } -function encodeLengthPrefixed(value: Uint8Array, version: IetfVersion): Uint8Array { - return concat([varint(BigInt(value.length), version), value]); +function encodeLengthPrefixed(value: Uint8Array): Uint8Array { + return concat([varint(BigInt(value.length)), value]); } diff --git a/js/net/src/ietf/goaway.test.ts b/js/net/src/ietf/goaway.test.ts index f9c7c790f8c..0f2e0b20ee8 100644 --- a/js/net/src/ietf/goaway.test.ts +++ b/js/net/src/ietf/goaway.test.ts @@ -6,56 +6,59 @@ import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Stream } from "../stream.ts"; import { wireOf } from "../wire.ts"; -import { NativeSession } from "./adapter.ts"; import { AuthMessage, AuthOk, IetfAuthWire } from "./auth.ts"; import { Connection } from "./connection.ts"; import { GoAway } from "./goaway.ts"; import { PublishNamespace } from "./publish_namespace.ts"; import { RequestOk } from "./request.ts"; +import { NativeSession } from "./session.ts"; import { SubscribeNamespace, SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; import { ALPN, Version } from "./version.ts"; -// Draft-17+ carry GOAWAY on the setup stream, with the peer's deadline. The session keeps -// serving afterwards so its groups in flight finish while the caller migrates. -test("a draft-17 GOAWAY surfaces its URI and deadline without closing the session", async () => { - const version = Version.DRAFT_17; - const pair = createMockTransportPair(ALPN.DRAFT_17); - const control = await Stream.open(pair.client, { version }); - const peer = await Stream.accept(pair.server, version); - if (!peer) throw new Error("no setup stream"); +// GOAWAY rides the setup stream, with the peer's deadline, and on draft-18 a Request ID. The +// session keeps serving afterwards so its groups in flight finish while the caller migrates. +for (const [version, alpn, requestId] of [ + [Version.DRAFT_18, ALPN.DRAFT_18, 0n], + [Version.DRAFT_22, ALPN.DRAFT_22, undefined], +] as const) { + test(`a ${alpn} GOAWAY surfaces its URI and deadline without closing the session`, async () => { + const pair = createMockTransportPair(alpn); + const control = await Stream.open(pair.client, { version }); + const peer = await Stream.accept(pair.server, version); + if (!peer) throw new Error("no setup stream"); - const connection = new Connection({ - url: new URL("https://relay.example/"), - quic: pair.client, - control, - maxRequestId: 100n, - version, - client: true, - }); + const connection = new Connection({ + url: new URL("https://relay.example/"), + quic: pair.client, + control, + version, + client: true, + }); - let closed = false; - void connection.closed.then(() => { - closed = true; - }); + let closed = false; + void connection.closed.then(() => { + closed = true; + }); - try { - await peer.writer.u53(GoAway.id); - await new GoAway({ newSessionUri: "", timeout: 5000n }).encode(peer.writer, version); + try { + await peer.writer.u53(GoAway.id); + await new GoAway({ newSessionUri: "", timeout: 5000n, requestId }).encode(peer.writer, version); - const drain = await wireOf(connection).goaway; - expect(drain.uri).toBe(""); - expect(drain.timeout).toBe(Milli(5000)); + const drain = await wireOf(connection).goaway; + expect(drain.uri).toBe(""); + expect(drain.timeout).toBe(Milli(5000)); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(closed).toBe(false); - } finally { - connection.abort(); - } -}); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(closed).toBe(false); + } finally { + connection.abort(); + } + }); +} test("a server rejects a client GOAWAY that names a redirect", async () => { - const version = Version.DRAFT_17; - const pair = createMockTransportPair(ALPN.DRAFT_17); + const version = Version.DRAFT_18; + const pair = createMockTransportPair(ALPN.DRAFT_18); const control = await Stream.open(pair.client, { version }); const peer = await Stream.accept(pair.server, version); if (!peer) throw new Error("no setup stream"); @@ -64,7 +67,6 @@ test("a server rejects a client GOAWAY that names a redirect", async () => { url: new URL("https://relay.example/"), quic: pair.client, control, - maxRequestId: 100n, version, client: false, }); @@ -90,8 +92,6 @@ test("a zero GOAWAY timeout reads as no deadline", async () => { }); for (const [version, alpn] of [ - [Version.DRAFT_15, ALPN.DRAFT_15], - [Version.DRAFT_17, ALPN.DRAFT_17], [Version.DRAFT_18, ALPN.DRAFT_18], [Version.DRAFT_22, ALPN.DRAFT_22], ] as const) { @@ -105,7 +105,6 @@ for (const [version, alpn] of [ url: new URL("https://relay.example/"), quic: pair.client, control, - maxRequestId: 100n, version, client: true, }); @@ -134,7 +133,6 @@ for (const [version, alpn] of [ url: new URL("https://relay.example/"), quic: pair.client, control, - maxRequestId: 100n, version, client: true, }); @@ -209,7 +207,6 @@ for (const [version, alpn] of [ url: new URL("https://relay.example/"), quic: pair.client, control, - maxRequestId: 100n, version, client: true, }); @@ -222,7 +219,7 @@ for (const [version, alpn] of [ await request.writer.u53(GoAway.id); await new GoAway({ newSessionUri: "" }).encode(request.writer, version); await request.writer.u53(RequestOk.id); - await new RequestOk({ requestId: undefined }).encode(request.writer, version); + await new RequestOk().encode(request.writer, version); await request.writer.u53(SubscribeNamespaceEntry.id); await new SubscribeNamespaceEntry({ suffix: Path.from("room") }).encode(request.writer, version); expect((await announced.next())?.prefix).toBe(Path.from("room")); @@ -244,7 +241,6 @@ for (const [version, alpn] of [ url: new URL("https://relay.example/"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, publish: origin.consume(), @@ -255,7 +251,7 @@ for (const [version, alpn] of [ expect(await request.reader.u53()).toBe(PublishNamespace.id); await PublishNamespace.decode(request.reader, version); await request.writer.u53(RequestOk.id); - await new RequestOk({ requestId: undefined }).encode(request.writer, version); + await new RequestOk().encode(request.writer, version); await request.writer.write(Uint8Array.of(0x10, 0, 2, 0, 0, 0x10, 0, 2, 0, 0)); expect((await pair.client.closed).closeCode).toBe(SessionCode.ProtocolViolation); } finally { @@ -275,7 +271,6 @@ for (const [version, alpn] of [ url: new URL("https://relay.example/"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, publish: origin.consume(), @@ -291,7 +286,7 @@ for (const [version, alpn] of [ try { const first = await offered(); await first.writer.u53(RequestOk.id); - await new RequestOk({ requestId: undefined }).encode(first.writer, version); + await new RequestOk().encode(first.writer, version); if (end === "reset") first.writer.reset(new StreamError(StreamCode.Cancel)); else first.writer.close(); await offered(); diff --git a/js/net/src/ietf/goaway.ts b/js/net/src/ietf/goaway.ts index ed2799a6ac9..d85b167e467 100644 --- a/js/net/src/ietf/goaway.ts +++ b/js/net/src/ietf/goaway.ts @@ -1,7 +1,7 @@ import { Milli } from "@moq/clock"; import type { Drain } from "../connection/goaway.ts"; import { ProtocolViolation } from "../error.ts"; -import { Reader, type Writer } from "../stream.ts"; +import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; import { type IetfVersion, Version } from "./version.ts"; @@ -29,9 +29,7 @@ export class GoAway { async #encode(w: Writer, version: IetfVersion): Promise { await w.string(this.newSessionUri); - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) { - await w.u62(this.timeout); - } + await w.u62(this.timeout); if (version === Version.DRAFT_18 && this.requestId !== undefined) { await w.u62(this.requestId); } @@ -45,17 +43,6 @@ export class GoAway { return Message.decode(r, (mr) => GoAway.#decode(mr, version)); } - /** - * Decode a body the caller already unframed, as the draft-14 to -16 control stream - * adapter does before routing a message. - */ - static async decodeBody(body: Uint8Array, version: IetfVersion): Promise { - const r = new Reader(undefined, body, version); - const msg = await GoAway.#decode(r, version); - if (!(await r.done())) throw new Error("GOAWAY has trailing bytes"); - return msg; - } - /** The drain signal this message carries. A zero timeout is the wire saying "none". */ drain(): Drain { return { @@ -71,10 +58,7 @@ export class GoAway { if (new TextEncoder().encode(newSessionUri).byteLength > 8192) { throw new ProtocolViolation("GOAWAY new session URI exceeds 8,192 bytes"); } - let timeout = 0n; - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) { - timeout = await r.u62(); - } + const timeout = await r.u62(); let requestId: bigint | undefined; if (version === Version.DRAFT_18 && !(await r.done())) { requestId = await r.u62(); diff --git a/js/net/src/ietf/ietf.test.ts b/js/net/src/ietf/ietf.test.ts index dd4315315b4..d927fe27788 100644 --- a/js/net/src/ietf/ietf.test.ts +++ b/js/net/src/ietf/ietf.test.ts @@ -94,39 +94,20 @@ async function encodeFetchFrameVersioned( } test("DEFAULT_PUBLISHER_PRIORITY has exact SUBSCRIBE_OK bytes per draft", async () => { - for (const version of [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, - ] as const) { - const requestId = version <= Version.DRAFT_16 ? 7n : undefined; - const baseline = await encodeVersioned(new Subscribe.SubscribeOk({ requestId, trackAlias: 42n }), version); + for (const version of [Version.DRAFT_18, Version.DRAFT_22] as const) { + const baseline = await encodeVersioned(new Subscribe.SubscribeOk({ trackAlias: 42n }), version); const encoded = await encodeVersioned( - new Subscribe.SubscribeOk({ requestId, trackAlias: 42n, properties: { priority: 37 } }), + new Subscribe.SubscribeOk({ trackAlias: 42n, properties: { priority: 37 } }), version, ); - if (version <= Version.DRAFT_16) { - expect(Array.from(encoded)).toEqual(Array.from(baseline)); - } else { - expect(Array.from(encoded)).toEqual([baseline[0], baseline[1] + 2, ...baseline.slice(2), 0x0e, 37]); - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, version); - expect(decoded.properties.priority).toBe(37); - } + expect(Array.from(encoded)).toEqual([baseline[0], baseline[1] + 2, ...baseline.slice(2), 0x0e, 37]); + const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, version); + expect(decoded.properties.priority).toBe(37); } }); test("DEFAULT_PUBLISHER_PRIORITY has exact PUBLISH bytes per draft", async () => { - for (const version of [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, - ] as const) { + for (const version of [Version.DRAFT_18, Version.DRAFT_22] as const) { const fields = { requestId: 1n, trackNamespace: Path.from("ns"), @@ -139,81 +120,18 @@ test("DEFAULT_PUBLISHER_PRIORITY has exact PUBLISH bytes per draft", async () => }; const baseline = await encodeVersioned(new Publish(fields), version); const encoded = await encodeVersioned(new Publish({ ...fields, priority: 37 }), version); - if (version <= Version.DRAFT_16) { - expect(Array.from(encoded)).toEqual(Array.from(baseline)); - } else { - // The new property precedes GROUP_ORDER, so its delta changes 0x22 to 0x14. - expect(Array.from(encoded)).toEqual([ - baseline[0], - baseline[1] + 2, - ...baseline.slice(2, -2), - 0x0e, - 37, - 0x14, - 2, - ]); - const decoded = await decodeVersioned(encoded, Publish.decode, version); - expect(decoded.priority).toBe(37); - } - } -}); - -test("Message Parameters: uint8 wire encoding changes in draft 17", async () => { - const params = new Parameters(); - params.subscriberPriority = 255; - - expect(Array.from(await encodeVersioned(params, Version.DRAFT_16))).toEqual([ - 0x01, // parameter count - 0x20, // SUBSCRIBER_PRIORITY - 0x40, - 0xff, // QUIC varint 255 - ]); - - for (const version of [Version.DRAFT_17, Version.DRAFT_18, Version.DRAFT_22]) { - const expected = new Uint8Array([ - 0x01, // parameter count - 0x20, // SUBSCRIBER_PRIORITY - 0xff, // raw uint8 255 - ]); - expect(Array.from(await encodeVersioned(params, version))).toEqual(Array.from(expected)); - - const decoded = await decodeVersioned(expected, Parameters.decode, version); - expect(decoded.subscriberPriority).toBe(255); - } -}); - -test("Message Parameters: Location loses its length prefix at draft-17", async () => { - // Draft-16 section 9.2 serializes every Message Parameter as a Key-Value-Pair and - // section 9.2.2.7 calls LARGEST_OBJECT "a length-prefixed Location structure". Draft-17 - // section 9.3, and section 10.2 from draft-18 on, drops the Length and defines the value - // as "Two consecutive varints (Group, Object)". These bytes are pinned against the Rust - // codec's wire vectors. - const params = new Parameters(); - params.largest = { groupId: 255n, objectId: 128n }; - - expect(Array.from(await encodeVersioned(params, Version.DRAFT_16))).toEqual([ - 0x01, // parameter count - 0x09, // LARGEST_OBJECT - 0x04, // byte-string length - 0x40, - 0xff, // QUIC varint 255 - 0x40, - 0x80, // QUIC varint 128 - ]); - - for (const version of [Version.DRAFT_17, Version.DRAFT_18, Version.DRAFT_22]) { - const expected = new Uint8Array([ - 0x01, // parameter count - 0x09, // LARGEST_OBJECT - 0x80, - 0xff, // leading-ones varint 255 - 0x80, - 0x80, // leading-ones varint 128 + // The new property precedes GROUP_ORDER, so its delta changes 0x22 to 0x14. + expect(Array.from(encoded)).toEqual([ + baseline[0], + baseline[1] + 2, + ...baseline.slice(2, -2), + 0x0e, + 37, + 0x14, + 2, ]); - expect(Array.from(await encodeVersioned(params, version))).toEqual(Array.from(expected)); - - const decoded = await decodeVersioned(expected, Parameters.decode, version); - expect(decoded.largest).toEqual({ groupId: 255n, objectId: 128n }); + const decoded = await decodeVersioned(encoded, Publish.decode, version); + expect(decoded.priority).toBe(37); } }); @@ -224,152 +142,32 @@ test("Message Parameters: Location preserves bigint precision", async () => { expect(params.largest).toEqual(largest); - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_17, Version.DRAFT_18]) { + for (const version of [Version.DRAFT_18, Version.DRAFT_22]) { const encoded = await encodeVersioned(params, version); const decoded = await decodeVersioned(encoded, Parameters.decode, version); expect(decoded.largest).toEqual(largest); } }); -test("Message Parameters: Location preserves full uint64 values in draft 17", async () => { +test("Message Parameters: Location preserves full uint64 values", async () => { const largest = { groupId: 2n ** 64n - 1n, objectId: 2n ** 62n }; const params = new Parameters(); params.largest = largest; expect(params.largest).toEqual(largest); - for (const version of [Version.DRAFT_17, Version.DRAFT_18, Version.DRAFT_22]) { + for (const version of [Version.DRAFT_18, Version.DRAFT_22]) { const encoded = await encodeVersioned(params, version); const decoded = await decodeVersioned(encoded, Parameters.decode, version); expect(decoded.largest).toEqual(largest); } }); -// Subscribe tests (v14) -test("Subscribe v14: round trip", async () => { - const msg = new Subscribe.Subscribe({ - requestId: 1n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 128, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("test" as Path.Valid); - expect(decoded.trackName).toBe("video"); - expect(decoded.subscriberPriority).toBe(128); -}); - -test("Subscribe v14: nested namespace", async () => { - const msg = new Subscribe.Subscribe({ - requestId: 100n, - trackNamespace: Path.from("conference/room123"), - trackName: "audio", - subscriberPriority: 255, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_14); - - expect(decoded.trackNamespace).toBe("conference/room123" as Path.Valid); -}); - -test("SubscribeOk v14: without largest", async () => { - const msg = new Subscribe.SubscribeOk({ requestId: 42n, trackAlias: 43n }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(42n); - expect(decoded.trackAlias).toBe(43n); -}); - -// Subscribe tests (v15) -test("Subscribe v15: round trip", async () => { - const msg = new Subscribe.Subscribe({ - requestId: 1n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 128, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_15); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("test" as Path.Valid); - expect(decoded.trackName).toBe("video"); - expect(decoded.subscriberPriority).toBe(128); -}); - -test("SubscribeOk v15: round trip", async () => { - const msg = new Subscribe.SubscribeOk({ requestId: 42n, trackAlias: 43n }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, Version.DRAFT_15); - - expect(decoded.requestId).toBe(42n); - expect(decoded.trackAlias).toBe(43n); -}); - -test("SubscribeError: round trip", async () => { - const msg = new Subscribe.SubscribeError({ requestId: 123n, errorCode: 500, reasonPhrase: "Not found" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeError.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(123n); - expect(decoded.errorCode).toBe(500); - expect(decoded.reasonPhrase).toBe("Not found"); -}); - -test("Unsubscribe: round trip", async () => { - const msg = new Subscribe.Unsubscribe({ requestId: 999n }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Subscribe.Unsubscribe.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(999n); -}); - -test("PublishDone: basic test", async () => { - const msg = new PublishDone({ requestId: 10n, statusCode: 0, reasonPhrase: "complete" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, PublishDone.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(10n); - expect(decoded.statusCode).toBe(0); - expect(decoded.reasonPhrase).toBe("complete"); -}); - -test("PublishDone: with error", async () => { - const msg = new PublishDone({ requestId: 10n, statusCode: 1, reasonPhrase: "error" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, PublishDone.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(10n); - expect(decoded.statusCode).toBe(1); - expect(decoded.reasonPhrase).toBe("error"); -}); - test("PublishDone: preserves unknown statuses across supported versions", async () => { - const versions = [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - ] as const; + const versions = [Version.DRAFT_18, Version.DRAFT_22] as const; for (const version of versions) { - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; const msg = new PublishDone({ - requestId: legacy ? 10n : undefined, statusCode: 0xface, reasonPhrase: "extension", }); @@ -380,331 +178,22 @@ test("PublishDone: preserves unknown statuses across supported versions", async } }); -// Announce/PublishNamespace tests -test("PublishNamespace: round trip", async () => { - const msg = new Announce.PublishNamespace({ requestId: 1n, trackNamespace: Path.from("test/broadcast") }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Announce.PublishNamespace.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("test/broadcast" as Path.Valid); -}); - -test("PublishNamespaceOk: round trip", async () => { - const msg = new Announce.PublishNamespaceOk({ requestId: 2n }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Announce.PublishNamespaceOk.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(2n); -}); - -test("PublishNamespaceError: round trip", async () => { - const msg = new Announce.PublishNamespaceError({ requestId: 3n, errorCode: 404, reasonPhrase: "Unauthorized" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Announce.PublishNamespaceError.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(3n); - expect(decoded.errorCode).toBe(404); - expect(decoded.reasonPhrase).toBe("Unauthorized"); -}); - -test("PublishNamespaceDone: round trip", async () => { - const msg = new Announce.PublishNamespaceDone({ trackNamespace: Path.from("old/stream") }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Announce.PublishNamespaceDone.decode, Version.DRAFT_14); - - expect(decoded.trackNamespace).toBe("old/stream" as Path.Valid); -}); - -test("UnsubscribeNamespace: names the prefix on draft 14 and the request on draft 15", async () => { - const msg = new SubscribeNamespace.UnsubscribeNamespace({ prefix: Path.from("cam/front"), requestId: 6n }); - - const v14 = await encodeVersioned(msg, Version.DRAFT_14); - const named = await decodeVersioned(v14, SubscribeNamespace.UnsubscribeNamespace.decode, Version.DRAFT_14); - expect(named.prefix).toBe("cam/front" as Path.Valid); - - const v15 = await encodeVersioned(msg, Version.DRAFT_15); - const numbered = await decodeVersioned(v15, SubscribeNamespace.UnsubscribeNamespace.decode, Version.DRAFT_15); - expect(numbered.requestId).toBe(6n); - - await expect((() => encodeVersioned(msg, Version.DRAFT_16))()).rejects.toThrow(/draft-14\/15 only/); -}); - -test("PublishNamespaceCancel: round trip", async () => { - const msg = new Announce.PublishNamespaceCancel({ - trackNamespace: Path.from("canceled"), - errorCode: 1, - reasonPhrase: "Shutdown", - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Announce.PublishNamespaceCancel.decode, Version.DRAFT_14); - - expect(decoded.trackNamespace).toBe("canceled" as Path.Valid); - expect(decoded.errorCode).toBe(1); - expect(decoded.reasonPhrase).toBe("Shutdown"); -}); - -// GoAway tests -test("GoAway: with URL", async () => { - const msg = new GoAway.GoAway({ newSessionUri: "https://example.com/new" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, GoAway.GoAway.decode, Version.DRAFT_14); - - expect(decoded.newSessionUri).toBe("https://example.com/new"); -}); - -test("GoAway: empty", async () => { - const msg = new GoAway.GoAway({ newSessionUri: "" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, GoAway.GoAway.decode, Version.DRAFT_14); - - expect(decoded.newSessionUri).toBe(""); -}); - -// Track tests -test("TrackStatusRequest: round trip", async () => { - const msg = new Track.TrackStatusRequest({ - requestId: 0n, - trackNamespace: Path.from("video/stream"), - trackName: "main", - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Track.TrackStatusRequest.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(0n); - expect(decoded.trackNamespace).toBe("video/stream" as Path.Valid); - expect(decoded.trackName).toBe("main"); -}); - -// TRACK_STATUS_OK carries what SUBSCRIBE_OK would, as far as each draft's answer has room. +// TRACK_STATUS_OK carries what SUBSCRIBE_OK would. test("TrackStatusOk: round trip on every draft", async () => { const properties = { timescale: Timescale.MILLI, priority: 7, groupOrder: 0x02, maxCacheDuration: 1000n }; - for (const version of Object.values(Version).filter((v): v is IetfVersion => v !== Version.DRAFT_07)) { - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; + for (const version of Object.values(Version)) { const msg = new Track.TrackStatusOk({ - requestId: legacy ? 3n : undefined, largest: { groupId: 4n, objectId: 1n }, properties, }); const encoded = await encodeVersioned(msg, version); const decoded = await decodeVersioned(encoded, Track.TrackStatusOk.decode, version); - expect(decoded.requestId).toBe(legacy ? 3n : undefined); expect(decoded.largest).toEqual({ groupId: 4n, objectId: 1n }); - // GROUP_ORDER is a field on draft-14 and a parameter on draft-15; REQUEST_OK has no - // properties block until draft-18. - const expected = - version === Version.DRAFT_14 || version === Version.DRAFT_15 - ? { groupOrder: 0x02 } - : version >= Version.DRAFT_18 - ? properties - : {}; - expect(decoded.properties).toEqual(expected); + expect(decoded.properties).toEqual(properties); } }); -// Validation tests -test("Subscribe v14: rejects invalid filter type", async () => { - const invalidBytes = new Uint8Array([ - 0x01, // subscribe_id - 0x02, // track_alias - 0x01, // namespace length - 0x04, - 0x74, - 0x65, - 0x73, - 0x74, // "test" - 0x05, - 0x76, - 0x69, - 0x64, - 0x65, - 0x6f, // "video" - 0x80, // subscriber_priority - 0x02, // group_order - 0x99, // INVALID filter_type - 0x00, // num_params - ]); - - await expect( - (async () => { - await decodeVersioned(invalidBytes, Subscribe.Subscribe.decode, Version.DRAFT_14); - })(), - ).rejects.toThrow(); -}); - -test("SubscribeOk v14: ignores non-zero expires", async () => { - const body = [ - 0x01, // request_id - 0x00, // track_alias - 0x05, // expires = 5 - 0x02, // group_order - 0x00, // content_exists - 0x00, // num_params - ]; - const bytes = new Uint8Array([0x00, body.length, ...body]); - - const decoded = await decodeVersioned(bytes, Subscribe.SubscribeOk.decode, Version.DRAFT_14); - expect(decoded.requestId).toBe(1n); -}); - -// Unicode tests -test("SubscribeError: unicode strings", async () => { - const msg = new Subscribe.SubscribeError({ requestId: 1n, errorCode: 400, reasonPhrase: "Error: 错误 🚫" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeError.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(1n); - expect(decoded.errorCode).toBe(400); - expect(decoded.reasonPhrase).toBe("Error: 错误 🚫"); -}); - -test("PublishNamespace: unicode namespace", async () => { - const msg = new Announce.PublishNamespace({ requestId: 1n, trackNamespace: Path.from("会议/房间") }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Announce.PublishNamespace.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("会议/房间" as Path.Valid); -}); - -// Publish v15 tests -test("Publish v15: round trip", async () => { - const msg = new Publish({ - requestId: 1n, - trackNamespace: Path.from("test/ns"), - trackName: "video", - trackAlias: 42n, - groupOrder: 0x02, - contentExists: false, - largest: undefined, - forward: true, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - const decoded = await decodeVersioned(encoded, Publish.decode, Version.DRAFT_15); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("test/ns" as Path.Valid); - expect(decoded.trackName).toBe("video"); - expect(decoded.trackAlias).toBe(42n); - expect(decoded.forward).toBe(true); -}); - -test("Publish v14: round trip", async () => { - const msg = new Publish({ - requestId: 1n, - trackNamespace: Path.from("test/ns"), - trackName: "video", - trackAlias: 42n, - groupOrder: 0x02, - contentExists: true, - largest: { groupId: 10n, objectId: 5n }, - forward: true, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Publish.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("test/ns" as Path.Valid); - expect(decoded.trackName).toBe("video"); - expect(decoded.trackAlias).toBe(42n); - expect(decoded.forward).toBe(true); - expect(decoded.contentExists).toBe(true); - expect(decoded.largest?.groupId).toBe(10n); - expect(decoded.largest?.objectId).toBe(5n); -}); - -// ClientSetup v15 tests -test("ClientSetup v15: round trip", async () => { - const msg = new Setup.ClientSetup({ versions: [Version.DRAFT_15] }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - const decoded = await decodeVersioned(encoded, Setup.ClientSetup.decode, Version.DRAFT_15); - - expect(decoded.versions.length).toBe(1); - expect(decoded.versions[0]).toBe(Version.DRAFT_15); -}); - -test("ClientSetup v14: round trip", async () => { - const msg = new Setup.ClientSetup({ versions: [Version.DRAFT_14] }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Setup.ClientSetup.decode, Version.DRAFT_14); - - expect(decoded.versions.length).toBe(1); - expect(decoded.versions[0]).toBe(Version.DRAFT_14); -}); - -// ServerSetup v15 tests -test("ServerSetup v15: round trip", async () => { - const msg = new Setup.ServerSetup({ version: Version.DRAFT_15 }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - const decoded = await decodeVersioned(encoded, Setup.ServerSetup.decode, Version.DRAFT_15); - - expect(decoded.version).toBe(Version.DRAFT_15); -}); - -test("ServerSetup v14: round trip", async () => { - const msg = new Setup.ServerSetup({ version: Version.DRAFT_14 }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Setup.ServerSetup.decode, Version.DRAFT_14); - - expect(decoded.version).toBe(Version.DRAFT_14); -}); - -// RequestOk / RequestError v15 tests -test("RequestOk: round trip", async () => { - const msg = new RequestOk({ requestId: 42n }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - const decoded = await decodeVersioned(encoded, RequestOk.decode, Version.DRAFT_15); - - expect(decoded.requestId).toBe(42n); -}); - -test("RequestError v15: round trip", async () => { - const msg = new RequestError({ requestId: 99n, errorCode: 500, reasonPhrase: "Internal error" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - const decoded = await decodeVersioned(encoded, RequestError.decode, Version.DRAFT_15); - - expect(decoded.requestId).toBe(99n); - expect(decoded.errorCode).toBe(500); - expect(decoded.reasonPhrase).toBe("Internal error"); - expect(decoded.retryInterval).toBe(0n); -}); - -test("RequestError v16: round trip with retryInterval", async () => { - const msg = new RequestError({ - requestId: 99n, - errorCode: 500, - reasonPhrase: "Internal error", - retryInterval: 5000n, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_16); - const decoded = await decodeVersioned(encoded, RequestError.decode, Version.DRAFT_16); - - expect(decoded.requestId).toBe(99n); - expect(decoded.errorCode).toBe(500); - expect(decoded.reasonPhrase).toBe("Internal error"); - expect(decoded.retryInterval).toBe(5000n); -}); - // --- Leading-ones varint tests --- test("Leading-ones varint: spec test vectors", () => { @@ -762,19 +251,12 @@ test("Leading-ones varint: boundary round-trips", () => { }); test("Leading-ones varint: 0xFC is a 7-byte form on draft-18+ (per #1595)", () => { - // Standalone decoder is permissive; draft-17 enforcement is in Reader#readLeadingOnes. - // Only check that it doesn't reject the prefix as "reserved" — it now needs more bytes. + // Only check that it doesn't reject the prefix as reserved: it needs more bytes. expect(() => { Varint.decodeLeadingOnes(new Uint8Array([0xfc])); }).toThrow(/buffer too short/); }); -test("Reader#u62: rejects 0xFC 7-byte form on draft-17", async () => { - const bytes = new Uint8Array([0xfc, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc]); - const reader = new Reader(undefined, bytes, Version.DRAFT_17); - await expect(reader.u62()).rejects.toThrow(/reserved on draft-17/); -}); - test("Reader#u62: accepts 0xFC 7-byte form on draft-18", async () => { const value = 0x12_3456_789a_bcn; const bytes = new Uint8Array([0xfc, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc]); @@ -807,277 +289,59 @@ test("Leading-ones varint: 7-byte form round-trips on draft-18", () => { expect(remain.length).toBe(0); }); -// --- Draft-17 message tests --- - -test("Subscribe v17: round trip with requiredRequestIdDelta", async () => { - const msg = new Subscribe.Subscribe({ - requestId: 1n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 128, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_17); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("test" as Path.Valid); - expect(decoded.trackName).toBe("video"); - expect(decoded.subscriberPriority).toBe(128); -}); - -test("SubscribeOk v17: no requestId", async () => { - const msg = new Subscribe.SubscribeOk({ trackAlias: 42n }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, Version.DRAFT_17); - - expect(decoded.requestId).toBe(undefined); - expect(decoded.trackAlias).toBe(42n); -}); - -test("RequestOk v17: no requestId", async () => { - const msg = new RequestOk({}); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, RequestOk.decode, Version.DRAFT_17); - - expect(decoded.requestId).toBe(undefined); -}); - -test("RequestError v17: no requestId, has retryInterval", async () => { - const msg = new RequestError({ - errorCode: 500, - reasonPhrase: "Internal error", - retryInterval: 3000n, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, RequestError.decode, Version.DRAFT_17); - - expect(decoded.requestId).toBe(undefined); - expect(decoded.errorCode).toBe(500); - expect(decoded.reasonPhrase).toBe("Internal error"); - expect(decoded.retryInterval).toBe(3000n); -}); - -test("GoAway v17: with timeout", async () => { - const msg = new GoAway.GoAway({ newSessionUri: "https://example.com/new", timeout: 5000n }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, GoAway.GoAway.decode, Version.DRAFT_17); - - expect(decoded.newSessionUri).toBe("https://example.com/new"); - expect(decoded.timeout).toBe(5000n); -}); - -test("GoAway v18: carries the Request ID", async () => { - const msg = new GoAway.GoAway({ newSessionUri: "moqt://relay.example/", timeout: 5000n, requestId: 1n }); - - // Draft-18 (#1559) requires a trailing Request ID on the control stream, so - // the v18 body is exactly one varint longer than the v17 body, as in Rust. - const encoded17 = await encodeVersioned(msg, Version.DRAFT_17); - const encoded18 = await encodeVersioned(msg, Version.DRAFT_18); - expect(encoded18.byteLength).toBe(encoded17.byteLength + 1); - - const decoded = await decodeVersioned(encoded18, GoAway.GoAway.decode, Version.DRAFT_18); - expect(decoded.newSessionUri).toBe("moqt://relay.example/"); - expect(decoded.timeout).toBe(5000n); - expect(decoded.requestId).toBe(1n); -}); - -test("GoAway v22: round trip without the Request ID", async () => { - // Draft-22 (#1623) removed the Request ID again: the body is just URI + timeout. - const msg = new GoAway.GoAway({ newSessionUri: "moqt://relay.example/", timeout: 5000n }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_22); - const decoded = await decodeVersioned(encoded, GoAway.GoAway.decode, Version.DRAFT_22); - - expect(decoded.newSessionUri).toBe("moqt://relay.example/"); - expect(decoded.timeout).toBe(5000n); -}); - -test("GoAway: rejects New Session URI over the 8192-byte cap", async () => { - const msg = new GoAway.GoAway({ newSessionUri: "a".repeat(8193) }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - await expect(decodeVersioned(encoded, GoAway.GoAway.decode, Version.DRAFT_14)).rejects.toThrow(/8,192/); -}); - -test("Setup v17: unified 0x2F00 round trip", async () => { - const params = new SetupOptions(); - params.setBytes(7n, new TextEncoder().encode("test-impl")); - const msg = new Setup.Setup({ parameters: params }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, Setup.Setup.decode, Version.DRAFT_17); - - expect(decoded.parameters.getBytes(7n)).toEqual(new TextEncoder().encode("test-impl")); -}); - -test("Publish v17: round trip with requiredRequestIdDelta", async () => { - const msg = new Publish({ - requestId: 1n, - trackNamespace: Path.from("test/ns"), - trackName: "video", - trackAlias: 42n, - groupOrder: 0x02, - contentExists: false, - largest: undefined, - forward: true, - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, Publish.decode, Version.DRAFT_17); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("test/ns" as Path.Valid); - expect(decoded.trackName).toBe("video"); - expect(decoded.trackAlias).toBe(42n); - expect(decoded.forward).toBe(true); -}); - -test("PublishNamespace v17: round trip", async () => { - const msg = new Announce.PublishNamespace({ requestId: 5n, trackNamespace: Path.from("live/stream") }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, Announce.PublishNamespace.decode, Version.DRAFT_17); - - expect(decoded.requestId).toBe(5n); - expect(decoded.trackNamespace).toBe("live/stream" as Path.Valid); -}); - -test("PublishNamespaceDone v17: encode rejects", async () => { - const msg = new Announce.PublishNamespaceDone({ trackNamespace: Path.from("old/stream") }); - - await expect((() => encodeVersioned(msg, Version.DRAFT_17))()).rejects.toThrow(/removed in draft-17/); -}); - -test("PublishNamespaceCancel v17: encode rejects", async () => { - const msg = new Announce.PublishNamespaceCancel({ trackNamespace: Path.from("canceled") }); +test("GoAway v18: carries the Request ID", async () => { + const msg = new GoAway.GoAway({ newSessionUri: "moqt://relay.example/", timeout: 5000n, requestId: 1n }); - await expect((() => encodeVersioned(msg, Version.DRAFT_17))()).rejects.toThrow(/removed in draft-17/); + // Draft-18 (#1559) requires a trailing Request ID on the control stream. + const encoded = await encodeVersioned(msg, Version.DRAFT_18); + const decoded = await decodeVersioned(encoded, GoAway.GoAway.decode, Version.DRAFT_18); + expect(decoded.newSessionUri).toBe("moqt://relay.example/"); + expect(decoded.timeout).toBe(5000n); + expect(decoded.requestId).toBe(1n); }); -test("PublishDone v17: no requestId", async () => { - const msg = new PublishDone({ statusCode: 0, reasonPhrase: "done" }); +test("GoAway v22: round trip without the Request ID", async () => { + // Draft-22 has no Request ID: the body is just URI + timeout. + const msg = new GoAway.GoAway({ newSessionUri: "moqt://relay.example/", timeout: 5000n }); - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, PublishDone.decode, Version.DRAFT_17); + const encoded = await encodeVersioned(msg, Version.DRAFT_22); + const decoded = await decodeVersioned(encoded, GoAway.GoAway.decode, Version.DRAFT_22); - expect(decoded.requestId).toBe(undefined); - expect(decoded.statusCode).toBe(0); - expect(decoded.reasonPhrase).toBe("done"); + expect(decoded.newSessionUri).toBe("moqt://relay.example/"); + expect(decoded.timeout).toBe(5000n); }); -// --- SubscribeUpdate tests --- - -// The first field is the update's own Request ID. requestId is the subscription -// (drafts 14 and 15) or the existing request (draft 16), and a round trip that -// only carries one id cannot tell them apart. -test("SubscribeUpdate drafts 14 to 16 round-trip the target separately from the update", async () => { - const own = 10n; - const target = 4n; - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16] as const) { - const msg = new Subscribe.SubscribeUpdate({ requestId: target, ownRequestId: own }); +test("Setup: unified 0x2F00 round trip", async () => { + const params = new SetupOptions(); + params.setBytes(7n, new TextEncoder().encode("test-impl")); + const msg = new Setup.Setup({ parameters: params }); - const encoded = await encodeVersioned(msg, version); - const reader = new Reader(undefined, encoded, version); - await reader.u16(); - expect(await reader.u62()).toBe(own); - expect(await reader.u62()).toBe(target); - - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeUpdate.decode, version); - expect(decoded.requestId).toBe(target); - const again = await encodeVersioned(decoded, version); - expect(Array.from(again)).toEqual(Array.from(encoded)); - } -}); + const encoded = await encodeVersioned(msg, Version.DRAFT_18); + const decoded = await decodeVersioned(encoded, Setup.Setup.decode, Version.DRAFT_18); -// Draft 14 lifts SUBSCRIBER_PRIORITY and FORWARD into fixed fields; every other -// parameter still rides the list rather than being dropped. -test("SubscribeUpdate v14: keeps parameters beside the fixed fields", async () => { - const parameters = new Parameters(); - parameters.subscriberPriority = 5; - parameters.forward = false; - parameters.vars.set(0x02n, 1000n); // DELIVERY_TIMEOUT - const msg = new Subscribe.SubscribeUpdate({ requestId: 4n, ownRequestId: 10n, parameters }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const reader = new Reader(undefined, encoded, Version.DRAFT_14); - await reader.u16(); - for (let i = 0; i < 5; i++) await reader.u62(); // own, target, start group, start object, end group - expect(await reader.u8()).toBe(5); - expect(await reader.bool()).toBe(false); - expect(await reader.u53()).toBe(1); - - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeUpdate.decode, Version.DRAFT_14); - expect(decoded.parameters.subscriberPriority).toBe(5); - expect(decoded.parameters.forward).toBe(false); - expect(decoded.parameters.vars.get(0x02n)).toBe(1000n); + expect(decoded.parameters.getBytes(7n)).toEqual(new TextEncoder().encode("test-impl")); }); -test("SubscribeUpdate v17: round trip with requiredRequestIdDelta", async () => { +test("SubscribeUpdate v18: round trip", async () => { const msg = new Subscribe.SubscribeUpdate({ requestId: 42n }); - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const reader = new Reader(undefined, encoded, Version.DRAFT_17); + const encoded = await encodeVersioned(msg, Version.DRAFT_18); + const reader = new Reader(undefined, encoded, Version.DRAFT_18); await reader.u16(); expect(await reader.u62()).toBe(42n); - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeUpdate.decode, Version.DRAFT_17); + const decoded = await decodeVersioned(encoded, Subscribe.SubscribeUpdate.decode, Version.DRAFT_18); expect(decoded.requestId).toBe(42n); }); -// --- PublishBlocked tests --- - -test("PublishBlocked v17: round trip", async () => { - const msg = new SubscribeNamespace.PublishBlocked({ - suffix: Path.from("stream1"), - trackName: "video", - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, SubscribeNamespace.PublishBlocked.decode, Version.DRAFT_17); - - expect(decoded.suffix).toBe("stream1" as Path.Valid); - expect(decoded.trackName).toBe("video"); -}); - // --- TrackStatusRequest version-aware tests --- -test("TrackStatusRequest v14: round trip with subscribe fields", async () => { - const msg = new Track.TrackStatusRequest({ - requestId: 1n, - trackNamespace: Path.from("video/stream"), - trackName: "main", - }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - const decoded = await decodeVersioned(encoded, Track.TrackStatusRequest.decode, Version.DRAFT_14); - - expect(decoded.requestId).toBe(1n); - expect(decoded.trackNamespace).toBe("video/stream" as Path.Valid); - expect(decoded.trackName).toBe("main"); -}); - -test("TrackStatusRequest v15: round trip with params", async () => { - const msg = new Track.TrackStatusRequest({ requestId: 2n, trackNamespace: Path.from("test"), trackName: "audio" }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - const decoded = await decodeVersioned(encoded, Track.TrackStatusRequest.decode, Version.DRAFT_15); - - expect(decoded.requestId).toBe(2n); - expect(decoded.trackNamespace).toBe("test" as Path.Valid); - expect(decoded.trackName).toBe("audio"); -}); - -test("TrackStatusRequest v17: round trip with requiredRequestIdDelta", async () => { +test("TrackStatusRequest v18: round trip", async () => { const msg = new Track.TrackStatusRequest({ requestId: 3n, trackNamespace: Path.from("live"), trackName: "data" }); - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, Track.TrackStatusRequest.decode, Version.DRAFT_17); + const encoded = await encodeVersioned(msg, Version.DRAFT_18); + const decoded = await decodeVersioned(encoded, Track.TrackStatusRequest.decode, Version.DRAFT_18); expect(decoded.requestId).toBe(3n); expect(decoded.trackNamespace).toBe("live" as Path.Valid); @@ -1089,7 +353,7 @@ test("TrackStatusRequest v17: round trip with requiredRequestIdDelta", async () // Helper to encode a namespace to raw bytes async function encodeNamespace(namespace: Path.Valid): Promise { const { stream, written } = createTestWritableStream(); - const writer = new Writer(stream, Version.DRAFT_14); + const writer = new Writer(stream, Version.DRAFT_18); await Namespace.encode(writer, namespace); writer.close(); await writer.closed; @@ -1098,14 +362,14 @@ async function encodeNamespace(namespace: Path.Valid): Promise { // Helper to decode a namespace from raw bytes async function decodeNamespace(bytes: Uint8Array): Promise { - const reader = new Reader(undefined, bytes, Version.DRAFT_14); + const reader = new Reader(undefined, bytes, Version.DRAFT_18); return await Namespace.decode(reader); } // Helper to encode raw IETF namespace tuple fields async function encodeNamespaceTuple(parts: string[]): Promise { const { stream, written } = createTestWritableStream(); - const writer = new Writer(stream, Version.DRAFT_14); + const writer = new Writer(stream, Version.DRAFT_18); await writer.u53(parts.length); for (const part of parts) await writer.string(part); writer.close(); @@ -1187,8 +451,8 @@ test("Namespace: Subscribe with empty namespace round trip", async () => { subscriberPriority: 128, }); - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_17); + const encoded = await encodeVersioned(msg, Version.DRAFT_18); + const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_18); expect(decoded.trackNamespace).toBe("" as Path.Valid); expect(decoded.trackName).toBe("video"); @@ -1197,15 +461,15 @@ test("Namespace: Subscribe with empty namespace round trip", async () => { test("Namespace: PublishNamespace with empty namespace round trip", async () => { const msg = new Announce.PublishNamespace({ requestId: 1n, trackNamespace: Path.empty() }); - const encoded = await encodeVersioned(msg, Version.DRAFT_17); - const decoded = await decodeVersioned(encoded, Announce.PublishNamespace.decode, Version.DRAFT_17); + const encoded = await encodeVersioned(msg, Version.DRAFT_18); + const decoded = await decodeVersioned(encoded, Announce.PublishNamespace.decode, Version.DRAFT_18); expect(decoded.trackNamespace).toBe("" as Path.Valid); }); // --- Draft-18 message tests --- -test("Subscribe v18: round trip (same wire as v17 minus required_request_id_delta)", async () => { +test("Subscribe v18: round trip", async () => { const msg = new Subscribe.Subscribe({ requestId: 1n, trackNamespace: Path.from("test"), @@ -1228,7 +492,6 @@ test("SubscribeOk v18: no requestId", async () => { const encoded = await encodeVersioned(msg, Version.DRAFT_18); const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, Version.DRAFT_18); - expect(decoded.requestId).toBe(undefined); expect(decoded.trackAlias).toBe(42n); }); @@ -1250,62 +513,8 @@ test("SubscribeOk v18: reads a multi-byte LARGEST_OBJECT group", async () => { expect(decoded.largest).toEqual({ groupId: 427n, objectId: 0n }); }); -test("SubscribeOk v14: advertises the largest content location", async () => { - const largest = { groupId: 9n, objectId: 3n }; - const encoded = await encodeVersioned( - new Subscribe.SubscribeOk({ requestId: 7n, trackAlias: 42n, largest, properties: { groupOrder: 2 } }), - Version.DRAFT_14, - ); - const expected = new Uint8Array([0, 8, 7, 42, 0, 2, 1, 9, 3, 0]); - expect(encoded).toEqual(expected); - const decoded = await decodeVersioned(expected, Subscribe.SubscribeOk.decode, Version.DRAFT_14); - expect(decoded.largest).toEqual(largest); -}); - -// LARGEST_OBJECT is required once the track has content, so every draft that defines it carries -// it: length-prefixed through draft-16, two bare varints after. -test("SubscribeOk: LARGEST_OBJECT rides every draft in that draft's form", async () => { - const largest = { groupId: 9n, objectId: 3n }; - - expect( - Array.from( - await encodeVersioned( - new Subscribe.SubscribeOk({ requestId: 7n, trackAlias: 42n, largest }), - Version.DRAFT_16, - ), - ), - ).toEqual([ - 0x00, - 0x07, // u16 message length - 7, // request id - 42, // track alias - 0x01, // one message parameter - 0x09, // LARGEST_OBJECT - 0x02, // byte-string length - 0x09, // QUIC varint 9 - 0x03, // QUIC varint 3 - ]); - - for (const version of [Version.DRAFT_17, Version.DRAFT_18, Version.DRAFT_22]) { - const encoded = await encodeVersioned(new Subscribe.SubscribeOk({ trackAlias: 42n, largest }), version); - expect(Array.from(encoded)).toEqual([ - 0x00, - 0x05, // u16 message length - 42, // track alias - 0x01, // one message parameter - 0x09, // LARGEST_OBJECT - 0x09, // leading-ones varint 9 - 0x03, // leading-ones varint 3 - ]); - - const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, version); - expect(decoded.largest).toEqual(largest); - } -}); - -// GROUP_ORDER (0x22) is only a legal SUBSCRIBE_OK *message parameter* through draft-15; a -// draft-16+ peer closes the session with PROTOCOL_VIOLATION when it sees one. The publisher's -// preference belongs in the DEFAULT_PUBLISHER_GROUP_ORDER track property, which shares the +// GROUP_ORDER (0x22) is not a SUBSCRIBE_OK *message parameter*; a peer closes the session with +// PROTOCOL_VIOLATION when it sees one. The publisher's preference belongs in the DEFAULT_PUBLISHER_GROUP_ORDER track property, which shares the // number 0x22 in the separate property registry. test("SubscribeOk v18: group order is a track property, not a parameter", async () => { const msg = new Subscribe.SubscribeOk({ trackAlias: 42n, properties: { groupOrder: 0x02 } }); @@ -1321,24 +530,7 @@ test("SubscribeOk v18: group order is a track property, not a parameter", async ]); }); -// Draft-16 has the block too, under the name Track Extensions. We don't write one there, but -// a peer that does used to fail the whole message on trailing bytes. -test("SubscribeOk v16: reads track extensions", async () => { - const body = [ - 7, // request id - 42, // track alias - 0x00, // zero message parameters - 0x22, // DEFAULT_PUBLISHER_GROUP_ORDER - 0x02, // descending - ]; - const bytes = new Uint8Array([0x00, body.length, ...body]); - - const decoded = await decodeVersioned(bytes, Subscribe.SubscribeOk.decode, Version.DRAFT_16); - expect(decoded.trackAlias).toBe(42n); -}); - -// Only Ascending and Descending are defined, so 0x0 is a malformed message rather than the -// "publisher decides" it means in the draft-14 fields. +// Only Ascending and Descending are defined, so 0x0 is a malformed message. test("SubscribeOk v18: rejects a zero group order property", async () => { const body = [ 42, // track alias @@ -1353,36 +545,7 @@ test("SubscribeOk v18: rejects a zero group order property", async () => { ); }); -// Draft-15 is the one version that takes it as a message parameter, and has no track -// properties to put it in. -test("SubscribeOk v15: group order is a message parameter", async () => { - const msg = new Subscribe.SubscribeOk({ requestId: 7n, trackAlias: 42n, properties: { groupOrder: 0x02 } }); - - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - expect(Array.from(encoded)).toEqual([ - 0x00, - 0x05, // u16 message length - 7, // request id - 42, // track alias - 0x01, // one message parameter - 0x22, // GROUP_ORDER - 0x02, // descending - ]); -}); - -test("SubscribeUpdate v18: 1 byte shorter than v17 (no required_request_id_delta)", async () => { - const msg = new Subscribe.SubscribeUpdate({ requestId: 10n }); - const v17 = await encodeVersioned(msg, Version.DRAFT_17); - const v18 = await encodeVersioned(msg, Version.DRAFT_18); - expect(v17.length).toBe(v18.length + 1); - - // Round-trip the v18 bytes too. A different shape that happens to be one byte - // shorter would silently pass the length check otherwise. - const decoded = await decodeVersioned(v18, Subscribe.SubscribeUpdate.decode, Version.DRAFT_18); - expect(decoded.requestId).toBe(10n); -}); - -test("PublishNamespace v18: round trip without required_request_id_delta", async () => { +test("PublishNamespace v18: round trip", async () => { const msg = new Announce.PublishNamespace({ requestId: 5n, trackNamespace: Path.from("v18/broadcast") }); const encoded = await encodeVersioned(msg, Version.DRAFT_18); const decoded = await decodeVersioned(encoded, Announce.PublishNamespace.decode, Version.DRAFT_18); @@ -1418,35 +581,24 @@ test("PublishDone v18: no requestId", async () => { const encoded = await encodeVersioned(msg, Version.DRAFT_18); const decoded = await decodeVersioned(encoded, PublishDone.decode, Version.DRAFT_18); - expect(decoded.requestId).toBe(undefined); expect(decoded.statusCode).toBe(2); expect(decoded.reasonPhrase).toBe("track ended"); }); -test("RequestOk v18: no requestId (regression: don't treat Draft18 as legacy)", async () => { +test("RequestOk v18: round trip", async () => { const msg = new RequestOk({}); const encoded = await encodeVersioned(msg, Version.DRAFT_18); const decoded = await decodeVersioned(encoded, RequestOk.decode, Version.DRAFT_18); - - expect(decoded.requestId).toBe(undefined); + expect(decoded.parameters.largest).toBeUndefined(); }); // A REQUEST_UPDATE_OK or TRACK_STATUS_OK must carry LARGEST_OBJECT once the track has -// objects, so a REQUEST_OK with one decodes. The Location is length-prefixed until draft-17. +// objects, so a REQUEST_OK with one decodes. test("RequestOk: accepts LARGEST_OBJECT", async () => { - const versions = [ - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, - ] as const; + const versions = [Version.DRAFT_18, Version.DRAFT_22] as const; for (const version of versions) { - const body = - version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? [0x07, 0x01, 0x09, 0x02, 0x05, 0x03] - : [0x01, 0x09, 0x05, 0x03]; + const body = [0x01, 0x09, 0x05, 0x03]; const bytes = new Uint8Array([0x00, body.length, ...body]); const decoded = await decodeVersioned(bytes, RequestOk.decode, version); expect(decoded.parameters.largest).toEqual({ groupId: 5n, objectId: 3n }); @@ -1463,34 +615,11 @@ test("RequestError v18: no requestId, retry_interval still present", async () => const encoded = await encodeVersioned(msg, Version.DRAFT_18); const decoded = await decodeVersioned(encoded, RequestError.decode, Version.DRAFT_18); - expect(decoded.requestId).toBe(undefined); expect(decoded.errorCode).toBe(500); expect(decoded.reasonPhrase).toBe("boom"); expect(decoded.retryInterval).toBe(1234n); }); -test("Subscribe v18 wire bytes match v17 (FIRST_OBJECT bit doesn't affect control messages)", async () => { - const msg = new Subscribe.Subscribe({ - requestId: 1n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 128, - }); - - const v17 = await encodeVersioned(msg, Version.DRAFT_17); - const v18 = await encodeVersioned(msg, Version.DRAFT_18); - - // Draft18 drops the required_request_id_delta (1 byte) from v17. - expect(v18.length).toBe(v17.length - 1); -}); - -test("PublishNamespaceDone v18: rejected (removed in draft-17+)", async () => { - const msg = new Announce.PublishNamespaceDone({ requestId: 1n }); - const { stream } = createTestWritableStream(); - const writer = new Writer(stream, Version.DRAFT_18); - await expect(msg.encode(writer, Version.DRAFT_18)).rejects.toThrow(/removed in draft-17/); -}); - test("GoAway v18: round trip (timeout field still present)", async () => { const msg = new GoAway.GoAway({ newSessionUri: "moqt://relay.example/", timeout: 5000n }); @@ -1501,63 +630,11 @@ test("GoAway v18: round trip (timeout field still present)", async () => { expect(decoded.timeout).toBe(5000n); }); -test("Parameters v18 wire is identical to v17 (no count prefix, delta encoded keys)", async () => { - const params = new SetupOptions(); - params.setVarint(0x2n, 42n); - params.setVarint(0x4n, 100n); - - const v17 = (() => { - const { stream, written } = createTestWritableStream(); - const w = new Writer(stream, Version.DRAFT_17); - return params.encode(w, Version.DRAFT_17).then(() => concatChunks(written)); - })(); - const v18 = (() => { - const { stream, written } = createTestWritableStream(); - const w = new Writer(stream, Version.DRAFT_18); - return params.encode(w, Version.DRAFT_18).then(() => concatChunks(written)); - })(); - - expect(Array.from(await v18)).toEqual(Array.from(await v17)); -}); - -test("SubscribeNamespace: message IDs match the draft registries", () => { - // 0x11 through draft-17, renumbered to 0x50 in draft-18. - expect({ - legacy: SubscribeNamespace.SubscribeNamespaceLegacy.id, - current: SubscribeNamespace.SubscribeNamespace.id, - }).toEqual({ legacy: 0x11, current: 0x50 }); -}); - -test("SubscribeNamespace: decode rejects the wrong draft version", async () => { - // Modern 0x50 is draft-18+ only. - const modern = await encodeVersioned( - new SubscribeNamespace.SubscribeNamespace({ namespace: Path.empty(), requestId: 0n }), - Version.DRAFT_18, - ); - await expect( - decodeVersioned(modern, SubscribeNamespace.SubscribeNamespace.decode, Version.DRAFT_17), - ).rejects.toThrow(/draft-18\+ only/); - - // Legacy 0x11 is draft-14..17 only. - const legacy = await encodeVersioned( - new SubscribeNamespace.SubscribeNamespaceLegacy({ namespace: Path.empty(), requestId: 0n }), - Version.DRAFT_17, - ); - await expect( - decodeVersioned(legacy, SubscribeNamespace.SubscribeNamespaceLegacy.decode, Version.DRAFT_18), - ).rejects.toThrow(/draft-14\.\.17 only/); +test("SubscribeNamespace: message ID matches the draft registry", () => { + expect(SubscribeNamespace.SubscribeNamespace.id).toBe(0x50); }); -test("SubscribeNamespace: draft-18 omits subscribe options", async () => { - // The legacy draft-17 body carries the Subscribe Options field, so it is - // longer than the modern draft-18 body. - const modern = new SubscribeNamespace.SubscribeNamespace({ namespace: Path.empty(), requestId: 0n }); - const legacy = new SubscribeNamespace.SubscribeNamespaceLegacy({ namespace: Path.empty(), requestId: 0n }); - const v18 = await encodeVersioned(modern, Version.DRAFT_18); - const v17 = await encodeVersioned(legacy, Version.DRAFT_17); - expect(v18.byteLength).toBeLessThan(v17.byteLength); - - // Modern round-trips on draft-18. +test("SubscribeNamespace: round trip", async () => { const m = await encodeVersioned( new SubscribeNamespace.SubscribeNamespace({ namespace: Path.from("example/meeting"), requestId: 4n }), Version.DRAFT_18, @@ -1565,41 +642,10 @@ test("SubscribeNamespace: draft-18 omits subscribe options", async () => { const dm = await decodeVersioned(m, SubscribeNamespace.SubscribeNamespace.decode, Version.DRAFT_18); expect(dm.requestId).toBe(4n); expect(dm.namespace).toBe("example/meeting" as Path.Valid); - - // Legacy round-trips on draft-16/17. - for (const version of [Version.DRAFT_16, Version.DRAFT_17]) { - const encoded = await encodeVersioned( - new SubscribeNamespace.SubscribeNamespaceLegacy({ namespace: Path.from("example/meeting"), requestId: 4n }), - version, - ); - const decoded = await decodeVersioned(encoded, SubscribeNamespace.SubscribeNamespaceLegacy.decode, version); - expect(decoded.requestId).toBe(4n); - expect(decoded.namespace).toBe("example/meeting" as Path.Valid); - } }); -// FORWARD is legal on SUBSCRIBE_NAMESPACE through draft-17. A value other than 0 or 1 -// still closes the session. Draft-18 dropped it from this message. -test("SubscribeNamespace: FORWARD follows the draft", async () => { - const legacy = (version: IetfVersion, value: number) => { - const body = [0x01]; - if (version === Version.DRAFT_17) body.push(0x00); - body.push(0x00, 0x01, 0x01, 0x10, value); - return framed(body); - }; - - for (const version of [Version.DRAFT_16, Version.DRAFT_17] as const) { - const decoded = await decodeVersioned( - legacy(version, 0), - SubscribeNamespace.SubscribeNamespaceLegacy.decode, - version, - ); - expect(decoded.requestId).toBe(1n); - await expect( - decodeVersioned(legacy(version, 2), SubscribeNamespace.SubscribeNamespaceLegacy.decode, version), - ).rejects.toThrow(ProtocolViolation); - } - +// FORWARD is not a SUBSCRIBE_NAMESPACE parameter, so carrying one closes the session. +test("SubscribeNamespace: FORWARD is rejected", async () => { await expect( decodeVersioned( framed([0x01, 0x00, 0x01, 0x10, 0x00]), @@ -1609,23 +655,6 @@ test("SubscribeNamespace: FORWARD follows the draft", async () => { ).rejects.toThrow(ProtocolViolation); }); -// EXPIRES is a draft-16 message parameter, but not on SUBSCRIBE. Draft-16 ignores it and -// still reads the parameter after it. Draft-18 closes the session. -test("Parameters: a known parameter on the wrong message", async () => { - const block = new Uint8Array([0x02, 0x08, 0x05, 0x18, 0x07]); - const ignored = await Parameters.decode( - new Reader(undefined, block, Version.DRAFT_16), - Version.DRAFT_16, - "subscribe", - ); - expect(ignored.subscriberPriority).toBe(7); - expect(ignored.expires).toBeUndefined(); - - await expect( - Parameters.decode(new Reader(undefined, block, Version.DRAFT_18), Version.DRAFT_18, "subscribe"), - ).rejects.toThrow(ProtocolViolation); -}); - // TRACK_NAMESPACE_PREFIX updates a SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS only, so a // subscription's REQUEST_UPDATE carrying it closes the session instead of being acked. test("Parameters: TRACK_NAMESPACE_PREFIX is not a subscription update parameter", async () => { @@ -1636,78 +665,6 @@ test("Parameters: TRACK_NAMESPACE_PREFIX is not a subscription update parameter" ).rejects.toThrow(ProtocolViolation); }); -test("Subscribe: SUBGROUP_DELIVERY_TIMEOUT follows its draft", async () => { - const body = framed([...TRACK_HEAD, 0x01, 0x06, 0x00]); - await expect(decodeVersioned(body, Subscribe.Subscribe.decode, Version.DRAFT_16)).rejects.toThrow( - ProtocolViolation, - ); - const decoded = await decodeVersioned(body, Subscribe.Subscribe.decode, Version.DRAFT_18); - expect(decoded.trackName).toBe("video"); -}); - -test("Group: draft-18 sets FIRST_OBJECT bit, draft-17 does not", async () => { - const makeGroup = () => - new Group({ - trackAlias: 7n, - groupId: 3, - subGroupId: 0, - publisherPriority: 0, - flags: { - hasExtensions: false, - hasSubgroup: false, - hasSubgroupObject: false, - hasEnd: true, - hasPriority: true, - firstObject: true, - }, - }); - - // Round-trips on each version. - for (const version of [Version.DRAFT_17, Version.DRAFT_18]) { - const encoded = await encodeVersioned(makeGroup(), version); - const decoded = await decodeVersioned(encoded, Group.decode, version); - expect(decoded.groupId).toBe(3); - expect(decoded.trackAlias).toBe(7n); - expect(decoded.flags.hasEnd).toBe(true); - expect(decoded.flags.hasPriority).toBe(true); - } - - // The draft-18 header carries the 0x40 FIRST_OBJECT bit (type 0x18 -> 0x58), - // so the same bytes are not a valid draft-17 subgroup header. - const v18 = await encodeVersioned(makeGroup(), Version.DRAFT_18); - const v17 = await encodeVersioned(makeGroup(), Version.DRAFT_17); - expect(Array.from(v18)).not.toEqual(Array.from(v17)); - await expect(decodeVersioned(v18, Group.decode, Version.DRAFT_17)).rejects.toThrow(/Unsupported group type/); -}); - -test("Frame object time: draft-15 uses absolute property types", async () => { - const flags: GroupFlags = { - hasExtensions: true, - hasSubgroup: false, - hasSubgroupObject: false, - hasEnd: true, - hasPriority: true, - firstObject: true, - }; - const timestamp = new Timestamp(96_000, Timescale.MILLI); - const frame = new Frame({ payload: new Uint8Array([0xaa]), timestamp }); - const encoded = await encodeFrameVersioned(frame, flags, Version.DRAFT_15); - - const reader = new Reader(undefined, encoded, Version.DRAFT_15); - expect(await reader.u53()).toBe(0); - const extensions = await reader.read(await reader.u53()); - const props = new Reader(undefined, extensions, Version.DRAFT_15); - expect(await props.u62()).toBe(0x10n); - expect(await props.u62()).toBe(96_000n); - expect(await props.done()).toBe(true); - - const decoded = await new Reader(undefined, encoded, Version.DRAFT_15).decode((c) => - Frame.decode(c, flags, Timescale.MILLI), - ); - expect(decoded.timestamp?.value).toBe(96_000); - expect(decoded.timestamp?.scale).toBe(Timescale.MILLI); -}); - // FIRST_OBJECT says the stream carries the subgroup from its first published object. It is // the only signal that a group arrived with its head missing, so the value has to survive // decode rather than being stripped along with the bit. @@ -1735,42 +692,6 @@ test("group flags round-trip firstObject", async () => { expect(decoded.flags.firstObject).toBe(firstObject); } } - - // The bit arrived in draft-18. Earlier drafts carry no such signal, so a group there is - // taken at its word rather than read as starting partway through. - for (const version of [Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_17]) { - const encoded = await encodeVersioned(makeGroup(false), version); - const decoded = await decodeVersioned(encoded, Group.decode, version); - expect(decoded.flags.firstObject).toBe(true); - } -}); - -test("Frame object time: draft-16 starts delta property types", async () => { - const flags: GroupFlags = { - hasExtensions: true, - hasSubgroup: false, - hasSubgroupObject: false, - hasEnd: true, - hasPriority: true, - firstObject: true, - }; - const timestamp = new Timestamp(96_000, Timescale.MILLI); - const frame = new Frame({ payload: new Uint8Array([0xaa]), timestamp }); - const encoded = await encodeFrameVersioned(frame, flags, Version.DRAFT_16); - - const reader = new Reader(undefined, encoded, Version.DRAFT_16); - expect(await reader.u53()).toBe(0); - const extensions = await reader.read(await reader.u53()); - const props = new Reader(undefined, extensions, Version.DRAFT_16); - expect(await props.u62()).toBe(0x10n); - expect(await props.u62()).toBe(96_000n); - expect(await props.done()).toBe(true); - - const decoded = await new Reader(undefined, encoded, Version.DRAFT_16).decode((c) => - Frame.decode(c, flags, Timescale.MILLI), - ); - expect(decoded.timestamp?.value).toBe(96_000); - expect(decoded.timestamp?.scale).toBe(Timescale.MILLI); }); // The End-of-Group header bit and an END_OF_GROUP (0x03) status are independent, so a @@ -1806,29 +727,6 @@ test("Frame: END_OF_GROUP is accepted whether or not the header marks the end", expect(empty.endOfTrack).toBe(false); }); -test("Frame flags draft-14 END_OF_TRACK properties", async () => { - const flags: GroupFlags = { - hasExtensions: true, - hasSubgroup: false, - hasSubgroupObject: false, - hasEnd: false, - hasPriority: true, - firstObject: true, - }; - // Object ID delta 0, one unknown integer property, zero payload length, END_OF_TRACK. - const wire = new Uint8Array([0, 2, 0x20, 7, 0, 4]); - const reader = new Reader(undefined, wire, Version.DRAFT_14); - const frame = await reader.decode((c) => Frame.decode(c, flags, undefined)); - expect(frame.endOfTrack).toBe(true); - expect(frame.hasProperties).toBe(true); - - // From draft 15, no status object may carry properties at all. - for (const status of [3, 4]) { - const later = new Reader(undefined, new Uint8Array([0, 2, 0x20, 7, 0, status]), Version.DRAFT_15); - await expect(later.decode((c) => Frame.decode(c, flags, undefined))).rejects.toThrow(ProtocolViolation); - } -}); - test("Frame decodes objects split at every byte", async () => { const flags: GroupFlags = { hasExtensions: true, @@ -1891,14 +789,7 @@ test("Frame reads only the current LOC Timestamp ID on every draft", async () => hasPriority: true, firstObject: true, }; - for (const version of [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, - ] as const) { + for (const version of [Version.DRAFT_18, Version.DRAFT_22] as const) { for (const id of [0x06, 0x10]) { const reader = new Reader(undefined, new Uint8Array([0, 2, id, 42, 1, 0xaa]), version); const frame = await reader.decode((c) => Frame.decode(c, flags, Timescale.MILLI)); @@ -2018,10 +909,9 @@ test("Subscribe draft-22: decodes every legal parameter", async () => { expect(msg.propertiesWanted).toBe(false); }); -// From draft-15 an omitted SUBSCRIBER_PRIORITY means 128, not the unset 127, so a relay -// forwards it unchanged. +// An omitted SUBSCRIBER_PRIORITY means 128, not the unset 127, so a relay forwards it unchanged. test("Subscribe: an omitted SUBSCRIBER_PRIORITY is 128", async () => { - for (const version of [Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_18, Version.DRAFT_22]) { + for (const version of [Version.DRAFT_18, Version.DRAFT_22]) { const msg = await decodeVersioned(framed([...TRACK_HEAD, 0x00]), Subscribe.Subscribe.decode, version); expect(msg.subscriberPriority).toBe(128); } @@ -2043,7 +933,7 @@ test("Subscribe v22: INCLUDE_PROPERTIES is a uint8", async () => { // FORWARD=0 decodes on every draft, so the request can be refused rather than failing. test("Subscribe: FORWARD=0 decodes", async () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_17, Version.DRAFT_22] as const) { + for (const version of [Version.DRAFT_22] as const) { const msg = new Subscribe.Subscribe({ requestId: 1n, trackNamespace: Path.from("live"), @@ -2094,24 +984,9 @@ test("Subscribe draft-22: Next Object is 0x21 0x05", async () => { expect(await encodeVersioned(msg, Version.DRAFT_22)).toEqual(body); }); -// The tagged forms through draft-18, with a token. -test("Fetch v15: decodes a joining FETCH", async () => { - const body = framed([ - 0x01, // Request ID - ...[0x02, 0x03, 0x00], // Relative Joining: subscription 3, offset 0 - 0x01, // Number of Parameters - ...[0x03, 0x03, 0x03, 0x00, 0xaa], // AUTHORIZATION TOKEN - ]); - - const msg = await decodeVersioned(body, Fetch.decode, Version.DRAFT_15); - expect(msg.requestId).toBe(1n); -}); - // TRACK_STATUS is identical to SUBSCRIBE on every draft, fields and parameters included. test("TrackStatusRequest: carries SUBSCRIBE fields", async () => { const cases: [IetfVersion, number[]][] = [ - // Priority, group order, forward, AbsoluteStart {5, 1}, no parameters. - [Version.DRAFT_14, [0x80, 0x02, 0x00, 0x03, 0x05, 0x01, 0x00]], // AUTHORIZATION TOKEN and INCLUDE_PROPERTIES. [Version.DRAFT_22, [0x02, 0x03, 0x03, 0x03, 0x00, 0xaa, 0x32, 0x00]], ]; @@ -2132,30 +1007,14 @@ test("TRACK_PROPERTY_FILTER is rejected on SUBSCRIBE and FETCH", async () => { await expect(decodeVersioned(body, Fetch.decode, Version.DRAFT_22)).rejects.toThrow(); }); -// INCLUDE_PROPERTIES postdates draft-16, where it arrives as a Key-Value-Pair; it must -// still read as present so the draft gate refuses it. -test("Subscribe v16: rejects INCLUDE_PROPERTIES", async () => { - // One parameter: INCLUDE_PROPERTIES (0x35), length 1, value 0. - const body = framed([...TRACK_HEAD, 0x01, 0x35, 0x01, 0x00]); - await expect(decodeVersioned(body, Subscribe.Subscribe.decode, Version.DRAFT_16)).rejects.toThrow(); -}); - -for (const version of [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, -]) { +for (const version of [Version.DRAFT_18, Version.DRAFT_22]) { for (const kind of [0x20n, 0x21n, 0x9dn, 0x11cn]) { test(`SETUP ${version}: repeated unknown option ${kind}`, async () => { const { stream, written } = createTestWritableStream(); const writer = new Writer(stream, version); - if (version <= Version.DRAFT_16) await writer.u53(2); await writer.u62(kind); for (let i = 0; i < 2; i++) { - if (i > 0) await writer.u62(version <= Version.DRAFT_15 ? kind : 0n); + if (i > 0) await writer.u62(0n); if (kind % 2n === 0n) await writer.u62(1n); else { await writer.u53(1); @@ -2164,22 +1023,20 @@ for (const version of [ } await writer.close(); await writer.closed; - await SetupOptions.decode(new Reader(undefined, concatChunks(written), version), version); + await SetupOptions.decode(new Reader(undefined, concatChunks(written), version)); }); } } -for (const version of [Version.DRAFT_17, Version.DRAFT_18, Version.DRAFT_22]) { +for (const version of [Version.DRAFT_18, Version.DRAFT_22]) { test(`SETUP ${version}: known options cannot repeat`, async () => { for (const bytes of [new Uint8Array([4, 1, 0, 2]), new Uint8Array([7, 1, 97, 0, 1, 98])]) { - await expect(SetupOptions.decode(new Reader(undefined, bytes, version), version)).rejects.toThrow( - /duplicate/, - ); + await expect(SetupOptions.decode(new Reader(undefined, bytes, version))).rejects.toThrow(/duplicate/); } }); test(`SETUP ${version}: repeated unknown options still require complete values`, async () => { const bytes = new Uint8Array([0x21, 1, 97, 0, 2, 98]); - await expect(SetupOptions.decode(new Reader(undefined, bytes, version), version)).rejects.toThrow(); + await expect(SetupOptions.decode(new Reader(undefined, bytes, version))).rejects.toThrow(); }); test(`GROUP_ORDER ${version}: only ascending and descending are legal`, async () => { for (const value of [0, 3, 255]) { @@ -2196,110 +1053,57 @@ for (const version of [Version.DRAFT_17, Version.DRAFT_18, Version.DRAFT_22]) { }); } -for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16]) { - test(`legacy GROUP_ORDER ${version}: zero keeps the publisher preference`, async () => { - const bytes = new Uint8Array([1, 0x22, 0]); - const params = await Parameters.decode(new Reader(undefined, bytes, version), version); - expect(params.groupOrder).toBe(0); - }); -} - -// Caller-selected SUBSCRIBE group order, on both wire shapes: draft-14's inline u8 and -// the draft-15+ message parameter. -test.each([ - ["v14", Version.DRAFT_14], - ["v22", Version.DRAFT_22], -] as const)("Subscribe %s: group order defaults to descending", async (_name, version) => { - const msg = new Subscribe.Subscribe({ - requestId: 1n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 128, - }); - expect(msg.groupOrder).toBe("descending"); - - const encoded = await encodeVersioned(msg, version); - const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, version); - expect(decoded.groupOrder).toBe("descending"); -}); - -test.each([ - ["v14", Version.DRAFT_14], - ["v22", Version.DRAFT_22], -] as const)("Subscribe %s: a caller can request ascending group order", async (_name, version) => { - const msg = new Subscribe.Subscribe({ - requestId: 1n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 128, - groupOrder: "ascending", - }); - - const encoded = await encodeVersioned(msg, version); +// Caller-selected SUBSCRIBE group order. +test.each([["v22", Version.DRAFT_22]] as const)( + "Subscribe %s: group order defaults to descending", + async (_name, version) => { + const msg = new Subscribe.Subscribe({ + requestId: 1n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 128, + }); + expect(msg.groupOrder).toBe("descending"); - // Differential, not a byte search: `toContain(1)` would pass on requestId 1n - // alone. Encode the same message as descending and require that exactly one - // byte moved, from 0x02 to 0x01. - const descending = await encodeVersioned( - new Subscribe.Subscribe({ + const encoded = await encodeVersioned(msg, version); + const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, version); + expect(decoded.groupOrder).toBe("descending"); + }, +); + +test.each([["v22", Version.DRAFT_22]] as const)( + "Subscribe %s: a caller can request ascending group order", + async (_name, version) => { + const msg = new Subscribe.Subscribe({ requestId: 1n, trackNamespace: Path.from("test"), trackName: "video", subscriberPriority: 128, - groupOrder: "descending", - }), - version, - ); - expect(encoded.length).toBe(descending.length); - const differing = [...encoded].flatMap((byte, i) => (byte === descending[i] ? [] : [i])); - expect(differing).toHaveLength(1); - expect(encoded[differing[0]]).toBe(Subscribe.GroupOrder.Ascending); - expect(descending[differing[0]]).toBe(Subscribe.GroupOrder.Descending); - - const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, version); - expect(decoded.groupOrder).toBe("ascending"); -}); + groupOrder: "ascending", + }); -// Draft-14 carries GROUP_ORDER as an inline u8 rather than a message parameter, so 0 ("no -// preference") is reachable there from a peer. It decodes as descending rather than surfacing -// as a third value the model has to handle, and anything past 2 is refused. -test("Subscribe v14: an inline group order of 0 decodes as descending", async () => { - const msg = new Subscribe.Subscribe({ - requestId: 1n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 128, - }); - const encoded = await encodeVersioned(msg, Version.DRAFT_14); - - // Rewrite the encoded inline order byte, which no encoder of ours emits as 0 or 3. - const orderAt = encoded.lastIndexOf(Subscribe.GroupOrder.Descending); - const zeroed = new Uint8Array(encoded); - zeroed[orderAt] = Subscribe.GroupOrder.Default; - const decoded = await decodeVersioned(zeroed, Subscribe.Subscribe.decode, Version.DRAFT_14); - expect(decoded.groupOrder).toBe("descending"); - - const invalid = new Uint8Array(encoded); - invalid[orderAt] = 3; - await expect(decodeVersioned(invalid, Subscribe.Subscribe.decode, Version.DRAFT_14)).rejects.toThrow(/group order/); -}); + const encoded = await encodeVersioned(msg, version); -// Draft-15 still lets a GROUP_ORDER parameter of 0 through, and it means the same. -test("Subscribe v15: a group order parameter of 0 decodes as descending", async () => { - const msg = new Subscribe.Subscribe({ - requestId: 1n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 128, - groupOrder: "ascending", - }); - const encoded = await encodeVersioned(msg, Version.DRAFT_15); - - // The parameter's value is the byte after its id, 0x22. - const orderAt = encoded.findIndex((byte, i) => byte === 0x22 && encoded[i + 1] === Subscribe.GroupOrder.Ascending); - expect(orderAt).toBeGreaterThan(-1); - const zeroed = new Uint8Array(encoded); - zeroed[orderAt + 1] = Subscribe.GroupOrder.Default; - const decoded = await decodeVersioned(zeroed, Subscribe.Subscribe.decode, Version.DRAFT_15); - expect(decoded.groupOrder).toBe("descending"); -}); + // Differential, not a byte search: `toContain(1)` would pass on requestId 1n + // alone. Encode the same message as descending and require that exactly one + // byte moved, from 0x02 to 0x01. + const descending = await encodeVersioned( + new Subscribe.Subscribe({ + requestId: 1n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 128, + groupOrder: "descending", + }), + version, + ); + expect(encoded.length).toBe(descending.length); + const differing = [...encoded].flatMap((byte, i) => (byte === descending[i] ? [] : [i])); + expect(differing).toHaveLength(1); + expect(encoded[differing[0]]).toBe(Subscribe.GroupOrder.Ascending); + expect(descending[differing[0]]).toBe(Subscribe.GroupOrder.Descending); + + const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, version); + expect(decoded.groupOrder).toBe("ascending"); + }, +); diff --git a/js/net/src/ietf/malformed.test.ts b/js/net/src/ietf/malformed.test.ts index 50cad8252ea..0382758948f 100644 --- a/js/net/src/ietf/malformed.test.ts +++ b/js/net/src/ietf/malformed.test.ts @@ -65,7 +65,6 @@ async function serving(version: IetfVersion, alpn: string) { url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version, client: false, publish: origin.consume(), @@ -88,7 +87,6 @@ async function client(version: IetfVersion, alpn: string) { url: new URL("https://relay.example/"), quic: pair.client, control, - maxRequestId: 100n, version, client: true, }); diff --git a/js/net/src/ietf/max-age.test.ts b/js/net/src/ietf/max-age.test.ts index 54d3457666c..6ae51961701 100644 --- a/js/net/src/ietf/max-age.test.ts +++ b/js/net/src/ietf/max-age.test.ts @@ -23,35 +23,20 @@ async function bytes(version: IetfVersion, encode: (writer: Writer) => Promise [...chunk])); } -for (const version of Object.values(Version).filter( - (version): version is IetfVersion => version !== Version.DRAFT_07, -)) { - test(`MAX_CACHE_DURATION is optional and legacy receive-only on ${version}`, async () => { - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; +for (const version of Object.values(Version)) { + test(`MAX_CACHE_DURATION is optional on ${version}`, async () => { for (const maxCacheDuration of [undefined, 0n, 30_000n]) { - const ok = new SubscribeOk({ - requestId: legacy ? 0n : undefined, - trackAlias: 0n, - properties: { maxCacheDuration }, - }); + const ok = new SubscribeOk({ trackAlias: 0n, properties: { maxCacheDuration } }); const encoded = await bytes(version, (writer) => ok.encode(writer, version)); const decoded = await SubscribeOk.decode(new Reader(undefined, encoded, version), version); - expect(decoded.properties.maxCacheDuration).toBe(legacy ? undefined : maxCacheDuration); + expect(decoded.properties.maxCacheDuration).toBe(maxCacheDuration); } }); test(`MAX_CACHE_DURATION decodes from the right field on ${version}`, async () => { for (const age of [0n, 30_000n]) { const payload = await bytes(version, async (writer) => { - const fields = - version === Version.DRAFT_14 - ? [0, 0, 0, 1, 0, 1, 4] - : version === Version.DRAFT_15 - ? [0, 0, 1, 4] - : version === Version.DRAFT_16 - ? [0, 0, 0, 4] - : [0, 0, 4]; - for (const field of fields) await writer.u53(field); + for (const field of [0, 0, 4]) await writer.u53(field); await writer.u62(age); }); const framed = await bytes(version, async (writer) => { @@ -64,7 +49,7 @@ for (const version of Object.values(Version).filter( }); } -for (const version of Object.values(Version).filter((v): v is IetfVersion => v !== Version.DRAFT_07)) { +for (const version of Object.values(Version)) { test(`subscriber max age round trips on ${version}`, async () => { for (const maxAge of [undefined, 0, 250, Number.MAX_SAFE_INTEGER]) { const msg = new Subscribe({ @@ -78,8 +63,7 @@ for (const version of Object.values(Version).filter((v): v is IetfVersion => v ! expect((await Subscribe.decode(new Reader(undefined, encoded, version), version)).maxAge).toBe(maxAge); const parameters = new Parameters(); parameters.maxAge = maxAge; - const legacy = version <= Version.DRAFT_16; - const update = new SubscribeUpdate({ requestId: 2n, ownRequestId: legacy ? 4n : undefined, parameters }); + const update = new SubscribeUpdate({ requestId: 2n, parameters }); const encodedUpdate = await bytes(version, (w) => update.encode(w, version)); expect( (await SubscribeUpdate.decode(new Reader(undefined, encodedUpdate, version), version)).parameters diff --git a/js/net/src/ietf/message.test.ts b/js/net/src/ietf/message.test.ts index cda580c04db..31a3efce58b 100644 --- a/js/net/src/ietf/message.test.ts +++ b/js/net/src/ietf/message.test.ts @@ -15,9 +15,9 @@ function recording(version: IetfVersion): { writer: Writer; chunks: Uint8Array[] } test("a control message goes out in one write, and one that fails to encode writes nothing", async () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_17]) { + for (const version of [Version.DRAFT_18]) { const { writer, chunks } = recording(version); - const err = new RequestError({ requestId: 3n, errorCode: 2, reasonPhrase: "nope" }); + const err = new RequestError({ errorCode: 2, reasonPhrase: "nope" }); await Message.write(writer, RequestError.id, err, version); expect(chunks.length).toBe(1); @@ -31,7 +31,7 @@ test("a control message goes out in one write, and one that fails to encode writ await err.encode(writer, version); expect(chunks.length).toBe(2); - const huge = new RequestError({ requestId: 3n, errorCode: 2, reasonPhrase: "x".repeat(65_536) }); + const huge = new RequestError({ errorCode: 2, reasonPhrase: "x".repeat(65_536) }); await expect(Message.write(writer, RequestError.id, huge, version)).rejects.toThrow("Message too large"); expect(chunks.length).toBe(2); } diff --git a/js/net/src/ietf/object.test.ts b/js/net/src/ietf/object.test.ts index 9682517c392..bc9256dd4b4 100644 --- a/js/net/src/ietf/object.test.ts +++ b/js/net/src/ietf/object.test.ts @@ -5,7 +5,7 @@ import { FetchHeader } from "./fetch.ts"; import { decodeObjectTime, encodeObjectExtensions, Group } from "./object.ts"; import { Version } from "./version.ts"; -const VERSION = Version.DRAFT_17; +const VERSION = Version.DRAFT_18; /** `timestamp` written at `timescale` and read back. */ async function roundtrip(timestamp: Timestamp, timescale: Timescale): Promise { @@ -33,7 +33,7 @@ test("a conversion past what a Timestamp holds is refused before anything is wri }); test("a data stream's header goes out in one write, stream type included", async () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_22]) { + for (const version of [Version.DRAFT_22]) { const chunks: Uint8Array[] = []; const writer = new Writer( new WritableStream({ write: (chunk) => void chunks.push(new Uint8Array(chunk)) }), diff --git a/js/net/src/ietf/object.ts b/js/net/src/ietf/object.ts index 92a42220c50..371d2f9f14d 100644 --- a/js/net/src/ietf/object.ts +++ b/js/net/src/ietf/object.ts @@ -1,9 +1,10 @@ import { ProtocolViolation, StreamCode, Stream as StreamError } from "../error.ts"; -import { asIetf, type Cursor, type Reader, Writer } from "../stream.ts"; +import type { Cursor, Reader } from "../stream.ts"; +import { Writer } from "../stream.ts"; import { type Timescale, Timestamp } from "../time.ts"; import { U64 } from "../util/u64.ts"; import { DEFAULT_WIRE } from "./priority.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; // Implementation limit for object extension blocks, independent of the IETF draft. const MAX_OBJECT_EXTENSIONS = 64 * 1024; @@ -21,48 +22,19 @@ const PROP_TIMESTAMP = 0x10n; // object 0 is still the payload head; a standalone terminal may name a later ID. const FIRST_OBJECT_BIT = 0x40; -/** Whether the subgroup header carries FIRST_OBJECT: draft-18 and later, not drafts 14-17. */ -export function hasFirstObjectBit(version: IetfVersion): boolean { - switch (version) { - case Version.DRAFT_14: - case Version.DRAFT_15: - case Version.DRAFT_16: - case Version.DRAFT_17: - return false; - default: - return true; - } -} - -function hasDeltaObjectPropertyTypes(version: IetfVersion | undefined): boolean { - switch (version) { - case Version.DRAFT_14: - case Version.DRAFT_15: - return false; - default: - return true; - } -} - -async function encodeObjectPropertyType(w: Writer, id: bigint, prev: bigint, version: IetfVersion): Promise { - const encoded = hasDeltaObjectPropertyTypes(version) ? id - prev : id; - await w.u62(encoded); +async function encodeObjectPropertyType(w: Writer, id: bigint, prev: bigint): Promise { + await w.u62(id - prev); } // The units come from the track's TIMESCALE property, not from here: repeating the // timescale on every object would cost bytes per frame to restate something fixed for // the track's lifetime. The timestamp is converted into the track's units so the value // on the wire matches what the track declared. -async function encodeObjectTime( - w: Writer, - timestamp: Timestamp, - timescale: Timescale, - version: IetfVersion, -): Promise { +async function encodeObjectTime(w: Writer, timestamp: Timestamp, timescale: Timescale): Promise { // The shared conversion: exact at the same scale, and refused before anything is written when // the result leaves the range a Timestamp, and so the receiver, can hold. const value = Math.round(timestamp.as(timescale)); - await encodeObjectPropertyType(w, PROP_TIMESTAMP, 0n, version); + await encodeObjectPropertyType(w, PROP_TIMESTAMP, 0n); await w.u62(BigInt(value)); } @@ -80,7 +52,7 @@ export async function encodeObjectExtensions( return new Uint8Array(); } - return Writer.collect(version, (w) => encodeObjectTime(w, timestamp, timescale, version)); + return Writer.collect(version, (w) => encodeObjectTime(w, timestamp, timescale)); } /** Decode the Timestamp an object's Properties block carries, consuming the whole block. */ @@ -91,7 +63,7 @@ export function decodeObjectTime(c: Cursor, timescale: Timescale): Timestamp | u while (c.remaining > 0) { const step = c.u62(); - const id = !hasDeltaObjectPropertyTypes(asIetf(c.version)) || first ? step : prevType + step; + const id = first ? step : prevType + step; first = false; prevType = id; @@ -169,7 +141,7 @@ export class Group { await w.write(await Writer.collect(version, (h) => this.#encode(h, version))); } - async #encode(w: Writer, version: IetfVersion): Promise { + async #encode(w: Writer, _version: IetfVersion): Promise { if (!this.flags.hasSubgroup && this.subGroupId !== 0) { throw new Error(`Subgroup ID must be 0 if hasSubgroup is false: ${this.subGroupId}`); } @@ -188,7 +160,7 @@ export class Group { if (this.flags.hasEnd) { id |= 0x08; } - if (this.flags.firstObject && hasFirstObjectBit(version)) { + if (this.flags.firstObject) { id |= FIRST_OBJECT_BIT; } await w.u53(id); @@ -203,14 +175,12 @@ export class Group { } /** Decode a SUBGROUP_HEADER. Pass `type` when the caller already read it to classify the stream. */ - static async decode(r: Reader, version: IetfVersion, type?: number): Promise { + static async decode(r: Reader, _version: IetfVersion, type?: number): Promise { const raw = type ?? (await r.u53()); - // Strip the draft-18 FIRST_OBJECT bit before the range check, but keep the value: - // it is the only signal that a subgroup starts partway through. Drafts that predate - // it carry no such signal, so they are taken at their word. - const legacy = !hasFirstObjectBit(version); - const firstObject = legacy || (raw & FIRST_OBJECT_BIT) !== 0; - const id = legacy ? raw : raw & ~FIRST_OBJECT_BIT; + // Strip the FIRST_OBJECT bit before the range check, but keep the value: it is the only + // signal that a subgroup starts partway through. + const firstObject = (raw & FIRST_OBJECT_BIT) !== 0; + const id = raw & ~FIRST_OBJECT_BIT; // An invalid type MUST close the session, including the reserved SUBGROUP_ID_MODE // 0b11 (draft-22 section 11.3.1). @@ -371,8 +341,7 @@ export class Frame { } const status = c.u53(); - // Only draft 14 lets a status object carry properties. - if (status !== 0 && hasProperties && asIetf(c.version) !== Version.DRAFT_14) { + if (status !== 0 && hasProperties) { throw new ProtocolViolation(`status object ${status} carries properties`); } diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index abfe20880d1..da5c8dd5bfd 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -2,15 +2,13 @@ import * as Epoch from "../epoch.ts"; import { ProtocolViolation, reason } from "../error.ts"; import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; -import * as Varint from "../varint.ts"; import * as Filter from "./filter.ts"; import * as Namespace from "./namespace.ts"; import { AUTHORIZATION_TOKEN, type Token, tokenFromRequest, unique } from "./token.ts"; import { type IetfVersion, Version } from "./version.ts"; // ---- Message Parameters (used in Subscribe, Publish, Fetch, etc.) ---- -// Count-prefixed KVPs through d16, then definition-specific Type-Value pairs. -// Parameter types are delta-encoded from d16 onward. +// A count, then definition-specific Type-Value pairs with delta-encoded types. // Varint parameter IDs (even) const MSG_PARAM_DELIVERY_TIMEOUT = 0x02n; @@ -48,9 +46,6 @@ const MSG_PARAM_ACTIVE_COUNT = 0x40b66n; /// EPOCH, from the MoQ Epoch extension: a UUIDv7's 16 bytes. See `epoch.ts`. const MSG_PARAM_EPOCH = 0x40b69n; -/// Message parameter ids defined in draft-16. A known id on the wrong message is ignored. -const DRAFT16_MESSAGE_PARAMS: readonly bigint[] = [0x02n, 0x03n, 0x08n, 0x09n, 0x10n, 0x20n, 0x21n, 0x22n, 0x32n]; - /** Which control message the parameter block belongs to. Omitted keeps the unfiltered decode. */ type ControlMessage = | "subscribe" @@ -70,15 +65,7 @@ type ControlMessage = /** True when this draft's definition of `message` includes `id`. */ function allows(message: ControlMessage, version: IetfVersion, id: bigint): boolean { - const d14 = version === Version.DRAFT_14; - const d15 = version === Version.DRAFT_15; - const d16 = version === Version.DRAFT_16; - const d17 = version === Version.DRAFT_17; - const from16 = version >= Version.DRAFT_16; - const from17 = version >= Version.DRAFT_17; - const from18 = version >= Version.DRAFT_18; const from22 = version >= Version.DRAFT_22; - const legacyForward = d15 || d16 || d17; const auth = id === MSG_PARAM_AUTHORIZATION_TOKEN; const forward = id === MSG_PARAM_FORWARD; const priority = id === MSG_PARAM_SUBSCRIBER_PRIORITY; @@ -87,13 +74,13 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool const expires = id === MSG_PARAM_EXPIRES; const largest = id === MSG_PARAM_LARGEST_OBJECT; const objectTimeout = id === MSG_PARAM_DELIVERY_TIMEOUT; - const subgroupTimeout = id === MSG_PARAM_SUBGROUP_DELIVERY_TIMEOUT && from18; + const subgroupTimeout = id === MSG_PARAM_SUBGROUP_DELIVERY_TIMEOUT; const range = id >= 0x25n && id <= 0x28n && from22; const trackRange = id === MSG_PARAM_TRACK_PROPERTY_FILTER && from22; const fill = id === MSG_PARAM_FILL_PARAMETERS && from22; const include = id === MSG_PARAM_INCLUDE_PROPERTIES && from22; - const newGroup = id === MSG_PARAM_NEW_GROUP_REQUEST && from16; - const epoch = id === MSG_PARAM_EPOCH && from17; + const newGroup = id === MSG_PARAM_NEW_GROUP_REQUEST; + const epoch = id === MSG_PARAM_EPOCH; switch (message) { case "subscribe-tracks": @@ -125,7 +112,7 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool id === MSG_PARAM_MAX_AGE || objectTimeout || auth || - (id === MSG_PARAM_MAX_CACHE_DURATION && from17) || + id === MSG_PARAM_MAX_CACHE_DURATION || subgroupTimeout || forward || priority || @@ -138,9 +125,7 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool epoch ); case "subscribe-ok": - // Draft-14 defines only MAX_CACHE_DURATION here. - if (d14) return id === MSG_PARAM_MAX_CACHE_DURATION; - return (id === MSG_PARAM_MAX_CACHE_DURATION && d15) || expires || largest || (order && d15); + return expires || largest; case "subscribe-update": case "request-update": return ( @@ -157,12 +142,12 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool newGroup ); case "namespace-update": - return from18 && (auth || id === 0x34n); + return auth || id === 0x34n; case "subscribe-namespace": - return auth || (forward && legacyForward) || id === MSG_PARAM_HIDDEN; + return auth || id === MSG_PARAM_HIDDEN; case "publish": return ( - (objectTimeout && (d15 || from22)) || + (objectTimeout && from22) || auth || (subgroupTimeout && from22) || expires || @@ -170,14 +155,14 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool forward || (priority && from22) || (filter && from22) || - (order && (d15 || from22)) + (order && from22) ); case "publish-namespace": return auth || id === MSG_PARAM_HOP_PATH || id === MSG_PARAM_ROUTE_COST || epoch; case "fetch": return ( auth || - (id === MSG_PARAM_FILL_TIMEOUT && from18) || + id === MSG_PARAM_FILL_TIMEOUT || priority || (filter && from22) || order || @@ -192,14 +177,6 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool } } -async function skipKvp(r: Reader, id: bigint): Promise { - if (id % 2n === 0n) { - await r.u62(); - } else { - await r.read(await r.u53()); - } -} - /// The object Range Filters (draft-22): SUBGROUP, OBJECTID, PRIORITY and OBJECT_PROPERTY. /// Each is length prefixed whatever the parity of its id. const MSG_PARAM_RANGE_FILTERS: readonly bigint[] = [0x25n, 0x26n, 0x27n, 0x28n]; @@ -254,29 +231,6 @@ function getMessageParamKind(id: bigint): MessageParamKind { } } -/** - * The draft-16 and earlier form: draft-16 section 9.2 serializes every Message Parameter as - * a Key-Value-Pair, and section 9.2.2.7 calls LARGEST_OBJECT "a length-prefixed Location - * structure", so the two QUIC-style varints sit inside a byte string. - */ -function decodeLocation(data: Uint8Array): MessageLocation { - const [groupId, objectData] = Varint.decodeBigInt(data); - const [objectId, trailing] = Varint.decodeBigInt(objectData); - if (trailing.length !== 0) { - throw new Error("trailing bytes in message parameter Location"); - } - return { groupId, objectId }; -} - -function encodeLocation({ groupId, objectId }: MessageLocation): Uint8Array { - const group = Varint.encode(groupId); - const object = Varint.encode(objectId); - const combined = new Uint8Array(group.length + object.length); - combined.set(group, 0); - combined.set(object, group.length); - return combined; -} - /** Message parameters used in control messages. */ export class Parameters { vars: Map; @@ -299,9 +253,9 @@ export class Parameters { this.#tokens = []; } - #repeat(id: bigint, value: Uint8Array, version: IetfVersion) { + #repeat(id: bigint, value: Uint8Array) { // Every token decodes, so one this endpoint cannot use closes the session. - if (id === MSG_PARAM_AUTHORIZATION_TOKEN) this.#tokens.push(tokenFromRequest(value, version)); + if (id === MSG_PARAM_AUTHORIZATION_TOKEN) this.#tokens.push(tokenFromRequest(value)); const values = this.#repeated.get(id) ?? []; values.push(value); this.#repeated.set(id, values); @@ -446,10 +400,7 @@ export class Parameters { /** INCLUDE_PROPERTIES: whether the peer wants Track Properties on the response. */ get includeProperties(): boolean | undefined { - // Draft-16 and earlier frame every parameter as a Key-Value-Pair, so an odd id lands - // in `bytes`. It still has to read as present, so the draft-22 gate can refuse it. - const legacy = this.bytes.get(MSG_PARAM_INCLUDE_PROPERTIES); - const v = legacy ? (legacy.length === 1 ? BigInt(legacy[0]) : 2n) : this.vars.get(MSG_PARAM_INCLUDE_PROPERTIES); + const v = this.vars.get(MSG_PARAM_INCLUDE_PROPERTIES); if (v === undefined) return undefined; // The draft allows exactly 0 or 1; anything else is a protocol violation. if (v > 1n) throw new Error(`invalid INCLUDE_PROPERTIES value: ${v}`); @@ -489,118 +440,67 @@ export class Parameters { else this.bytes.set(MSG_PARAM_EPOCH, Epoch.toBytes(v)); } - /** Encode the block, leaving out the varints in `omit`, which the message carries as fixed fields. */ - async encode(w: Writer, version: IetfVersion, omit: readonly bigint[] = []) { + /** Encode the block. */ + async encode(w: Writer, version: IetfVersion) { const filter = this.#filter; - const vars = [...this.vars].filter(([id]) => !omit.includes(id)); - await w.u53(vars.length + this.bytes.size + this.#locations.size + (filter ? 1 : 0)); - - if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { - for (const [id, value] of vars) { - await w.u62(id); - await w.u62(value); - } - - for (const [id, value] of this.bytes) { - await w.u62(id); - await w.u53(value.length); - await w.write(value); - } - - for (const [id, value] of this.#locations) { - const encoded = encodeLocation(value); - await w.u62(id); - await w.u53(encoded.length); - await w.write(encoded); - } - - if (filter) { - const encoded = Filter.encode(filter, version); - await w.u62(MSG_PARAM_SUBSCRIPTION_FILTER); - await w.u53(encoded.length); - await w.write(encoded); - } - } else { - // d16+: Delta encoding, merge all parameter storage, sort by key - const all: { key: bigint; storage: "var" | "bytes" | "location" | "filter" }[] = []; - for (const [id] of vars) all.push({ key: id, storage: "var" }); - for (const id of this.bytes.keys()) all.push({ key: id, storage: "bytes" }); - for (const id of this.#locations.keys()) all.push({ key: id, storage: "location" }); - if (filter) all.push({ key: MSG_PARAM_SUBSCRIPTION_FILTER, storage: "filter" }); - all.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); - - let prevId = 0n; - for (let i = 0; i < all.length; i++) { - const { key, storage } = all[i]; - const delta = i === 0 ? key : key - prevId; - prevId = key; - await w.u62(delta); - - if (version === Version.DRAFT_16) { - if (storage === "var") { - // biome-ignore lint/style/noNonNullAssertion: key is guaranteed to exist in vars map - await w.u62(this.vars.get(key)!); - } else { - const value = - storage === "bytes" - ? // biome-ignore lint/style/noNonNullAssertion: key is guaranteed to exist in bytes map - this.bytes.get(key)! - : storage === "filter" && filter - ? Filter.encode(filter, version) - : // biome-ignore lint/style/noNonNullAssertion: key is guaranteed to exist in locations map - encodeLocation(this.#locations.get(key)!); - await w.u53(value.length); - await w.write(value); - } - continue; + await w.u53(this.vars.size + this.bytes.size + this.#locations.size + (filter ? 1 : 0)); + + // Merge all parameter storage and sort by key, for delta encoding. + const keys = [...this.vars.keys(), ...this.bytes.keys(), ...this.#locations.keys()]; + if (filter) keys.push(MSG_PARAM_SUBSCRIPTION_FILTER); + keys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + let prevId = 0n; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const delta = i === 0 ? key : key - prevId; + prevId = key; + await w.u62(delta); + + switch (getMessageParamKind(key)) { + case "varint": { + const value = this.vars.get(key); + if (value === undefined) throw new Error(`invalid varint message parameter: ${key.toString()}`); + await w.u62(value); + break; } - - switch (getMessageParamKind(key)) { - case "varint": { - const value = this.vars.get(key); - if (value === undefined) throw new Error(`invalid varint message parameter: ${key.toString()}`); - await w.u62(value); - break; - } - case "uint8": { - const value = this.vars.get(key); - if (value === undefined || value < 0n || value > 0xffn) { - throw new Error(`invalid uint8 message parameter: ${key.toString()}`); - } - await w.u8(Number(value)); - break; - } - case "bool": { - const value = this.vars.get(key); - if (value !== 0n && value !== 1n) { - throw new Error(`invalid bool message parameter: ${key.toString()}`); - } - await w.bool(value === 1n); - break; - } - case "location": { - const location = this.#locations.get(key); - if (location === undefined) - throw new Error(`invalid Location message parameter: ${key.toString()}`); - // Draft-17 section 9.3, and section 10.2 from draft-18 on, drops the - // Length from a Message Parameter and defines a Location value as - // "Two consecutive varints (Group, Object)", so they go out bare. - await w.u62(location.groupId); - await w.u62(location.objectId); - break; - } - case "filter": { - if (!filter) throw new Error(`invalid LOCATION_FILTER message parameter: ${key.toString()}`); - await w.write(Filter.encodeParam(filter, version)); - break; + case "uint8": { + const value = this.vars.get(key); + if (value === undefined || value < 0n || value > 0xffn) { + throw new Error(`invalid uint8 message parameter: ${key.toString()}`); } - case "bytes": { - const value = this.bytes.get(key); - if (value === undefined) throw new Error(`invalid bytes message parameter: ${key.toString()}`); - await w.u53(value.length); - await w.write(value); - break; + await w.u8(Number(value)); + break; + } + case "bool": { + const value = this.vars.get(key); + if (value !== 0n && value !== 1n) { + throw new Error(`invalid bool message parameter: ${key.toString()}`); } + await w.bool(value === 1n); + break; + } + case "location": { + const location = this.#locations.get(key); + if (location === undefined) + throw new Error(`invalid Location message parameter: ${key.toString()}`); + // Section 10.2 has no Length on a Message Parameter and defines a Location + // value as "Two consecutive varints (Group, Object)", so they go out bare. + await w.u62(location.groupId); + await w.u62(location.objectId); + break; + } + case "filter": { + if (!filter) throw new Error(`invalid LOCATION_FILTER message parameter: ${key.toString()}`); + await w.write(Filter.encodeParam(filter, version)); + break; + } + case "bytes": { + const value = this.bytes.get(key); + if (value === undefined) throw new Error(`invalid bytes message parameter: ${key.toString()}`); + await w.u53(value.length); + await w.write(value); + break; } } } @@ -609,11 +509,8 @@ export class Parameters { /** * Decode a parameter block. * - * `message`, when set, is the allow-list for that control message. A parameter the - * draft defines for a different message is ignored through draft-16 and closes the - * session from draft-17 on. An id the draft does not define at all closes from - * draft-16 on. Draft-14 and draft-15 ignore anything the message does not list. - * Omitting `message`, as a draft-14 request does, stores every id. + * `message`, when set, is the allow-list for that control message: any other id closes + * the session. Omitting `message` stores every id. * * Only a request may carry an AUTHORIZATION TOKEN, and every one decodes. * @@ -626,69 +523,18 @@ export class Parameters { let prevType = 0n; for (let i = 0; i < count; i++) { - let id: bigint; - if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { - id = await r.u62(); - } else { - // d16+: delta encoding - const delta = await r.u62(); - id = i === 0 ? delta : prevType + delta; - prevType = id; - } + const delta = await r.u62(); + const id = i === 0 ? delta : prevType + delta; + prevType = id; + // A parameter value has no length, so an unlisted id cannot be skipped. if (message !== undefined && !allows(message, version, id)) { - // Draft-17 on has no length on a parameter value, so an unlisted id cannot be skipped. - const ignore = - version === Version.DRAFT_14 || - version === Version.DRAFT_15 || - (version === Version.DRAFT_16 && DRAFT16_MESSAGE_PARAMS.includes(id)); - if (!ignore) { - throw new ProtocolViolation(`message parameter ${id} is not defined for ${message}`); - } - await skipKvp(r, id); - continue; - } - - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - if (id % 2n === 0n) { - if (params.vars.has(id)) { - throw new Error(`duplicate message parameter id: ${id.toString()}`); - } - const varint = await r.u62(); - // A bool is a varint here. Only 0 and 1 are legal once the message lists it. - if (message !== undefined && id === MSG_PARAM_FORWARD && varint !== 0n && varint !== 1n) { - throw new ProtocolViolation(`invalid message parameter value: ${id}`); - } - params.vars.set(id, varint); - } else { - const size = await r.u53(); - const bytes = await r.read(size); - if (MSG_PARAM_REPEATABLE.includes(id)) { - params.#repeat(id, bytes, version); - } else if (id === MSG_PARAM_LARGEST_OBJECT) { - if (params.#locations.has(id)) { - throw new Error(`duplicate message parameter id: ${id.toString()}`); - } - params.#locations.set(id, decodeLocation(bytes)); - } else if (id === MSG_PARAM_SUBSCRIPTION_FILTER && version !== Version.DRAFT_14) { - // Draft-14 carries the filter in the SUBSCRIBE body, so 0x21 is undefined there. - if (params.#filter !== undefined) { - throw new Error(`duplicate message parameter id: ${id.toString()}`); - } - params.#filter = Filter.decode(bytes, version); - } else { - if (params.bytes.has(id)) { - throw new Error(`duplicate message parameter id: ${id.toString()}`); - } - params.bytes.set(id, bytes); - } - } - continue; + throw new ProtocolViolation(`message parameter ${id} is not defined for ${message}`); } if (MSG_PARAM_REPEATABLE.includes(id)) { const size = await r.u53(); - params.#repeat(id, await r.read(size), version); + params.#repeat(id, await r.read(size)); continue; } @@ -724,7 +570,7 @@ export class Parameters { break; } case "location": { - // Two bare varints from draft-17 on; see the matching comment in encode. + // Two bare varints; see the matching comment in encode. const groupId = await r.u62(); const objectId = await r.u62(); params.#locations.set(id, { groupId, objectId }); diff --git a/js/net/src/ietf/peer-violations.test.ts b/js/net/src/ietf/peer-violations.test.ts index ea1cee43721..689c9f5d115 100644 --- a/js/net/src/ietf/peer-violations.test.ts +++ b/js/net/src/ietf/peer-violations.test.ts @@ -6,12 +6,12 @@ import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Stream, Writer } from "../stream.ts"; import { Slots } from "../util/limits.ts"; -import { NativeSession } from "./adapter.ts"; import { Connection } from "./connection.ts"; import { Parameters } from "./parameters.ts"; import { PublishNamespace } from "./publish_namespace.ts"; import { Publisher } from "./publisher.ts"; import { RequestOk } from "./request.ts"; +import { NativeSession } from "./session.ts"; import { Subscribe, SubscribeOk } from "./subscribe.ts"; import { SubscribeNamespaceEntry, SubscribeNamespaceEntryDone } from "./subscribe_namespace.ts"; import { SubscribeTracks } from "./subscribe_tracks.ts"; @@ -36,7 +36,6 @@ for (const [version, alpn] of versions) { url: new URL("https://example.com"), quic: pair.server, control: serverControl, - maxRequestId: 100n, version, client: false, solicit: true, @@ -45,7 +44,6 @@ for (const [version, alpn] of versions) { url: new URL("https://example.com"), quic: pair.client, control: clientControl, - maxRequestId: 100n, version, client: true, solicit: true, @@ -90,9 +88,9 @@ for (const [version, alpn] of versions) { const peer = await Stream.accept(pair.client, version); if (!peer) throw new Error("missing subscription"); expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, version); + await Subscribe.decode(peer.reader, version); await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: 7n }).encode(peer.writer, version); + await new SubscribeOk({ trackAlias: 7n }).encode(peer.writer, version); await track.info(); } expect((await pair.client.closed).closeCode).toBe(SessionCode.ProtocolViolation); diff --git a/js/net/src/ietf/properties.ts b/js/net/src/ietf/properties.ts index 5d2f2065635..ed9836cc4a7 100644 --- a/js/net/src/ietf/properties.ts +++ b/js/net/src/ietf/properties.ts @@ -1,6 +1,6 @@ import type { Reader, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; // TIMESCALE (0x08), from the MOQ Properties registry shared with draft-ietf-moq-loc-04. // Track scope: it declares the units of every object Timestamp on the track, and its @@ -40,24 +40,11 @@ export interface Properties { groupOrder?: number; } -/** Whether {@link encode} writes a TIMESCALE on this version. - * - * Drafts 14-16 do not. 14 and 15 have no properties block, and 16's Track Extensions - * predate the TIMESCALE registration; an older peer rejects trailing bytes it does not - * parse. A Timestamp on those drafts would have no units. Draft-16 is still read, so a - * peer that sends the block is understood. - */ -export function sendsTimescale(version: IetfVersion): boolean { - return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; -} - /// Write the block, which is the final field of the message: no count and no length, so /// the caller must not append anything after it. /// /// Properties are serialized in ascending order by type, delta-encoded. -export async function encode(w: Writer, properties: Properties, version: IetfVersion): Promise { - if (!sendsTimescale(version)) return; - +export async function encode(w: Writer, properties: Properties, _version: IetfVersion): Promise { let prevType = 0n; if (properties.maxCacheDuration !== undefined) { @@ -95,15 +82,7 @@ export async function encode(w: Writer, properties: Properties, version: IetfVer /// /// Unlike an unknown message parameter, an unknown property is skipped rather than fatal, /// which is what lets a relay forward properties it does not implement. -/// -/// Drafts before 16 have no such block, so this reads nothing and leaves the reader alone. -export async function decode(r: Reader, version: IetfVersion): Promise { - // Draft-16 calls the block Track Extensions, draft-17+ Track Properties. Same encoding, so - // read either rather than faulting the message for trailing bytes. - if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { - return {}; - } - +export async function decode(r: Reader, _version: IetfVersion): Promise { const properties: Properties = {}; let prevType = 0n; let i = 0; diff --git a/js/net/src/ietf/publish.ts b/js/net/src/ietf/publish.ts index 5c50fab437b..1d8ca5f77ba 100644 --- a/js/net/src/ietf/publish.ts +++ b/js/net/src/ietf/publish.ts @@ -6,8 +6,6 @@ import { Parameters } from "./parameters.ts"; import * as Properties from "./properties.ts"; import { type IetfVersion, Version } from "./version.ts"; -// PUBLISH messages are new in draft-14 - export class Publish { static id = 0x1d; @@ -63,51 +61,28 @@ export class Publish { async #encode(w: Writer, version: IetfVersion): Promise { await w.u62(this.requestId); - if (version === Version.DRAFT_17) { - await w.u62(0n); // required_request_id_delta = 0 (Draft17 only) - } await Namespace.encode(w, this.trackNamespace); await w.string(this.trackName); await w.u62(this.trackAlias); - if (version === Version.DRAFT_14) { - await w.u8(this.groupOrder); - await w.bool(this.contentExists); - if (this.contentExists !== !!this.largest) { - throw new Error("contentExists and largest must both be true or false"); - } - if (this.largest) { - await w.u62(this.largest.groupId); - await w.u62(this.largest.objectId); - } - await w.bool(this.forward); - await w.u53(0); // size of parameters - } else { - // v15+: fields in parameters - if (this.contentExists !== !!this.largest) { - throw new Error("contentExists and largest must both be true or false"); - } - const params = this.parameters ?? new Parameters(); - // 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. - if (version === Version.DRAFT_15) { - params.groupOrder = this.groupOrder; - } - params.forward = this.forward; - if (this.largest) { - params.largest = this.largest; - } - await params.encode(w, version); - - // Track Properties are the final field, so nothing may follow. - await Properties.encode( - w, - this.properties ?? { groupOrder: this.groupOrder, priority: this.priority }, - version, - ); + if (this.contentExists !== !!this.largest) { + throw new Error("contentExists and largest must both be true or false"); } + const params = this.parameters ?? new Parameters(); + // The publisher's group order is a DEFAULT_PUBLISHER_GROUP_ORDER track property: a + // draft-18 or 19 peer closes the session on a GROUP_ORDER parameter here. + params.forward = this.forward; + if (this.largest) { + params.largest = this.largest; + } + await params.encode(w, version); + + // Track Properties are the final field, so nothing may follow. + await Properties.encode( + w, + this.properties ?? { groupOrder: this.groupOrder, priority: this.priority }, + version, + ); } async encode(w: Writer, version: IetfVersion): Promise { @@ -120,34 +95,12 @@ export class Publish { static async #decode(r: Reader, version: IetfVersion): Promise { const requestId = await r.u62(); - if (version === Version.DRAFT_17) { - await r.u62(); // required_request_id_delta (Draft17 only) - } const { namespace: trackNamespace, name: trackName } = await Namespace.decodeTrack(r); const trackAlias = await r.u62(); - if (version === Version.DRAFT_14) { - const groupOrder = await r.u8(); - const contentExists = await r.bool(); - const largest = contentExists ? { groupId: await r.u62(), objectId: await r.u62() } : undefined; - const forward = await r.bool(); - await Parameters.decode(r, version); // ignore parameters - return new Publish({ - requestId, - trackNamespace, - trackName, - trackAlias, - groupOrder, - contentExists, - largest, - forward, - }); - } - // v15+: parameters followed by Track Properties (draft-17+) const params = await Parameters.decode(r, version, "publish"); const properties = await Properties.decode(r, version); - // GROUP_ORDER is legal here in draft-15 and draft-22. Draft-16 ignores it. - // Draft-17 and draft-18 close the session. + // GROUP_ORDER is legal here from draft-22; draft-18 closes the session. const groupOrder = properties.groupOrder ?? params.groupOrder ?? 0x02; const forward = params.forward ?? true; const largest = params.largest; @@ -167,46 +120,7 @@ export class Publish { } } -export class PublishError { - static id = 0x1f; - - requestId: bigint; - errorCode: number; - reasonPhrase: string; - - constructor({ - requestId, - errorCode, - reasonPhrase, - }: { requestId: bigint; errorCode: number; reasonPhrase: string }) { - this.requestId = requestId; - this.errorCode = errorCode; - this.reasonPhrase = reasonPhrase; - } - - async #encode(w: Writer): Promise { - await w.u62(this.requestId); - await w.u62(BigInt(this.errorCode)); - await w.string(this.reasonPhrase); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, PublishError.#decode); - } - - static async #decode(r: Reader): Promise { - const requestId = await r.u62(); - const errorCode = Number(await r.u62()); - const reasonPhrase = await r.string(); - return new PublishError({ requestId, errorCode, reasonPhrase }); - } -} - -/** PUBLISH_DONE status codes this implementation distinguishes. Stable across drafts 14 through 22. */ +/** PUBLISH_DONE status codes this implementation distinguishes. Stable across drafts 18 through 22. */ export const PublishDoneStatus = { INTERNAL_ERROR: 0x0, UNAUTHORIZED: 0x1, @@ -224,10 +138,6 @@ export function publishDoneClean(statusCode: number, version: IetfVersion): bool if (statusCode === PublishDoneStatus.TRACK_ENDED) return true; if (statusCode !== PublishDoneStatus.SUBSCRIPTION_ENDED) return false; switch (version) { - case Version.DRAFT_14: - case Version.DRAFT_15: - case Version.DRAFT_16: - case Version.DRAFT_17: case Version.DRAFT_18: return true; default: @@ -235,11 +145,9 @@ export function publishDoneClean(statusCode: number, version: IetfVersion): bool } } -// In draft-14, this message is renamed from SUBSCRIBE_DONE to PUBLISH_DONE export class PublishDone { static readonly id = 0x0b; - requestId: bigint | undefined; statusCode: number; /** * How many data streams the publisher opened for the subscription, fill streams included. @@ -249,44 +157,34 @@ export class PublishDone { reasonPhrase: string; constructor({ - requestId, statusCode, streamCount = 0n, reasonPhrase, - }: { requestId?: bigint; statusCode: number; streamCount?: bigint; reasonPhrase: string }) { - this.requestId = requestId; + }: { statusCode: number; streamCount?: bigint; reasonPhrase: string }) { this.statusCode = statusCode; this.streamCount = streamCount; this.reasonPhrase = reasonPhrase; } - async #encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - if (this.requestId === undefined) throw new Error("requestId required for draft14-16"); - await w.u62(this.requestId); - } + async #encode(w: Writer): Promise { await w.u62(BigInt(this.statusCode)); await w.u62(this.streamCount); await w.string(this.reasonPhrase); } - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (mw) => this.#encode(mw, version)); + async encode(w: Writer, _version: IetfVersion): Promise { + return Message.encode(w, (mw) => this.#encode(mw)); } - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (mr) => PublishDone.#decode(mr, version)); + static async decode(r: Reader, _version: IetfVersion): Promise { + return Message.decode(r, PublishDone.#decode); } - static async #decode(r: Reader, version: IetfVersion): Promise { - const requestId = - version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? await r.u62() - : undefined; + static async #decode(r: Reader): Promise { const statusCode = Number(await r.u62()); const streamCount = await r.u62(); const reasonPhrase = await r.string(); - return new PublishDone({ requestId, statusCode, streamCount, reasonPhrase }); + return new PublishDone({ statusCode, streamCount, reasonPhrase }); } } diff --git a/js/net/src/ietf/publish_namespace.ts b/js/net/src/ietf/publish_namespace.ts index ff8108c99b5..8702598e82b 100644 --- a/js/net/src/ietf/publish_namespace.ts +++ b/js/net/src/ietf/publish_namespace.ts @@ -7,7 +7,7 @@ import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; import { Parameters } from "./parameters.ts"; import { MalformedAuthToken, tokenIntoRequest } from "./token.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; /** * Which advertisement extensions a session negotiated: what its PUBLISH_NAMESPACE and @@ -73,7 +73,6 @@ export async function decodeAdvert( return { cluster: negotiated.cluster ? Cluster.fromParams(params) : undefined, epoch }; } -// In draft-14, ANNOUNCE is renamed to PUBLISH_NAMESPACE export class PublishNamespace { static id = 0x06; @@ -100,12 +99,9 @@ export class PublishNamespace { async #encode(w: Writer, version: IetfVersion): Promise { await w.u62(this.requestId); - if (version === Version.DRAFT_17) { - await w.u62(0n); // required_request_id_delta: only 0 supported until stream-per-request - } await Namespace.encode(w, this.trackNamespace); const params = advertIntoParams(this.params); - if (this.token) tokenIntoRequest(params, this.token, version); + if (this.token) tokenIntoRequest(params, this.token); await params.encode(w, version); } @@ -129,9 +125,6 @@ export class PublishNamespace { static async #decode(r: Reader, version: IetfVersion, negotiated: Negotiated): Promise { const requestId = await r.u62(); - if (version === Version.DRAFT_17) { - await r.u62(); // required_request_id_delta - } const trackNamespace = await Namespace.decode(r); const params = await decodeAdvert(r, version, "publish-namespace", negotiated); return new PublishNamespace({ requestId, trackNamespace, params }); @@ -142,52 +135,29 @@ export class PublishNamespace { * REQUEST_UPDATE (0x02) on a PUBLISH_NAMESPACE stream: the cluster parameters that * changed (draft-lcurley-moq-cluster, Updating an Advertisement). * - * Draft-17+ only: the extension negotiates on nothing earlier. The publisher sends one when - * a forwarded route's price or hop chain moves behind the same original publisher. + * The publisher sends one when a forwarded route's price or hop chain moves behind the same + * original publisher. */ export class PublishNamespaceUpdate { static id = 0x02; /** The update's own Request ID; every REQUEST_UPDATE consumes one. */ requestId: bigint; - /** Draft-16 only: the PUBLISH_NAMESPACE this updates, which later drafts name by stream. */ - namespaceRequestId?: bigint; update: Cluster.Update; /** A new AUTHORIZATION TOKEN, renewing the request's. Decoding leaves it unset. */ token?: Uint8Array; - constructor({ - requestId, - namespaceRequestId, - update, - token, - }: { - requestId: bigint; - namespaceRequestId?: bigint; - update: Cluster.Update; - token?: Uint8Array; - }) { + constructor({ requestId, update, token }: { requestId: bigint; update: Cluster.Update; token?: Uint8Array }) { this.requestId = requestId; - this.namespaceRequestId = namespaceRequestId; this.update = update; this.token = token; } async #encode(w: Writer, version: IetfVersion): Promise { - PublishNamespaceUpdate.#supported(version); await w.u62(this.requestId); - if (version === Version.DRAFT_16) { - if (this.namespaceRequestId === undefined) { - throw new Error("a draft-16 REQUEST_UPDATE names its PUBLISH_NAMESPACE"); - } - await w.u62(this.namespaceRequestId); - } - if (version === Version.DRAFT_17) { - await w.u62(0n); // required_request_id_delta (draft-17 only, removed in draft-18 per #1615) - } const params = new Parameters(); Cluster.updateIntoParams(this.update, params); - if (this.token) tokenIntoRequest(params, this.token, version); + if (this.token) tokenIntoRequest(params, this.token); await params.encode(w, version); } @@ -196,8 +166,7 @@ export class PublishNamespaceUpdate { } /** - * Decode the message. A truncated or trailing body, a draft with no such message, or a - * malformed parameter block surfaces as a {@link ProtocolViolation}, which the session + * Decode the message. A truncated or trailing body, or a malformed parameter block surfaces as a {@link ProtocolViolation}, which the session * dispatch closes over. A stream reset or session ending remains transport-local. */ static async decode(r: Reader, version: IetfVersion): Promise { @@ -219,204 +188,11 @@ export class PublishNamespaceUpdate { } } - static #supported(version: IetfVersion) { - if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { - throw new Error("REQUEST_UPDATE on PUBLISH_NAMESPACE requires draft-16+"); - } - } - static async #decode(r: Reader, version: IetfVersion): Promise { - PublishNamespaceUpdate.#supported(version); const requestId = await r.u62(); - const namespaceRequestId = version === Version.DRAFT_16 ? await r.u62() : undefined; - if (version === Version.DRAFT_17) { - await r.u62(); // required_request_id_delta (draft-17 only, removed in draft-18 per #1615) - } const params = await Parameters.decode(r, version, "publish-namespace"); // An epoch is fixed for the life of an advertisement, so no update carries one. if (params.epoch !== undefined) throw new ProtocolViolation("EPOCH on a PUBLISH_NAMESPACE update"); - return new PublishNamespaceUpdate({ requestId, namespaceRequestId, update: Cluster.updateFromParams(params) }); - } -} - -export class PublishNamespaceOk { - static id = 0x07; - - requestId: bigint; - - constructor({ requestId }: { requestId: bigint }) { - this.requestId = requestId; - } - - async #encode(w: Writer): Promise { - await w.u62(this.requestId); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, PublishNamespaceOk.#decode); - } - - static async #decode(r: Reader): Promise { - const requestId = await r.u62(); - return new PublishNamespaceOk({ requestId }); - } -} - -export class PublishNamespaceError { - static id = 0x08; - - requestId: bigint; - errorCode: number; - reasonPhrase: string; - - constructor({ - requestId, - errorCode, - reasonPhrase, - }: { requestId: bigint; errorCode: number; reasonPhrase: string }) { - this.requestId = requestId; - this.errorCode = errorCode; - this.reasonPhrase = reasonPhrase; - } - - async #encode(w: Writer): Promise { - await w.u62(this.requestId); - await w.u62(BigInt(this.errorCode)); - await w.string(this.reasonPhrase); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, PublishNamespaceError.#decode); - } - - static async #decode(r: Reader): Promise { - const requestId = await r.u62(); - const errorCode = Number(await r.u62()); - const reasonPhrase = await r.string(); - return new PublishNamespaceError({ requestId, errorCode, reasonPhrase }); - } -} - -// Removed in d17 -export class PublishNamespaceCancel { - static id = 0x0c; - - trackNamespace: Path.Valid; - requestId: bigint; // v16: uses request_id instead of track_namespace - errorCode: number; - reasonPhrase: string; - - constructor({ - trackNamespace = "" as Path.Valid, - errorCode = 0, - reasonPhrase = "", - requestId = 0n, - }: { - trackNamespace?: Path.Valid; - errorCode?: number; - reasonPhrase?: string; - requestId?: bigint; - } = {}) { - this.trackNamespace = trackNamespace; - this.requestId = requestId; - this.errorCode = errorCode; - this.reasonPhrase = reasonPhrase; - } - - async #encode(w: Writer, version: IetfVersion): Promise { - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) { - throw new Error("PublishNamespaceCancel removed in draft-17+"); - } - if (version === Version.DRAFT_16) { - await w.u62(this.requestId); - } else { - await Namespace.encode(w, this.trackNamespace); - } - await w.u62(BigInt(this.errorCode)); - await w.string(this.reasonPhrase); - } - - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (wr) => this.#encode(wr, version)); - } - - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (rd) => PublishNamespaceCancel.#decode(rd, version)); - } - - static async #decode(r: Reader, version: IetfVersion): Promise { - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) { - throw new Error("PublishNamespaceCancel removed in draft-17+"); - } - let trackNamespace = "" as Path.Valid; - let requestId = 0n; - if (version === Version.DRAFT_16) { - requestId = await r.u62(); - } else { - trackNamespace = await Namespace.decode(r); - } - const errorCode = Number(await r.u62()); - const reasonPhrase = await r.string(); - return new PublishNamespaceCancel({ trackNamespace, errorCode, reasonPhrase, requestId }); - } -} - -// In draft-14, UNANNOUNCE is renamed to PUBLISH_NAMESPACE_DONE -// In draft-16, uses request_id instead of track_namespace -// Removed in d17 -export class PublishNamespaceDone { - static readonly id = 0x09; - - trackNamespace: Path.Valid; - requestId: bigint; // v16: uses request_id instead of track_namespace - - constructor({ - trackNamespace = "" as Path.Valid, - requestId = 0n, - }: { - trackNamespace?: Path.Valid; - requestId?: bigint; - } = {}) { - this.trackNamespace = trackNamespace; - this.requestId = requestId; - } - - async #encode(w: Writer, version: IetfVersion): Promise { - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) { - throw new Error("PublishNamespaceDone removed in draft-17+"); - } - if (version === Version.DRAFT_16) { - await w.u62(this.requestId); - } else { - await Namespace.encode(w, this.trackNamespace); - } - } - - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (wr) => this.#encode(wr, version)); - } - - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (rd) => PublishNamespaceDone.#decode(rd, version)); - } - - static async #decode(r: Reader, version: IetfVersion): Promise { - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) { - throw new Error("PublishNamespaceDone removed in draft-17+"); - } - if (version === Version.DRAFT_16) { - const requestId = await r.u62(); - return new PublishNamespaceDone({ requestId }); - } - const trackNamespace = await Namespace.decode(r); - return new PublishNamespaceDone({ trackNamespace }); + return new PublishNamespaceUpdate({ requestId, update: Cluster.updateFromParams(params) }); } } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 93725115f6d..72ca463f41f 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -15,7 +15,6 @@ import { Reader, Stream, Writer } from "../stream.ts"; import { Timescale, Timestamp } from "../time.ts"; import type { Producer as TrackProducer } from "../track.ts"; import { wireOf } from "../wire.ts"; -import { ControlStreamAdapter, NativeSession, type Session } from "./adapter.ts"; import type * as Cluster from "./cluster.ts"; import { ObjectDatagram } from "./datagram.ts"; import { toRequestCode } from "./error.ts"; @@ -25,24 +24,13 @@ import { Frame, Group as GroupMessage } from "./object.ts"; import { Parameters } from "./parameters.ts"; import { toWire } from "./priority.ts"; import { PublishDone, PublishDoneStatus } from "./publish.ts"; -import { - PublishNamespace, - PublishNamespaceCancel, - PublishNamespaceDone, - PublishNamespaceUpdate, -} from "./publish_namespace.ts"; +import { PublishNamespace, PublishNamespaceUpdate } from "./publish_namespace.ts"; import { Publisher } from "./publisher.ts"; import { RequestError, RequestOk } from "./request.ts"; -import { GroupOrder, Subscribe, SubscribeError, SubscribeOk, SubscribeUpdate, Unsubscribe } from "./subscribe.ts"; -import { - SubscribeNamespace, - SubscribeNamespaceEntry, - SubscribeNamespaceEntryDone, - SubscribeNamespaceLegacy, - SubscribeNamespaceOk, - UnsubscribeNamespace, -} from "./subscribe_namespace.ts"; -import { TRACK_STATUS_ERROR_14, TrackStatusOk, TrackStatusRequest } from "./track.ts"; +import { NativeSession, type Session } from "./session.ts"; +import { GroupOrder, Subscribe, SubscribeOk, SubscribeUpdate } from "./subscribe.ts"; +import { SubscribeNamespace, SubscribeNamespaceEntry, SubscribeNamespaceEntryDone } from "./subscribe_namespace.ts"; +import { TrackStatusOk, TrackStatusRequest } from "./track.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; function publish(origin: OriginProducer, path: Path.Valid) { @@ -136,7 +124,7 @@ async function readPublishNamespace(stream: Stream): Promise { /** Answer a PUBLISH_NAMESPACE, which is what unblocks the announce loop. */ async function acceptPublishNamespace(stream: Stream): Promise { await stream.writer.u53(RequestOk.id); - await new RequestOk({ requestId: undefined }).encode(stream.writer, VERSION); + await new RequestOk().encode(stream.writer, VERSION); } /** @@ -151,8 +139,7 @@ async function declinePublishNamespace(stream: Stream, retryInterval = 1n): Prom try { await stream.writer.u53(RequestError.id); await new RequestError({ - requestId: undefined, - // UNINTERESTED, draft-22 section 16.11.2. + // UNINTERESTED, draft-19 section 15.11.2. errorCode: 0x20, reasonPhrase: "no", retryInterval, @@ -205,14 +192,7 @@ function publisher( }; } -const IETF_VERSIONS = [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, -] as const; +const IETF_VERSIONS = [Version.DRAFT_18, Version.DRAFT_22] as const; /** A request stream recording what the publisher writes, with the requester's side under test control. */ function requestStream(version: IetfVersion) { @@ -238,8 +218,8 @@ function requestStream(version: IetfVersion) { send: (bytes: Uint8Array) => input.enqueue(bytes), cancel: () => input.error(new Error("request cancelled")), /** - * End the request as `version` cancels one: by FIN through draft 18, which is what the - * adapter turns UNSUBSCRIBE into on drafts 14 to 16, and by a reset after. + * End the request as `version` cancels one: by FIN through draft 18, and by a reset + * after. */ withdraw: () => (version <= Version.DRAFT_18 ? input.close() : input.error(new Error("request cancelled"))), }; @@ -256,10 +236,6 @@ function trackStatus(namespace: string, propertiesWanted = true) { /** The error code of the refusal on `wire`. */ async function refusalCode(wire: Reader, version: IetfVersion): Promise { - if (version === Version.DRAFT_14) { - expect(await wire.u53()).toBe(TRACK_STATUS_ERROR_14); - return (await SubscribeError.decode(wire, version)).errorCode; - } expect(await wire.u53()).toBe(RequestError.id); return (await RequestError.decode(wire, version)).errorCode; } @@ -289,24 +265,20 @@ test("TRACK_STATUS is answered on every draft without subscribing", async () => expect(subscribed).not.toHaveBeenCalled(); const r = wire(); - expect(await r.u53()).toBe(TrackStatusOk.id(version)); + expect(await r.u53()).toBe(TrackStatusOk.id); const ok = await TrackStatusOk.decode(r, version); expect(await r.done()).toBe(true); - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; - expect(ok.requestId).toBe(legacy ? 7n : undefined); expect(ok.largest).toEqual({ groupId: 4n, objectId: 1n }); const expected = - version === Version.DRAFT_14 || version === Version.DRAFT_15 - ? { groupOrder: GroupOrder.Descending } - : version < Version.DRAFT_18 || (!wanted && version >= Version.DRAFT_22) - ? {} - : { - timescale: Timescale.MILLI, - priority: toWire(2), - groupOrder: GroupOrder.Descending, - maxCacheDuration: 5000n, - }; + !wanted && version >= Version.DRAFT_22 + ? {} + : { + timescale: Timescale.MILLI, + priority: toWire(2), + groupOrder: GroupOrder.Descending, + maxCacheDuration: 5000n, + }; expect(ok.properties).toEqual(expected); origin.close(); } @@ -324,7 +296,7 @@ test("TRACK_STATUS for an empty track names no largest", async () => { const { stream, wire } = requestStream(version); await pub.runTrackStatusRequest(trackStatus("room"), stream); const r = wire(); - expect(await r.u53()).toBe(TrackStatusOk.id(version)); + expect(await r.u53()).toBe(TrackStatusOk.id); expect((await TrackStatusOk.decode(r, version)).largest).toBeUndefined(); origin.close(); }); @@ -511,7 +483,7 @@ test("a SUBSCRIBE withdrawn during setup releases its demand on every draft", as // A resume subscribes again as a new SUBSCRIBE does, so the requester leaving while the // resumed track is never answered ends the request at once too. test("a SUBSCRIBE withdrawn while resuming releases its demand", async () => { - for (const version of IETF_VERSIONS.filter((version) => version > Version.DRAFT_16)) { + for (const version of IETF_VERSIONS) { const fx = fixture(version); const client = await Stream.open(fx.pair.client, { version }); const server = await Stream.accept(fx.pair.server, version); @@ -581,11 +553,11 @@ test("FETCH gets NOT_SUPPORTED", async () => { return written.flatMap((chunk) => Array.from(chunk)); }; - for (const version of [Version.DRAFT_14, Version.DRAFT_16, Version.DRAFT_22] as const) { + for (const version of [Version.DRAFT_22] as const) { const fetch = await refusal(version, (pub, stream) => pub.runFetch(new Fetch({ requestId: 7n }), stream)); // FETCH_ERROR on draft-14, REQUEST_ERROR after; the code follows the Length and any Request ID. - expect(fetch[0]).toBe(version === Version.DRAFT_14 ? 0x19 : 0x05); - expect(fetch[version <= Version.DRAFT_16 ? 4 : 3]).toBe(0x3); + expect(fetch[0]).toBe(0x05); + expect(fetch[3]).toBe(0x3); } }); @@ -680,7 +652,7 @@ test("reserved namespace requests get DOES_NOT_EXIST while other dot namespaces expect(await wire.u53()).toBe(RequestError.id); expect((await RequestError.decode(wire, version)).errorCode).toBe(0x10); } else { - expect(await wire.u53()).toBe(TrackStatusOk.id(version)); + expect(await wire.u53()).toBe(TrackStatusOk.id); } } finally { request.cancel(); @@ -697,7 +669,7 @@ test("reserved namespace requests get DOES_NOT_EXIST while other dot namespaces /** A request's USE_VALUE token decodes but grants nothing yet, so a SUBSCRIBE outside the grant is still refused. */ test("a request token does not widen the grant", async () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_22] as const) { + for (const version of [Version.DRAFT_22] as const) { const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const nothing = new Signal({ @@ -708,10 +680,8 @@ test("a request token does not widen the grant", async () => { publish(origin, Path.from("room")); const ascii = (text: string) => [...new TextEncoder().encode(text)]; - // Draft-14 carries priority, group order, forward, and a Largest Object filter inline. - const fields = version === Version.DRAFT_14 ? [0x80, 0x02, 0x01, 0x02] : []; const token = [0x03, 0x00, 0xaa]; // USE_VALUE, type 0 - const body = [0, 1, 4, ...ascii("room"), 5, ...ascii("video"), ...fields, 1, 0x03, token.length, ...token]; + const body = [0, 1, 4, ...ascii("room"), 5, ...ascii("video"), 1, 0x03, token.length, ...token]; const msg = await Subscribe.decode( new Reader(undefined, new Uint8Array([0, body.length, ...body]), version), version, @@ -734,7 +704,7 @@ test("a request token does not widen the grant", async () => { // SUBSCRIBE_ERROR on draft-14, REQUEST_ERROR after; the code follows the Length and any Request ID. const wire = written.flatMap((chunk) => Array.from(chunk)); expect(wire[0]).toBe(0x05); - expect(wire[version === Version.DRAFT_14 ? 4 : 3]).toBe(toRequestCode("unauthorized", "subscribe", version)); + expect(wire[3]).toBe(toRequestCode("unauthorized", "subscribe", version)); } }); @@ -1136,43 +1106,6 @@ test("a declined advertisement is retried on the next change", async () => { origin.close(); }); -/** The solicited legacy path carries a route's token too, so closing it remakes the request. */ -test("closing a solicited legacy advertisement's token makes it again without it", async () => { - // No retry timer can stand in for the wake. - jest.useFakeTimers(); - const pair = createMockTransportPair(ALPN.DRAFT_18); - const session = new NativeSession(pair.server, Version.DRAFT_15, true); - const tokens = new Tokens(); - const { pub, origin } = publisher(pair.server, { requiresSolicitation: true, session, auth: tokens }); - const { sent, restore } = carriedTokens(); - try { - const token = tokens.issue("first"); - origin.createBroadcast(Path.from("mine")).announce({ auth: token }); - - const subscription = await Stream.open(pair.client, { version: Version.DRAFT_15 }); - const accepted = await Stream.accept(pair.server, Version.DRAFT_15); - if (!accepted) throw new Error("the subscription stream was never accepted"); - void pub.runSubscribeNamespace(new SubscribeNamespace({ requestId: 0n, namespace: Path.empty() }), accepted); - - const old = await nextStream(pair.client); - if (!old) throw new Error("never advertised"); - expect(await readPublishNamespace(old)).toBe(Path.from("mine")); - await acceptPublishNamespace(old); - await flush(); - token.close(); - - const again = await nextStream(pair.client); - if (!again) throw new Error("never advertised again"); - expect(await readPublishNamespace(again)).toBe(Path.from("mine")); - expect(sent).toEqual(["first", undefined]); - subscription.close(); - } finally { - restore(); - origin.close(); - jest.useRealTimers(); - } -}); - /** * A SUBSCRIBE_NAMESPACE below an advertised route still hears that route: it serves the * requested prefix, so it lands as the empty suffix, then paths beneath the prefix follow @@ -1663,8 +1596,8 @@ test("a request pinned to another epoch is refused DOES_NOT_EXIST", async () => }; // Control: the named instance, or none at all, is served. - expect(await (await status("room", epoch)).u53()).toBe(TrackStatusOk.id(VERSION)); - expect(await (await status("room", undefined)).u53()).toBe(TrackStatusOk.id(VERSION)); + expect(await (await status("room", epoch)).u53()).toBe(TrackStatusOk.id); + expect(await (await status("room", undefined)).u53()).toBe(TrackStatusOk.id); const missing = toRequestCode("does_not_exist", "track_status", VERSION); expect(await refusalCode(await status("room", Epoch.mint()), VERSION)).toBe(missing); @@ -2034,246 +1967,6 @@ test("swapping a route's token is one REQUEST_UPDATE carrying it", async () => { } }); -/** - * Draft 16 renews in place too: the REQUEST_UPDATE names the PUBLISH_NAMESPACE it renews, - * and the adapter routes its answer back, so the request is neither withdrawn nor made again. - */ -test("draft 16 swaps a route's token with one REQUEST_UPDATE", async () => { - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPNS[version]); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, false); - void adapter.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const tokens = new Tokens(); - const { pub, origin } = publisher(pair.server, { session: adapter, auth: tokens }); - const { sent, restore } = carriedTokens(); - try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ auth: tokens.issue("first") }); - void pub.runPublishNamespaces(); - - expect(await peer.reader.u53()).toBe(PublishNamespace.id); - const advertised = await PublishNamespace.decode(peer.reader, version); - await peer.writer.u53(RequestOk.id); - await new RequestOk({ requestId: advertised.requestId }).encode(peer.writer, version); - - // The next renewal waits on the answer to this one, which only routing delivers. - for (const token of ["second", "third"]) { - broadcast.announce({ auth: tokens.issue(token) }); - expect(await peer.reader.u53()).toBe(PublishNamespaceUpdate.id); - const update = await PublishNamespaceUpdate.decode(peer.reader, version); - expect(update.namespaceRequestId).toBe(advertised.requestId); - await peer.writer.u53(RequestOk.id); - await new RequestOk({ requestId: update.requestId }).encode(peer.writer, version); - } - - // Answered and still held: nothing withdrawn or advertised again. - const more = watch(peer.reader.u53()); - await flush(); - expect(more.state).toBe("pending"); - expect(sent).toEqual(["first", "second", "third"]); - } finally { - restore(); - origin.close(); - adapter.close(); - } -}); - -/** - * A draft-16 request rides the control stream, where dropping it tells the peer nothing, so - * an unanswered update withdraws it with PUBLISH_NAMESPACE_DONE before offering it again. - */ -test("an unanswered draft 16 update withdraws its request before offering it again", async () => { - jest.useFakeTimers(); - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPNS[version]); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, false); - void adapter.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const tokens = new Tokens(); - const { pub, origin } = publisher(pair.server, { session: adapter, auth: tokens }); - const warn = spyOn(console, "warn").mockImplementation(() => void 0); - try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ auth: tokens.issue("first") }); - void pub.runPublishNamespaces(); - - expect(await peer.reader.u53()).toBe(PublishNamespace.id); - const advertised = await PublishNamespace.decode(peer.reader, version); - await peer.writer.u53(RequestOk.id); - await new RequestOk({ requestId: advertised.requestId }).encode(peer.writer, version); - - broadcast.announce({ auth: tokens.issue("second") }); - expect(await peer.reader.u53()).toBe(PublishNamespaceUpdate.id); - await PublishNamespaceUpdate.decode(peer.reader, version); - - const next = watch(peer.reader.u53()); - await advanceUntil(() => next.state !== "pending", 10_100); - expect(next.value).toBe(PublishNamespaceDone.id); - expect((await PublishNamespaceDone.decode(peer.reader, version)).requestId).toBe(advertised.requestId); - - const fresh = watch(peer.reader.u53()); - await advanceUntil(() => fresh.state !== "pending", 30_000); - expect(fresh.value).toBe(PublishNamespace.id); - } finally { - warn.mockRestore(); - origin.close(); - adapter.close(); - jest.useRealTimers(); - } -}); - -/** - * A draft-14-16 request rides the control stream, where the deadline's reset tells the peer - * nothing, so an advertisement the peer never answers is withdrawn with PUBLISH_NAMESPACE_DONE. - * It stays terminal: nothing is offered again. - */ -test.each([ - ["14", Version.DRAFT_14], - ["15", Version.DRAFT_15], - ["16", Version.DRAFT_16], -] as const)("an unanswered draft %s advertisement is withdrawn at its deadline", async (_, version) => { - jest.useFakeTimers(); - const pair = createMockTransportPair(ALPNS[version]); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, false); - void adapter.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const { pub, origin } = publisher(pair.server, { session: adapter }); - const warn = spyOn(console, "warn").mockImplementation(() => void 0); - try { - origin.createBroadcast(Path.from("mine")).announce(); - void pub.runPublishNamespaces(); - - expect(await peer.reader.u53()).toBe(PublishNamespace.id); - const advertised = await PublishNamespace.decode(peer.reader, version); - - const next = watch(peer.reader.u53()); - await advance(9_950); - expect(next.state).toBe("pending"); - await advanceUntil(() => next.state !== "pending", 100); - expect(next.value).toBe(PublishNamespaceDone.id); - const done = await PublishNamespaceDone.decode(peer.reader, version); - if (version === Version.DRAFT_16) expect(done.requestId).toBe(advertised.requestId); - else expect(done.trackNamespace).toBe(advertised.trackNamespace); - - const more = watch(peer.reader.u53()); - await advance(30_000); - expect(more.state).toBe("pending"); - } finally { - warn.mockRestore(); - origin.close(); - adapter.close(); - jest.useRealTimers(); - } -}); - -/** - * A draft-16 PUBLISH_NAMESPACE_CANCEL can beat the answer to our update. The peer already let - * the namespace go, and draft 16 sends no PUBLISH_NAMESPACE_DONE after a cancel, so the next - * message is the namespace offered again. - */ -test("a draft 16 cancel ahead of the answer offers the namespace again without DONE", async () => { - jest.useFakeTimers(); - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPNS[version]); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, false); - void adapter.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const tokens = new Tokens(); - const { pub, origin } = publisher(pair.server, { session: adapter, auth: tokens }); - const warn = spyOn(console, "warn").mockImplementation(() => void 0); - try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ auth: tokens.issue("first") }); - void pub.runPublishNamespaces(); - - expect(await peer.reader.u53()).toBe(PublishNamespace.id); - const advertised = await PublishNamespace.decode(peer.reader, version); - await peer.writer.u53(RequestOk.id); - await new RequestOk({ requestId: advertised.requestId }).encode(peer.writer, version); - - broadcast.announce({ auth: tokens.issue("second") }); - expect(await peer.reader.u53()).toBe(PublishNamespaceUpdate.id); - await PublishNamespaceUpdate.decode(peer.reader, version); - await peer.writer.u53(PublishNamespaceCancel.id); - await new PublishNamespaceCancel({ requestId: advertised.requestId, reasonPhrase: "gone" }).encode( - peer.writer, - version, - ); - - const next = watch(peer.reader.u53()); - await advanceUntil(() => next.state !== "pending", 30_000); - expect(next.value).toBe(PublishNamespace.id); - } finally { - warn.mockRestore(); - origin.close(); - adapter.close(); - jest.useRealTimers(); - } -}); - -/** - * A draft-16 receiver refuses an update with REQUEST_ERROR and then PUBLISH_NAMESPACE_CANCEL. - * Draft 16 sends no PUBLISH_NAMESPACE_DONE after a cancel, so the next message is the - * namespace offered again once the refusal's wait is out. - */ -test("a refused draft 16 update offers the namespace again without DONE", async () => { - jest.useFakeTimers(); - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPNS[version]); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, false); - void adapter.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const tokens = new Tokens(); - const { pub, origin } = publisher(pair.server, { session: adapter, auth: tokens }); - const warn = spyOn(console, "warn").mockImplementation(() => void 0); - try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ auth: tokens.issue("first") }); - void pub.runPublishNamespaces(); - - expect(await peer.reader.u53()).toBe(PublishNamespace.id); - const advertised = await PublishNamespace.decode(peer.reader, version); - await peer.writer.u53(RequestOk.id); - await new RequestOk({ requestId: advertised.requestId }).encode(peer.writer, version); - - broadcast.announce({ auth: tokens.issue("second") }); - expect(await peer.reader.u53()).toBe(PublishNamespaceUpdate.id); - const update = await PublishNamespaceUpdate.decode(peer.reader, version); - await peer.writer.u53(RequestError.id); - await new RequestError({ - requestId: update.requestId, - errorCode: 0x1, - reasonPhrase: "no", - retryInterval: 1n, - }).encode(peer.writer, version); - await peer.writer.u53(PublishNamespaceCancel.id); - await new PublishNamespaceCancel({ requestId: advertised.requestId, reasonPhrase: "no" }).encode( - peer.writer, - version, - ); - - const next = watch(peer.reader.u53()); - await advanceUntil(() => next.state !== "pending", 30_000); - expect(next.value).toBe(PublishNamespace.id); - } finally { - warn.mockRestore(); - origin.close(); - adapter.close(); - jest.useRealTimers(); - } -}); - /** A token another session issued is never carried, so swapping between two sends nothing. */ test("a foreign route token is not carried", async () => { const foreign = new Tokens(); @@ -2357,7 +2050,7 @@ test("a republish withdraws the PUBLISH_NAMESPACE and advertises again", async ( republish(origin, broadcast); - // Draft-17+ withdraws by cancelling the request, never a FIN. + // Draft-18+ withdraws by cancelling the request, never a FIN. expect(await old.reader.done().catch(error)).toMatchObject({ code: StreamCode.Cancel }); const next = await take(streams); expect(await acceptClustered(next)).toMatchObject({ cost: 4n }); @@ -2602,13 +2295,7 @@ test.each([ }); test("subscription completion sends PUBLISH_DONE on every supported draft", async () => { - const versions = [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - ] as const; + const versions = [Version.DRAFT_18, Version.DRAFT_22] as const; for (const version of versions) { // A revocation bridged from upstream keeps its meaning: UNAUTHORIZED, not INTERNAL_ERROR. @@ -2645,7 +2332,6 @@ test("subscription completion sends PUBLISH_DONE on every supported draft", asyn expect(await client.reader.u53()).toBe(PublishDone.id); const done = await PublishDone.decode(client.reader, version); - expect(done.requestId).toBe(version <= Version.DRAFT_16 ? requestId : undefined); expect(done.statusCode).toBe(status); await running; @@ -2656,113 +2342,8 @@ test("subscription completion sends PUBLISH_DONE on every supported draft", asyn } }); -// The adapter delivers a draft-14 to -16 update to its subscription's stream. Left unread, it -// keeps that stream from reporting closed, so a later UNSUBSCRIBE would never end the subscription. -// Applied, it reprices the subscription, which drafts 15 and 16 answer with REQUEST_OK naming it. -test("drafts 14 to 16 apply an update and still end a subscription on UNSUBSCRIBE", async () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16] as const) { - const pair = createMockTransportPair(ALPNS[version]); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, false); - void adapter.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - - const path = Path.from("test"); - const { pub, origin } = publisher(pair.server, { session: adapter }); - const broadcast = publish(origin, path); - const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - - await peer.writer.u53(Subscribe.id); - await new Subscribe({ requestId: 0n, trackNamespace: path, trackName: "video", subscriberPriority: 0 }).encode( - peer.writer, - version, - ); - const server = await adapter.acceptBi(); - if (!server) throw new Error("no subscribe stream"); - expect(await server.reader.u53()).toBe(Subscribe.id); - const running = pub.runSubscribe(await Subscribe.decode(server.reader, version), server); - - expect(await peer.reader.u53()).toBe(SubscribeOk.id); - await SubscribeOk.decode(peer.reader, version); - - const parameters = new Parameters(); - parameters.subscriberPriority = 10; - await peer.writer.u53(SubscribeUpdate.id); - await new SubscribeUpdate({ requestId: 0n, ownRequestId: 2n, parameters }).encode(peer.writer, version); - if (version !== Version.DRAFT_14) { - expect(await peer.reader.u53()).toBe(RequestOk.id); - expect((await RequestOk.decode(peer.reader, version)).requestId).toBe(2n); - } - while (track.subscription.peek()?.priority !== 117) await track.subscription.changed(); - - await peer.writer.u53(Unsubscribe.id); - await new Unsubscribe({ requestId: 0n }).encode(peer.writer, version); - - await running; - broadcast.close(); - origin.close(); - adapter.close(); - } -}); - -// Drafts 14 to 16 define no namespace update, so one the adapter routes onto a namespace -// subscription is refused, by name on drafts 15 and 16, and the subscription stands until -// the peer ends it. -test("drafts 14 to 16 refuse a namespace update without ending the subscription", async () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16] as const) { - const pair = createMockTransportPair(ALPNS[version]); - const control = await Stream.open(pair.server, { version }); - const adapter = new ControlStreamAdapter(pair.server, control, version, 100n, false); - void adapter.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - - const { pub, origin } = publisher(pair.server, { session: adapter, requiresSolicitation: true }); - // Draft 16 asks on a stream of its own, beyond the adapter's reach, so the adapter - // answers its update itself. Earlier drafts ride the control stream. - const ask = version === Version.DRAFT_16 ? await Stream.open(pair.client, { version }) : peer; - await ask.writer.u53(SubscribeNamespaceLegacy.id); - await new SubscribeNamespaceLegacy({ requestId: 0n, namespace: Path.from("") }).encode(ask.writer, version); - const server = await adapter.acceptBi(); - if (!server) throw new Error("no namespace stream"); - expect(await server.reader.u53()).toBe(SubscribeNamespaceLegacy.id); - const asked = await SubscribeNamespaceLegacy.decode(server.reader, version); - const running = pub.runSubscribeNamespace( - new SubscribeNamespace({ requestId: asked.requestId, namespace: asked.namespace }), - server, - ); - - expect(await ask.reader.u53()).toBe(version === Version.DRAFT_14 ? SubscribeNamespaceOk.id : RequestOk.id); - await ask.reader.read(await ask.reader.u16()); - - await peer.writer.u53(SubscribeUpdate.id); - await new SubscribeUpdate({ requestId: 0n, ownRequestId: 2n }).encode(peer.writer, version); - if (version !== Version.DRAFT_14) { - expect(await peer.reader.u53()).toBe(RequestError.id); - const refusal = await RequestError.decode(peer.reader, version); - expect(refusal.requestId).toBe(2n); - expect(refusal.errorCode).toBe(0x03); - } - if (version === Version.DRAFT_16) { - // Still subscribed: a new broadcast is advertised inline. - const broadcast = publish(origin, Path.from("after-update")); - expect(await ask.reader.u53()).toBe(SubscribeNamespaceEntry.id); - expect((await SubscribeNamespaceEntry.decode(ask.reader, version)).suffix).toBe(Path.from("after-update")); - broadcast.close(); - ask.writer.close(); - } else { - await peer.writer.u53(UnsubscribeNamespace.id); - await new UnsubscribeNamespace({ prefix: asked.namespace, requestId: 0n }).encode(peer.writer, version); - } - await running; - origin.close(); - adapter.close(); - } -}); - /** Draft-22 is the only version whose Location Filters and fills the publisher acts on. */ -const V20 = Version.DRAFT_22; +const V22 = Version.DRAFT_22; /** The PUBLISH_DONE status for a subscription the track itself ended. */ const TRACK_ENDED_STATUS = 0x2; @@ -2797,10 +2378,6 @@ interface ServedFill { /** The subprotocol each draft negotiates, so the mock pair names the version under test. */ const ALPNS: Record = { - [Version.DRAFT_14]: ALPN.DRAFT_14, - [Version.DRAFT_15]: ALPN.DRAFT_15, - [Version.DRAFT_16]: ALPN.DRAFT_16, - [Version.DRAFT_17]: ALPN.DRAFT_17, [Version.DRAFT_18]: ALPN.DRAFT_18, [Version.DRAFT_22]: ALPN.DRAFT_22, }; @@ -2813,7 +2390,7 @@ const ALPNS: Record = { * queues, but taking the reader late races the publisher rather than the test. */ function fixture( - version: IetfVersion = V20, + version: IetfVersion = V22, grant?: Signal, ): { pair: ReturnType; @@ -2889,8 +2466,8 @@ async function nextUni( /** Read a group stream to its end. */ async function readGroup(stream: ReadableStream | Reader): Promise { - const reader = stream instanceof Reader ? stream : new Reader(stream, undefined, V20); - const header = await GroupMessage.decode(reader, V20); + const reader = stream instanceof Reader ? stream : new Reader(stream, undefined, V22); + const header = await GroupMessage.decode(reader, V22); // Decoded by hand rather than through Frame: a filter that trims a group's head puts the // first object's absolute id in the delta, which Frame.decode refuses on principle. @@ -2916,8 +2493,8 @@ async function readGroup(stream: ReadableStream | Reader): Promise): Promise { - const reader = new Reader(stream, undefined, V20); - const header = await GroupMessage.decode(reader, V20); + const reader = new Reader(stream, undefined, V22); + const header = await GroupMessage.decode(reader, V22); expect(header.flags.hasEnd).toBe(false); const frame = await reader.decode((c) => Frame.decode(c, header.flags, undefined)); expect(frame.endOfTrack).toBe(true); @@ -2935,7 +2512,7 @@ async function readFill( stream: ReadableStream | Reader, order: "ascending" | "descending" = "descending", ): Promise { - const reader = stream instanceof Reader ? stream : new Reader(stream, undefined, V20); + const reader = stream instanceof Reader ? stream : new Reader(stream, undefined, V22); const objects: { group: number; id: number; payload: string }[] = []; let group = 0; let id = 0; @@ -2945,7 +2522,7 @@ async function readFill( let header: { type: number; requestId: bigint } | undefined; try { const type = await reader.u53(); - header = { type, requestId: (await FetchHeader.decode(reader, V20)).requestId }; + header = { type, requestId: (await FetchHeader.decode(reader, V22)).requestId }; } catch (err) { return { objects, reset: error(err) }; } @@ -3378,9 +2955,7 @@ async function subscribeOkLargest(version: IetfVersion): Promise { expect(await subscribeOkLargest(version)).toEqual({ groupId: 5n, objectId: 0n }); @@ -3420,8 +2995,8 @@ test("draft-22: an opt-out peer gets no track properties", async () => { const served = await nextUni(fx.uni); if (!served) throw new Error("the group was never served"); - const reader = new Reader(served, undefined, V20); - const header = await GroupMessage.decode(reader, V20); + const reader = new Reader(served, undefined, V22); + const header = await GroupMessage.decode(reader, V22); expect(header.flags.hasExtensions).toBe(propertiesWanted); await reader.u53(); // object id delta @@ -3441,36 +3016,32 @@ test("draft-22: an opt-out peer gets no track properties", async () => { * Through draft-15 SUBSCRIBE_OK restates the order the subscription is served in; later * drafts announce the publisher's default, newest first. */ -test.each([ - [Version.DRAFT_14, GroupOrder.Ascending], - [Version.DRAFT_15, GroupOrder.Ascending], - [Version.DRAFT_17, GroupOrder.Descending], -] as const)("%s: SUBSCRIBE_OK announces the served order", async (version, announced) => { - const fx = fixture(version); - fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); +test.each([[Version.DRAFT_18, GroupOrder.Descending]] as const)( + "%s: SUBSCRIBE_OK announces the served order", + async (version, announced) => { + const fx = fixture(version); + fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const { client, ok } = await runSubscribe( - fx, - new Subscribe({ - requestId: 7n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 0, - groupOrder: "ascending", - }), - ); - expect(ok.properties.groupOrder).toBe(announced); + const { client, ok } = await runSubscribe( + fx, + new Subscribe({ + requestId: 7n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + groupOrder: "ascending", + }), + ); + expect(ok.properties.groupOrder).toBe(announced); - fx.close(); - client.close(); -}); + fx.close(); + client.close(); + }, +); -/** - * Drafts 14-16 cannot send TIMESCALE, so a subscribed group carries no Timestamp even though - * the track has units. Draft-17 declares the units and stamps the object. - */ -test("drafts 14-16 send no Timestamp without TIMESCALE", async () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_17] as const) { +/** A track with units declares TIMESCALE in SUBSCRIBE_OK and stamps each object. */ +test("a timed track declares TIMESCALE and stamps its objects", async () => { + for (const version of [Version.DRAFT_18] as const) { const fx = fixture(version); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); const { client, ok } = await runSubscribe( @@ -3483,22 +3054,19 @@ test("drafts 14-16 send no Timestamp without TIMESCALE", async () => { }), ); - const stamped = version >= Version.DRAFT_17; - expect(ok.properties.timescale !== undefined).toBe(stamped); + expect(ok.properties.timescale !== undefined).toBe(true); writeGroup(track, 1); const served = await nextUni(fx.uni); if (!served) throw new Error("the group was never served"); const reader = new Reader(served, undefined, version); const header = await GroupMessage.decode(reader, version); - expect(header.flags.hasExtensions).toBe(stamped); + expect(header.flags.hasExtensions).toBe(true); await reader.u53(); // object id delta - if (stamped) { - const length = await reader.u53(); - expect(length).toBeGreaterThan(0); - await reader.read(length); - } + const length = await reader.u53(); + expect(length).toBeGreaterThan(0); + await reader.read(length); expect(await reader.read(await reader.u53())).toEqual(new TextEncoder().encode("0.0")); fx.close(); @@ -3524,8 +3092,8 @@ test.each([false, true])("draft-22: exact terminal 5/5 survives group FIN=%s", a for (let i = 0; i < 5; i++) group.writeFrame({ payload: new Uint8Array([i]) }); const uni = await fx.uni.read(); if (!uni.value) throw new Error("missing group stream"); - const reader = new Reader(uni.value, undefined, V20); - const header = await GroupMessage.decode(reader, V20); + const reader = new Reader(uni.value, undefined, V22); + const header = await GroupMessage.decode(reader, V22); expect(header.groupId).toBe(5); for (let i = 0; i < 5; i++) { const frame = await reader.decode((c) => Frame.decode(c, header.flags, undefined)); @@ -3542,8 +3110,8 @@ test.each([false, true])("draft-22: exact terminal 5/5 survives group FIN=%s", a if (finished) { const marker = await fx.uni.read(); if (!marker.value) throw new Error("missing terminal stream"); - const end = new Reader(marker.value, undefined, V20); - const endHeader = await GroupMessage.decode(end, V20); + const end = new Reader(marker.value, undefined, V22); + const endHeader = await GroupMessage.decode(end, V22); expect(endHeader.groupId).toBe(5); expect(endHeader.flags.hasEnd).toBe(false); expect(endHeader.flags.firstObject).toBe(false); @@ -3558,7 +3126,7 @@ test.each([false, true])("draft-22: exact terminal 5/5 survives group FIN=%s", a expect(await reader.done()).toBe(true); } expect(await client.reader.u53()).toBe(PublishDone.id); - expect((await PublishDone.decode(client.reader, V20)).streamCount).toBe(finished ? 2n : 1n); + expect((await PublishDone.decode(client.reader, V22)).streamCount).toBe(finished ? 2n : 1n); } finally { fx.close(); client.close(); @@ -3591,8 +3159,8 @@ test("draft-22: an inline terminal reset after its FIN falls back to a standalon for (let i = 0; i < 5; i++) group.writeFrame({ payload: new Uint8Array([i]) }); const uni = await fx.uni.read(); if (!uni.value) throw new Error("missing group stream"); - const reader = new Reader(uni.value, undefined, V20); - const header = await GroupMessage.decode(reader, V20); + const reader = new Reader(uni.value, undefined, V22); + const header = await GroupMessage.decode(reader, V22); for (let i = 0; i < 5; i++) await reader.decode((c) => Frame.decode(c, header.flags, undefined)); // The group stream's close fails once it FINs, like a reset after its FIN. @@ -3620,14 +3188,14 @@ test("draft-22: an inline terminal reset after its FIN falls back to a standalon expect(inline.endOfTrack).toBe(true); const marker = await fx.uni.read(); if (!marker.value) throw new Error("missing standalone terminal stream"); - const end = new Reader(marker.value, undefined, V20); - const endHeader = await GroupMessage.decode(end, V20); + const end = new Reader(marker.value, undefined, V22); + const endHeader = await GroupMessage.decode(end, V22); expect(endHeader.groupId).toBe(5); const frame = await end.decode((c) => Frame.decode(c, endHeader.flags, undefined)); expect(frame.endOfTrack).toBe(true); expect(frame.idDelta).toBe(5); expect(await client.reader.u53()).toBe(PublishDone.id); - expect((await PublishDone.decode(client.reader, V20)).streamCount).toBe(2n); + expect((await PublishDone.decode(client.reader, V22)).streamCount).toBe(2n); } finally { spy.mockRestore(); fx.close(); @@ -3668,7 +3236,7 @@ test("draft-22: a clean close past a bounded filter's end still sends PUBLISH_DO track.close(); expect(await client.reader.u53()).toBe(PublishDone.id); - const done = await PublishDone.decode(client.reader, V20); + const done = await PublishDone.decode(client.reader, V22); expect(done.statusCode).toBe(TRACK_ENDED_STATUS); expect(done.streamCount).toBe(2n); @@ -3724,9 +3292,9 @@ test("draft-22: the subscriber leaving ends a fill still reading its group", asy const reading = readFill(data); // Opening the stream precedes fetching the group. Observe an actual object so the // cancellation tests a fill reading its open group, even when header writes yield. - const reader = new Reader(observed, undefined, V20); + const reader = new Reader(observed, undefined, V22); expect(await reader.u53()).toBe(FetchHeader.type); - await FetchHeader.decode(reader, V20); + await FetchHeader.decode(reader, V22); const flags = await reader.u53(); if (flags & 0x08) await reader.u53(); if (flags & 0x04) await reader.u53(); @@ -3893,7 +3461,7 @@ test("draft-22: PUBLISH_DONE waits for a queued group and counts every stream", expect((await readGroup(served)).sequence).toBe(0); expect(await response).toBe(PublishDone.id); - const done = await PublishDone.decode(client.reader, V20); + const done = await PublishDone.decode(client.reader, V22); expect(done.statusCode).toBe(TRACK_ENDED_STATUS); // The group's stream and the END_OF_TRACK marker's. expect(done.streamCount).toBe(2n); @@ -3912,46 +3480,51 @@ test("draft-22: PUBLISH_DONE waits for a queued group and counts every stream", * stream on draft-16 and later: one for a match that already exists, one announced * after, then NAMESPACE_DONE when that announcement ends. */ -test.each([ - ["draft-16", Version.DRAFT_16], - ["draft-18", Version.DRAFT_18], -] as const)("a non-SOLICIT %s SUBSCRIBE_NAMESPACE carries NAMESPACE then NAMESPACE_DONE", async (_, version) => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const session = new NativeSession(pair.server, version, true); - const { pub, origin } = publisher(pair.server, { requiresSolicitation: false, session }); - const early = publish(origin, Path.from("early-cam")); - - const subscription = await Stream.open(pair.client, { version }); - const accepted = await Stream.accept(pair.server, version); - if (!accepted) throw new Error("missing subscription"); - const run = pub.runSubscribeNamespace(new SubscribeNamespace({ requestId: 1n, namespace: Path.empty() }), accepted); +test.each([["draft-18", Version.DRAFT_18]] as const)( + "a non-SOLICIT %s SUBSCRIBE_NAMESPACE carries NAMESPACE then NAMESPACE_DONE", + async (_, version) => { + const pair = createMockTransportPair(ALPN.DRAFT_18); + const session = new NativeSession(pair.server, version, true); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: false, session }); + const early = publish(origin, Path.from("early-cam")); + + const subscription = await Stream.open(pair.client, { version }); + const accepted = await Stream.accept(pair.server, version); + if (!accepted) throw new Error("missing subscription"); + const run = pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 1n, namespace: Path.empty() }), + accepted, + ); - try { - expect(await subscription.reader.u53()).toBe(RequestOk.id); - await RequestOk.decode(subscription.reader, version); + try { + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, version); - expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); - expect((await SubscribeNamespaceEntry.decode(subscription.reader, version)).suffix).toBe( - Path.from("early-cam"), - ); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, version)).suffix).toBe( + Path.from("early-cam"), + ); - publish(origin, Path.from("late-cam")); - expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); - expect((await SubscribeNamespaceEntry.decode(subscription.reader, version)).suffix).toBe(Path.from("late-cam")); + publish(origin, Path.from("late-cam")); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, version)).suffix).toBe( + Path.from("late-cam"), + ); - early.close(); - expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntryDone.id); - expect((await SubscribeNamespaceEntryDone.decode(subscription.reader, version)).suffix).toBe( - Path.from("early-cam"), - ); - } finally { - subscription.close(); - origin.close(); - await run; - } -}); + early.close(); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntryDone.id); + expect((await SubscribeNamespaceEntryDone.decode(subscription.reader, version)).suffix).toBe( + Path.from("early-cam"), + ); + } finally { + subscription.close(); + origin.close(); + await run; + } + }, +); -for (const version of [Version.DRAFT_15, Version.DRAFT_18] as const) { +for (const version of [Version.DRAFT_18] as const) { for (const declared of [false, true]) { for (const solicited of [false, true]) { for (const optIn of [false, true]) { @@ -3990,7 +3563,7 @@ for (const version of [Version.DRAFT_15, Version.DRAFT_18] as const) { const expected = !declared || (solicited && optIn) ? [".stats/node", "visible"] : ["visible"]; const advertisements: Stream[] = []; for (const path of expected) { - if (subscription && version !== Version.DRAFT_15) { + if (subscription && true) { expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); expect((await SubscribeNamespaceEntry.decode(subscription.reader, version)).suffix).toBe( Path.from(path), @@ -4003,9 +3576,7 @@ for (const version of [Version.DRAFT_15, Version.DRAFT_18] as const) { const msg = await PublishNamespace.decode(stream.reader, version); expect(msg.trackNamespace).toBe(Path.from(path)); await stream.writer.u53(RequestOk.id); - await new RequestOk({ - requestId: version === Version.DRAFT_15 ? msg.requestId : undefined, - }).encode(stream.writer, version); + await new RequestOk({}).encode(stream.writer, version); } } subscription?.close(); @@ -4055,7 +3626,7 @@ test("requester FIN keeps a draft-22 subscription serving; STOP_SENDING cancels } }); -for (const version of [Version.DRAFT_17, Version.DRAFT_18] as const) { +for (const version of [Version.DRAFT_18] as const) { test(`requester FIN cancels a ${version.toString(16)} subscription`, async () => { const fx = fixture(version); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); @@ -4214,7 +3785,7 @@ test("unsupported REQUEST_UPDATE is refused and ends with UPDATE_FAILED", async // subscribes again as a new SUBSCRIBE would, at the live edge. test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async () => { for (const startPaused of [false, true]) { - const fx = fixture(V20); + const fx = fixture(V22); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); const write = (text: string) => { const group = track.appendGroup(); @@ -4236,9 +3807,9 @@ test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async const parameters = new Parameters(); parameters.forward = on; await client.writer.u53(SubscribeUpdate.id); - await new SubscribeUpdate({ requestId, parameters }).encode(client.writer, V20); + await new SubscribeUpdate({ requestId, parameters }).encode(client.writer, V22); expect(await client.reader.u53()).toBe(RequestOk.id); - return RequestOk.decode(client.reader, V20); + return RequestOk.decode(client.reader, V22); }; try { write("before"); @@ -4277,7 +3848,7 @@ test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async test("a paused SUBSCRIBE ends once its grant is revoked", async () => { const grant = new Signal(undefined); - const fx = fixture(V20, grant); + const fx = fixture(V22, grant); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); const { client } = await runSubscribe( fx, @@ -4293,7 +3864,7 @@ test("a paused SUBSCRIBE ends once its grant is revoked", async () => { try { grant.set({ publish: new Path.Patterns([]), subscribe: new Path.Patterns([]) }); expect(await client.reader.u53()).toBe(PublishDone.id); - expect((await PublishDone.decode(client.reader, V20)).statusCode).toBe(PublishDoneStatus.UNAUTHORIZED); + expect((await PublishDone.decode(client.reader, V22)).statusCode).toBe(PublishDoneStatus.UNAUTHORIZED); } finally { client.close(); track.finish(); @@ -4304,7 +3875,7 @@ test("a paused SUBSCRIBE ends once its grant is revoked", async () => { // The track has no max age, so only the pause itself can release a write parked on flow control. test("a pause resets a group stream parked on flow control", async () => { - const fx = fixture(V20); + const fx = fixture(V22); let writing!: () => void; const parked = new Promise((resolve) => { writing = resolve; @@ -4339,7 +3910,7 @@ test("a pause resets a group stream parked on flow control", async () => { const parameters = new Parameters(); parameters.forward = false; await client.writer.u53(SubscribeUpdate.id); - await new SubscribeUpdate({ requestId: 2n, parameters }).encode(client.writer, V20); + await new SubscribeUpdate({ requestId: 2n, parameters }).encode(client.writer, V22); expect(await client.reader.u53()).toBe(RequestOk.id); expect(reset).toBe(true); } finally { @@ -4472,7 +4043,7 @@ test.each([0, Number.MAX_SAFE_INTEGER + 2])( const parameters = new Parameters(); parameters.activeCount = count; await stream.writer.u53(RequestOk.id); - await new RequestOk({ requestId: undefined, parameters }).encode(stream.writer, VERSION); + await new RequestOk({ parameters }).encode(stream.writer, VERSION); const closed = await Promise.race([ pair.server.closed, @@ -4538,46 +4109,36 @@ test("draft-22: a range update replaces the live subscription bounds", async () } }); -test.each([Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_17, Version.DRAFT_18])( - "IETF %p: range updates preserve the shared subscription", - async (version) => { - const fx = fixture(version); - const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const { client } = await runSubscribe( - fx, - new Subscribe({ - requestId: 0n, - trackNamespace: Path.from("test"), - trackName: "video", - subscriberPriority: 0, - }), - ); - try { - for (const id of [2n, 4n]) { - const parameters = new Parameters(); - if (id === 2n) - parameters.subscriptionFilter = { kind: "absolute", startGroup: 6n, startObject: 0n, endGroup: 7n }; - await client.writer.u53(SubscribeUpdate.id); - await new SubscribeUpdate({ - requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? 0n : id, - ownRequestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? id : undefined, - parameters, - }).encode(client.writer, version); - expect(await client.reader.u53()).toBe(RequestOk.id); - const ok = await RequestOk.decode(client.reader, version); - expect(ok.requestId).toBe( - version === Version.DRAFT_15 || version === Version.DRAFT_16 ? id : undefined, - ); - expect(track.subscription.peek()?.groups).toEqual({ start: { included: 6 }, end: { excluded: 8 } }); - } - } finally { - client.close(); - track.finish(); - track.close(); - fx.close(); +test("IETF draft-18: range updates preserve the shared subscription", async () => { + const fx = fixture(Version.DRAFT_18); + const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); + const { client } = await runSubscribe( + fx, + new Subscribe({ + requestId: 0n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + }), + ); + try { + for (const id of [2n, 4n]) { + const parameters = new Parameters(); + if (id === 2n) + parameters.subscriptionFilter = { kind: "absolute", startGroup: 6n, startObject: 0n, endGroup: 7n }; + await client.writer.u53(SubscribeUpdate.id); + await new SubscribeUpdate({ requestId: id, parameters }).encode(client.writer, fx.version); + expect(await client.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(client.reader, fx.version); + expect(track.subscription.peek()?.groups).toEqual({ start: { included: 6 }, end: { excluded: 8 } }); } - }, -); + } finally { + client.close(); + track.finish(); + track.close(); + fx.close(); + } +}); test("draft-22: an update fills its snapshot under its own request ID", async () => { const fx = fixture(); @@ -4610,7 +4171,7 @@ test("draft-22: an update fills its snapshot under its own request ID", async () for (let i = 0; i < 2; i++) { const stream = await nextUni(fx.uni); if (!stream) throw new Error("missing update data stream"); - const reader = new Reader(stream, undefined, V20); + const reader = new Reader(stream, undefined, V22); if ((await reader.peekU62()) === BigInt(FetchHeader.type)) served = await readFill(reader, "ascending"); else expect((await readGroup(reader)).objects).toEqual([]); } @@ -4821,8 +4382,8 @@ test("draft-22: narrowing gives up an excluded group's open stream", async () => if (!stream) throw new Error("missing initial open group"); const [data, observed] = stream.tee(); const reading = readGroup(data).then(() => undefined, error); - const reader = new Reader(observed, undefined, V20); - const header = await GroupMessage.decode(reader, V20); + const reader = new Reader(observed, undefined, V22); + const header = await GroupMessage.decode(reader, V22); expect((await reader.decode((body) => Frame.decode(body, header.flags, Timescale.MILLI))).payload).toEqual( new Uint8Array([1]), ); @@ -5090,7 +4651,7 @@ async function readServed( for (let i = 0; i < count; i++) { const stream = await nextUni(uni); if (!stream) throw new Error("missing stream after widening"); - const reader = new Reader(stream, undefined, V20); + const reader = new Reader(stream, undefined, V22); if ((await reader.peekU62()) === BigInt(FetchHeader.type)) served.push(...(await readFill(reader, "ascending")).objects.map(({ payload }) => payload)); else served.push(...(await readGroup(reader)).objects.map(({ payload }) => payload)); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index ca433a84b34..8719a3005b7 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -37,7 +37,6 @@ import { untilGivenUp } from "../util/given_up.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import * as Varint from "../varint.ts"; import { type Advertised, type Advertisements, sameInstance, wireOf } from "../wire.ts"; -import type { Session } from "./adapter.ts"; import * as Cluster from "./cluster.ts"; import { encodeDatagram } from "./datagram.ts"; import { requestReason } from "./error.ts"; @@ -51,13 +50,7 @@ import { Parameters } from "./parameters.ts"; import { DEFAULT_WIRE, fromWire, toWire } from "./priority.ts"; import * as Properties from "./properties.ts"; import { Publish, PublishDone, PublishDoneStatus } from "./publish.ts"; -import { - type AdvertParams, - PublishNamespace, - PublishNamespaceDone, - PublishNamespaceOk, - PublishNamespaceUpdate, -} from "./publish_namespace.ts"; +import { type AdvertParams, PublishNamespace, PublishNamespaceUpdate } from "./publish_namespace.ts"; import { RequestError, RequestOk } from "./request.ts"; import { type Framed, @@ -68,12 +61,12 @@ import { refuseReserved, refuseUpdate, } from "./request_stream.ts"; -import { GroupOrder, Subscribe, SubscribeOk, SubscribeUpdate } from "./subscribe.ts"; +import type { Session } from "./session.ts"; +import { GroupOrder, Subscribe, SubscribeOk } from "./subscribe.ts"; import { type SubscribeNamespace, SubscribeNamespaceEntry, SubscribeNamespaceEntryDone, - SubscribeNamespaceOk, } from "./subscribe_namespace.ts"; import { PublishSkipped, type SubscribeTracks } from "./subscribe_tracks.ts"; import { MalformedAuthToken } from "./token.ts"; @@ -237,7 +230,7 @@ interface RunGroup { * carry none. * * Undefined when SUBSCRIBE_OK carries no TIMESCALE: the track has no timeline, the - * subscriber sent INCLUDE_PROPERTIES=0, or the draft (14-16) cannot send the property. A + * subscriber sent INCLUDE_PROPERTIES=0. A * timestamp whose units were never declared is worse than none. Our own reader discards * it; another may read it as some default and time the media wrong. */ @@ -291,10 +284,10 @@ type Woken = /** Stop forwarding without ending the subscription. */ class Paused extends Error { - /** The update to answer once forwarding stopped, if one asked; its own Request ID on drafts 15 and 16. */ - readonly answer?: { requestId?: bigint }; + /** Whether an update asked for the pause, which is answered once forwarding stopped. */ + readonly answer: boolean; - constructor(answer?: { requestId?: bigint }) { + constructor(answer = false) { super("paused"); this.answer = answer; } @@ -543,7 +536,7 @@ export class Publisher { } if (refusal) { - await request.refuse("subscribe", { ...refusal, requestId: msg.requestId }); + await request.refuse("subscribe", refusal); return; } if (!broadcast) return; @@ -579,10 +572,10 @@ export class Publisher { throw err; // The broadcast resolved, so only a vanished route still means a missing publisher; // a missing track is the publisher's answer. - await request.refuse("subscribe", { - ...this.#failed(err, rendezvous && failed.code === StreamCode.Unroutable), - requestId: msg.requestId, - }); + await request.refuse( + "subscribe", + this.#failed(err, rendezvous && failed.code === StreamCode.Unroutable), + ); return; } // Every subscription a resume makes counts toward the same PUBLISH_DONE. @@ -592,10 +585,7 @@ export class Publisher { // a new SUBSCRIBE would, so nothing carries across but the request. while (paused) { track.close(); - if (paused.answer && version !== Version.DRAFT_14) { - const ok = new RequestOk({ requestId: paused.answer.requestId }); - await Message.write(request.writer, RequestOk.id, ok, version); - } + if (paused.answer) await Message.write(request.writer, RequestOk.id, new RequestOk(), version); const answer = await this.#pausedSubscribe(msg, request, streams); if (!answer) return; track = subscribe(broadcast); @@ -606,11 +596,8 @@ export class Publisher { if (!this.#gate.allows(name)) throw unauthorized(name); } catch (err) { if (left.signal.aborted) return; - await refuseUpdate(request.writer, version, "subscribe", { - ...this.#failed(err), - requestId: answer.requestId, - }); - await this.#publishFailed(request, streams, msg.requestId); + await refuseUpdate(request.writer, version, "subscribe", this.#failed(err)); + await this.#publishFailed(request, streams); request.close(); return; } @@ -632,7 +619,7 @@ export class Publisher { msg: Subscribe, request: RequestStream, streams: StreamCount, - ): Promise<{ requestId?: bigint } | undefined> { + ): Promise<{ requestId: bigint } | undefined> { const version = this.#session.version; for (;;) { const revoked = new Once(); @@ -646,7 +633,6 @@ export class Publisher { } if (framed === true) { const done = new PublishDone({ - requestId: msg.requestId, statusCode: PublishDoneStatus.UNAUTHORIZED, streamCount: BigInt(streams.opened), reasonPhrase: "not granted", @@ -659,58 +645,40 @@ export class Publisher { await request.ended; return undefined; } - let update: { params: Parameters; requestId?: bigint }; + let update: { params: Parameters; requestId: bigint }; try { update = await this.#decodeUpdate(request, framed); } catch (err) { if (!(err instanceof UpdateFailed)) throw err; - await this.#publishFailed(request, streams, msg.requestId); + await this.#publishFailed(request, streams); request.close(); return undefined; } this.#applyPublished(msg, update.params); if (update.params.forward === true) return { requestId: update.requestId }; - if (version === Version.DRAFT_14) continue; - await Message.write(request.writer, RequestOk.id, new RequestOk({ requestId: update.requestId }), version); + await Message.write(request.writer, RequestOk.id, new RequestOk(), version); } } /** - * Decode a SUBSCRIBE's REQUEST_UPDATE, with its own Request ID on drafts 15 and 16. One this - * publisher cannot apply is refused on the wire, and the thrown {@link UpdateFailed} ends - * the subscription. + * Decode a SUBSCRIBE's REQUEST_UPDATE. One this publisher cannot apply is refused on the + * wire, and the thrown {@link UpdateFailed} ends the subscription. */ - async #decodeUpdate(request: RequestStream, framed: Framed): Promise<{ params: Parameters; requestId?: bigint }> { + async #decodeUpdate(request: RequestStream, framed: Framed): Promise<{ params: Parameters; requestId: bigint }> { const version = this.#session.version; if (framed.type !== 0x02) throw new ProtocolViolation(`unexpected message on a subscription: 0x${framed.type.toString(16)}`); - const refused = async ( - condition: "not_supported" | "malformed_auth_token", - reasonPhrase: string, - requestId: bigint | undefined, - ) => { - await refuseUpdate(request.writer, version, "subscribe", { - requestId, - condition, - reasonPhrase, - }); + const refused = async (condition: "not_supported" | "malformed_auth_token", reasonPhrase: string) => { + await refuseUpdate(request.writer, version, "subscribe", { condition, reasonPhrase }); return new UpdateFailed(); }; - let requestId: bigint | undefined; let params: Parameters; + let requestId: bigint; try { - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - // The adapter already spent the update's own Request ID. - const update = await SubscribeUpdate.decode(framed.reader, version); - requestId = update.ownRequestId; - params = update.parameters; - } else { - ({ params, requestId } = await this.#subscriptionUpdate(framed)); - } + ({ params, requestId } = await this.#subscriptionUpdate(framed)); } catch (err) { // Section 8.9 rejects the update, which says more than that we do not support it. - if (err instanceof MalformedAuthToken) - throw await refused("malformed_auth_token", err.message, err.requestId); + if (err instanceof MalformedAuthToken) throw await refused("malformed_auth_token", err.message); throw malformed(err, "REQUEST_UPDATE"); } if (params.trackPropertyFilter) @@ -727,7 +695,7 @@ export class Publisher { key !== 0x20n && key !== 0x22n, ); - if (unsupported) throw await refused("not_supported", "REQUEST_UPDATE parameters not supported", requestId); + if (unsupported) throw await refused("not_supported", "REQUEST_UPDATE parameters not supported"); return { params, requestId }; } @@ -748,7 +716,7 @@ export class Publisher { /** The data streams opened over every time the subscription forwarded. */ streams?: StreamCount; /** The update a resumed SUBSCRIBE answers with REQUEST_OK instead of SUBSCRIBE_OK. */ - answer?: { requestId?: bigint }; + answer?: { requestId: bigint }; }): Promise { const version = this.#session.version; const name = msg.trackNamespace; @@ -758,9 +726,7 @@ export class Publisher { let ranking: Priority | undefined; try { // A Timestamp goes out only when this SUBSCRIBE_OK actually carries TIMESCALE. - // Drafts 14-16 never write that property, so their objects stay unstamped. - const timescale = - msg.propertiesWanted && Properties.sendsTimescale(version) ? (info.timescale ?? undefined) : undefined; + const timescale = msg.propertiesWanted ? (info.timescale ?? undefined) : undefined; // The model ranks higher-first, the IETF wire lower-first. Every group this // subscription serves carries the same publisher priority, which is what lets a // relay prefer catalog and audio over video when it has no subscriber preference @@ -817,20 +783,12 @@ export class Publisher { } } else if (answer) { // The resuming update learns where the subscription now joins. - if (version !== Version.DRAFT_14) { - const parameters = new Parameters(); - if (edge.largest) - parameters.largest = { groupId: edge.largest.group, objectId: edge.largest.object }; - const ok = new RequestOk({ requestId: answer.requestId, parameters }); - await Message.write(request.writer, RequestOk.id, ok, version); - } + const parameters = new Parameters(); + if (edge.largest) parameters.largest = { groupId: edge.largest.group, objectId: edge.largest.object }; + await Message.write(request.writer, RequestOk.id, new RequestOk({ parameters }), version); } else { // Send SUBSCRIBE_OK const ok = new SubscribeOk({ - requestId: - version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? msg.requestId - : undefined, trackAlias: msg.requestId, // Required once the track has content; a fill-requesting subscriber sizes its // backfill against this. Below draft-22 round it down to the start of its @@ -850,13 +808,8 @@ export class Publisher { // An untimed track declares none, so it stays untimed downstream. timescale: info.timescale ?? undefined, priority: publisherPriority, - // Through draft-15 this restates the order the subscription is served - // in. Later drafts carry the publisher's default instead, newest first. - groupOrder: - (version === Version.DRAFT_14 || version === Version.DRAFT_15) && - order === "ascending" - ? GroupOrder.Ascending - : Properties.DESCENDING, + // The publisher's default, newest first. + groupOrder: Properties.DESCENDING, maxCacheDuration: info.maxAge === undefined ? undefined : BigInt(info.maxAge), } : // INCLUDE_PROPERTIES=0. The block stays present but empty, which also means @@ -997,8 +950,7 @@ export class Publisher { if (params.groupOrder !== undefined) track.update({ ...track.subscription.peek(), order: msg.groupOrder }); // Every data stream closes before the pause is answered. - if (params.forward === false) throw new Paused({ requestId: ownRequestId }); - if (version === Version.DRAFT_14) continue; + if (params.forward === false) throw new Paused(true); const parameters = new Parameters(); if (snapshot.largest) parameters.largest = { groupId: snapshot.largest.group, objectId: snapshot.largest.object }; @@ -1010,14 +962,13 @@ export class Publisher { maxDelay: Milli(Varint.MAX_U53), }) : undefined; - const ok = new RequestOk({ requestId: ownRequestId, parameters }); try { - await Message.write(request.writer, RequestOk.id, ok, version); + await Message.write(request.writer, RequestOk.id, new RequestOk({ parameters }), version); } catch (err) { source?.close(); throw err; } - if (selected && source && ownRequestId !== undefined) startFill(ownRequestId, selected, source); + if (selected && source) startFill(ownRequestId, selected, source); } })(); const requestEnded = race([updates, request.ended]); @@ -1099,8 +1050,8 @@ export class Publisher { return publishError; } - // Draft 14 on has no end location in PUBLISH_DONE: an END_OF_TRACK object is what - // tells the subscriber where the track ended. + // PUBLISH_DONE has no end location: an END_OF_TRACK object is what tells the + // subscriber where the track ended. const final = hooks.subscriberEnd(track); if (ended && !publishError && final !== undefined && !streams.terminalSent) { await this.#runEndOfTrack({ @@ -1130,14 +1081,9 @@ export class Publisher { // PUBLISH_DONE is required for every supported draft. The peer may have already // closed its side to unsubscribe, in which case there is nowhere to send it. - // Draft 14 registers no UPDATE_FAILED, so a failed update ends there as any error does. - const updateFailed = publishError instanceof UpdateFailed && version !== Version.DRAFT_14; + const updateFailed = publishError instanceof UpdateFailed; try { const done = new PublishDone({ - requestId: - version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? msg.requestId - : undefined, statusCode: unauthorized ? PublishDoneStatus.UNAUTHORIZED : updateFailed @@ -1603,10 +1549,7 @@ export class Publisher { * * On draft-16 and later every visible match is a NAMESPACE on this stream, whatever * the peer's SETUP said. A NAMESPACE is discovery, not a route, so a peer that also - * hears the unsolicited PUBLISH_NAMESPACE still gets the match here. Draft-14/15 - * predate those messages, so each advertisement is a PUBLISH_NAMESPACE request, and - * only for what {@link runPublishNamespaces} does not already say (the whole set when - * the peer asked to be told on request, otherwise the hidden remainder). + * hears the unsolicited PUBLISH_NAMESPACE still gets the match here. * * @internal */ @@ -1623,16 +1566,6 @@ export class Publisher { if (this.#withdrawal.closing.peek()) return; const version = this.#session.version; let prefix = msg.namespace; - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15; - - // Draft-14/15: the open PUBLISH_NAMESPACE request per advertised suffix. - const requests: Requests = new Map(); - // Drains the requests map, so calling it twice is safe and the second is a no-op. - const withdrawAll = async () => { - for (const path of [...requests.keys()]) { - await this.#withdraw(path, requests); - } - }; const ending = request.ended; const readUpdate = () => @@ -1640,22 +1573,12 @@ export class Publisher { const framed = await request.next(); return { update: framed && (await namespaceUpdate(framed, version)) }; })().catch((err: unknown) => ({ error: error(err) })); - // Drafts 14-16 route an update onto the stream too, which stays open behind one left unread. - let updating = version !== Version.DRAFT_17 ? readUpdate() : undefined; + let updating: ReturnType | undefined = readUpdate(); try { const answer = async (activeCount?: number) => { - if (version === Version.DRAFT_14) { - const ok = new SubscribeNamespaceOk({ requestId: msg.requestId }); - await Message.write(request.writer, SubscribeNamespaceOk.id, ok, version); - return; - } const parameters = new Parameters(); parameters.activeCount = activeCount; - const ok = new RequestOk({ - requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - parameters, - }); - await Message.write(request.writer, RequestOk.id, ok, version); + await Message.write(request.writer, RequestOk.id, new RequestOk({ parameters }), version); }; // MoQ Active Count: the OK counts what the first pass below sends, so it waits for @@ -1663,22 +1586,16 @@ export class Publisher { let answered = !this.#activeCount; if (answered) await answer(); - // Hidden namespaces are left out unless the peer opted in (MoQ Hidden). Draft-16 - // and later carry every remaining match. Draft-14/15 only add what the - // unsolicited loop cannot say. + // Hidden namespaces are left out unless the peer opted in (MoQ Hidden). const visible = (covered: Path.Valid) => !this.#hidden || msg.hidden || !hiddenBelow(prefix, covered); - const carries = (covered: Path.Valid) => - visible(covered) && - (legacy ? this.#requiresSolicitation || (this.#hidden && hiddenBelow(Path.empty(), covered)) : true) && - this.#gate.allows(covered); + const carries = (covered: Path.Valid) => visible(covered) && this.#gate.allows(covered); // Nothing is advertised before the grant it would be checked against. await this.#ready; // Inline entries always land, and the receiver treats a repeated NAMESPACE as a // replacement, so repricing one is just sending it again, a new original - // publisher included, as Rust does. Only a legacy PUBLISH_NAMESPACE request can - // be declined. + // publisher included, as Rust does. const namespace = async (suffix: Path.Valid, snap: Advertised): Promise => { // Either extension puts the Parameters field on NAMESPACE, even an empty one. const params = this.#advert !== undefined || this.#epoch ? this.#params(snap.route) : undefined; @@ -1686,27 +1603,18 @@ export class Publisher { await Message.write(request.writer, SubscribeNamespaceEntry.id, entry, version); return "held"; }; - const target: Target = legacy - ? { - advertise: (suffix, snap) => this.#advertise(Path.join(prefix, suffix), requests, snap.route), - replace: (suffix, snap) => this.#replace(Path.join(prefix, suffix), requests, snap.route), - update: (suffix, _snap, reprice) => this.#update(Path.join(prefix, suffix), requests, reprice), - withdraw: (suffix) => this.#withdraw(Path.join(prefix, suffix), requests), - held: (suffix) => requests.get(Path.join(prefix, suffix))?.auth, - carry: (auth) => this.#carry(auth), - } - : { - advertise: namespace, - // A repeat under another epoch replaces the instance in place. - replace: namespace, - update: namespace, - held: () => undefined, - carry: () => undefined, - withdraw: async (suffix) => { - const done = new SubscribeNamespaceEntryDone({ suffix }); - await Message.write(request.writer, SubscribeNamespaceEntryDone.id, done, version); - }, - }; + const target: Target = { + advertise: namespace, + // A repeat under another epoch replaces the instance in place. + replace: namespace, + update: namespace, + held: () => undefined, + carry: () => undefined, + withdraw: async (suffix) => { + const done = new SubscribeNamespaceEntryDone({ suffix }); + await Message.write(request.writer, SubscribeNamespaceEntryDone.id, done, version); + }, + }; // Keyed by suffix, as the entries name them. const ns: Namespaces = { active: new Map(), refused: new Map(), offered: new Map() }; @@ -1754,20 +1662,13 @@ export class Publisher { const update = next.update; updating = undefined; if (!update) continue; - // Drafts 14-16 spend the update's own Request ID in the adapter and name it in - // the answer. They define no namespace update, so refusing one leaves the - // subscription standing, and draft 14 answers no update at all. - const keyed = version <= Version.DRAFT_16; - if (!keyed) this.#session.spendRequest(update.requestId); + this.#session.checkRequestId(update.requestId); if (update.refused || (update.prefix !== undefined && Namespace.reserved(update.prefix, version))) { await refuseUpdate(request.writer, version, "subscribe_namespace", { - requestId: update.requestId, condition: update.refused ?? "does_not_exist", reasonPhrase: "namespace update refused", }); - if (!keyed) break; - updating = readUpdate(); - continue; + break; } if (update.prefix !== undefined && update.prefix !== prefix) { for (const suffix of ns.active.keys()) await target.withdraw(suffix); @@ -1778,14 +1679,11 @@ export class Publisher { // A new cursor starts its backoff fresh, as in Rust. retry = 0; } - await Message.write(request.writer, RequestOk.id, new RequestOk({ requestId: undefined }), version); + await Message.write(request.writer, RequestOk.id, new RequestOk(), version); updating = readUpdate(); } } - // Withdraw before the ask stream FINs, as Rust does: a peer that reads that - // FIN as the subscription ending can drop the DONEs still in flight. - await withdrawAll(); request.close(); await request.writer.closed; } catch (err: unknown) { @@ -1794,8 +1692,6 @@ export class Publisher { request.abort(e); if (e instanceof ProtocolViolation || this.#withdrawal.closing.peek()) throw e; } finally { - // This subscription's advertisements die with it. - await withdrawAll(); // Closing stopped the read, which settles it. await updating; } @@ -1807,8 +1703,8 @@ export class Publisher { * * The peers that never send SUBSCRIBE_NAMESPACE are exactly the ones expecting a * publisher to announce itself, so announcing is the default. A peer that would - * rather ask says so in its SETUP (MoQ Solicit) and this does nothing. On draft-16 - * and later {@link runSubscribeNamespace} also carries every match, so a peer that + * rather ask says so in its SETUP (MoQ Solicit) and this does nothing. + * {@link runSubscribeNamespace} also carries every match, so a peer that * did not ask hears each namespace both ways. A NAMESPACE is discovery only, not a * second route. * @@ -2009,8 +1905,7 @@ export class Publisher { * the token the request carries. * * moq-transport renames a request's token with REQUEST_UPDATE, but cannot name one on a - * request that had none, nor drop one, and before draft-16 cannot update a namespace at - * all: those withdraw the request and make it again. + * request that had none, nor drop one: those withdraw the request and make it again. */ #change( key: Path.Valid, @@ -2023,9 +1918,7 @@ export class Publisher { const from = target.held(key); const to = target.carry(next.route.auth); if (from === to) return diff; - const version = this.#session.version; - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15; - if (from === undefined || to === undefined || legacy) return "restart"; + if (from === undefined || to === undefined) return "restart"; return { ...(diff === "same" ? {} : diff), token: to }; } @@ -2070,14 +1963,9 @@ export class Publisher { /** * What a REQUEST_ERROR's retry interval, in milliseconds, says about re-offering the - * namespace. - * - * Draft-14/15 carry no such field, so a 0 there says nothing and our own backoff - * stands; everywhere else 0 means the peer does not want this again. + * namespace: 0 means the peer does not want this again. */ #refusal(err: RequestError): Answer { - const version = this.#session.version; - if (version === Version.DRAFT_14 || version === Version.DRAFT_15) return "dropped"; return err.retryInterval === 0n ? "never" : Instant.add(now(), Nano.fromMilli(Milli(Number(err.retryInterval)))); @@ -2117,17 +2005,11 @@ export class Publisher { const version = this.#session.version; const token = this.#auth?.carried(auth); const msg = new PublishNamespace({ requestId, trackNamespace: path, params, token }); - const legacy = - version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; - await deadline.send( - stream, - async () => { - await Message.write(opened.writer, PublishNamespace.id, msg, version); - }, - legacy ? () => this.#done(opened, path, requestId) : undefined, - ); + await deadline.send(stream, async () => { + await Message.write(opened.writer, PublishNamespace.id, msg, version); + }); - // Read response (RequestOk and PublishNamespaceOk share 0x07) + // Read response const response = await opened.next(); if (response === undefined) throw new Error("advertisement ended before its response"); if (response.type === RequestError.id) { @@ -2146,11 +2028,7 @@ export class Publisher { throw new Error(`PublishNamespace rejected: typeId=0x${response.type.toString(16)}`); } // Draft-14 sends PublishNamespaceOk (requestId only, no parameters) - if (this.#session.version === Version.DRAFT_14) { - await PublishNamespaceOk.decode(response.reader, this.#session.version); - } else { - this.#answered(await RequestOk.decode(response.reader, this.#session.version)); - } + this.#answered(await RequestOk.decode(response.reader, this.#session.version)); return "held"; })(), ); @@ -2158,7 +2036,7 @@ export class Publisher { if (answer === "held" && request) { const held: NamespaceRequest = { path, requestId, stream: request, auth }; requests.set(path, held); - if (this.#session.version >= Version.DRAFT_18) held.reply = this.#readAdvertisement(held, requests); + held.reply = this.#readAdvertisement(held, requests); } return answer; } catch (err: unknown) { @@ -2220,8 +2098,7 @@ export class Publisher { * MAX_REQUEST_UPDATES the peer set without reading it; a change landing meanwhile waits * for the next turn. The answer decides whether the peer still holds the namespace: a * REQUEST_ERROR closes the peer's side and withdraws the advertisement, so ours is - * finished too (with nothing more sent on draft 16, where the peer cancels it), a draft-16 - * cancel ends it the same way, and an unanswered update is dropped, since a peer that + * finished too, and an unanswered update is dropped, since a peer that * ignored it cannot be assumed to hold either price. Either way the retry re-offers it fresh. * * A different original publisher is an update like any other: the receiver drains what @@ -2246,17 +2123,11 @@ export class Publisher { (async (): Promise => { const writer = request.stream.writer; request.updating = true; - const namespaceRequestId = version === Version.DRAFT_16 ? request.requestId : undefined; - const msg = new PublishNamespaceUpdate({ requestId, namespaceRequestId, update, token }); + const msg = new PublishNamespaceUpdate({ requestId, update, token }); await Message.write(writer, PublishNamespaceUpdate.id, msg, version); const response = await (request.reply ?? request.stream.next()); - if (response === undefined) { - // Draft 16's adapter ends the stream on the peer's PUBLISH_NAMESPACE_CANCEL, - // which can beat the answer. - if (version === Version.DRAFT_16) return "dropped"; - throw new Error("advertisement ended before its update response"); - } + if (response === undefined) throw new Error("advertisement ended before its update response"); if (response.type === RequestError.id) { const err = await RequestError.decode(response.reader, version); const why = requestReason(err.errorCode, err.reasonPhrase, "publish_namespace", version); @@ -2268,7 +2139,7 @@ export class Publisher { } this.#answered(await RequestOk.decode(response.reader, version)); request.updating = false; - if (version >= Version.DRAFT_18) request.reply = this.#readAdvertisement(request, requests); + request.reply = this.#readAdvertisement(request, requests); return "held"; })(), ADVERTISE_TIMEOUT, @@ -2278,11 +2149,7 @@ export class Publisher { const e = err instanceof TimeoutError ? controlTimeout(err) : error(err); console.warn(`publish_namespace update failed: broadcast=${path} error=${reason(e)}`); requests.delete(path); - // Aborting a request riding the control stream tells the peer nothing, so it would - // hold the namespace beside the one advertised next. A violation ends the session. - if (version === Version.DRAFT_16 && !(e instanceof ProtocolViolation)) - await this.#end(request).catch(() => undefined); - else request.stream.abort(e); + request.stream.abort(e); return "dropped"; } @@ -2290,17 +2157,10 @@ export class Publisher { if (auth) request.auth = auth; return answer; } - if (version === Version.DRAFT_16) { - // A draft-16 peer lets the namespace go with PUBLISH_NAMESPACE_CANCEL, ahead of its - // answer or right after a refusal, and no PUBLISH_NAMESPACE_DONE may follow a cancel. - requests.delete(path); - request.stream.abort(new Error("released by the peer")); - } else { - // The peer already closed out a refused request, and its STOP_SENDING can - // reject the wait on our FIN. That is the withdrawal finishing, not a reason to end - // the loop. - await this.#withdraw(path, requests).catch(() => undefined); - } + // The peer already closed out a refused request, and its STOP_SENDING can + // reject the wait on our FIN. That is the withdrawal finishing, not a reason to end + // the loop. + await this.#withdraw(path, requests).catch(() => undefined); return answer; } @@ -2325,31 +2185,11 @@ export class Publisher { } /** - * Withdraw one PUBLISH_NAMESPACE request, already out of its map: PUBLISH_NAMESPACE_DONE - * then FIN before draft-17, a cancel in both directions after. + * Withdraw one PUBLISH_NAMESPACE request, already out of its map, by cancelling it in both + * directions: a FIN is only allowed to end SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS. */ async #end(request: NamespaceRequest) { - // Draft-17+ withdraws by cancelling the request in both directions; a FIN is only - // allowed to end SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS. - const version = this.#session.version; - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) { - request.stream.abort(new StreamError(StreamCode.Cancel, { message: "withdrawn" })); - return; - } - - await this.#done(request.stream, request.path, request.requestId); - request.stream.close(); - await request.stream.writer.closed; - } - - /** Write the PUBLISH_NAMESPACE_DONE that withdraws a draft-14-16 request. */ - async #done(request: RequestStream, path: Path.Valid, requestId: bigint) { - try { - const done = new PublishNamespaceDone({ trackNamespace: path, requestId }); - await Message.write(request.writer, PublishNamespaceDone.id, done, this.#session.version); - } catch { - // Stream might already be closed - } + request.stream.abort(new StreamError(StreamCode.Cancel, { message: "withdrawn" })); } /** @@ -2374,7 +2214,7 @@ export class Publisher { } const version = this.#session.version; const name = msg.trackNamespace; - const denied: Refusal = { requestId: msg.requestId, condition: "unauthorized", reasonPhrase: "not granted" }; + const denied: Refusal = { condition: "unauthorized", reasonPhrase: "not granted" }; // Answer only for what our grant lets us publish. Checked before the track resolves, // so a denied request never reaches the origin. @@ -2388,13 +2228,12 @@ export class Publisher { const hold = new AbortController(); const cancel = new StreamError(StreamCode.Cancel, { message: "cancel" }); void request.ended.then(() => hold.abort(cancel)); - // Nothing may follow the request. Drafts 14-16 route only their own follow-ups here. - if (version > Version.DRAFT_16) - void request.next().then( - (framed) => framed && request.abort(new ProtocolViolation("unexpected request stream message")), - // The request ended, which the hold already heard. - () => undefined, - ); + // Nothing may follow the request. + void request.next().then( + (framed) => framed && request.abort(new ProtocolViolation("unexpected request stream message")), + // The request ended, which the hold already heard. + () => undefined, + ); // A grant that narrows abandons the lookup, and resets an answer the transport has // not taken yet rather than delivering it. @@ -2411,10 +2250,6 @@ export class Publisher { const info = await wireOf(broadcast).resolveTrackInfo(msg.trackName, hold.signal); const largest = wireOf(broadcast).largest(msg.trackName); answer = new TrackStatusOk({ - requestId: - version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? msg.requestId - : undefined, largest: largest && { groupId: BigInt(largest.group), objectId: BigInt(largest.frame) }, properties: msg.propertiesWanted ? { @@ -2430,13 +2265,13 @@ export class Publisher { request.abort(cancel); return; } - const refusal = !this.#gate.allows(name) ? denied : { ...this.#failed(err), requestId: msg.requestId }; + const refusal = !this.#gate.allows(name) ? denied : this.#failed(err); await request.refuse("track_status", refusal); return; } answering = true; - await Message.write(request.writer, TrackStatusOk.id(version), answer, version); + await Message.write(request.writer, TrackStatusOk.id, answer, version); // The answer is all this stream carries. request.close(); await request.writer.closed.catch(() => undefined); @@ -2446,22 +2281,6 @@ export class Publisher { } } - /** - * Refuses a draft-16/17 SUBSCRIBE_NAMESPACE that asks for PUBLISH alone: we never send PUBLISH. - * - * @internal - */ - refuseSubscribeNamespace(requestId: bigint, stream: Stream): Promise { - const request = new RequestStream(stream, this.#session, this.#quic); - return request.serve( - request.refuse("subscribe_namespace", { - requestId, - condition: "not_supported", - reasonPhrase: "SUBSCRIBE_NAMESPACE for PUBLISH is not supported", - }), - ); - } - /** Answer a request to discover tracks under a namespace prefix. @internal */ runSubscribeTracks(msg: SubscribeTracks, stream: Stream): Promise { const request = new RequestStream(stream, this.#session, this.#quic); @@ -2525,7 +2344,7 @@ export class Publisher { if (!framed) return request.ended; if (framed.type !== 0x02) throw new ProtocolViolation("expected SUBSCRIBE_TRACKS update"); await Message.decode(framed.reader, async (body) => { - this.#session.spendRequest(await body.u62()); + this.#session.checkRequestId(await body.u62()); await Parameters.decode(body, version, "tracks-update"); }); refusal = { condition: "not_supported", reasonPhrase: "SUBSCRIBE_TRACKS updates are not supported" }; @@ -3043,8 +2862,7 @@ export class Publisher { const version = this.#session.version; const update = await Message.decode(framed.reader, async (body) => { const requestId = await body.u62(); - this.#session.spendRequest(requestId); - if (version === Version.DRAFT_17) await body.u62(); + this.#session.checkRequestId(requestId); const params = await Parameters.decode(body, version, "request-update"); return { params, requestId }; }); @@ -3053,17 +2871,13 @@ export class Publisher { return update; } - /** End a subscription whose update failed, naming `requestId` on drafts 14 through 16. */ - async #publishFailed(request: RequestStream, streams: StreamCount, requestId?: bigint) { - const version = this.#session.version; + /** End a subscription whose update failed. */ + async #publishFailed(request: RequestStream, streams: StreamCount) { await Message.write( request.writer, PublishDone.id, new PublishDone({ - requestId, - // Draft 14 registers no UPDATE_FAILED, so a failed update ends there as any error does. - statusCode: - version === Version.DRAFT_14 ? PublishDoneStatus.INTERNAL_ERROR : PublishDoneStatus.UPDATE_FAILED, + statusCode: PublishDoneStatus.UPDATE_FAILED, streamCount: BigInt(streams.opened), reasonPhrase: "update failed", }), @@ -3107,7 +2921,6 @@ export class Publisher { return; } await request.refuse("fetch", { - requestId: msg.requestId, condition: msg.rangeFilters ? "invalid_filter" : "not_supported", reasonPhrase: msg.rangeFilters ? "Range Filters exceed MAX_FILTER_RANGES=0" : "FETCH is not supported", }); diff --git a/js/net/src/ietf/request.ts b/js/net/src/ietf/request.ts index 30f4e5f070b..6b25c0191b4 100644 --- a/js/net/src/ietf/request.ts +++ b/js/net/src/ietf/request.ts @@ -2,52 +2,19 @@ import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; import { Parameters } from "./parameters.ts"; import * as Properties from "./properties.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; -export class MaxRequestId { - static id = 0x15; - - requestId: bigint; - - constructor({ requestId }: { requestId: bigint }) { - this.requestId = requestId; - } - - async #encode(w: Writer): Promise { - await w.u62(this.requestId); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async #decode(r: Reader): Promise { - return new MaxRequestId({ requestId: await r.u62() }); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, MaxRequestId.#decode); - } -} - -/// REQUEST_OK (0x07 in v15) - Generic success response for any request. -/// Replaces PublishNamespaceOk, SubscribeNamespaceOk in v15. +/// REQUEST_OK (0x07) - Generic success response for any request. export class RequestOk { static id = 0x07; - requestId: bigint | undefined; parameters: Parameters; - constructor({ requestId, parameters = new Parameters() }: { requestId?: bigint; parameters?: Parameters }) { - this.requestId = requestId; + constructor({ parameters = new Parameters() }: { parameters?: Parameters } = {}) { this.parameters = parameters; } async #encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - if (this.requestId === undefined) throw new Error("requestId required for draft14-16"); - await w.u62(this.requestId); - } await this.parameters.encode(w, version); } @@ -56,13 +23,9 @@ export class RequestOk { } static async #decode(r: Reader, version: IetfVersion): Promise { - const requestId = - version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? await r.u62() - : undefined; const parameters = await Parameters.decode(r, version, "request-ok"); await Properties.decode(r, version); - return new RequestOk({ requestId, parameters }); + return new RequestOk({ parameters }); } static async decode(r: Reader, version: IetfVersion): Promise { @@ -70,61 +33,46 @@ export class RequestOk { } } -/// REQUEST_ERROR (0x05 in v15) - Generic error response for any request. -/// Replaces SubscribeError, PublishError, PublishNamespaceError, etc. in v15. +/// REQUEST_ERROR (0x05) - Generic error response for any request. export class RequestError { static id = 0x05; - requestId: bigint | undefined; errorCode: number; reasonPhrase: string; retryInterval: bigint; constructor({ - requestId, errorCode, reasonPhrase, retryInterval = 0n, }: { - requestId?: bigint; errorCode: number; reasonPhrase: string; retryInterval?: bigint; }) { - this.requestId = requestId; this.errorCode = errorCode; this.reasonPhrase = reasonPhrase; this.retryInterval = retryInterval; } - async #encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - if (this.requestId === undefined) throw new Error("requestId required for draft14-16"); - await w.u62(this.requestId); - } + async #encode(w: Writer): Promise { await w.u62(BigInt(this.errorCode)); - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15) { - await w.u62(this.retryInterval); - } + await w.u62(this.retryInterval); await w.string(this.reasonPhrase); } - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (wr) => this.#encode(wr, version)); + async encode(w: Writer, _version: IetfVersion): Promise { + return Message.encode(w, (wr) => this.#encode(wr)); } - static async #decode(r: Reader, version: IetfVersion): Promise { - const requestId = - version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? await r.u62() - : undefined; + static async #decode(r: Reader): Promise { const errorCode = Number(await r.u62()); - const retryInterval = version === Version.DRAFT_14 || version === Version.DRAFT_15 ? 0n : await r.u62(); + const retryInterval = await r.u62(); const reasonPhrase = await r.string(); - return new RequestError({ requestId, errorCode, reasonPhrase, retryInterval }); + return new RequestError({ errorCode, reasonPhrase, retryInterval }); } - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (rd) => RequestError.#decode(rd, version)); + static async decode(r: Reader, _version: IetfVersion): Promise { + return Message.decode(r, RequestError.#decode); } } diff --git a/js/net/src/ietf/request_ids.test.ts b/js/net/src/ietf/request_ids.test.ts index 4c9f59554ca..efa71f65058 100644 --- a/js/net/src/ietf/request_ids.test.ts +++ b/js/net/src/ietf/request_ids.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; import { SessionCode, SessionError } from "../error.ts"; -import { SpentIds } from "./adapter.ts"; +import { checkPeerRequestId } from "./session.ts"; -function code(ids: SpentIds, id: bigint): SessionCode | undefined { +function code(id: bigint, client: boolean): SessionCode | undefined { try { - ids.spend(id); + checkPeerRequestId(id, client); } catch (err) { if (err instanceof SessionError) return err.code; throw err; @@ -12,41 +12,15 @@ function code(ids: SpentIds, id: bigint): SessionCode | undefined { return undefined; } -describe("draft-17+ request ID history", () => { - test.each([0n, 1n])("allows reordering but permanently spends IDs, first=%s", (first) => { - const ids = new SpentIds(first); - for (const offset of [4n, 0n, 2n, 8n, 6n]) ids.spend(first + offset); - for (const offset of [0n, 2n, 4n, 6n, 8n]) expect(code(ids, first + offset)).toBe(SessionCode.InvalidRequestId); - expect(code(ids, first ^ 1n)).toBe(SessionCode.InvalidRequestId); +describe("peer request IDs", () => { + test.each([false, true])("accept any order and reuse, but only the peer's parity, client=%s", (client) => { + const first = client ? 1n : 0n; + for (const offset of [4n, 0n, 2n, 4n]) expect(code(first + offset, client)).toBeUndefined(); + expect(code(first ^ 1n, client)).toBe(SessionCode.InvalidRequestId); }); - test("contiguous traffic in either order leaves no gap", () => { - const forward = new SpentIds(0n, 0); - const reverse = new SpentIds(0n, 1); - for (let i = 0n; i < 4096n; i++) { - forward.spend(i * 2n); - reverse.spend((4095n - i) * 2n); - } - expect(code(forward, 0n)).toBe(SessionCode.InvalidRequestId); - expect(code(reverse, 8190n)).toBe(SessionCode.InvalidRequestId); - }); - - test("a full history refuses a new gap without forgetting IDs", () => { - const ids = new SpentIds(0n, 1); - ids.spend(0n); - ids.spend(4n); - expect(code(ids, 8n)).toBe(SessionCode.Internal); - ids.spend(2n); - ids.spend(8n); - for (const id of [0n, 2n, 4n, 8n]) expect(code(ids, id)).toBe(SessionCode.InvalidRequestId); - }); - - test("a huge sparse ID does not allocate its intervening gap", () => { - const ids = new SpentIds(0n, 1); - ids.spend((1n << 62n) - 2n); - ids.spend(0n); - for (const id of [(1n << 62n) - 2n, 1n << 62n, (1n << 64n) - 2n]) { - expect(code(ids, id)).toBe(SessionCode.InvalidRequestId); - } + test("IDs past 2^62 are refused", () => { + expect(code((1n << 62n) - 2n, false)).toBeUndefined(); + for (const id of [1n << 62n, (1n << 64n) - 2n]) expect(code(id, false)).toBe(SessionCode.InvalidRequestId); }); }); diff --git a/js/net/src/ietf/request_stream.test.ts b/js/net/src/ietf/request_stream.test.ts index 7851874eefb..c334d0917fb 100644 --- a/js/net/src/ietf/request_stream.test.ts +++ b/js/net/src/ietf/request_stream.test.ts @@ -2,16 +2,9 @@ import { expect, test } from "bun:test"; import { ProtocolViolation, SessionCode } from "../error.ts"; import { Reader, Stream } from "../stream.ts"; import * as Varint from "../varint.ts"; -import type { Session } from "./adapter.ts"; -import { type RequestKind, toRequestCode } from "./error.ts"; -import { FetchError } from "./fetch.ts"; -import { PublishError } from "./publish.ts"; -import { PublishNamespaceError } from "./publish_namespace.ts"; import { RequestError } from "./request.ts"; -import { namespaceUpdate, nextMessage, RequestStream, refuse, refuseUpdate } from "./request_stream.ts"; -import { SubscribeError } from "./subscribe.ts"; -import { SubscribeNamespaceError } from "./subscribe_namespace.ts"; -import { TRACK_STATUS_ERROR_14 } from "./track.ts"; +import { namespaceUpdate, nextMessage, RequestStream, refuse } from "./request_stream.ts"; +import type { Session } from "./session.ts"; import { type IetfVersion, Version, versionName } from "./version.ts"; /** @@ -159,51 +152,15 @@ function recording(version: IetfVersion) { return { stream, wire: () => new Reader(undefined, Uint8Array.from(written), version) }; } -const refusal = { requestId: 7n, condition: "not_supported", reasonPhrase: "no" } as const; +const refusal = { condition: "not_supported", reasonPhrase: "no" } as const; -test("draft 14 refuses each request kind with its own error message and REQUEST_ERROR's body", async () => { - const ids: Record = { - subscribe: SubscribeError.id, - fetch: FetchError.id, - publish: PublishError.id, - publish_namespace: PublishNamespaceError.id, - subscribe_namespace: SubscribeNamespaceError.id, - track_status: TRACK_STATUS_ERROR_14, - }; - for (const [kind, id] of Object.entries(ids) as [RequestKind, number][]) { - const { stream, wire } = recording(Version.DRAFT_14); - await refuse(stream, Version.DRAFT_14, kind, refusal); - const r = wire(); - expect(await r.u53()).toBe(id); - const err = await RequestError.decode(r, Version.DRAFT_14); - expect(err.requestId).toBe(7n); - expect(err.errorCode).toBe(toRequestCode("not_supported", kind, Version.DRAFT_14)); - expect(await r.done()).toBe(true); - } -}); - -test("a refusal names its Request ID on drafts 15 and 16 only", async () => { - for (const version of [Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_17, Version.DRAFT_22]) { +test("a refusal carries its reason", async () => { + for (const version of [Version.DRAFT_22]) { const { stream, wire } = recording(version); await refuse(stream, version, "fetch", refusal); const r = wire(); expect(await r.u53()).toBe(RequestError.id); const err = await RequestError.decode(r, version); - expect(err.requestId).toBe(version <= Version.DRAFT_16 ? 7n : undefined); expect(err.reasonPhrase).toBe("no"); } }); - -test("draft 14 answers no update", async () => { - for (const version of [Version.DRAFT_14, Version.DRAFT_15]) { - const { stream, wire } = recording(version); - await refuseUpdate(stream.writer, version, "subscribe", refusal); - const r = wire(); - if (version === Version.DRAFT_14) { - expect(await r.done()).toBe(true); - continue; - } - expect(await r.u53()).toBe(RequestError.id); - expect((await RequestError.decode(r, version)).requestId).toBe(7n); - } -}); diff --git a/js/net/src/ietf/request_stream.ts b/js/net/src/ietf/request_stream.ts index 8b72fcf1587..0732883c18b 100644 --- a/js/net/src/ietf/request_stream.ts +++ b/js/net/src/ietf/request_stream.ts @@ -1,59 +1,35 @@ import { error, ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type * as Path from "../path.ts"; import { Reader, type Stream, type Writer } from "../stream.ts"; -import type { Session } from "./adapter.ts"; import { type RequestCondition, type RequestKind, toRequestCode } from "./error.ts"; -import { FetchError } from "./fetch.ts"; import { GoAway } from "./goaway.ts"; import * as Message from "./message.ts"; import { Parameters } from "./parameters.ts"; -import { PublishError } from "./publish.ts"; -import { PublishNamespaceError } from "./publish_namespace.ts"; import { RequestError } from "./request.ts"; -import { SubscribeError } from "./subscribe.ts"; -import { SubscribeNamespaceError } from "./subscribe_namespace.ts"; +import type { Session } from "./session.ts"; import { MalformedAuthToken } from "./token.ts"; -import { TRACK_STATUS_ERROR_14 } from "./track.ts"; import { type IetfVersion, Version } from "./version.ts"; /** Whether the draft treats the requester finishing its messages as cancellation. */ function finCancels(version: IetfVersion): boolean { - return ( - version === Version.DRAFT_14 || - version === Version.DRAFT_15 || - version === Version.DRAFT_16 || - version === Version.DRAFT_17 || - version === Version.DRAFT_18 - ); + return version === Version.DRAFT_18; } /** - * Why a request is refused, and which one: only drafts 14-16 name the Request ID. + * Why a request is refused. * @internal */ export interface Refusal { - requestId?: bigint; condition: RequestCondition; reasonPhrase: string; } -// Each draft-14 error message has the REQUEST_ERROR body that draft-15 folded them into. -const ERROR_14: Record = { - subscribe: SubscribeError.id, - fetch: FetchError.id, - publish: PublishError.id, - publish_namespace: PublishNamespaceError.id, - subscribe_namespace: SubscribeNamespaceError.id, - track_status: TRACK_STATUS_ERROR_14, -}; - async function writeRefusal(w: Writer, version: IetfVersion, kind: RequestKind, refusal: Refusal): Promise { const err = new RequestError({ - requestId: version <= Version.DRAFT_16 ? refusal.requestId : undefined, errorCode: toRequestCode(refusal.condition, kind, version), reasonPhrase: refusal.reasonPhrase, }); - await Message.write(w, version === Version.DRAFT_14 ? ERROR_14[kind] : RequestError.id, err, version); + await Message.write(w, RequestError.id, err, version); } /** @@ -66,17 +42,10 @@ export async function refuse(stream: Stream, version: IetfVersion, kind: Request } /** - * Refuse a REQUEST_UPDATE, leaving its request standing. Draft 14 answers no update. + * Refuse a REQUEST_UPDATE, leaving its request standing. * @internal */ -export async function refuseUpdate( - w: Writer, - version: IetfVersion, - kind: RequestKind, - refusal: Refusal, -): Promise { - if (version !== Version.DRAFT_14) await writeRefusal(w, version, kind, refusal); -} +export const refuseUpdate = writeRefusal; /** * Refuse an unrecognized reserved namespace before it reaches the application. @@ -102,7 +71,7 @@ const goaways = new WeakMap(); /** * Read the type of a request stream's next message, or undefined once the peer finishes. - * From draft 18 on, the peer may send one GOAWAY on a request stream, consumed here; a second, + * The peer may send one GOAWAY on a request stream, consumed here; a second, * or a client's naming a new session, is a protocol violation. * @internal */ @@ -116,7 +85,7 @@ export async function nextMessage(reader: Reader, version: IetfVersion, client: for (;;) { if (await reader.done()) return undefined; const type = await reader.u53(); - if (type !== GoAway.id || version < Version.DRAFT_18) return type; + if (type !== GoAway.id) return type; if (goaway.seen) throw new ProtocolViolation("second GOAWAY on a request stream"); goaway.seen = true; // Draft-18's Request ID belongs on the control stream only. @@ -142,18 +111,8 @@ export async function namespaceUpdate( try { return await Message.decode(framed.reader, async (body) => { const requestId = await body.u62(); - // Drafts 14-16 define no namespace update. The update's own Request ID is all its - // refusal names; the target is what the adapter routed it by. - if (version <= Version.DRAFT_16) { - await body.readAll(); - return { requestId, refused: "not_supported" }; - } const params = await Parameters.decode(body, version, "namespace-update"); - return { - requestId, - prefix: params.namespacePrefix, - refused: undefined, - }; + return { requestId, prefix: params.namespacePrefix }; }); } catch (err) { if (err instanceof MalformedAuthToken && err.requestId !== undefined) @@ -297,13 +256,11 @@ export class RequestStream { }, () => this.#end(), ); - // Drafts 14-16 adapt the request onto the control stream, whose virtual writer - // finishes while the request lives. - if (version > Version.DRAFT_16) - this.writer.closed.then( - () => this.#end(), - () => this.#end(), - ); + // Our own side finishing or resetting ends the request too. + this.writer.closed.then( + () => this.#end(), + () => this.#end(), + ); } return this.#ahead; } diff --git a/js/net/src/ietf/session.ts b/js/net/src/ietf/session.ts new file mode 100644 index 00000000000..aa77c9587a0 --- /dev/null +++ b/js/net/src/ietf/session.ts @@ -0,0 +1,74 @@ +import { SessionCode, SessionError } from "../error.ts"; +import { type OpenOptions, Stream } from "../stream.ts"; +import type { IetfVersion } from "./version.ts"; + +/** Opens request streams and allocates request IDs. Implemented by {@link NativeSession}; tests substitute their own. */ +export interface Session { + openBi(options?: Partial): Stream | Promise; + acceptBi(): Promise; + nextRequestId(signal?: AbortSignal): Promise; + /** Check a request ID the peer used, closing the session on a wrong-parity one. */ + checkRequestId(id: bigint): void; + close(): void; + readonly version: IetfVersion; + /** Whether we opened the session. */ + readonly client: boolean; +} + +/** A thin wrapper around WebTransport: each request gets its own bidi stream. */ +export class NativeSession implements Session { + #quic: WebTransport; + // moq-transport reserves even request IDs for the client and odd for the server, + // so the two peers' ID spaces never overlap. + #requestId: bigint; + readonly version: IetfVersion; + readonly client: boolean; + + constructor(quic: WebTransport, version: IetfVersion, client: boolean) { + this.#quic = quic; + this.version = version; + this.client = client; + this.#requestId = client ? 0n : 1n; + } + + checkRequestId(id: bigint): void { + try { + checkPeerRequestId(id, this.client); + } catch (err) { + if (err instanceof SessionError) this.#quic.close({ closeCode: err.code, reason: err.message }); + throw err; + } + } + + async openBi(options?: Partial): Promise { + return Stream.open(this.#quic, { ...options, version: this.version }); + } + + async acceptBi(): Promise { + return Stream.accept(this.#quic, this.version); + } + + async nextRequestId(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const id = this.#requestId; + this.#requestId += 2n; + return id; + } + + /** Closes the underlying WebTransport session. */ + close() { + this.#quic.close(); + } +} + +/** + * Check a request ID the peer used: it must have the peer's parity, odd from a server. + * + * Request streams can arrive out of order, and a reused ID is accepted too: each request owns + * its stream, so a reuse only confuses the reusing peer's own joining FETCHes, and remembering + * every skipped ID would cost memory the peer chooses. Mirrors moq-net. + */ +export function checkPeerRequestId(id: bigint, client: boolean): void { + // No peer reaches 2^62 requests; refusing past it matches moq-net, whose counter would overflow. + if (id >= 1n << 62n || id % 2n !== (client ? 1n : 0n)) throw new SessionError(SessionCode.InvalidRequestId); +} diff --git a/js/net/src/ietf/setup.ts b/js/net/src/ietf/setup.ts index b14619aea68..2c67887c861 100644 --- a/js/net/src/ietf/setup.ts +++ b/js/net/src/ietf/setup.ts @@ -1,9 +1,9 @@ import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; import { SetupOptions } from "./setup_options.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; -// Draft-17 unified SETUP message (0x2F00) +// The SETUP message (0x2F00) export class Setup { static id = 0x2f00; @@ -13,134 +13,11 @@ export class Setup { this.parameters = parameters; } - async #encode(w: Writer, version: IetfVersion): Promise { - await this.parameters.encode(w, version); + async encode(w: Writer, _version: IetfVersion): Promise { + return Message.encode(w, (mw) => this.parameters.encode(mw)); } - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (mw) => this.#encode(mw, version)); - } - - static async #decode(r: Reader, version: IetfVersion): Promise { - const parameters = await SetupOptions.decode(r, version); - return new Setup({ parameters }); - } - - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (mr) => Setup.#decode(mr, version)); - } -} - -const MAX_VERSIONS = 128; - -export class ClientSetup { - static id = 0x20; - - versions: number[]; - parameters: SetupOptions; - - constructor({ versions, parameters = new SetupOptions() }: { versions: number[]; parameters?: SetupOptions }) { - this.versions = versions; - this.parameters = parameters; - } - - async #encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_15 || version === Version.DRAFT_16) { - // Draft15+: no versions list, just parameters - await this.parameters.encode(w, version); - } else if (version === Version.DRAFT_14) { - await w.u53(this.versions.length); - for (const v of this.versions) { - await w.u53(v); - } - await this.parameters.encode(w, version); - } else { - // d17 uses unified Setup, not ClientSetup - throw new Error("ClientSetup not used for this version"); - } - } - - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (mw) => this.#encode(mw, version)); - } - - static async #decode(r: Reader, version: IetfVersion): Promise { - if (version === Version.DRAFT_15 || version === Version.DRAFT_16) { - // Draft15+: no versions list, just parameters - const parameters = await SetupOptions.decode(r, version); - return new ClientSetup({ versions: [version], parameters }); - } else if (version === Version.DRAFT_14) { - // Number of supported versions - const numVersions = await r.u53(); - if (numVersions > MAX_VERSIONS) { - throw new Error(`too many versions: ${numVersions}`); - } - - const supportedVersions: number[] = []; - - for (let i = 0; i < numVersions; i++) { - const v = await r.u53(); - supportedVersions.push(v); - } - - const parameters = await SetupOptions.decode(r, version); - - return new ClientSetup({ versions: supportedVersions, parameters }); - } else { - // d17 uses unified Setup, not ClientSetup - throw new Error("ClientSetup not used for this version"); - } - } - - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (mr) => ClientSetup.#decode(mr, version)); - } -} - -export class ServerSetup { - static id = 0x21; - - version: number; - parameters: SetupOptions; - - constructor({ version, parameters = new SetupOptions() }: { version: number; parameters?: SetupOptions }) { - this.version = version; - this.parameters = parameters; - } - - async #encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_15 || version === Version.DRAFT_16) { - // Draft15+: no version field, just parameters - await this.parameters.encode(w, version); - } else if (version === Version.DRAFT_14) { - await w.u53(this.version); - await this.parameters.encode(w, version); - } else { - // d17 uses unified Setup, not ServerSetup - throw new Error("ServerSetup not used for this version"); - } - } - - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (mw) => this.#encode(mw, version)); - } - - static async #decode(r: Reader, version: IetfVersion): Promise { - if (version === Version.DRAFT_15 || version === Version.DRAFT_16) { - // Draft15+: no version field, just parameters - const parameters = await SetupOptions.decode(r, version); - return new ServerSetup({ version, parameters }); - } else if (version === Version.DRAFT_14) { - const selectedVersion = await r.u53(); - const parameters = await SetupOptions.decode(r, version); - return new ServerSetup({ version: selectedVersion, parameters }); - } else { - // d17 uses unified Setup, not ServerSetup - throw new Error("ServerSetup not used for this version"); - } - } - - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (mr) => ServerSetup.#decode(mr, version)); + static async decode(r: Reader, _version: IetfVersion): Promise { + return Message.decode(r, async (mr) => new Setup({ parameters: await SetupOptions.decode(mr) })); } } diff --git a/js/net/src/ietf/setup_options.test.ts b/js/net/src/ietf/setup_options.test.ts index 9a5b58b25c4..afc7a672d53 100644 --- a/js/net/src/ietf/setup_options.test.ts +++ b/js/net/src/ietf/setup_options.test.ts @@ -7,7 +7,6 @@ import { implementationFromSetup, implementationIntoSetup, SetupOption, SetupOpt test("setup option keys match the registries", () => { expect({ ...SetupOption }).toEqual({ Path: 0x01n, - MaxRequestId: 0x02n, AuthorizationToken: 0x03n, MaxAuthTokenCacheSize: 0x04n, Authority: 0x05n, diff --git a/js/net/src/ietf/setup_options.ts b/js/net/src/ietf/setup_options.ts index 3ba20dc11d1..b984b67b0de 100644 --- a/js/net/src/ietf/setup_options.ts +++ b/js/net/src/ietf/setup_options.ts @@ -2,12 +2,10 @@ import { ProtocolViolation } from "../error.ts"; import type { Reader, Writer } from "../stream.ts"; import { decodeUtf8 } from "../util/utf8.ts"; import { AUTHORIZATION_TOKEN } from "./token.ts"; -import { type IetfVersion, Version } from "./version.ts"; /// Setup Option key constants (separate namespace from Message Parameters). export const SetupOption = { Path: 1n, - MaxRequestId: 2n, AuthorizationToken: AUTHORIZATION_TOKEN, MaxAuthTokenCacheSize: 4n, Authority: 5n, @@ -33,29 +31,7 @@ export const SetupOption = { // Unknown SETUP options may repeat, including GREASE (draft-22 section 9.1). const KNOWN_SETUP_OPTIONS: readonly bigint[] = Object.values(SetupOption); -/** - * Requests a draft-14 to -16 peer may hold open at once. - * - * Sized like moq-net's window: twice its default 100,000 announces and 20,000 - * other requests. Draft-17 dropped MAX_REQUEST_ID. Grants are batched per - * half window, so only about half of it is guaranteed to a peer holding requests open. - */ -export const REQUEST_WINDOW = 240_000n; - -/** - * The SETUP MAX_REQUEST_ID that first admits `window` requests from the peer. - * - * It is exclusive (the draft's "maximum request ID plus 1"), and the peer uses - * only its own parity: even IDs are the client's, odd the server's. - */ -export function initialMaxRequestId(peerClient: boolean, window: bigint = REQUEST_WINDOW): bigint { - return window * 2n + (peerClient ? 0n : 1n); -} - -/// Setup Options, used in SETUP messages. -/// -/// In d14-d16 these are count-prefixed ("Setup Parameters"). -/// In d17 these have no count prefix ("Setup Options") and read/write to end of message. +/// Setup Options, used in SETUP messages: delta-coded, with no count prefix, to the end of the message. export class SetupOptions { vars: Map; bytes: Map; @@ -97,108 +73,56 @@ export class SetupOptions { return this.vars.get(id); } - async encode(w: Writer, version: IetfVersion) { - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15) { - // d17+: no count prefix; d16: count prefix - if (version === Version.DRAFT_16) { - await w.u53(this.vars.size + this.bytes.size); - } - - // Delta encoding: collect all keys, sort, encode deltas - const all: { key: bigint; isVar: boolean }[] = []; - for (const id of this.vars.keys()) all.push({ key: id, isVar: true }); - for (const id of this.bytes.keys()) all.push({ key: id, isVar: false }); - all.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); - - let prevId = 0n; - for (let i = 0; i < all.length; i++) { - const { key, isVar } = all[i]; - const delta = i === 0 ? key : key - prevId; - prevId = key; - await w.u62(delta); - - if (isVar) { - // biome-ignore lint/style/noNonNullAssertion: key is guaranteed to exist in vars map - await w.u62(this.vars.get(key)!); - } else { - // biome-ignore lint/style/noNonNullAssertion: key is guaranteed to exist in bytes map - const value = this.bytes.get(key)!; - await w.u53(value.length); - await w.write(value); - } - } - } else { - await w.u53(this.vars.size + this.bytes.size); - - for (const [id, value] of this.vars) { - await w.u62(id); - await w.u62(value); - } - - for (const [id, value] of this.bytes) { - await w.u62(id); + async encode(w: Writer) { + // Delta encoding: collect all keys, sort, encode deltas + const all: { key: bigint; isVar: boolean }[] = []; + for (const id of this.vars.keys()) all.push({ key: id, isVar: true }); + for (const id of this.bytes.keys()) all.push({ key: id, isVar: false }); + all.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + + let prevId = 0n; + for (let i = 0; i < all.length; i++) { + const { key, isVar } = all[i]; + const delta = i === 0 ? key : key - prevId; + prevId = key; + await w.u62(delta); + + if (isVar) { + // biome-ignore lint/style/noNonNullAssertion: key is guaranteed to exist in vars map + await w.u62(this.vars.get(key)!); + } else { + // biome-ignore lint/style/noNonNullAssertion: key is guaranteed to exist in bytes map + const value = this.bytes.get(key)!; await w.u53(value.length); await w.write(value); } } } - static async decode(r: Reader, version: IetfVersion): Promise { + static async decode(r: Reader): Promise { const params = new SetupOptions(); - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) { - // d17+: no count prefix, read until reader is done - let prevType = 0n; - let i = 0; - while (!(await r.done())) { - const delta = await r.u62(); - const id = i === 0 ? delta : prevType + delta; - prevType = id; - i++; - - if (id % 2n === 0n) { - if (KNOWN_SETUP_OPTIONS.includes(id) && params.vars.has(id)) { - throw new Error(`duplicate parameter id: ${id.toString()}`); - } - const varint = await r.u62(); - params.setVarint(id, varint); - } else { - if (KNOWN_SETUP_OPTIONS.includes(id) && params.bytes.has(id)) { - throw new Error(`duplicate parameter id: ${id.toString()}`); - } - const size = await r.u53(); - const bytes = await r.read(size); - params.setBytes(id, bytes); - } - } - } else { - const count = await r.u53(); - let prevType = 0n; - - for (let i = 0; i < count; i++) { - let id: bigint; - if (version === Version.DRAFT_16) { - const delta = await r.u62(); - id = i === 0 ? delta : prevType + delta; - prevType = id; - } else { - id = await r.u62(); + let prevType = 0n; + let i = 0; + while (!(await r.done())) { + const delta = await r.u62(); + const id = i === 0 ? delta : prevType + delta; + prevType = id; + i++; + + if (id % 2n === 0n) { + if (KNOWN_SETUP_OPTIONS.includes(id) && params.vars.has(id)) { + throw new Error(`duplicate parameter id: ${id.toString()}`); } - - if (id % 2n === 0n) { - if (KNOWN_SETUP_OPTIONS.includes(id) && params.vars.has(id)) { - throw new Error(`duplicate parameter id: ${id.toString()}`); - } - const varint = await r.u62(); - params.setVarint(id, varint); - } else { - if (KNOWN_SETUP_OPTIONS.includes(id) && params.bytes.has(id)) { - throw new Error(`duplicate parameter id: ${id.toString()}`); - } - const size = await r.u53(); - const bytes = await r.read(size); - params.setBytes(id, bytes); + const varint = await r.u62(); + params.setVarint(id, varint); + } else { + if (KNOWN_SETUP_OPTIONS.includes(id) && params.bytes.has(id)) { + throw new Error(`duplicate parameter id: ${id.toString()}`); } + const size = await r.u53(); + const bytes = await r.read(size); + params.setBytes(id, bytes); } } diff --git a/js/net/src/ietf/subscribe.ts b/js/net/src/ietf/subscribe.ts index cf51b757b7f..d6cd89489fa 100644 --- a/js/net/src/ietf/subscribe.ts +++ b/js/net/src/ietf/subscribe.ts @@ -2,14 +2,13 @@ import type * as Epoch from "../epoch.ts"; import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import type { Order } from "../track.ts"; -import { supported as epochSupported } from "./epoch.ts"; import * as Filter from "./filter.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; -import { type MessageLocation, MSG_PARAM_FORWARD, MSG_PARAM_SUBSCRIBER_PRIORITY, Parameters } from "./parameters.ts"; +import { type MessageLocation, Parameters } from "./parameters.ts"; import { DEFAULT_WIRE } from "./priority.ts"; import * as Properties from "./properties.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; /** * The GROUP_ORDER wire values. @@ -17,8 +16,7 @@ import { type IetfVersion, Version } from "./version.ts"; * `Default` (0x0) is the wire's "no preference": the publisher picks, which in practice * means its own DEFAULT_PUBLISHER_GROUP_ORDER track property. It decodes as descending, the * order this publisher prefers, so the model never sees it and this client never sends it. - * Only drafts 14 to 16 can carry it; {@link Parameters} rejects it as a protocol violation - * on later drafts. + * {@link Parameters} rejects it as a protocol violation. */ export const GroupOrder = { Default: 0x00, @@ -90,7 +88,7 @@ export class Subscribe { rendezvous: boolean; /** - * The publisher instance the subscriber expects (EPOCH, MoQ Epoch, draft-17+). Only a + * The publisher instance the subscriber expects (EPOCH, MoQ Epoch). Only a * session that negotiated the extension names one; the session refuses one otherwise. */ epoch?: Epoch.Valid; @@ -141,46 +139,31 @@ export class Subscribe { async #encode(w: Writer, version: IetfVersion): Promise { await w.u62(this.requestId); - if (version === Version.DRAFT_17) { - await w.u62(0n); // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) - } await Namespace.encode(w, this.trackNamespace); await w.string(this.trackName); - if (version === Version.DRAFT_14) { - await w.u8(this.subscriberPriority); - await w.u8(encodeGroupOrder(this.groupOrder)); - await w.bool(this.forward); - await w.write(Filter.encode(this.filter, version)); - const params = new Parameters(); - params.maxAge = this.maxAge; - await params.encode(w, version); - } else { - // v15+: fields moved into parameters - const params = new Parameters(); - params.subscriberPriority = this.subscriberPriority; - params.maxAge = this.maxAge; - params.groupOrder = encodeGroupOrder(this.groupOrder); - params.forward = this.forward; - params.subscriptionFilter = this.filter; - - // FILL_PARAMETERS and INCLUDE_PROPERTIES are draft-22 only. An older peer reads - // either as an unknown parameter, which is a protocol violation, so they are - // dropped rather than downgraded. - if (Filter.isDraft22(version)) { - if (this.fill) { - params.fillParameters = Filter.encodeFill(this.fill, version); - } - // The default is 1, so only the opt-out is worth bytes. - if (!this.propertiesWanted) { - params.includeProperties = false; - } + const params = new Parameters(); + params.subscriberPriority = this.subscriberPriority; + params.maxAge = this.maxAge; + params.groupOrder = encodeGroupOrder(this.groupOrder); + params.forward = this.forward; + params.subscriptionFilter = this.filter; + + // FILL_PARAMETERS and INCLUDE_PROPERTIES arrived in draft-22. An older peer reads + // either as an unknown parameter, which is a protocol violation, so they are + // dropped rather than downgraded. + if (Filter.isDraft22(version)) { + if (this.fill) { + params.fillParameters = Filter.encodeFill(this.fill, version); + } + // The default is 1, so only the opt-out is worth bytes. + if (!this.propertiesWanted) { + params.includeProperties = false; } - // The same for EPOCH, which exists from draft-17. - if (epochSupported(version)) params.epoch = this.epoch; - - await params.encode(w, version); } + params.epoch = this.epoch; + + await params.encode(w, version); } async encode(w: Writer, version: IetfVersion): Promise { @@ -193,33 +176,8 @@ export class Subscribe { static async #decode(r: Reader, version: IetfVersion): Promise { const requestId = await r.u62(); - if (version === Version.DRAFT_17) { - await r.u62(); // required_request_id_delta (read and ignore) - } const { namespace: trackNamespace, name: trackName } = await Namespace.decodeTrack(r); - if (version === Version.DRAFT_14) { - const subscriberPriority = await r.u8(); - - const groupOrder = decodeGroupOrder(await r.u8()); - - const forward = await r.bool(); - const filter = await Filter.decodeInline(r); - - const params = await Parameters.decode(r, version, "subscribe"); - - return new Subscribe({ - requestId, - maxAge: params.maxAge, - trackNamespace, - trackName, - subscriberPriority, - groupOrder, - filter, - forward, - }); - } - // v15+: fields are in parameters const params = await Parameters.decode(r, version, "subscribe"); const subscriberPriority = params.subscriberPriority ?? DEFAULT_WIRE; const groupOrder = decodeGroupOrder(params.groupOrder ?? GroupOrder.Descending); @@ -266,7 +224,6 @@ export class Subscribe { export class SubscribeOk { static id = 0x04; - requestId: bigint | undefined; trackAlias: bigint; /** @@ -276,7 +233,7 @@ export class SubscribeOk { largest: MessageLocation | undefined; /** - * The Track Properties to send (draft-17+): the track's Timescale and our group order. + * The Track Properties to send: the track's Timescale and our group order. * * Empty opts the response out of properties entirely, which is what a peer that sent * INCLUDE_PROPERTIES=0 asked for; declaring no Timescale also opts the track out of @@ -285,56 +242,32 @@ export class SubscribeOk { properties: Properties.Properties; constructor({ - requestId, trackAlias, largest, properties, }: { - requestId?: bigint; trackAlias: bigint; largest?: MessageLocation; properties?: Properties.Properties; }) { - this.requestId = requestId; this.trackAlias = trackAlias; this.largest = largest; this.properties = properties ?? {}; } async #encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - if (this.requestId === undefined) throw new Error("requestId required for draft14-16"); - await w.u62(this.requestId); - } await w.u62(this.trackAlias); - if (version === Version.DRAFT_14) { - await w.u62(0n); // expires = 0 - await w.u8(this.properties.groupOrder ?? GroupOrder.Descending); - await w.bool(this.largest !== undefined); - if (this.largest !== undefined) { - await w.u62(this.largest.groupId); - await w.u62(this.largest.objectId); - } - await w.u53(0); // no parameters - } else { - // v15+: just parameters after track_alias - const params = new Parameters(); - // GROUP_ORDER is a legal SUBSCRIBE_OK 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. - if (version === Version.DRAFT_15 && this.properties.groupOrder !== undefined) { - params.groupOrder = this.properties.groupOrder; - } - if (this.largest !== undefined) { - params.largest = this.largest; - } - await params.encode(w, version); - - // Track Properties are the final field, so nothing may follow. - await Properties.encode(w, this.properties, version); + // The publisher's group order is a DEFAULT_PUBLISHER_GROUP_ORDER track property: a + // peer closes the session on a GROUP_ORDER parameter here. + const params = new Parameters(); + if (this.largest !== undefined) { + params.largest = this.largest; } + await params.encode(w, version); + + // Track Properties are the final field, so nothing may follow. + await Properties.encode(w, this.properties, version); } async encode(w: Writer, version: IetfVersion): Promise { @@ -346,135 +279,33 @@ export class SubscribeOk { } static async #decode(r: Reader, version: IetfVersion): Promise { - const requestId = - version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 - ? await r.u62() - : undefined; const trackAlias = await r.u62(); - let largest: MessageLocation | undefined; - let properties: Properties.Properties = {}; - - if (version === Version.DRAFT_14) { - // EXPIRES is when the publisher expects to end the subscription. That end - // arrives as PUBLISH_DONE regardless, so there is nothing to act on. - await r.u62(); + // LARGEST_OBJECT is required once the track has content, so rejecting it would tear + // down a session over a parameter compliant publishers must send. + const params = await Parameters.decode(r, version, "subscribe-ok"); + const properties = await Properties.decode(r, version); - properties.groupOrder = await r.u8(); - - const contentExists = await r.bool(); - if (contentExists) { - const groupId = await r.u62(); - const objectId = await r.u62(); - largest = { groupId, objectId }; - } - - properties.maxCacheDuration = (await Parameters.decode(r, version, "subscribe-ok")).maxCacheDuration; - } else { - // v15+: parameters followed by Track Properties (draft-17+). LARGEST_OBJECT is - // required on every draft once the track has content, so rejecting it would tear - // down a session over a parameter compliant publishers must send. - const params = await Parameters.decode(r, version, "subscribe-ok"); - largest = params.largest; - properties = await Properties.decode(r, version); - if (version === Version.DRAFT_15) { - properties.maxCacheDuration = params.maxCacheDuration; - properties.groupOrder = params.groupOrder; - } - } - - return new SubscribeOk({ requestId, trackAlias, largest, properties }); - } -} - -export class SubscribeError { - static id = 0x05; - - requestId: bigint; - errorCode: number; - reasonPhrase: string; - - constructor({ - requestId, - errorCode, - reasonPhrase, - }: { requestId: bigint; errorCode: number; reasonPhrase: string }) { - this.requestId = requestId; - this.errorCode = errorCode; - this.reasonPhrase = reasonPhrase; - } - - async #encode(w: Writer): Promise { - await w.u62(this.requestId); - await w.u62(BigInt(this.errorCode)); - await w.string(this.reasonPhrase); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, SubscribeError.#decode); - } - - static async #decode(r: Reader): Promise { - const requestId = await r.u62(); - const errorCode = Number(await r.u62()); - const reasonPhrase = await r.string(); - - return new SubscribeError({ requestId, errorCode, reasonPhrase }); + return new SubscribeOk({ trackAlias, largest: params.largest, properties }); } } export class SubscribeUpdate { static id = 0x02; + /** The request being updated. */ requestId: bigint; - /** This update's own Request ID, the first field on drafts 14 to 16, which their answer names. */ - ownRequestId?: bigint; - - /** What the update changes. Draft 14's fixed SUBSCRIBER_PRIORITY and FORWARD fields land here too. */ + /** What the update changes. */ parameters: Parameters; - constructor({ - requestId, - ownRequestId, - parameters = new Parameters(), - }: { requestId: bigint; ownRequestId?: bigint; parameters?: Parameters }) { + constructor({ requestId, parameters = new Parameters() }: { requestId: bigint; parameters?: Parameters }) { this.requestId = requestId; - this.ownRequestId = ownRequestId; this.parameters = parameters; } async #encode(w: Writer, version: IetfVersion): Promise { - // Drafts 14 to 16 send a new Request ID and then the request being updated. - // Later drafts dropped the new id, so the one field left is the target. - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - if (this.ownRequestId === undefined) throw new Error("ownRequestId required for draft14-16"); - await w.u62(this.ownRequestId); - await w.u62(this.requestId); - if (version === Version.DRAFT_14) { - // Every field is mandatory and the start must not move. Our subscriptions start at - // {0, 0} open ended (`joinFilter`), so zeroes repeat their bounds. - await w.u62(0n); // start_group - await w.u62(0n); // start_object - await w.u62(0n); // end_group - await w.u8(this.parameters.subscriberPriority ?? DEFAULT_WIRE); - await w.bool(this.parameters.forward ?? true); - await this.parameters.encode(w, version, [MSG_PARAM_SUBSCRIBER_PRIORITY, MSG_PARAM_FORWARD]); - } else { - await this.parameters.encode(w, version); - } - } else { - if (this.ownRequestId !== undefined) throw new Error("ownRequestId is only valid on draft14-16"); - // v17+: REQUEST_UPDATE - await w.u62(this.requestId); - if (version === Version.DRAFT_17) { - await w.u62(0n); // required_request_id_delta (draft-17 only, removed in draft-18 per #1615) - } - await this.parameters.encode(w, version); - } + await w.u62(this.requestId); + await this.parameters.encode(w, version); } async encode(w: Writer, version: IetfVersion): Promise { @@ -486,60 +317,8 @@ export class SubscribeUpdate { } static async #decode(r: Reader, version: IetfVersion): Promise { - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - const ownRequestId = await r.u62(); - const requestId = await r.u62(); - if (version !== Version.DRAFT_14) { - const parameters = await Parameters.decode(r, version, "subscribe-update"); - return new SubscribeUpdate({ requestId, ownRequestId, parameters }); - } - // The range is left as it is: every field is mandatory, so a narrowed range cannot be - // told from a repeated one, and draft 14 accepts extra objects as the worst outcome - // of an update. - await r.u62(); // start_group - await r.u62(); // start_object - await r.u62(); // end_group - const priority = await r.u8(); - const forward = await r.bool(); - const parameters = await Parameters.decode(r, version, "subscribe-update"); - parameters.subscriberPriority = priority; - parameters.forward = forward; - return new SubscribeUpdate({ requestId, ownRequestId, parameters }); - } - - // v17+: REQUEST_UPDATE. Request ID is the existing request. const requestId = await r.u62(); - if (version === Version.DRAFT_17) { - await r.u62(); // required_request_id_delta (draft-17 only, removed in draft-18 per #1615) - } const parameters = await Parameters.decode(r, version, "subscribe-update"); return new SubscribeUpdate({ requestId, parameters }); } } - -export class Unsubscribe { - static readonly id = 0x0a; - - requestId: bigint; - - constructor({ requestId }: { requestId: bigint }) { - this.requestId = requestId; - } - - async #encode(w: Writer): Promise { - await w.u62(this.requestId); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, Unsubscribe.#decode); - } - - static async #decode(r: Reader): Promise { - const requestId = await r.u62(); - return new Unsubscribe({ requestId }); - } -} diff --git a/js/net/src/ietf/subscribe_namespace.ts b/js/net/src/ietf/subscribe_namespace.ts index d3a62cc91b9..dc893a54aea 100644 --- a/js/net/src/ietf/subscribe_namespace.ts +++ b/js/net/src/ietf/subscribe_namespace.ts @@ -1,4 +1,3 @@ -import { ProtocolViolation } from "../error.ts"; import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; @@ -12,27 +11,9 @@ import { NEGOTIATED_NONE, type Negotiated, } from "./publish_namespace.ts"; -import { type IetfVersion, Version } from "./version.ts"; +import type { IetfVersion } from "./version.ts"; -// True for the drafts that use the legacy 0x11 SUBSCRIBE_NAMESPACE message. -// Draft-18+ uses the renumbered 0x50 message instead. -function isLegacyVersion(version: IetfVersion): boolean { - switch (version) { - case Version.DRAFT_14: - case Version.DRAFT_15: - case Version.DRAFT_16: - case Version.DRAFT_17: - return true; - default: - return false; - } -} - -// SUBSCRIBE_NAMESPACE message (draft-18+, type 0x50). -// -// Draft-18 renumbered the message from 0x11 to 0x50 and dropped the Subscribe -// Options field when it split SUBSCRIBE_TRACKS (0x51) off into its own message -// type (#1542). Draft-14 through draft-17 use SubscribeNamespaceLegacy. +// SUBSCRIBE_NAMESPACE message (type 0x50). export class SubscribeNamespace { static id = 0x50; @@ -52,9 +33,6 @@ export class SubscribeNamespace { } async #encode(w: Writer, version: IetfVersion): Promise { - if (isLegacyVersion(version)) { - throw new Error(`SUBSCRIBE_NAMESPACE (0x50) is draft-18+ only, not ${version}`); - } await w.u62(this.requestId); await Namespace.encode(w, this.namespace); const params = new Parameters(); @@ -71,9 +49,6 @@ export class SubscribeNamespace { } static async #decode(r: Reader, version: IetfVersion): Promise { - if (isLegacyVersion(version)) { - throw new Error(`SUBSCRIBE_NAMESPACE (0x50) is draft-18+ only, not ${version}`); - } const requestId = await r.u62(); const namespace = await Namespace.decode(r); const params = await Parameters.decode(r, version, "subscribe-namespace"); @@ -82,103 +57,6 @@ export class SubscribeNamespace { } } -/** - * What a draft-16/17 SUBSCRIBE_NAMESPACE asks for (d16 §9.25, d17 §9.20). - * - * Draft-14/15 have no field and only ever ask for namespaces. Draft-18 dropped it - * when SUBSCRIBE_TRACKS took over the PUBLISH half. - */ -export const SubscribeOptions = { - /** PUBLISH for each matching track. */ - PUBLISH: 0x00, - /** NAMESPACE for each matching namespace. */ - NAMESPACE: 0x01, - /** Both PUBLISH and NAMESPACE. */ - BOTH: 0x02, -} as const; - -/** One of the {@link SubscribeOptions} values. */ -export type SubscribeOptions = (typeof SubscribeOptions)[keyof typeof SubscribeOptions]; - -// SUBSCRIBE_NAMESPACE message for draft-14 through draft-17 (type 0x11). -// -// In v16 this moves from the control stream to its own bidi stream. Draft-16/17 -// carry SubscribeOptions; draft-17 additionally prefixes a Required Request ID -// delta (removed in draft-18 per #1615). Draft-18+ uses SubscribeNamespace. -export class SubscribeNamespaceLegacy { - static id = 0x11; - - namespace: Path.Valid; - requestId: bigint; - /** Encoded on v16/v17 only; earlier drafts always mean NAMESPACE. */ - subscribeOptions: SubscribeOptions; - /** MoQ Hidden: see {@link SubscribeNamespace.hidden}. */ - hidden: boolean; - - constructor({ - namespace, - requestId, - subscribeOptions = SubscribeOptions.NAMESPACE, - hidden = false, - }: { - namespace: Path.Valid; - requestId: bigint; - subscribeOptions?: SubscribeOptions; - hidden?: boolean; - }) { - this.namespace = namespace; - this.requestId = requestId; - this.subscribeOptions = subscribeOptions; - this.hidden = hidden; - } - - async #encode(w: Writer, version: IetfVersion): Promise { - if (!isLegacyVersion(version)) { - throw new Error(`legacy SUBSCRIBE_NAMESPACE (0x11) is draft-14..17 only, not ${version}`); - } - await w.u62(this.requestId); - if (version === Version.DRAFT_17) { - await w.u62(0n); // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) - } - await Namespace.encode(w, this.namespace); - if (version === Version.DRAFT_16 || version === Version.DRAFT_17) { - await w.u53(this.subscribeOptions); - } - const params = new Parameters(); - params.hidden = this.hidden; - await params.encode(w, version); - } - - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (wr) => this.#encode(wr, version)); - } - - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (rd) => SubscribeNamespaceLegacy.#decode(rd, version)); - } - - static async #decode(r: Reader, version: IetfVersion): Promise { - if (!isLegacyVersion(version)) { - throw new Error(`legacy SUBSCRIBE_NAMESPACE (0x11) is draft-14..17 only, not ${version}`); - } - const requestId = await r.u62(); - if (version === Version.DRAFT_17) { - await r.u62(); // required_request_id_delta (draft-17 only, removed in draft-18 per #1615) - } - const namespace = await Namespace.decode(r); - let subscribeOptions: SubscribeOptions = SubscribeOptions.NAMESPACE; - if (version === Version.DRAFT_16 || version === Version.DRAFT_17) { - // Full width, so a value past 2^53 is still a protocol violation. - const raw = await r.u62(); - if (raw > BigInt(SubscribeOptions.BOTH)) throw new ProtocolViolation(`invalid Subscribe Options: ${raw}`); - subscribeOptions = Number(raw) as SubscribeOptions; - } - const params = await Parameters.decode(r, version, "subscribe-namespace"); - - return new SubscribeNamespaceLegacy({ namespace, requestId, subscribeOptions, hidden: params.hidden }); - } -} - /// SUBSCRIBE_TRACKS message ID (0x51) introduced in draft-18 (#1542). /// /// moq-lite does not implement PUBLISH replication through a CDN, which is the @@ -186,114 +64,8 @@ export class SubscribeNamespaceLegacy { /// with REQUEST_ERROR NOT_SUPPORTED. export const SUBSCRIBE_TRACKS_ID = 0x51; -export class SubscribeNamespaceOk { - static id = 0x12; - - requestId: bigint; - - constructor({ requestId }: { requestId: bigint }) { - this.requestId = requestId; - } - - async #encode(w: Writer): Promise { - await w.u62(this.requestId); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, SubscribeNamespaceOk.#decode); - } - - static async #decode(r: Reader): Promise { - const requestId = await r.u62(); - return new SubscribeNamespaceOk({ requestId }); - } -} - -export class SubscribeNamespaceError { - static id = 0x13; - - requestId: bigint; - errorCode: number; - reasonPhrase: string; - - constructor({ - requestId, - errorCode, - reasonPhrase, - }: { requestId: bigint; errorCode: number; reasonPhrase: string }) { - this.requestId = requestId; - this.errorCode = errorCode; - this.reasonPhrase = reasonPhrase; - } - - async #encode(w: Writer): Promise { - await w.u62(this.requestId); - await w.u62(BigInt(this.errorCode)); - await w.string(this.reasonPhrase); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, SubscribeNamespaceError.#decode); - } - - static async #decode(r: Reader): Promise { - const requestId = await r.u62(); - const errorCode = Number(await r.u62()); - const reasonPhrase = await r.string(); - - return new SubscribeNamespaceError({ requestId, errorCode, reasonPhrase }); - } -} - -/** UNSUBSCRIBE_NAMESPACE (0x14), drafts 14 and 15 only: draft 14 names the prefix, draft 15 the request. */ -export class UnsubscribeNamespace { - static id = 0x14; - - /** Draft 14: the Track Namespace Prefix of the SUBSCRIBE_NAMESPACE it cancels. */ - prefix: Path.Valid; - /** Draft 15: the Request ID of the SUBSCRIBE_NAMESPACE it cancels. */ - requestId: bigint; - - constructor({ prefix = "" as Path.Valid, requestId = 0n }: { prefix?: Path.Valid; requestId?: bigint }) { - this.prefix = prefix; - this.requestId = requestId; - } - - async #encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_14) { - await Namespace.encode(w, this.prefix); - } else if (version === Version.DRAFT_15) { - await w.u62(this.requestId); - } else { - throw new Error(`UNSUBSCRIBE_NAMESPACE is draft-14/15 only, not ${version}`); - } - } - - async encode(w: Writer, version: IetfVersion): Promise { - return Message.encode(w, (wr) => this.#encode(wr, version)); - } - - static async decode(r: Reader, version: IetfVersion): Promise { - return Message.decode(r, (rd) => UnsubscribeNamespace.#decode(rd, version)); - } - - static async #decode(r: Reader, version: IetfVersion): Promise { - if (version === Version.DRAFT_14) return new UnsubscribeNamespace({ prefix: await Namespace.decode(r) }); - if (version === Version.DRAFT_15) return new UnsubscribeNamespace({ requestId: await r.u62() }); - throw new Error(`UNSUBSCRIBE_NAMESPACE is draft-14/15 only, not ${version}`); - } -} - /** - * NAMESPACE message (0x08), v16+, sent on the SUBSCRIBE_NAMESPACE bidi stream. + * NAMESPACE message (0x08), sent on the SUBSCRIBE_NAMESPACE bidi stream. * * The base message carries only the suffix. A session that negotiated the MoQ Cluster or * MoQ Epoch extension uses the extended form instead, which appends one Parameters field @@ -347,7 +119,7 @@ export class SubscribeNamespaceEntry { } } -/// NAMESPACE_DONE message (0x0E) — v16 only, sent on SUBSCRIBE_NAMESPACE bidi stream +/// NAMESPACE_DONE message (0x0E), sent on the SUBSCRIBE_NAMESPACE bidi stream. export class SubscribeNamespaceEntryDone { static id = 0x0e; @@ -374,35 +146,3 @@ export class SubscribeNamespaceEntryDone { return new SubscribeNamespaceEntryDone({ suffix }); } } - -/// PUBLISH_BLOCKED message (0x0F) — draft-17 only, sent on SUBSCRIBE_NAMESPACE bidi stream -export class PublishBlocked { - static id = 0x0f; - - suffix: Path.Valid; - trackName: string; - - constructor({ suffix, trackName }: { suffix: Path.Valid; trackName: string }) { - this.suffix = suffix; - this.trackName = trackName; - } - - async #encode(w: Writer): Promise { - await Namespace.encode(w, this.suffix); - await w.string(this.trackName); - } - - async encode(w: Writer, _version: IetfVersion): Promise { - return Message.encode(w, this.#encode.bind(this)); - } - - static async decode(r: Reader, _version: IetfVersion): Promise { - return Message.decode(r, PublishBlocked.#decode); - } - - static async #decode(r: Reader): Promise { - const suffix = await Namespace.decode(r); - const trackName = await r.string(); - return new PublishBlocked({ suffix, trackName }); - } -} diff --git a/js/net/src/ietf/subscribe_tracks.test.ts b/js/net/src/ietf/subscribe_tracks.test.ts index 1a910440c96..25f75a01e70 100644 --- a/js/net/src/ietf/subscribe_tracks.test.ts +++ b/js/net/src/ietf/subscribe_tracks.test.ts @@ -9,13 +9,13 @@ import { createMockTransportPair } from "../mock.ts"; import { Producer as Origin } from "../origin.ts"; import * as Path from "../path.ts"; import { Reader, Stream, Writer } from "../stream.ts"; -import { NativeSession } from "./adapter.ts"; import * as Message from "./message.ts"; import { Group } from "./object.ts"; import { Parameters } from "./parameters.ts"; import { Publish, PublishDone, PublishDoneStatus } from "./publish.ts"; import { Publisher } from "./publisher.ts"; import { RequestError, RequestOk } from "./request.ts"; +import { NativeSession } from "./session.ts"; import { Subscribe } from "./subscribe.ts"; import { PublishSkipped, SubscribeTracks } from "./subscribe_tracks.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index b93a1981800..e03d3879706 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -13,28 +13,17 @@ import { Tail } from "../tail.ts"; import { Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import * as demandUtil from "../util/demand.ts"; -import { ControlStreamAdapter, NativeSession } from "./adapter.ts"; import type * as Cluster from "./cluster.ts"; import { Connection } from "./connection.ts"; import { ObjectDatagram } from "./datagram.ts"; -import { EndOfTrackProperties, encodeObjectExtensions, type GroupFlags, Group as GroupMessage } from "./object.ts"; +import { encodeObjectExtensions, type GroupFlags, Group as GroupMessage } from "./object.ts"; import { Parameters } from "./parameters.ts"; import type { Properties } from "./properties.ts"; -import { - PublishNamespace, - PublishNamespaceCancel, - PublishNamespaceOk, - PublishNamespaceUpdate, -} from "./publish_namespace.ts"; -import { MaxRequestId, RequestError, RequestOk } from "./request.ts"; -import { Subscribe, SubscribeOk, SubscribeUpdate, Unsubscribe } from "./subscribe.ts"; -import { - SubscribeNamespace, - SubscribeNamespaceEntry, - SubscribeNamespaceEntryDone, - SubscribeNamespaceLegacy, - UnsubscribeNamespace, -} from "./subscribe_namespace.ts"; +import { PublishNamespace, PublishNamespaceUpdate } from "./publish_namespace.ts"; +import { RequestError, RequestOk } from "./request.ts"; +import { NativeSession } from "./session.ts"; +import { Subscribe, SubscribeOk, SubscribeUpdate } from "./subscribe.ts"; +import { SubscribeNamespace, SubscribeNamespaceEntry, SubscribeNamespaceEntryDone } from "./subscribe_namespace.ts"; import { Subscriber } from "./subscriber.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; @@ -85,82 +74,6 @@ test("every peer is asked", async () => { expect(await nextStream(pair.client)).toBeDefined(); }); -/** - * Draft-16 is the first that allows a zero-field track namespace. Asking a foreign peer - * (one that declared no MoQ Solicit) for one earlier is a protocol violation, so an - * unscoped subscriber sends nothing and still takes an unsolicited announcement. - */ -test.each([ - ["draft-14", Version.DRAFT_14, ALPN.DRAFT_14], - ["draft-15", Version.DRAFT_15, ALPN.DRAFT_15], -] as const)("%s does not ask a foreign peer for the empty namespace", async (_name, version, alpn) => { - const pair = createMockTransportPair(alpn); - const session = new NativeSession(pair.server, version, true); - const subscriber = new Subscriber({ session }); - const announced = subscriber.announced(); - - expect(await nextStream(pair.client)).toBeUndefined(); - - const stream = await Stream.open(pair.server, { version }); - void subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("surprise") }), - stream, - ); - expect(await announced.next()).toMatchObject({ prefix: Path.from("surprise"), kind: "start" }); - announced.close(); -}); - -/** With no request stream to end it, the unasked reader ends with the session, from either side. */ -test.each([ - ["local", "server"], - ["remote", "client"], -] as const)("an unasked draft-14 reader ends on a %s close", async (_name, side) => { - const pair = createMockTransportPair(ALPN.DRAFT_14); - const session = new NativeSession(pair.server, Version.DRAFT_14, true); - const subscriber = new Subscriber({ session, quic: pair.server }); - const announced = subscriber.announced(); - - pair[side].close(); - - expect(await announced.next()).toBeUndefined(); -}); - -/** A peer that declared MoQ Solicit is ours: it only tells when asked, so it still is. */ -test.each([ - ["draft-14", Version.DRAFT_14, ALPN.DRAFT_14], - ["draft-15", Version.DRAFT_15, ALPN.DRAFT_15], -] as const)("%s still asks a soliciting peer for the empty namespace", async (_name, version, alpn) => { - const pair = createMockTransportPair(alpn); - const session = new NativeSession(pair.server, version, true); - const subscriber = new Subscriber({ session, solicit: true }); - - subscriber.announced(); - - expect(await nextStream(pair.client)).toBeDefined(); -}); - -/** A named prefix is still legal on the drafts that reject the empty one. */ -test("draft-14 still asks for a named prefix", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_14); - const session = new NativeSession(pair.server, Version.DRAFT_14, true); - const subscriber = new Subscriber({ session }); - - subscriber.announced(Path.Pattern.subtree(Path.from("cam"))); - - expect(await nextStream(pair.client)).toBeDefined(); -}); - -/** The empty prefix is the "every namespace" request from draft-16 on. */ -test("draft-16 still asks for the empty namespace", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_16); - const session = new NativeSession(pair.server, Version.DRAFT_16, true); - const subscriber = new Subscriber({ session }); - - subscriber.announced(); - - expect(await nextStream(pair.client)).toBeDefined(); -}); - /** * The other half of discovery: a peer that tells us unasked. Asking must not make us deaf * to a PUBLISH_NAMESPACE that arrives on its own stream instead. @@ -242,7 +155,7 @@ async function acceptSubscribeNamespace(transport: WebTransport, version: IetfVe await stream.reader.u53(); await SubscribeNamespace.decode(stream.reader, version); await stream.writer.u53(RequestOk.id); - await new RequestOk({ requestId: undefined }).encode(stream.writer, version); + await new RequestOk().encode(stream.writer, version); return stream; } @@ -373,43 +286,6 @@ test("a subscription that dies releases what it advertised", async () => { expect(await survivor.next()).toMatchObject({ prefix: Path.from("orphan"), kind: "end" }); }); -/** - * Draft-14/15 name their namespace-scoped messages instead of numbering them, so the - * adapter can hold one request per namespace. A second would overwrite the first and the - * withdrawals would go to the wrong stream, then to none at all, killing the session. The - * case that makes a second reference legitimate needs an inline NAMESPACE, which those - * drafts do not have. - */ -test("a duplicate legacy publish_namespace is still refused", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const session = new NativeSession(pair.server, Version.DRAFT_15, true); - const subscriber = new Subscriber({ session }); - - const announced = subscriber.announced(); - - const first = await Stream.open(pair.server, { version: Version.DRAFT_15 }); - const handler = subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("twice") }), - first, - ); - expect(await announced.next()).toMatchObject({ prefix: Path.from("twice"), kind: "start" }); - - // The same namespace again, on its own request. - const second = await Stream.open(pair.server, { version: Version.DRAFT_15 }); - await subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 2n, trackNamespace: Path.from("twice") }), - second, - ); - - // Refused, so it took no reference: the first request ending still retracts the path. - const peer = await nextStream(pair.client); - if (!peer) throw new Error("no PUBLISH_NAMESPACE stream to close"); - peer.close(); - await handler; - - expect(await announced.next()).toMatchObject({ prefix: Path.from("twice"), kind: "end" }); -}); - /** * The read loop is not awaited when the local consumer closes first, and closing the * stream cancels the transport without discarding what the reader already buffered. An @@ -445,42 +321,6 @@ test("an entry buffered past a consumer close does not pin the path", async () = expect(seeded).toBe("nothing"); }); -/** - * The reservation has to be synchronous. The count is only taken once the OK is written, - * so two legacy requests dispatched together would both get past a check that looked only - * at what is announced, and both would take a reference for one namespace. - */ -test("concurrent legacy publish_namespace requests take one reference", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const session = new NativeSession(pair.server, Version.DRAFT_15, true); - const subscriber = new Subscriber({ session }); - - const announced = subscriber.announced(); - - // Dispatched together, as the connection would on two incoming streams. - const first = await Stream.open(pair.server, { version: Version.DRAFT_15 }); - const second = await Stream.open(pair.server, { version: Version.DRAFT_15 }); - const one = subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("raced") }), - first, - ); - const two = subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 2n, trackNamespace: Path.from("raced") }), - second, - ); - - expect(await announced.next()).toMatchObject({ prefix: Path.from("raced"), kind: "start" }); - await two; - - // Only one reference was taken, so the surviving request ending retracts the path. - const peer = await nextStream(pair.client); - if (!peer) throw new Error("no PUBLISH_NAMESPACE stream to close"); - peer.close(); - await one; - - expect(await announced.next()).toMatchObject({ prefix: Path.from("raced"), kind: "end" }); -}); - /** The Hop IDs a cluster-negotiated session declared, ours first. */ const SELF: Hop = HopSchema.parse(7n); const PEER: Hop = HopSchema.parse(9n); @@ -611,7 +451,6 @@ test("a malformed PUBLISH_NAMESPACE update closes the session", async () => { url: new URL("https://example.com"), quic: pair.server, control, - maxRequestId: 100n, version: VERSION, client: false, cluster: { self: SELF, peer: PEER }, @@ -702,85 +541,6 @@ test("a PUBLISH_NAMESPACE update that starts looping back is detached", async () await handler; }); -/** - * Draft-16 renews a PUBLISH_NAMESPACE with a REQUEST_UPDATE naming it, answered by the - * update's own Request ID. One repeating a token is refused that way too, and cancels the - * advertisement, which outlives its stream on that draft. - */ -test("a draft-16 PUBLISH_NAMESPACE update is answered by its own Request ID", async () => { - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPN.DRAFT_16); - const session = new NativeSession(pair.server, version, true); - const subscriber = new Subscriber({ session }); - - const request = await Stream.open(pair.server, { version }); - const handler = subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 1n, trackNamespace: Path.from("theirs") }), - request, - ); - const peer = await nextStream(pair.client); - if (!peer) throw new Error("no PUBLISH_NAMESPACE stream"); - expect(await peer.reader.u53()).toBe(RequestOk.id); - expect((await RequestOk.decode(peer.reader, version)).requestId).toBe(1n); - - await peer.writer.u53(PublishNamespaceUpdate.id); - await new PublishNamespaceUpdate({ requestId: 3n, namespaceRequestId: 1n, update: {} }).encode( - peer.writer, - version, - ); - expect(await peer.reader.u53()).toBe(RequestOk.id); - expect((await RequestOk.decode(peer.reader, version)).requestId).toBe(3n); - - // Two identical AUTHORIZATION TOKENs. - const body = [5, 1, 2, 0x03, 3, 0x03, 0x00, 0xaa, 0, 3, 0x03, 0x00, 0xaa]; - await peer.writer.u53(PublishNamespaceUpdate.id); - await peer.writer.u16(body.length); - for (const byte of body) await peer.writer.u8(byte); - expect(await peer.reader.u53()).toBe(RequestError.id); - expect((await RequestError.decode(peer.reader, version)).requestId).toBe(5n); - expect(await peer.reader.u53()).toBe(PublishNamespaceCancel.id); - expect((await PublishNamespaceCancel.decode(peer.reader, version)).requestId).toBe(1n); - - await handler; -}); - -// Drafts 14 and 15 have no update for an advertisement. One routed onto it changes nothing -// and leaves it standing, and draft 15 still answers it with a REQUEST_ERROR naming the update. -test("a legacy update on a PUBLISH_NAMESPACE is refused and the advertisement stands", async () => { - for (const [version, alpn] of [ - [Version.DRAFT_14, ALPN.DRAFT_14], - [Version.DRAFT_15, ALPN.DRAFT_15], - ] as const) { - const pair = createMockTransportPair(alpn); - const session = new NativeSession(pair.server, version, true); - const subscriber = new Subscriber({ session }); - const announced = subscriber.announced(); - - const request = await Stream.open(pair.server, { version }); - const handler = subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("theirs") }), - request, - ); - const peer = await nextStream(pair.client); - if (!peer) throw new Error("no PUBLISH_NAMESPACE stream"); - expect(await peer.reader.u53()).toBe(version === Version.DRAFT_14 ? PublishNamespaceOk.id : RequestOk.id); - await peer.reader.read(await peer.reader.u16()); - expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "start" }); - - await peer.writer.u53(SubscribeUpdate.id); - await new SubscribeUpdate({ requestId: 0n, ownRequestId: 2n }).encode(peer.writer, version); - if (version === Version.DRAFT_15) { - expect(await peer.reader.u53()).toBe(RequestError.id); - expect((await RequestError.decode(peer.reader, version)).requestId).toBe(2n); - } - - // Only the withdrawal ends the advertisement. - peer.close(); - await handler; - expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "end" }); - } -}); - /** * REQUEST_UPDATE keeps an omitted parameter, so an explicit ROUTE_COST of 0 lands on the * path already held without disturbing the announcement. Consumers hear `update` with the @@ -938,144 +698,6 @@ test("a repeated PUBLISH_NAMESPACE is a protocol violation", async () => { await expect(announced.next()).rejects.toThrow(); }); -/** - * Drafts 14-16 carry every request over the control stream adapter's virtual streams, - * whose abort is local. UNSUBSCRIBE is the only cancellation that reaches the peer there, - * so a cancelled subscription is only really cancelled if that message lands on the real - * control stream. - */ -test("a legacy cancel reaches the control stream", async () => { - const LEGACY = Version.DRAFT_16; - const pair = createMockTransportPair(ALPN.DRAFT_16); - - // The one real bidi everything is multiplexed onto. - const controlStream = await Stream.open(pair.server, { version: LEGACY }); - const session = new ControlStreamAdapter(pair.server, controlStream, LEGACY, 100n, true); - const subscriber = new Subscriber({ session }); - - // The peer's view of that control stream. - const peer = await nextStream(pair.client); - expect(peer).toBeDefined(); - - // Ask for a track, which writes SUBSCRIBE, then drop the only consumer. - const broadcast = subscriber.consume(Path.from("room")); - const track = broadcast.track("video").subscribe(); - - // Let the SUBSCRIBE reach the control stream before walking away. - await new Promise((resolve) => setTimeout(resolve, 50)); - track.close(); - - // Everything the subscriber actually put on the wire. - const seen: bigint[] = []; - const deadline = Date.now() + STREAM_WAIT; - while (Date.now() < deadline) { - const type = await Promise.race([ - // biome-ignore lint/style/noNonNullAssertion: guarded by the expect above - peer!.reader.u53().catch(() => undefined), - new Promise((resolve) => setTimeout(() => resolve(undefined), 100)), - ]); - if (type === undefined) break; - - seen.push(BigInt(type)); - // biome-ignore lint/style/noNonNullAssertion: guarded by the expect above - const size = await peer!.reader.u16(); - // biome-ignore lint/style/noNonNullAssertion: guarded by the expect above - if (size > 0) await peer!.reader.read(size); - if (BigInt(type) === BigInt(Unsubscribe.id)) break; - } - - expect(seen).toContain(BigInt(Unsubscribe.id)); -}); - -/** - * A draft-14-16 SUBSCRIBE rides the control stream, where a reset tells the peer nothing, so - * one the peer never answers is cancelled with UNSUBSCRIBE at its deadline. - */ -test.each([ - ["14", Version.DRAFT_14, ALPN.DRAFT_14], - ["15", Version.DRAFT_15, ALPN.DRAFT_15], - ["16", Version.DRAFT_16, ALPN.DRAFT_16], -] as const)("an unanswered draft %s subscribe is unsubscribed at its deadline", async (_, version, alpn) => { - jest.useFakeTimers(); - const pair = createMockTransportPair(alpn); - const control = await Stream.open(pair.server, { version }); - const session = new ControlStreamAdapter(pair.server, control, version, 100n, true); - void session.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const subscriber = new Subscriber({ session }); - const broadcast = subscriber.consume(Path.from("room")); - const track = broadcast.track("video").subscribe(); - const warn = spyOn(console, "warn").mockImplementation(() => void 0); - try { - expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, version); - - const next = peer.reader.u53(); - jest.advanceTimersByTime(10_000); - expect(await next).toBe(Unsubscribe.id); - expect((await Unsubscribe.decode(peer.reader, version)).requestId).toBe(request.requestId); - expect(((await track.closed) as Error).message).toContain("request timed out after 10000ms"); - } finally { - warn.mockRestore(); - track.close(); - broadcast.close(); - session.close(); - jest.useRealTimers(); - } -}); - -/** - * A publisher that rejects a SUBSCRIBE has torn the request down before answering, so there - * is nothing left to cancel. Naming a dead request id back at it is what a strict peer can - * read as a protocol violation and close an otherwise healthy session over. - */ -test("a rejected subscribe is not unsubscribed", async () => { - const LEGACY = Version.DRAFT_16; - const pair = createMockTransportPair(ALPN.DRAFT_16); - - const controlStream = await Stream.open(pair.server, { version: LEGACY }); - const session = new ControlStreamAdapter(pair.server, controlStream, LEGACY, 100n, true); - // The mux read loop, which is what routes a response back to its virtual stream. - void session.run().catch(() => void 0); - const subscriber = new Subscriber({ session }); - - const peer = await nextStream(pair.client); - expect(peer).toBeDefined(); - // biome-ignore lint/style/noNonNullAssertion: guarded above - const wire = peer!; - - const broadcast = subscriber.consume(Path.from("room")); - const track = broadcast.track("video").subscribe(); - - // Read the SUBSCRIBE, then reject it the way a publisher that cannot serve it would. - const subscribeType = await wire.reader.u53(); - const subscribeSize = await wire.reader.u16(); - await wire.reader.read(subscribeSize); - expect(subscribeType).toBe(3); - - await wire.writer.u53(RequestError.id); - await new RequestError({ - requestId: 0n, - // DOES_NOT_EXIST, draft-16 section 13.4.2. - errorCode: 0x10, - reasonPhrase: "not found", - retryInterval: 0n, - }).encode(wire.writer, LEGACY); - - await new Promise((resolve) => setTimeout(resolve, 100)); - track.close(); - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Nothing further belongs on the control stream: the request is already gone. - const next = await Promise.race([ - wire.reader.u53().catch(() => undefined), - new Promise((resolve) => setTimeout(() => resolve(undefined), 200)), - ]); - - expect(next).toBeUndefined(); -}); - /** The alias the group streams below are published on. */ const ALIAS = 9n; @@ -1121,7 +743,7 @@ async function subscribeTrack({ properties?: Properties; subscription?: track.Subscription; } = {}): Promise<{ subscriber: Subscriber; track: track.Subscriber }> { - const pair = createMockTransportPair(version === Version.DRAFT_16 ? ALPN.DRAFT_16 : ALPN.DRAFT_18); + const pair = createMockTransportPair(ALPN.DRAFT_18); const session = new NativeSession(pair.server, version, true); const subscriber = new Subscriber({ session }); @@ -1133,9 +755,9 @@ async function subscribeTrack({ peer.writer.version = version; expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, version); + await Subscribe.decode(peer.reader, version); await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS, properties }).encode(peer.writer, version); + await new SubscribeOk({ trackAlias: ALIAS, properties }).encode(peer.writer, version); return { subscriber, track }; } @@ -1199,33 +821,8 @@ test("an object without a Timestamp on a TIMESCALE track is malformed", async () stop.mockRestore(); }); -test("a draft-14 END_OF_TRACK carrying properties ends the subscription as unsupported", async () => { - // Delta 0, one unknown integer property, an empty payload, then END_OF_TRACK: standalone, - // and after a one-byte object. - const marker = [0, 2, 0x20, 7, 0, 4]; - for (const wire of [marker, [0, 0, 1, 42, ...marker]]) { - const { subscriber, track } = await subscribeTrack({ version: Version.DRAFT_14 }); - const reader = new Reader(undefined, new Uint8Array(wire), Version.DRAFT_14); - const stop = spyOn(reader, "stop"); - - await subscriber.handleGroup(stampedGroup(0), reader); - - const err = stop.mock.calls[0][0]; - expect(err).toBeInstanceOf(EndOfTrackProperties); - expect(track.closed.peek()).toBe(err as EndOfTrackProperties); - // The group before a trailing marker completes. - if (wire !== marker) { - const group = await track.ordered().nextGroup(); - expect((await group?.readFrame())?.payload).toEqual(new Uint8Array([42])); - expect(await group?.closed).toBeNull(); - } - stop.mockRestore(); - } -}); - test("a compact END_OF_TRACK at object 5 with properties is subgroup 5, refused without ending the track", async () => { - // Draft 14 allows the properties and draft 15 refuses them, but the subgroup is refused first. - for (const version of [Version.DRAFT_14, Version.DRAFT_18]) { + for (const version of [Version.DRAFT_18]) { const { subscriber, track } = await subscribeTrack({ version }); const header = stampedGroup(0); header.flags.hasSubgroupObject = true; @@ -1264,11 +861,10 @@ test("an info-only lookup waits for SUBSCRIBE_OK instead of abandoning", async ( const peer = await nextStream(pair.client); if (!peer) throw new Error("missing SUBSCRIBE stream"); expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, VERSION); + await Subscribe.decode(peer.reader, VERSION); await peer.writer.u53(SubscribeOk.id); await new SubscribeOk({ - requestId: request.requestId, trackAlias: ALIAS, properties: { priority: 37 }, }).encode(peer.writer, VERSION); @@ -1283,11 +879,10 @@ test("SUBSCRIBE_OK's largest is known before any group arrives", async () => { const peer = await nextStream(pair.client); if (!peer) throw new Error("missing SUBSCRIBE stream"); expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, VERSION); + await Subscribe.decode(peer.reader, VERSION); await peer.writer.u53(SubscribeOk.id); await new SubscribeOk({ - requestId: request.requestId, trackAlias: ALIAS, largest: { groupId: 7n, objectId: 3n }, }).encode(peer.writer, VERSION); @@ -1351,7 +946,7 @@ test.each(["acceptance", "timeout"])("returning demand preserves pending setup u const peer = await nextStream(pair.client); if (!peer) throw new Error("missing pending subscribe"); expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, VERSION); + await Subscribe.decode(peer.reader, VERSION); jest.advanceTimersByTime(6_000); first.close(); await resumed.promise; @@ -1365,7 +960,7 @@ test.each(["acceptance", "timeout"])("returning demand preserves pending setup u expect(await Promise.race([waiting.promise.then(() => true), info.then(() => false)])).toBe(true); if (outcome === "acceptance") { await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, VERSION); + await new SubscribeOk({ trackAlias: ALIAS }).encode(peer.writer, VERSION); expect(await info).toMatchObject({ priority: 127 - 128 }); expect(returned.closed.peek()).toBeUndefined(); } else { @@ -1430,98 +1025,12 @@ test("setup abandonment closes before a queued viewer can attach", async () => { expect(next).toBeInstanceOf(Stream); if (!(next instanceof Stream)) throw new Error("returning viewer did not get a new subscribe"); expect(await next.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(next.reader, VERSION); + await Subscribe.decode(next.reader, VERSION); await next.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(next.writer, VERSION); + await new SubscribeOk({ trackAlias: ALIAS }).encode(next.writer, VERSION); expect(await info).toMatchObject({ priority: 127 - 128 }); }); -test("abandonment cancels once and a late acceptance cannot capture a reused alias", async () => { - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPN.DRAFT_16); - const control = await Stream.open(pair.server, { version }); - const session = new ControlStreamAdapter(pair.server, control, version, 100n, true); - void session.run().catch(() => void 0); - const subscriber = new Subscriber({ session }); - const peer = await nextStream(pair.client); - if (!peer) throw new Error("missing control stream"); - peer.reader.version = version; - peer.writer.version = version; - const broadcast = subscriber.consume(Path.from("room")); - const make = hooks.makeRequest; - let oldRequest: track.Request | undefined; - // The error the abandoned request is rejected with. - const rejected = Promise.withResolvers(); - const captured = spyOn(hooks, "makeRequest").mockImplementationOnce((options) => { - const request = make(options); - const reject = request.reject.bind(request); - spyOn(request, "reject").mockImplementation((err) => { - rejected.resolve(err); - reject(err); - }); - oldRequest = request; - return request; - }); - const decoded = Promise.withResolvers(); - const release = Promise.withResolvers(); - const decode = SubscribeOk.decode; - const held = spyOn(SubscribeOk, "decode").mockImplementationOnce(async (...args) => { - const ok = await decode(...args); - decoded.resolve(); - await release.promise; - return ok; - }); - const first = broadcast.track("video").subscribe(); - let second: track.Subscriber | undefined; - try { - expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, version); - if (!oldRequest) throw new Error("missing pending track"); - const accepted = spyOn(oldRequest, "accept"); - await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, version); - await decoded.promise; - first.close(); - expect(await rejected.promise).toBeInstanceOf(Error); - expect(await peer.reader.u53()).toBe(Unsubscribe.id); - expect((await Unsubscribe.decode(peer.reader, version)).requestId).toBe(request.requestId); - - second = broadcast.track("video").subscribe(); - // The next message must be the new request, not a duplicate UNSUBSCRIBE. - expect(await peer.reader.u53()).toBe(Subscribe.id); - const replacement = await Subscribe.decode(peer.reader, version); - await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: replacement.requestId, trackAlias: ALIAS }).encode(peer.writer, version); - await second.info(); - release.resolve(); - await subscriber.handleGroup( - new GroupMessage({ - trackAlias: ALIAS, - groupId: 0, - subGroupId: 0, - publisherPriority: 0, - flags: groupFlags(true), - }), - new Reader(undefined, encodeObjects([0]), VERSION), - ); - const groups = second.ordered(); - const group = await groups.nextGroup(); - expect(await group?.readString()).toBe("object 0"); - expect(accepted).not.toHaveBeenCalled(); - expect(second.closed.peek()).toBeUndefined(); - groups.close(); - accepted.mockRestore(); - } finally { - release.resolve(); - held.mockRestore(); - captured.mockRestore(); - first.close(); - second?.close(); - broadcast.close(); - session.close(); - } -}); - test("early group waits for SUBSCRIBE_OK priority before track acceptance", async () => { const pair = createMockTransportPair(ALPN.DRAFT_18); const session = new NativeSession(pair.server, VERSION, true); @@ -1530,7 +1039,7 @@ test("early group waits for SUBSCRIBE_OK priority before track acceptance", asyn const peer = await nextStream(pair.client); if (!peer) throw new Error("missing SUBSCRIBE stream"); expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, VERSION); + await Subscribe.decode(peer.reader, VERSION); const flags = { ...groupFlags(true), hasPriority: false }; const group = new GroupMessage({ trackAlias: ALIAS, groupId: 3, subGroupId: 0, publisherPriority: 0, flags }); @@ -1540,7 +1049,6 @@ test("early group waits for SUBSCRIBE_OK priority before track acceptance", asyn await peer.writer.u53(SubscribeOk.id); await new SubscribeOk({ - requestId: request.requestId, trackAlias: ALIAS, properties: { priority: 37 }, }).encode(peer.writer, VERSION); @@ -1670,13 +1178,12 @@ test("a clear FIRST_OBJECT past object 0 is dropped", async () => { /** * A header-only stream with FIRST_OBJECT clear carries none of its group, so it is dropped - * like any headless stream. With the bit set, or on drafts 14-17 that cannot clear it, the - * same stream is an empty group. Matches the Rust rule. + * like any headless stream. With the bit set, the same stream is an + * empty group. Matches the Rust rule. */ test.each([ [VERSION, false, [4]], [VERSION, true, [3, 4]], - [Version.DRAFT_16, true, [3, 4]], ])("draft %s with FIRST_OBJECT=%s: a header-only stream delivers %p", async (version, firstObject, expected) => { // A budget, so the empty group outlives its successor's arrival. const { subscriber, track } = await subscribeTrack({ version, subscription: { maxDelay: Milli(60_000) } }); @@ -1708,55 +1215,6 @@ test.each([ track.close(); }); -/** - * Drafts 14-17 have no FIRST_OBJECT bit, so a subgroup that starts at the live edge - * arrives with `firstObject` forced on and a non-zero first delta. That stream is the - * in-progress group: drop it, keep the subscription, and deliver the next group, which - * starts at object 0. A gap after an object was delivered still fails that group. - */ -test.each([Version.DRAFT_14, Version.DRAFT_16])("draft %s drops a subgroup that starts mid-group", async (version) => { - const warning = spyOn(console, "warn").mockImplementation(() => {}); - onTestFinished(() => warning.mockRestore()); - const { subscriber, track } = await subscribeTrack({ version }); - - // The header cannot say otherwise on this draft: decode reports firstObject. - const partial = new GroupMessage({ - trackAlias: ALIAS, - groupId: 3, - subGroupId: 0, - publisherPriority: 0, - flags: groupFlags(true), - }); - await subscriber.handleGroup(partial, new Reader(undefined, encodeObjects([2, 0]), version)); - expect(track.closed.peek()).toBeUndefined(); - expect(warning).not.toHaveBeenCalled(); - - const whole = groupFlags(true); - await subscriber.handleGroup( - new GroupMessage({ trackAlias: ALIAS, groupId: 4, subGroupId: 0, publisherPriority: 0, flags: whole }), - new Reader(undefined, encodeObjects([0, 0]), version), - ); - - const ordered = track.ordered(); - const group = await ordered.nextGroup(); - expect(group?.sequence).toBe(4); - expect(await group?.readString()).toBe("object 0"); - - await subscriber.handleGroup( - new GroupMessage({ trackAlias: ALIAS, groupId: 5, subGroupId: 0, publisherPriority: 0, flags: whole }), - new Reader(undefined, encodeObjects([0, 5]), version), - ); - const gapped = await ordered.nextGroup(); - expect(gapped?.sequence).toBe(5); - expect(await gapped?.readString()).toBe("object 0"); - await expect(gapped?.readFrameSequence()).rejects.toThrow(/object IDs must start at 0/); - expect(warning).toHaveBeenCalledWith(expect.stringContaining("object IDs must start at 0")); - expect(track.closed.peek()).toBeUndefined(); - - ordered.close(); - track.close(); -}); - /** * FIRST_OBJECT is the publisher's claim, and the object ids are what actually happened. A * peer that sets the bit and then starts at object 5 is contradicting itself, so the group @@ -1850,75 +1308,6 @@ test("every object in a chunk reaches the reader before it wakes", async () => { track.close(); }); -// Hold the actual legacy cancellation write so returning demand lands in the teardown gap. -test("returning demand survives a blocked unsubscribe", async () => { - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPN.DRAFT_16); - const control = await Stream.open(pair.server, { version }); - const session = new ControlStreamAdapter(pair.server, control, version, 100n, true); - void session.run().catch(() => void 0); - const subscriber = new Subscriber({ session }); - const peer = await nextStream(pair.client); - if (!peer) throw new Error("missing control stream"); - peer.reader.version = version; - peer.writer.version = version; - - const cancelStarted = Promise.withResolvers(); - const releaseCancel = Promise.withResolvers(); - const oldClosed = Promise.withResolvers(); - const open = session.openBi.bind(session); - const opening = spyOn(session, "openBi").mockImplementationOnce(() => { - const stream = open(); - const write = stream.writer.write.bind(stream.writer); - // Each message is one write that leads with its one-byte type. - spyOn(stream.writer, "write").mockImplementation(async (chunk) => { - if (chunk[0] === Unsubscribe.id) { - cancelStarted.resolve(); - await releaseCancel.promise; - } - await write(chunk); - }); - const close = stream.close.bind(stream); - spyOn(stream, "close").mockImplementation(() => { - close(); - oldClosed.resolve(); - }); - return stream; - }); - - const broadcast = subscriber.consume(Path.from("room")); - const first = broadcast.track("video").subscribe(); - expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, version); - await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, version); - - // Receiving a group proves setup has accepted the track before demand disappears. - await subscriber.handleGroup( - new GroupMessage({ - trackAlias: ALIAS, - groupId: 0, - subGroupId: 0, - publisherPriority: 0, - flags: groupFlags(true), - }), - new Reader(undefined, encodeObjects([0]), VERSION), - ); - const ordered = first.ordered(); - expect(await ordered.nextGroup()).toBeDefined(); - ordered.close(); - first.close(); - await cancelStarted.promise; - const returned = broadcast.track("video").subscribe(); - releaseCancel.resolve(); - await oldClosed.promise; - expect(returned.closed.peek()).toBeUndefined(); - expect(opening).toHaveBeenCalledTimes(2); - returned.close(); - broadcast.close(); - session.close(); -}); - test("local readers filter hidden unsolicited namespaces from a legacy peer", async () => { const pair = createMockTransportPair(ALPN.DRAFT_18); const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true) }); @@ -1997,10 +1386,9 @@ test("an object datagram is a datagram group", async () => { const peer = await nextStream(pair.client); if (!peer) throw new Error("the subscriber never opened a subscribe stream"); expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, VERSION); + await Subscribe.decode(peer.reader, VERSION); await peer.writer.u53(SubscribeOk.id); await new SubscribeOk({ - requestId: request.requestId, trackAlias: ALIAS, properties: { timescale: Timescale.MILLI }, }).encode(peer.writer, VERSION); @@ -2085,14 +1473,7 @@ test("a more specific epochless announcement replaces the consumed source", asyn announced.close(); }); -for (const version of [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, -]) { +for (const version of [Version.DRAFT_18, Version.DRAFT_22]) { test(`status zero without the end bit delivers an empty object and continues (${version})`, async () => { const { subscriber, track } = await subscribeTrack({ version }); onTestFinished(() => track.close()); @@ -2115,126 +1496,6 @@ for (const version of [ }); } -// Retirement before allocation spends nothing; retirement after allocation drains that request. -test.each(["waiting", "allocated"] as const)( - "legacy retirement while %s keeps subsequent request IDs contiguous", - async (phase) => { - const version = Version.DRAFT_15; - const pair = createMockTransportPair(ALPN.DRAFT_15); - const control = await Stream.open(pair.server, { version }); - const session = new ControlStreamAdapter(pair.server, control, version, phase === "waiting" ? 0n : 4n, true); - const running = session.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const entered = Promise.withResolvers(); - const allocated = Promise.withResolvers(); - const resume = Promise.withResolvers(); - const allocate = session.nextRequestId.bind(session); - const intercepted = spyOn(session, "nextRequestId").mockImplementationOnce(async (signal) => { - entered.resolve(); - const id = await allocate(signal); - allocated.resolve(); - await resume.promise; - return id; - }); - const subscriber = new Subscriber({ session }); - const announced = subscriber.announced(); - const path = Path.from("room"); - const held = subscriber.consume(path); - const old = held - .track("video") - .info() - .then( - () => undefined, - (err: unknown) => err, - ); - await entered.promise; - if (phase === "allocated") await allocated.promise; - - const source = createMockTransportPair(ALPN.DRAFT_15); - const sourceStream = await Stream.open(source.server, { version }); - const sourcePeer = await Stream.accept(source.client, version); - if (!sourcePeer) throw new Error("no announcement stream"); - const advertising = subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 1n, trackNamespace: path }), - sourceStream, - ); - let fresh: ReturnType | undefined; - try { - expect(await sourcePeer.reader.u53()).toBe(RequestOk.id); - await RequestOk.decode(sourcePeer.reader, version); - await announced.next(); - resume.resolve(); - expect(await old).toBeInstanceOf(Error); - if (phase === "waiting") { - await peer.writer.u53(MaxRequestId.id); - await new MaxRequestId({ requestId: 4n }).encode(peer.writer, version); - } else { - expect(await peer.reader.u53()).toBe(Subscribe.id); - const canceled = await Subscribe.decode(peer.reader, version); - expect(canceled.requestId).toBe(0n); - expect(await peer.reader.u53()).toBe(Unsubscribe.id); - expect((await Unsubscribe.decode(peer.reader, version)).requestId).toBe(0n); - } - fresh = subscriber.consume(path); - const info = fresh - .track("video") - .info() - .catch((err: unknown) => err); - expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, version); - expect(request.requestId).toBe(phase === "waiting" ? 0n : 2n); - await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ - requestId: request.requestId, - trackAlias: request.requestId, - properties: {}, - }).encode(peer.writer, version); - expect(await info).not.toBeInstanceOf(Error); - } finally { - intercepted.mockRestore(); - resume.resolve(); - fresh?.close(); - held.close(); - announced.close(); - sourcePeer.close(); - await advertising; - peer.close(); - session.close(); - void running; - } - }, -); - -test("an announce consumer closed while waiting on request-ID credit takes no ID", async () => { - const version = Version.DRAFT_15; - const pair = createMockTransportPair(ALPN.DRAFT_15); - const control = await Stream.open(pair.server, { version }); - const session = new ControlStreamAdapter(pair.server, control, version, 0n, true); - const running = session.run().catch(() => undefined); - // Stand in for exhausted credit: the allocation waits until its signal aborts. - let cancelled = false; - spyOn(session, "nextRequestId").mockImplementation( - (signal) => - new Promise((_, reject) => { - signal?.addEventListener("abort", () => { - cancelled = true; - reject(signal.reason); - }); - }), - ); - const subscriber = new Subscriber({ session }); - - const announced = subscriber.announced(Path.Pattern.subtree(Path.from("room"))); - await Promise.resolve(); - announced.close(); - for (let i = 0; i < 8; i++) await Promise.resolve(); - expect(cancelled).toBe(true); - - session.close(); - void running; -}); - /** * A request on another stream can overtake a cancel, so a peer that refuses overlapping * requests (PREFIX_OVERLAP) would see the broader one beside the narrower one it replaces. @@ -2286,33 +1547,6 @@ test("a request overlapping one on its way out waits for the peer to end it", as session.close(); }); -/** Draft 14/15 requests share the control stream, so the next one waits only for the UNSUBSCRIBE_NAMESPACE. */ -test("a draft-15 request overlapping one on its way out follows its UNSUBSCRIBE_NAMESPACE", async () => { - const version = Version.DRAFT_15; - const pair = createMockTransportPair(ALPN.DRAFT_15); - const control = await Stream.open(pair.server, { version }); - const session = new ControlStreamAdapter(pair.server, control, version, 100n, true); - void session.run().catch(() => undefined); - const peer = await Stream.accept(pair.client, version); - if (!peer) throw new Error("no control stream"); - const subscriber = new Subscriber({ session }); - - const narrow = subscriber.announced(Path.Pattern.subtree(Path.from("a/b"))); - expect(await peer.reader.u53()).toBe(SubscribeNamespaceLegacy.id); - expect((await SubscribeNamespaceLegacy.decode(peer.reader, version)).namespace).toBe(Path.from("a/b")); - - narrow.close(); - const broad = subscriber.announced(Path.Pattern.subtree(Path.from("a"))); - - expect(await peer.reader.u53()).toBe(UnsubscribeNamespace.id); - await UnsubscribeNamespace.decode(peer.reader, version); - expect(await peer.reader.u53()).toBe(SubscribeNamespaceLegacy.id); - expect((await SubscribeNamespaceLegacy.decode(peer.reader, version)).namespace).toBe(Path.from("a")); - - broad.close(); - session.close(); -}); - /** Answer the next SUBSCRIBE_NAMESPACE with REQUEST_OK, carrying `activeCount` when given. */ async function answerCounted(transport: WebTransport, activeCount: number | undefined): Promise { const stream = await nextStream(transport); @@ -2322,7 +1556,7 @@ async function answerCounted(transport: WebTransport, activeCount: number | unde const parameters = new Parameters(); parameters.activeCount = activeCount; await stream.writer.u53(RequestOk.id); - await new RequestOk({ requestId: undefined, parameters }).encode(stream.writer, VERSION); + await new RequestOk({ parameters }).encode(stream.writer, VERSION); return stream; } @@ -2455,7 +1689,7 @@ for (const negotiated of [false, true]) { const request = await Subscribe.decode(peer.reader, VERSION); expect(request.maxAge).toBe(negotiated ? 250 : undefined); await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, VERSION); + await new SubscribeOk({ trackAlias: ALIAS }).encode(peer.writer, VERSION); await track.info(); // The aggregate can change before SUBSCRIBE_OK: the first update must catch it. const tolerant = track.fork({ maxDelay: Milli(900), priority: 9 }); @@ -2468,56 +1702,6 @@ for (const negotiated of [false, true]) { peer.close(); }); } -/** - * Draft 16 answers every update, and the adapter routes the answer back by the update's own - * request ID, so one update is in flight at a time and the next waits for it. - */ -test("a draft 16 subscription update waits for the last one's answer", async () => { - const version = Version.DRAFT_16; - const pair = createMockTransportPair(ALPN.DRAFT_16); - const control = await Stream.open(pair.server, { version }); - const session = new ControlStreamAdapter(pair.server, control, version, 100n, true); - void session.run().catch(() => void 0); - const subscriber = new Subscriber({ session }); - const peer = await nextStream(pair.client); - if (!peer) throw new Error("missing control stream"); - peer.reader.version = version; - peer.writer.version = version; - - const track = subscriber.consume(Path.from("room")).track("video").subscribe({ priority: 1 }); - try { - expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, version); - await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, version); - await track.info(); - - const higher = track.fork({ priority: 9 }); - expect(await peer.reader.u53()).toBe(SubscribeUpdate.id); - const first = await SubscribeUpdate.decode(peer.reader, version); - const highest = track.fork({ priority: 20 }); - let sent = false; - const next = peer.reader.u53().then((id) => { - sent = true; - return id; - }); - // The mock transport schedules nothing on timers, so settled promise chains are final. - for (let i = 0; i < 20; i++) await new Promise((resolve) => setImmediate(resolve)); - expect(sent).toBe(false); - - await peer.writer.u53(RequestOk.id); - await new RequestOk({ requestId: first.ownRequestId }).encode(peer.writer, version); - expect(await next).toBe(SubscribeUpdate.id); - const second = await SubscribeUpdate.decode(peer.reader, version); - expect(second.parameters.subscriberPriority).toBe(127 - 20); - higher.close(); - highest.close(); - } finally { - track.close(); - session.close(); - } -}); - /** Advertise `path` inline under `epoch`, on a session that negotiated MoQ Epoch alone. */ async function inlineEpoch(stream: Stream, path: Path.Valid, epoch: Epoch.Valid): Promise { await stream.writer.u53(SubscribeNamespaceEntry.id); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index a9cdd085908..9e7aa82e3ba 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -42,46 +42,23 @@ import { Deadline, requestOptions } from "../util/deadline.ts"; import { abandoned as demandAbandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; import { overrideBroadcastWire, wireOf } from "../wire.ts"; -import type { Session } from "./adapter.ts"; import { DuplicateTrackAlias, RetiredTrackAlias, TrackAliases } from "./aliases.ts"; import * as Cluster from "./cluster.ts"; import { ObjectDatagram } from "./datagram.ts"; -import { requestReason, toRequestCode } from "./error.ts"; +import { requestReason } from "./error.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; -import { - decodeObjectTime, - EndOfTrackProperties, - Frame, - type Group as GroupMessage, - hasFirstObjectBit, - ObjectIdGap, -} from "./object.ts"; +import { decodeObjectTime, EndOfTrackProperties, Frame, type Group as GroupMessage, ObjectIdGap } from "./object.ts"; import { Parameters } from "./parameters.ts"; import { DEFAULT_WIRE, fromWire, toWire } from "./priority.ts"; import { type Publish, PublishDone, PublishDoneStatus, publishDoneClean } from "./publish.ts"; -import { - type AdvertParams, - type PublishNamespace, - PublishNamespaceCancel, - PublishNamespaceDone, - PublishNamespaceOk, - PublishNamespaceUpdate, -} from "./publish_namespace.ts"; +import { type AdvertParams, type PublishNamespace, PublishNamespaceUpdate } from "./publish_namespace.ts"; import { RequestError, RequestOk } from "./request.ts"; import { type Framed, RequestStream, refuseReserved, refuseUpdate } from "./request_stream.ts"; -import { joinFilter, Subscribe, SubscribeError, SubscribeOk, SubscribeUpdate, Unsubscribe } from "./subscribe.ts"; -import { - PublishBlocked, - SubscribeNamespace, - SubscribeNamespaceEntry, - SubscribeNamespaceEntryDone, - SubscribeNamespaceLegacy, - SubscribeNamespaceOk, - UnsubscribeNamespace, -} from "./subscribe_namespace.ts"; +import type { Session } from "./session.ts"; +import { joinFilter, Subscribe, SubscribeOk, SubscribeUpdate } from "./subscribe.ts"; +import { SubscribeNamespace, SubscribeNamespaceEntry, SubscribeNamespaceEntryDone } from "./subscribe_namespace.ts"; import { MalformedAuthToken } from "./token.ts"; -import { Version } from "./version.ts"; // Wire ceiling (2^62-1). A draining session stamps it on every live route so any other // candidate outranks it, while the route stays selectable as the last path. Matches Rust @@ -143,7 +120,7 @@ function sees(filter: Filter, path: Path.Valid): boolean { /** * Handles subscribing to broadcasts using moq-transport protocol. - * Uses the stream-per-request pattern (real bidi streams for v17, virtual for v14-v16). + * Each request rides its own bidi stream. * * @internal */ @@ -174,11 +151,6 @@ export class Subscriber { // Dedup consumed broadcasts per path: repeat consume() calls share one subscription. #consumes = new BroadcastCache(); - // Paths with a legacy PUBLISH_NAMESPACE request in flight, reserved synchronously. - // The count below is only taken once the OK is written, and two requests that both - // got past the duplicate check before either attached would both take one. - #legacyRequests = new Set(); - // Every announced path, with the live advertisements that reference it. // // A peer may advertise one namespace twice on a session: an unsolicited @@ -207,10 +179,6 @@ export class Subscriber { // Whether a SUBSCRIBE_NAMESPACE's REQUEST_OK counts the NAMESPACEs that follow it (MoQ Active Count). #activeCount: boolean; - // What the peer's SETUP declared about being solicited (MoQ Solicit), `undefined` - // when it declared nothing. - #solicit?: boolean; - // Settles when the peer sends GOAWAY, repricing this session's routes to the drain cost. #goaway?: GetPromise; // Our grant (MoQ Auth): a subscription it stops covering is cancelled. Undefined until @@ -236,7 +204,6 @@ export class Subscriber { hidden = false, maxAge = false, activeCount = false, - solicit, goaway, grant, auth, @@ -256,8 +223,6 @@ export class Subscriber { maxAge?: boolean; /** Whether both SETUPs declared MoQ Active Count. */ activeCount?: boolean; - /** What the peer's SETUP declared about being solicited (MoQ Solicit). */ - solicit?: boolean; /** Settles when the peer sends GOAWAY. */ goaway?: GetPromise; /** The union of our tokens' grants (MoQ Auth), which bounds what we subscribe to. */ @@ -274,7 +239,6 @@ export class Subscriber { this.#hidden = hidden; this.#maxAge = maxAge; this.#activeCount = activeCount; - this.#solicit = solicit; this.#goaway = goaway; this.#grant = grant; this.#auth = auth; @@ -337,9 +301,7 @@ export class Subscriber { * * The peer is asked with SUBSCRIBE_NAMESPACE regardless of what it declared, and an * unsolicited PUBLISH_NAMESPACE lands here too, so a peer that only tells and one - * that only answers are both discovered. A draft-14 or draft-15 peer that declared no - * MoQ Solicit is not asked for the empty prefix, so an unscoped subscriber only hears - * what that peer tells. + * that only answers are both discovered. * * Hidden routes (a `.`-prefixed segment below the scope's head) are left out unless * `options.hidden` opts in. The opt-in rides the SUBSCRIBE_NAMESPACE when the peer @@ -501,23 +463,6 @@ export class Subscriber { ): Promise { const version = this.#session.version; - // A zero-field track namespace was a protocol violation until draft-16 allowed - // it. A peer that never declared MoQ Solicit is not ours: it may enforce that, and - // it tells us unasked anyway, so send nothing and stay registered for its - // unsolicited PUBLISH_NAMESPACE. Returning would drop this consumer before one - // could land. A peer that declared Solicit only tells when asked, so it still is. - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15; - if (legacy && prefix.length === 0 && this.#solicit === undefined) { - // Nothing marks where what it tells unasked starts or ends, so there is nothing to wait for. - announced.synced(); - // No request stream ends this wait, so the session's end has to. `race`, so a - // consumer that ends first leaves no reaction on the session's close behind. - const ends: PromiseLike[] = [announced.closed]; - if (this.#quic) ends.push(this.#quic.closed); - await race(ends).catch(() => undefined); - return; - } - // Suffixes live on this stream, with the advertisement each holds and the epoch it named, // so a repeat is recognized as an update to it rather than a second one, which would leak. // An advertisement stays live on the wire until its NAMESPACE_DONE, even while its @@ -551,9 +496,6 @@ export class Subscriber { if (announced.closed.peek() !== undefined) return; } - // v14/v15: SubscribeNamespace on control stream (via adapter virtual stream) - // v16+: SubscribeNamespace on its own real bidi stream - // A consumer that leaves while waiting on request-ID credit takes no ID, so a granted one // never opens a request nobody wants. const left = new AbortController(); @@ -568,53 +510,22 @@ export class Subscriber { if (requestId === undefined) return; try { - // v16: use a real bidi stream (not virtual control stream) const deadline = new Deadline(); const request = new RequestStream( - await deadline.wait( - deadline.open( - version === Version.DRAFT_16 && this.#session.openNativeBi - ? this.#session.openNativeBi(requestOptions) - : this.#session.openBi(requestOptions), - ), - ), + await deadline.wait(deadline.open(this.#session.openBi(requestOptions))), this.#session, this.#quic, ); const run = async () => { - // Draft-18+ uses SUBSCRIBE_NAMESPACE (0x50); earlier drafts use the - // legacy 0x11 message with a Subscribe Options field. - if ( - version === Version.DRAFT_14 || - version === Version.DRAFT_15 || - version === Version.DRAFT_16 || - version === Version.DRAFT_17 - ) { - const msg = new SubscribeNamespaceLegacy({ namespace: prefix, requestId, hidden }); - await Message.write(request.writer, SubscribeNamespaceLegacy.id, msg, version); - } else { - const msg = new SubscribeNamespace({ namespace: prefix, requestId, hidden }); - await Message.write(request.writer, SubscribeNamespace.id, msg, version); - } + const msg = new SubscribeNamespace({ namespace: prefix, requestId, hidden }); + await Message.write(request.writer, SubscribeNamespace.id, msg, version); console.debug(`subscribe_namespace written: requestId=${requestId}`); // End the request, settling once it is off the wire. `reading` is whatever reads // the stream now, which has to finish before anything else may. const end = async (reading: Promise) => { release(); - // Draft-14/15 adapt the request onto the control stream, so whatever we send - // after the UNSUBSCRIBE_NAMESPACE reaches the peer after it. - if (legacy) { - try { - const unsub = new UnsubscribeNamespace({ prefix, requestId }); - await Message.write(request.writer, UnsubscribeNamespace.id, unsub, version); - } catch { - // Stream might already be closed - } - request.close(); - return; - } // A request on another stream can overtake the cancel, so this one is off the // wire only once the peer ends its half. What it sends meanwhile is dropped. request.cancel(); @@ -642,8 +553,6 @@ export class Subscriber { } return count ?? 0; } - // v14: SubscribeNamespaceOk, whose body says nothing more. - if (framed.type === SubscribeNamespaceOk.id) return 0; throw new Error(`SubscribeNamespace rejected: typeId=0x${framed.type.toString(16)}`); })(); // A consumer that leaves before the peer answers ends the request now, not on the answer. @@ -724,9 +633,6 @@ export class Subscriber { const held = live.get(path); live.delete(path); if (held?.id !== undefined) this.#detachAnnounce(path, held.id); - } else if (msgType === PublishBlocked.id && version === Version.DRAFT_17) { - const blocked = await PublishBlocked.decode(framed.reader, version); - console.debug(`publish_blocked: suffix=${blocked.suffix} track=${blocked.trackName}`); } else { throw new Error( `unexpected message on subscribe_namespace stream: 0x${msgType.toString(16)}`, @@ -808,7 +714,7 @@ export class Subscriber { } } - // The adapter is gone. If the transport has already closed, that close is the + // The session is gone. If the transport has already closed, that close is the // error. A still-open transport, such as a GOAWAY drain, has no peer code yet, // so this does not wait for it. Either way the route failed, so a resumed track moves on. async #closedSession(): Promise { @@ -847,8 +753,7 @@ export class Subscriber { let requestId: bigint | undefined; try { valid?.throwIfAborted(); - // The allocator cancels its own wait. Once it returns an ID, setup must spend - // it on the wire before cancellation, or the next legacy request would skip it. + // The allocator cancels its own wait. requestId = await this.#session.nextRequestId(valid); } catch (err) { request.reject(error(err)); @@ -937,11 +842,6 @@ export class Subscriber { if (!state.sent && !afterSetup) return; torn = true; - // Once the SUBSCRIBE is out the publisher may be serving, whether or not it has - // answered yet: data streams are independent of the request stream. Aborting says - // nothing on v14-16, where the request rides a virtual stream whose reset is - // local, so the UNSUBSCRIBE has to go out explicitly. - if (state.sent && !state.rejected) await this.#cancelSubscribe(state.stream, requestId); state.stream.abort(e); }; @@ -960,8 +860,8 @@ export class Subscriber { const producer = subscription.track; let disposeGrant: Dispose | undefined; try { - // Which terminal fired decides whether we owe the publisher a cancellation, so - // tag them rather than racing bare promises. + // Which terminal fired decides how the track closes, so tag them rather than + // racing bare promises. const publisherEnded = Symbol("publisher"); const localEnded = Symbol("local"); const revokedEnded = Symbol("revoked"); @@ -1019,14 +919,9 @@ export class Subscriber { } // Closing settled `done`, so an update still being written finishes and nothing starts - // another. Its bytes must not interleave with the cancellation, and on drafts 14 and 15 - // its spent request ID must reach the wire before any later one can. + // another. Its bytes must not interleave with the close. await updates; - // The publisher already ended the request, so there is nothing to cancel. Sending - // UNSUBSCRIBE here would name a request it has already torn down. - if (terminal !== publisherEnded) await this.#cancelSubscribe(stream, requestId); - stream.close(); console.debug(`subscribe close: id=${requestId} broadcast=${broadcast} track=${request.name}`); } catch (err) { @@ -1100,9 +995,8 @@ export class Subscriber { } /** - * Follow the aggregate subscriber priority upstream until `done` settles: SUBSCRIBE_UPDATE - * through draft 16, REQUEST_UPDATE after, each under a Request ID of its own. From draft 16 - * the answer comes back on this stream (routed by the adapter on draft 16), so one update is + * Follow the aggregate subscriber priority upstream until `done` settles with REQUEST_UPDATE, + * each under a Request ID of its own. The answer comes back on this stream, so one update is * in flight at a time and changes meanwhile coalesce. No draft lets an update change the * group order, so it stays as asked. */ @@ -1116,9 +1010,6 @@ export class Subscriber { done: Promise, ): Promise { const version = this.#session.version; - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; - // Draft 14 answers no update, and draft 15's answer reaches no subscription. - const answered = version !== Version.DRAFT_14 && version !== Version.DRAFT_15; const stopped = done.then( () => null, () => null, @@ -1157,9 +1048,7 @@ export class Subscriber { const parameters = new Parameters(); parameters.subscriberPriority = wanted; parameters.maxAge = wantedMaxAge; - const update = legacy - ? new SubscribeUpdate({ requestId, ownRequestId: own, parameters }) - : new SubscribeUpdate({ requestId: own, parameters }); + const update = new SubscribeUpdate({ requestId: own, parameters }); let message: Uint8Array; try { message = await Message.frame(stream.writer.version, SubscribeUpdate.id, update, version); @@ -1167,15 +1056,10 @@ export class Subscriber { // Nothing reached the wire, but the track can no longer follow its subscribers. console.warn(`subscribe update failed: id=${requestId} error=${reason(error(err))}`); producer.abort(error(err)); - // Drafts 14 and 15 require the next request ID, so one spent and never sent would - // stall every later request: as in Rust, that ends the session. - if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { - this.#fail(SessionCode.Internal, "subscribe update failed to encode"); - } return; } // Counted before writing: the answer can be read before the write's promise resolves. - if (answered) unanswered.update((count) => count + 1); + unanswered.update((count) => count + 1); try { await stream.writer.write(message); } catch { @@ -1188,26 +1072,6 @@ export class Subscriber { } } - /** - * Tell the publisher to stop serving a subscription we are walking away from. - * - * v14-16 cancel with UNSUBSCRIBE (draft-16 section 9.12), which is what lets the - * publisher destroy the subscription (section 5.1.1); v17+ removed the message and - * rely on the stream reset instead. Every path that abandons an Established - * subscription goes through here, because those versions carry the request over a - * virtual stream whose reset never reaches the peer. - */ - async #cancelSubscribe(stream: RequestStream, requestId: bigint) { - const version = this.#session.version; - if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16) return; - - try { - await Message.write(stream.writer, Unsubscribe.id, new Unsubscribe({ requestId }), version); - } catch { - // The stream may already be gone; there is nothing further to tell the peer. - } - } - // Opens the subscribe stream, sends SUBSCRIBE, and reads the response. // `state` is populated as soon as the stream opens and again when the // trackAlias is registered, so the caller can clean both up on timeout @@ -1228,11 +1092,8 @@ export class Subscriber { }> { const version = this.#session.version; - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; - // Virtual streams use no transport credit. Their owner must send UNSUBSCRIBE before - // resetting them, including on a deadline, so they stay out of the real-stream guard. state.stream = new RequestStream( - legacy ? await this.#session.openBi() : await deadline.open(this.#session.openBi(requestOptions)), + await deadline.open(this.#session.openBi(requestOptions)), this.#session, this.#quic, ); @@ -1240,10 +1101,7 @@ export class Subscriber { // The timeout can fire while the open is still in flight, in which case cleanup ran // with nothing to release. Bail now that the stream is recorded, so settling this // promise hands it back to be torn down. - // A legacy ID is already spent and cannot be skipped. Its virtual open is local, - // so finish this request and let cleanup send UNSUBSCRIBE, even if retirement - // won the await above. No response data is released from the canceled setup. - if (state.cancelled && !legacy) throw new Error("subscribe cancelled before it was sent"); + if (state.cancelled) throw new Error("subscribe cancelled before it was sent"); const msg = new Subscribe({ requestId, @@ -1278,10 +1136,7 @@ export class Subscriber { let reasonPhrase = "unknown error"; try { if (response.type === RequestError.id) { - const err = - version === Version.DRAFT_14 - ? await SubscribeError.decode(response.reader, version) - : await RequestError.decode(response.reader, version); + const err = await RequestError.decode(response.reader, version); reasonPhrase = requestReason(err.errorCode, err.reasonPhrase, "subscribe", version); } } catch { @@ -1303,11 +1158,8 @@ export class Subscriber { // Staged until the answer is written in below, so no reader sees the track before its // Largest and start. const staged = hooks.stageRequest(request, { - // No TIMESCALE (always so on drafts 14-16, which can't carry it) means no timeline, - // and the track must not claim one when served onward. + // No TIMESCALE means no timeline, and the track must not claim one when served onward. timescale: ok.properties.timescale, - // Drafts 14 and 15 carry only per-subgroup priorities, which the model does not keep, - // so they get the later drafts' default too. priority: fromWire(ok.properties.priority ?? DEFAULT_WIRE), maxAge: maxCacheDuration === undefined ? undefined : Milli(Number(maxCacheDuration)), // A moq-transport peer may stamp arrival times, and nothing on the wire says it didn't. @@ -1380,55 +1232,25 @@ export class Subscriber { if (this.#reflected(msg.params.cluster)) { console.debug(`dropping reflected publish_namespace: broadcast=${path}`); await request.refuse("publish_namespace", { - requestId: msg.requestId, condition: "uninterested", reasonPhrase: "route loops back", }); return; } - // Draft-14/15 key their namespace-scoped messages by name, not request ID, so the - // adapter can hold only one request per namespace: a second would overwrite the - // first, and the withdrawals would then close the wrong stream and fail to find - // the other. Nothing is lost by refusing it, because the case that makes a second - // reference legitimate (an inline NAMESPACE for a path a PUBLISH_NAMESPACE already - // carried) needs a message those drafts do not have. - const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15; - if (legacy && (this.#announced.has(path) || this.#legacyRequests.has(path))) { - console.warn("duplicate PublishNamespace"); - // No draft registers a code for a duplicate, and this refusal is a draft-14/15 - // implementation limit, which is what INTERNAL_ERROR describes. - await request.refuse("publish_namespace", { - requestId: msg.requestId, - condition: "internal", - reasonPhrase: "duplicate namespace", - }); - return; - } - - // Everywhere else a path this session already knows is not refused: the same - // namespace can reach us twice, and each advertisement is tracked apart from news. - // This request owns exactly one of those references, named by its id while - // attached, and gives it back when the stream ends. + // A path this session already knows is not refused: the same namespace can reach us + // twice, and each advertisement is tracked apart from news. This request owns exactly + // one of those references, named by its id while attached, and gives it back when the + // stream ends. this.#announceSlots.acquire(); - if (legacy) this.#legacyRequests.add(path); let attached: number | undefined; // Fixed for the advertisement's life: no REQUEST_UPDATE carries one. const epoch = msg.params.epoch; try { // Send OK first. This must complete before notifying consumers, - // because consumers may trigger Subscribe writes that would - // interleave with our OK on the control stream. - if (version === Version.DRAFT_14) { - const ok = new PublishNamespaceOk({ requestId: msg.requestId }); - await Message.write(request.writer, PublishNamespaceOk.id, ok, version); - } else { - const ok = new RequestOk({ - requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - }); - await Message.write(request.writer, RequestOk.id, ok, version); - } + // because consumers may trigger Subscribe writes. + await Message.write(request.writer, RequestOk.id, new RequestOk(), version); // Only now is the advertisement ours to announce, for the reason above: a // consumer reacting with a SUBSCRIBE must not interleave with that OK. @@ -1439,7 +1261,6 @@ export class Subscriber { // close. Nothing else would deliver a re-parented route. What the peer holds // is kept current, since an update carries only what changed. let held = msg.params.cluster; - const done = version === Version.DRAFT_16 || legacy; for (;;) { const framed = await race([request.next(), request.ended.then(() => undefined)]); if (framed === undefined) { @@ -1448,24 +1269,6 @@ export class Subscriber { break; } - // Drafts 14-15 have no update for an advertisement, so a SUBSCRIBE_UPDATE the - // adapter routed here is refused and changes nothing. Draft 14 answers no update; - // draft 15 names the update's own Request ID, which the adapter already spent. - if (legacy && framed.type === SubscribeUpdate.id) { - const body = await framed.reader.read(await framed.reader.u16()); - if (version === Version.DRAFT_15) { - await refuseUpdate(request.writer, version, "publish_namespace", { - requestId: await new Reader(undefined, body, version).u62(), - condition: "not_supported", - reasonPhrase: "update not supported", - }); - } - continue; - } - if (done && framed.type === PublishNamespaceDone.id) { - await PublishNamespaceDone.decode(framed.reader, version); - break; - } // A repeated PUBLISH_NAMESPACE lands here too: a second request on the // stream is the base draft's duplicate request ID. if (framed.type !== PublishNamespaceUpdate.id) { @@ -1474,36 +1277,23 @@ export class Subscriber { ); } - // Draft-16 updates ride the control stream, which already spent their own Request - // ID and keys their answer by it. - const keyed = version === Version.DRAFT_16; let update: PublishNamespaceUpdate; try { update = await PublishNamespaceUpdate.decode(framed.reader, version); } catch (err) { if (!(err instanceof MalformedAuthToken)) throw err; - // A refused update still spends its own request ID. - if (!keyed && err.requestId !== undefined) this.#session.spendRequest(err.requestId); + // A refused update's request ID is still checked. + if (err.requestId !== undefined) this.#session.checkRequestId(err.requestId); // Section 8.9 rejects the update, which withdraws the advertisement as any // refused update does. await refuseUpdate(request.writer, version, "publish_namespace", { - requestId: err.requestId, condition: "malformed_auth_token", reasonPhrase: err.message, }); - // Draft-16's advertisement outlives its stream, so withdraw it explicitly. - if (keyed) { - const cancel = new PublishNamespaceCancel({ - requestId: msg.requestId, - errorCode: toRequestCode("malformed_auth_token", "publish_namespace", version), - reasonPhrase: err.message, - }); - await Message.write(request.writer, PublishNamespaceCancel.id, cancel, version); - } request.close(); break; } - if (!keyed) this.#session.spendRequest(update.requestId); + this.#session.checkRequestId(update.requestId); // The parameters exist only on a session that negotiated the extension; // anywhere else they are the peer's violation. @@ -1536,12 +1326,10 @@ export class Subscriber { // Nothing here can fail to apply, so every update is acknowledged. A leaf // routes nothing, so a repricing changes nothing it holds; consumers still // hear the new route so a forwarder can reprice. - const ok = new RequestOk({ requestId: keyed ? update.requestId : undefined }); - await Message.write(request.writer, RequestOk.id, ok, version); + await Message.write(request.writer, RequestOk.id, new RequestOk(), version); } } finally { this.#announceSlots.release(); - if (legacy) this.#legacyRequests.delete(path); // Give back exactly what was taken: a request that never got its OK out never // referenced the path, and retracting there would drop someone else's count. @@ -1568,7 +1356,6 @@ export class Subscriber { Namespace.reserved(msg.trackNamespace, this.#session.version) ? refuseReserved(request) : request.refuse("publish", { - requestId: msg.requestId, condition: "not_supported", reasonPhrase: "publish not supported", }), @@ -1645,7 +1432,7 @@ export class Subscriber { if (!frame || frame instanceof Error) { // FIRST_OBJECT clear on a stream that ends with no object: it starts partway // through its group and carries none of it, so drop it like any headless - // stream. Drafts 14-17 cannot clear the bit, so there it is an empty group. + // stream. if (!producer && !group.flags.firstObject) { console.debug( `dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`, @@ -1696,15 +1483,11 @@ export class Subscriber { stream.stop(new Error("subgroups are not supported")); return; } - // The producer is still unopened only when the first object failed. On a draft - // with no FIRST_OBJECT bit, that non-zero delta is a headless group: drop the - // stream and leave the subscription up for the next group. Delivering the - // object would renumber a P-frame as the keyframe the group opens with. - if ( - producer === undefined && - e instanceof ObjectIdGap && - (!group.flags.firstObject || !hasFirstObjectBit(this.#session.version)) - ) { + // The producer is still unopened only when the first object failed. With FIRST_OBJECT + // clear, that non-zero delta is a headless group: drop the stream and leave the + // subscription up for the next group. Delivering the object would renumber a P-frame + // as the keyframe the group opens with. + if (producer === undefined && e instanceof ObjectIdGap && !group.flags.firstObject) { console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); stream.stop(new Error("a group must start at object 0")); return; diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index 27a33406830..199f25a8ce4 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -7,9 +7,9 @@ import { GRACE } from "../ledger.ts"; import { createMockTransportPair } from "../mock.ts"; import * as Path from "../path.ts"; import { Reader, Stream } from "../stream.ts"; -import { NativeSession } from "./adapter.ts"; import { type GroupFlags, Group as GroupMessage } from "./object.ts"; import { PublishDone } from "./publish.ts"; +import { NativeSession } from "./session.ts"; import { Subscribe, SubscribeOk } from "./subscribe.ts"; import { Subscriber } from "./subscriber.ts"; import { ALPN, Version } from "./version.ts"; @@ -66,10 +66,9 @@ async function subscribed(largest?: number) { const peer = await Stream.accept(pair.client, VERSION); if (!peer) throw new Error("the subscriber never opened a subscribe stream"); expect(await peer.reader.u53()).toBe(Subscribe.id); - const request = await Subscribe.decode(peer.reader, VERSION); + await Subscribe.decode(peer.reader, VERSION); await peer.writer.u53(SubscribeOk.id); await new SubscribeOk({ - requestId: request.requestId, trackAlias: ALIAS, largest: largest === undefined ? undefined : { groupId: BigInt(largest), objectId: 0n }, }).encode(peer.writer, VERSION); diff --git a/js/net/src/ietf/token.test.ts b/js/net/src/ietf/token.test.ts index 64e916e48a6..5c8f235349f 100644 --- a/js/net/src/ietf/token.test.ts +++ b/js/net/src/ietf/token.test.ts @@ -15,26 +15,11 @@ import { } from "./token.ts"; import { type IetfVersion, Version } from "./version.ts"; -const VERSIONS: IetfVersion[] = [ - Version.DRAFT_14, - Version.DRAFT_15, - Version.DRAFT_16, - Version.DRAFT_17, - Version.DRAFT_18, - Version.DRAFT_22, -]; +const VERSIONS: IetfVersion[] = [Version.DRAFT_18, Version.DRAFT_22]; // A kind past one varint byte and a value that is not text, matching the Rust tests. const TOKEN: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff, 0x03, 0x80, 0x6a]) }; -function leadingOnes(version: IetfVersion): boolean { - return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; -} - -function varint(v: bigint, version: IetfVersion): Uint8Array { - return leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v); -} - /** The option as it arrives, after a trip through the SETUP parameter block. */ async function received(params: SetupOptions, version: IetfVersion): Promise { const chunks: Uint8Array[] = []; @@ -46,7 +31,7 @@ async function received(params: SetupOptions, version: IetfVersion): Promise varint(field, version)), value]; +function raw(fields: bigint[], value: Uint8Array = new Uint8Array()): Uint8Array { + const parts = [...fields.map((field) => Varint.encodeLeadingOnes(field)), value]; const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); let offset = 0; for (const part of parts) { @@ -72,9 +57,9 @@ function raw(version: IetfVersion, fields: bigint[], value: Uint8Array = new Uin } /** The Token structure as a SETUP option. */ -function structure(version: IetfVersion, fields: bigint[], value: Uint8Array = new Uint8Array()): SetupOptions { +function structure(fields: bigint[], value: Uint8Array = new Uint8Array()): SetupOptions { const params = new SetupOptions(); - params.setBytes(SetupOption.AuthorizationToken, raw(version, fields, value)); + params.setBytes(SetupOption.AuthorizationToken, raw(fields, value)); return params; } @@ -91,122 +76,93 @@ function sessionCode(fn: () => unknown): SessionCode | undefined { test("USE_VALUE round trips on every draft", async () => { for (const version of VERSIONS) { const params = new SetupOptions(); - tokenIntoSetup(params, TOKEN, version); - expect(tokenFromSetup(await received(params, version), version)).toEqual(TOKEN); + tokenIntoSetup(params, TOKEN); + expect(tokenFromSetup(await received(params, version))).toEqual(TOKEN); } }); test("a request carries an out-of-band token by value on every draft", () => { const value = new Uint8Array([0x6a, 0x77, 0x74]); - for (const version of VERSIONS) { - const params = new Parameters(); - tokenIntoRequest(params, value, version); - const raw = params.bytes.get(AUTHORIZATION_TOKEN); - expect(raw).toBeDefined(); - expect(tokenFromRequest(raw ?? new Uint8Array(), version)).toEqual({ kind: TOKEN_OUT_OF_BAND, value }); - } + const params = new Parameters(); + tokenIntoRequest(params, value); + const raw = params.bytes.get(AUTHORIZATION_TOKEN); + expect(raw).toBeDefined(); + expect(tokenFromRequest(raw ?? new Uint8Array())).toEqual({ kind: TOKEN_OUT_OF_BAND, value }); }); /** The same bytes `rs/moq-net/src/ietf/token.rs` asserts, so the two agree on the wire. */ test("the encoding matches the cross-language vector", () => { const token: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff]) }; - for (const [version, expected] of [ - [Version.DRAFT_14, [0x03, 0x41, 0x2c, 0x00, 0xff]], - [Version.DRAFT_17, [0x03, 0x81, 0x2c, 0x00, 0xff]], - ] as const) { - const params = new SetupOptions(); - tokenIntoSetup(params, token, version); - expect(params.getBytes(SetupOption.AuthorizationToken)).toEqual(new Uint8Array(expected)); - } + const params = new SetupOptions(); + tokenIntoSetup(params, token); + expect(params.getBytes(SetupOption.AuthorizationToken)).toEqual(new Uint8Array([0x03, 0x81, 0x2c, 0x00, 0xff])); }); test("an absent option is no token", () => { - for (const version of VERSIONS) { - expect(tokenFromSetup(new SetupOptions(), version)).toBeUndefined(); - } + expect(tokenFromSetup(new SetupOptions())).toBeUndefined(); }); test("an empty value is a token", () => { - for (const version of VERSIONS) { - const params = structure(version, [0x3n, TOKEN_OUT_OF_BAND]); - expect(tokenFromSetup(params, version)).toEqual({ kind: TOKEN_OUT_OF_BAND, value: new Uint8Array() }); - } + const params = structure([0x3n, TOKEN_OUT_OF_BAND]); + expect(tokenFromSetup(params)).toEqual({ kind: TOKEN_OUT_OF_BAND, value: new Uint8Array() }); }); /** We advertise no cache, so a registration is the draft's own USE_VALUE. */ test("REGISTER is a value", () => { - for (const version of VERSIONS) { - const params = structure(version, [0x1n, 7n, TOKEN.kind], TOKEN.value); - expect(tokenFromSetup(params, version)).toEqual(TOKEN); - } + const params = structure([0x1n, 7n, TOKEN.kind], TOKEN.value); + expect(tokenFromSetup(params)).toEqual(TOKEN); }); /** Section 9.1.4: nothing can have been registered before SETUP, so an alias there is a violation. */ test("a SETUP alias reference is a protocol violation", () => { - for (const version of VERSIONS) { - for (const aliasType of [0x0n, 0x2n]) { - const params = structure(version, [aliasType, 7n]); - expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.ProtocolViolation); - } + for (const aliasType of [0x0n, 0x2n]) { + const params = structure([aliasType, 7n]); + expect(sessionCode(() => tokenFromSetup(params))).toBe(SessionCode.ProtocolViolation); } }); test("an undecodable structure is a formatting error", () => { - for (const version of VERSIONS) { - for (const fields of [[], [0x3n], [0x1n], [0x1n, 7n], [0x0n], [0x2n], [0x4n, 0n]]) { - const params = structure(version, fields); - expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.KeyValueFormatting); - } + for (const fields of [[], [0x3n], [0x1n], [0x1n, 7n], [0x0n], [0x2n], [0x4n, 0n]]) { + const params = structure(fields); + expect(sessionCode(() => tokenFromSetup(params))).toBe(SessionCode.KeyValueFormatting); } }); /** One credential per connection: a second token is refused, not unioned or dropped. */ test("two tokens are refused", async () => { + const varint = Varint.encodeLeadingOnes; for (const version of VERSIONS) { - const value = [...varint(0x3n, version), ...varint(TOKEN_OUT_OF_BAND, version)]; - const key = SetupOption.AuthorizationToken; - // Delta-encoded from draft-16, so the repeat is a delta of zero. - const repeat = version === Version.DRAFT_14 || version === Version.DRAFT_15 ? key : 0n; - const count = leadingOnes(version) ? [] : [...varint(2n, version)]; - const entry = (k: bigint) => [...varint(k, version), ...varint(BigInt(value.length), version), ...value]; - const bytes = new Uint8Array([...count, ...entry(key), ...entry(repeat)]); - - await expect(SetupOptions.decode(new Reader(undefined, bytes, version), version)).rejects.toThrow( - /duplicate parameter/, - ); + const value = [...varint(0x3n), ...varint(TOKEN_OUT_OF_BAND)]; + // Keys are delta-encoded, so the repeat is a delta of zero. + const entry = (k: bigint) => [...varint(k), ...varint(BigInt(value.length)), ...value]; + const bytes = new Uint8Array([...entry(SetupOption.AuthorizationToken), ...entry(0n)]); + + await expect(SetupOptions.decode(new Reader(undefined, bytes, version))).rejects.toThrow(/duplicate parameter/); } }); test("a request token by value decodes", () => { - for (const version of VERSIONS) { - expect(tokenFromRequest(raw(version, [0x3n, TOKEN.kind], TOKEN.value), version)).toEqual(TOKEN); - } + expect(tokenFromRequest(raw([0x3n, TOKEN.kind], TOKEN.value))).toEqual(TOKEN); }); /** Only SETUP falls back to a value: on a request, a registration overflows the cache. */ test("a request registration overflows the cache", () => { - for (const version of VERSIONS) { - const token = raw(version, [0x1n, 7n, TOKEN.kind], TOKEN.value); - expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.AuthTokenCacheOverflow); - } + const token = raw([0x1n, 7n, TOKEN.kind], TOKEN.value); + expect(sessionCode(() => tokenFromRequest(token))).toBe(SessionCode.AuthTokenCacheOverflow); }); test("a request alias reference is an unknown alias", () => { - for (const version of VERSIONS) { - for (const aliasType of [0x0n, 0x2n]) { - const token = raw(version, [aliasType, 7n]); - expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.UnknownAuthTokenAlias); - } + for (const aliasType of [0x0n, 0x2n]) { + const token = raw([aliasType, 7n]); + expect(sessionCode(() => tokenFromRequest(token))).toBe(SessionCode.UnknownAuthTokenAlias); } }); test("an undecodable request token is a formatting error", () => { - for (const version of VERSIONS) { - // A registration, deletion, or alias use missing a field it names, or an alias use - // carrying a token type. - for (const fields of [[], [0x3n], [0x1n], [0x1n, 7n], [0x0n], [0x2n], [0x2n, 7n, 0n], [0x4n, 0n]]) { - const token = raw(version, fields); - expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.KeyValueFormatting); - } + // A registration, deletion, or alias use missing a field it names, or an alias use + // carrying a token type. + for (const fields of [[], [0x3n], [0x1n], [0x1n, 7n], [0x0n], [0x2n], [0x2n, 7n, 0n], [0x4n, 0n]]) { + const token = raw(fields); + expect(sessionCode(() => tokenFromRequest(token))).toBe(SessionCode.KeyValueFormatting); } }); diff --git a/js/net/src/ietf/token.ts b/js/net/src/ietf/token.ts index cc37632a705..8d4cd98b223 100644 --- a/js/net/src/ietf/token.ts +++ b/js/net/src/ietf/token.ts @@ -2,7 +2,6 @@ import { ProtocolViolation, SessionCode } from "../error.ts"; import * as Varint from "../varint.ts"; import type { Parameters } from "./parameters.ts"; import type { SetupOptions } from "./setup_options.ts"; -import { type IetfVersion, Version } from "./version.ts"; /** * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-22 section 9.1.4) and @@ -53,11 +52,6 @@ export interface Token { */ export const TOKEN_OUT_OF_BAND = 0x0n; -/** Draft-17 replaced QUIC's two-bit-length varint with a leading-ones one. */ -function leadingOnes(version: IetfVersion): boolean { - return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; -} - /** * The token the peer's SETUP presented, if any. * @@ -67,10 +61,10 @@ function leadingOnes(version: IetfVersion): boolean { * @throws {ProtocolViolation} carrying the session code to close with. * @internal */ -export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Token | undefined { +export function tokenFromSetup(params: SetupOptions): Token | undefined { const raw = params.getBytes(AUTHORIZATION_TOKEN); if (raw === undefined) return undefined; - return decode(raw, version, true); + return decode(raw, true); } /** @@ -114,8 +108,8 @@ export function unique(tokens: Token[]) { * @throws {ProtocolViolation} carrying the session code to close with. * @internal */ -export function tokenFromRequest(raw: Uint8Array, version: IetfVersion): Token { - return decode(raw, version, false); +export function tokenFromRequest(raw: Uint8Array): Token { + return decode(raw, false); } /** @@ -125,13 +119,13 @@ export function tokenFromRequest(raw: Uint8Array, version: IetfVersion): Token { * `UnknownAuthTokenAlias` for an alias reference on a request (`ProtocolViolation` in SETUP), * or `KeyValueFormatting` for a structure that cannot be decoded. */ -function decode(raw: Uint8Array, version: IetfVersion, setup: boolean): Token { +function decode(raw: Uint8Array, setup: boolean): Token { // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. const malformed = (cause?: unknown) => new ProtocolViolation("malformed AUTHORIZATION TOKEN", { cause, code: SessionCode.KeyValueFormatting }); const unvarint = (buf: Uint8Array): [bigint, Uint8Array] => { try { - return leadingOnes(version) ? Varint.decodeLeadingOnes(buf) : Varint.decodeBigInt(buf); + return Varint.decodeLeadingOnes(buf); } catch (err) { throw malformed(err); } @@ -180,8 +174,8 @@ function decode(raw: Uint8Array, version: IetfVersion, setup: boolean): Token { * * @internal */ -export function tokenIntoSetup(params: SetupOptions, token: Token, version: IetfVersion) { - params.setBytes(AUTHORIZATION_TOKEN, encode(token, version)); +export function tokenIntoSetup(params: SetupOptions, token: Token) { + params.setBytes(AUTHORIZATION_TOKEN, encode(token)); } /** @@ -189,13 +183,13 @@ export function tokenIntoSetup(params: SetupOptions, token: Token, version: Ietf * * @internal */ -export function tokenIntoRequest(params: Parameters, value: Uint8Array, version: IetfVersion) { - params.bytes.set(AUTHORIZATION_TOKEN, encode({ kind: TOKEN_OUT_OF_BAND, value }, version)); +export function tokenIntoRequest(params: Parameters, value: Uint8Array) { + params.bytes.set(AUTHORIZATION_TOKEN, encode({ kind: TOKEN_OUT_OF_BAND, value })); } /** A Token structure by value: USE_VALUE, its type, then the token. */ -function encode(token: Token, version: IetfVersion): Uint8Array { - const varint = (v: bigint) => (leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v)); +function encode(token: Token): Uint8Array { + const varint = Varint.encodeLeadingOnes; const aliasType = varint(USE_VALUE); const kind = varint(token.kind); diff --git a/js/net/src/ietf/track.ts b/js/net/src/ietf/track.ts index 573d57403e4..c06ab1883ab 100644 --- a/js/net/src/ietf/track.ts +++ b/js/net/src/ietf/track.ts @@ -1,21 +1,14 @@ import type * as Epoch from "../epoch.ts"; import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; -import { supported as epochSupported } from "./epoch.ts"; import * as Filter from "./filter.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; import { type MessageLocation, Parameters } from "./parameters.ts"; import * as Properties from "./properties.ts"; import { RequestOk } from "./request.ts"; -import { Subscribe, SubscribeOk } from "./subscribe.ts"; -import { type IetfVersion, Version } from "./version.ts"; - -// we only support Group Order descending -const GROUP_ORDER = 0x02; - -/** TRACK_STATUS_ERROR on draft-14, with the SUBSCRIBE_ERROR body. Later drafts refuse with REQUEST_ERROR. */ -export const TRACK_STATUS_ERROR_14 = 0x0f; +import { Subscribe } from "./subscribe.ts"; +import type { IetfVersion } from "./version.ts"; export class TrackStatusRequest { static id = 0x0d; @@ -25,7 +18,7 @@ export class TrackStatusRequest { trackName: string; /** Whether the requester wants Track Properties on the answer (INCLUDE_PROPERTIES, draft-22). */ propertiesWanted: boolean; - /** The publisher instance the requester expects (EPOCH, MoQ Epoch, draft-17+). */ + /** The publisher instance the requester expects (EPOCH, MoQ Epoch). */ epoch?: Epoch.Valid; constructor({ @@ -50,26 +43,15 @@ export class TrackStatusRequest { async #encode(w: Writer, version: IetfVersion): Promise { await w.u62(this.requestId); - if (version === Version.DRAFT_17) { - await w.u62(0n); // required_request_id_delta — always 0, not supported - } await Namespace.encode(w, this.trackNamespace); await w.string(this.trackName); - if (version === Version.DRAFT_14) { - await w.u8(0); // subscriber_priority - await w.u8(GROUP_ORDER); // group_order - await w.bool(false); // forward - await w.u53(0x2); // filter_type = LargestObject - await w.u53(0); // no parameters - } else { - // v15+: just parameters. Only the opt-out is worth bytes, and an older peer would - // read INCLUDE_PROPERTIES as unknown, which is a protocol violation. - const params = new Parameters(); - if (!this.propertiesWanted && Filter.isDraft22(version)) params.includeProperties = false; - if (epochSupported(version)) params.epoch = this.epoch; - await params.encode(w, version); - } + // Only the opt-out is worth bytes, and a peer before draft-22 would read + // INCLUDE_PROPERTIES as unknown, which is a protocol violation. + const params = new Parameters(); + if (!this.propertiesWanted && Filter.isDraft22(version)) params.includeProperties = false; + params.epoch = this.epoch; + await params.encode(w, version); } async encode(w: Writer, version: IetfVersion): Promise { @@ -90,85 +72,43 @@ export class TrackStatusRequest { /** * TRACK_STATUS_OK: what a SUBSCRIBE_OK would say about the track, with no subscription. * - * Draft-14 gives it its own type and the SUBSCRIBE_OK body, with a Track Alias of 0. Later - * drafts answer with a REQUEST_OK, which gained Track Properties in draft-18; before that - * only the message parameters carry anything. + * The answer is a REQUEST_OK carrying the message parameters and Track Properties. */ export class TrackStatusOk { - /** The draft-14 message type. */ - static readonly ID_14 = 0x0e; - - /** The message type on `version`. */ - static id(version: IetfVersion): number { - return version === Version.DRAFT_14 ? TrackStatusOk.ID_14 : RequestOk.id; - } - - /** Present on drafts 14-16 only. */ - requestId: bigint | undefined; + static readonly id = RequestOk.id; /** The largest Location in the track (LARGEST_OBJECT), absent while it has no content. */ largest: MessageLocation | undefined; - /** The Track Properties, on the drafts whose answer has room for them. */ + /** The Track Properties. */ properties: Properties.Properties; - constructor({ - requestId, - largest, - properties = {}, - }: { requestId?: bigint; largest?: MessageLocation; properties?: Properties.Properties }) { - this.requestId = requestId; + constructor({ largest, properties = {} }: { largest?: MessageLocation; properties?: Properties.Properties } = {}) { this.largest = largest; this.properties = properties; } - /** Whether the answer carries a Track Properties block on `version`. */ - static #hasProperties(version: IetfVersion): boolean { - return version >= Version.DRAFT_18; - } - async #encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_15 || version === Version.DRAFT_16) { - if (this.requestId === undefined) throw new Error("requestId required for draft14-16"); - await w.u62(this.requestId); - } - - // The parameters SUBSCRIBE_OK carries: GROUP_ORDER is one only on draft-15. + // The parameters SUBSCRIBE_OK carries. const params = new Parameters(); if (this.largest !== undefined) params.largest = this.largest; - if (version === Version.DRAFT_15 && this.properties.groupOrder !== undefined) { - params.groupOrder = this.properties.groupOrder; - } await params.encode(w, version); // Track Properties are the final field, so nothing may follow. - if (TrackStatusOk.#hasProperties(version)) await Properties.encode(w, this.properties, version); + await Properties.encode(w, this.properties, version); } async encode(w: Writer, version: IetfVersion): Promise { - if (version === Version.DRAFT_14) { - const { requestId, largest, properties } = this; - return new SubscribeOk({ requestId, trackAlias: 0n, largest, properties }).encode(w, version); - } return Message.encode(w, (mw) => this.#encode(mw, version)); } static async decode(r: Reader, version: IetfVersion): Promise { - if (version === Version.DRAFT_14) { - const { requestId, largest, properties } = await SubscribeOk.decode(r, version); - return new TrackStatusOk({ requestId, largest, properties }); - } return Message.decode(r, (mr) => TrackStatusOk.#decode(mr, version)); } static async #decode(r: Reader, version: IetfVersion): Promise { - const requestId = version === Version.DRAFT_15 || version === Version.DRAFT_16 ? await r.u62() : undefined; const params = await Parameters.decode(r, version, "subscribe-ok"); - const properties = TrackStatusOk.#hasProperties(version) ? await Properties.decode(r, version) : {}; - if (version === Version.DRAFT_15) { - properties.groupOrder = params.groupOrder; - properties.maxCacheDuration = params.maxCacheDuration; - } - return new TrackStatusOk({ requestId, largest: params.largest, properties }); + const properties = await Properties.decode(r, version); + return new TrackStatusOk({ largest: params.largest, properties }); } } diff --git a/js/net/src/ietf/version.ts b/js/net/src/ietf/version.ts index c4123a8014a..cf527745d57 100644 --- a/js/net/src/ietf/version.ts +++ b/js/net/src/ietf/version.ts @@ -2,36 +2,6 @@ * Supported MoQ Transport protocol versions */ export const Version = { - /** - * draft-ietf-moq-transport-07 - * https://www.ietf.org/archive/id/draft-ietf-moq-transport-07.txt - */ - DRAFT_07: 0xff000007, - - /** - * draft-ietf-moq-transport-14 - * https://www.ietf.org/archive/id/draft-ietf-moq-transport-14.txt - */ - DRAFT_14: 0xff00000e, - - /** - * draft-ietf-moq-transport-15 - * https://www.ietf.org/archive/id/draft-ietf-moq-transport-15.txt - */ - DRAFT_15: 0xff00000f, - - /** - * draft-ietf-moq-transport-16 - * https://www.ietf.org/archive/id/draft-ietf-moq-transport-16.txt - */ - DRAFT_16: 0xff000010, - - /** - * draft-ietf-moq-transport-17 - * https://www.ietf.org/archive/id/draft-ietf-moq-transport-17.txt - */ - DRAFT_17: 0xff000011, - /** * draft-ietf-moq-transport-18 * https://www.ietf.org/archive/id/draft-ietf-moq-transport-18.txt @@ -49,10 +19,6 @@ export type Version = (typeof Version)[keyof typeof Version]; // ALPN / WebTransport subprotocol identifiers for draft versions. export const ALPN = { - DRAFT_14: "moq-00", - DRAFT_15: "moqt-15", - DRAFT_16: "moqt-16", - DRAFT_17: "moqt-17", DRAFT_18: "moqt-18", DRAFT_22: "moqt-22", } as const; @@ -61,20 +27,9 @@ export const ALPN = { * IETF protocol versions used by the ietf/ module. * Use this narrower type for version-branched encode/decode to get exhaustive matching. */ -export type IetfVersion = - | typeof Version.DRAFT_14 - | typeof Version.DRAFT_15 - | typeof Version.DRAFT_16 - | typeof Version.DRAFT_17 - | typeof Version.DRAFT_18 - | typeof Version.DRAFT_22; +export type IetfVersion = typeof Version.DRAFT_18 | typeof Version.DRAFT_22; const VERSION_NAMES: Record = { - [Version.DRAFT_07]: "moq-transport-07", - [Version.DRAFT_14]: "moq-transport-14", - [Version.DRAFT_15]: "moq-transport-15", - [Version.DRAFT_16]: "moq-transport-16", - [Version.DRAFT_17]: "moq-transport-17", [Version.DRAFT_18]: "moq-transport-18", [Version.DRAFT_22]: "moq-transport-22", }; diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index d653c0040d2..7d65dcf01e0 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -17,6 +17,7 @@ import * as Ietf from "./ietf/index.ts"; import { Parameters } from "./ietf/parameters.ts"; import { Publisher as IetfPublisher } from "./ietf/publisher.ts"; import { RequestOk } from "./ietf/request.ts"; +import { refuse as refuseRequest } from "./ietf/request_stream.ts"; import { SubscribeTracks } from "./ietf/subscribe_tracks.ts"; import { hooks } from "./internal.ts"; import * as Lite from "./lite/index.ts"; @@ -65,14 +66,13 @@ function withoutDiscovery(): () => number { throw new Error("discovery unsupported"); }; spyOn(LitePublisher.prototype, "runAnnounce").mockImplementation(refuse); - // A reset virtual stream never reaches a pre-draft-17 peer, so answer with the error message. - spyOn(IetfPublisher.prototype, "runSubscribeNamespace").mockImplementation(function ( - this: IetfPublisher, - msg, - stream, - ) { + // Every draft answers on the request's own stream; the refusal codes match across them. + spyOn(IetfPublisher.prototype, "runSubscribeNamespace").mockImplementation((_msg, stream) => { refused++; - return this.refuseSubscribeNamespace(msg.requestId, stream); + return refuseRequest(stream, Ietf.Version.DRAFT_18, "subscribe_namespace", { + condition: "not_supported", + reasonPhrase: "discovery unsupported", + }); }); return () => refused; } @@ -240,41 +240,38 @@ test("integration: lite subscription options and updates reach the publisher", a }); // Both handshake paths: a SETUP-negotiated draft (16) and ALPN-selected ones (17+). -test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])( - "integration: %s carries the subscriber max age to the publisher", - async (alpn) => { - const pair = createMockTransportPair(alpn); - const origin = new OriginProducer(); - const [client, server] = await Promise.all([ - connect(url, { transport: pair.client }), - accept(pair.server, url, { publish: origin.consume() }), - ]); +test.each([Ietf.ALPN.DRAFT_22])("integration: %s carries the subscriber max age to the publisher", async (alpn) => { + const pair = createMockTransportPair(alpn); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = publish(origin, Path.from("test")); - const accepted = (async () => { - const request = await wireOf(broadcast).requested(); - if (!request) throw new Error("no request"); - return request.accept({ timescale: Timescale.MILLI }); - })(); + const broadcast = publish(origin, Path.from("test")); + const accepted = (async () => { + const request = await wireOf(broadcast).requested(); + if (!request) throw new Error("no request"); + return request.accept({ timescale: Timescale.MILLI }); + })(); - const remote = wireOf(client).consume(Path.from("test")); - const subscriber = remote.track("video").subscribe({ maxDelay: Milli(250) }); - const producer = await accepted; - // Without the negotiated parameter the publisher keeps its whole backlog instead. - expect(producer.subscription.peek()?.maxDelay).toBe(Milli(250)); + const remote = wireOf(client).consume(Path.from("test")); + const subscriber = remote.track("video").subscribe({ maxDelay: Milli(250) }); + const producer = await accepted; + // Without the negotiated parameter the publisher keeps its whole backlog instead. + expect(producer.subscription.peek()?.maxDelay).toBe(Milli(250)); - const updated = producer.subscription.changed(); - subscriber.update({ maxDelay: Milli(500) }); - expect((await updated)?.maxDelay).toBe(Milli(500)); + const updated = producer.subscription.changed(); + subscriber.update({ maxDelay: Milli(500) }); + expect((await updated)?.maxDelay).toBe(Milli(500)); - subscriber.close(); - remote.close(); - broadcast.close(); - origin.close(); - client.abort(); - server.abort(); - }, -); + subscriber.close(); + remote.close(); + broadcast.close(); + origin.close(); + client.abort(); + server.abort(); +}); test("integration: lite carries a fractional maxDelay as a whole millisecond", async () => { const pair = createMockTransportPair(Lite.ALPN_05); @@ -552,20 +549,17 @@ async function announcedUntil(announced: Announce.Consumer, until: string) { // A `.`-named broadcast is left out of discovery unless the request opts in or names the // dot segment. lite-06 cannot carry the opt-in, so its peer never lists the hidden path. -for (const [protocol, carriesOptIn, version] of [ +for (const [protocol, carriesOptIn] of [ [Lite.ALPN_07_WIP, true], [Lite.ALPN_06, false], [Ietf.ALPN.DRAFT_18, true], - [Ietf.ALPN.DRAFT_16, true], - ["", true, Ietf.Version.DRAFT_14], - [Ietf.ALPN.DRAFT_15, true], ] as const) { test(`integration: ${protocol} hides dot paths from discovery`, async () => { const pair = createMockTransportPair(protocol); const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), - accept(pair.server, url, { publish: origin.consume(), version }), + accept(pair.server, url, { publish: origin.consume() }), ]); // Published first, so a reader that may see it lists it before `visible`. @@ -754,10 +748,6 @@ test("integration: lite draft-05 missing datagram writer does not close streams" }); for (const [name, protocol, version] of [ - ["draft-14", "", Ietf.Version.DRAFT_14], - ["draft-15", Ietf.ALPN.DRAFT_15, undefined], - ["draft-16", Ietf.ALPN.DRAFT_16, undefined], - ["draft-17", Ietf.ALPN.DRAFT_17, undefined], ["draft-18", Ietf.ALPN.DRAFT_18, undefined], ["draft-22", Ietf.ALPN.DRAFT_22, undefined], ] as const) { @@ -792,8 +782,7 @@ for (const [name, protocol, version] of [ ); expect(datagram?.sequence).toBe(7); expect(dec.decode(datagram?.payload)).toBe("dgram"); - // Drafts 14-16 cannot declare TIMESCALE, so their datagrams arrive untimed. - if (timed && !["draft-14", "draft-15", "draft-16"].includes(name)) { + if (timed) { expect(datagram?.timestamp?.as(Timescale.MILLI)).toBe(1234); } else { expect(datagram?.timestamp).toBeUndefined(); @@ -1352,22 +1341,6 @@ test("integration: cancelling an ietf track listing preserves an existing subscr } }); -test("integration: ietf draft-14", async () => { - await runPublishSubscribeFlow("", Ietf.Version.DRAFT_14); -}); - -test("integration: ietf draft-15", async () => { - await runPublishSubscribeFlow(Ietf.ALPN.DRAFT_15); -}); - -test("integration: ietf draft-16", async () => { - await runPublishSubscribeFlow(Ietf.ALPN.DRAFT_16); -}); - -test("integration: ietf draft-17", async () => { - await runPublishSubscribeFlow(Ietf.ALPN.DRAFT_17); -}); - test("integration: ietf draft-18", async () => { await runPublishSubscribeFlow(Ietf.ALPN.DRAFT_18); }); @@ -1453,51 +1426,7 @@ test("integration: lite subscriber teardown on last unsubscribe", async () => { }); test("integration: ietf subscriber teardown on last unsubscribe", async () => { - await runSubscriberTeardown(Ietf.ALPN.DRAFT_17); -}); - -// Draft-14 sends an explicit Unsubscribe after the demand loop breaks; exercise that path too. -// Uses a dynamic serve (draft-14 doesn't complete SUBSCRIBE_OK for a statically inserted track). -test("integration: ietf draft-14 subscriber teardown on last unsubscribe", async () => { - const pair = createMockTransportPair(""); - const origin = new OriginProducer(); - const [client, server] = await Promise.all([ - connect(url, { transport: pair.client }), - accept(pair.server, url, { version: Ietf.Version.DRAFT_14, publish: origin.consume() }), - ]); - - const broadcast = publish(origin, Path.from("test")); - - // Serve dynamically, keeping the served producer so we can watch its demand. - let served: TrackProducer | undefined; - const serving = (async () => { - for (;;) { - const req = await wireOf(broadcast).requested(); - if (!req) break; - served = req.accept({ timescale: Timescale.MILLI }); - served.writeFrame(textFrame("hello")); - } - })(); - - // Draft-14 only completes SUBSCRIBE_OK once the session is warmed by an announce round-trip. - const announced = client.announced(); - await announced.next(); - - const remote = wireOf(client).consume(Path.from("test")); - const sub = remote.track("video").subscribe().ordered(); - expect(await sub.readString()).toBe("hello"); - await waitUntil(() => served?.demand().wanted.peek() !== undefined); - - // Closing the subscriber sends Unsubscribe and tears the subscription down, so demand drops. - sub.close(); - await waitUntil(() => served?.demand().wanted.peek() === undefined); - - broadcast.close(); - await serving; - announced.close(); - remote.close(); - client.abort(); - server.abort(); + await runSubscriberTeardown(Ietf.ALPN.DRAFT_18); }); // A fetched group can stay open indefinitely (a catalog track, a JSON stream), so abandoning the @@ -1727,61 +1656,16 @@ test("integration: lite consume dedup", async () => { }); test("integration: ietf consume dedup", async () => { - await runConsumeDedup(Ietf.ALPN.DRAFT_17); -}); - -// Drafts 14-16 multiplex both directions over one control stream. A subscribe must resolve when it -// races an inbound announce, without warming the session by reading that announce first. -async function runSubscribeWithoutWarmup(protocol: string, version?: number) { - const pair = createMockTransportPair(protocol); - const origin = new OriginProducer(); - const [client, server] = await Promise.all([ - connect(url, { transport: pair.client }), - accept(pair.server, url, { version, publish: origin.consume() }), - ]); - - const broadcast = publish(origin, Path.from("test")); - const serving = (async () => { - const req = await wireOf(broadcast).requested(); - if (req) req.accept({ timescale: Timescale.MILLI }).writeFrame(textFrame("hello")); - })(); - - const remote = wireOf(client).consume(Path.from("test")); - const track = remote.track("video").subscribe().ordered(); - const data = await Promise.race([ - track.readString(), - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out waiting for SUBSCRIBE_OK")), 2000), - ), - ]); - expect(data).toBe("hello"); - - broadcast.close(); - await serving; - remote.close(); - client.abort(); - server.abort(); -} - -test("integration: ietf draft-14 subscribe without announce warmup", async () => { - await runSubscribeWithoutWarmup("", Ietf.Version.DRAFT_14); -}); - -test("integration: ietf draft-15 subscribe without announce warmup", async () => { - await runSubscribeWithoutWarmup(Ietf.ALPN.DRAFT_15); -}); - -test("integration: ietf draft-16 subscribe without announce warmup", async () => { - await runSubscribeWithoutWarmup(Ietf.ALPN.DRAFT_16); + await runConsumeDedup(Ietf.ALPN.DRAFT_18); }); test("integration: subscribe to non-existent broadcast", async () => { - const pair = createMockTransportPair(""); + const pair = createMockTransportPair(Ietf.ALPN.DRAFT_18); const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), - accept(pair.server, url, { version: Ietf.Version.DRAFT_14, publish: origin.consume() }), + accept(pair.server, url, { publish: origin.consume() }), ]); // Client tries to consume a broadcast that nobody is publishing @@ -1958,17 +1842,13 @@ test("integration: a republish is not served from the previous generation's cach // session every time it is toggled. Each generation is a new producer under the old name, and the // gap between them can be a single microtask, so the announce loop has to key the path on which // producer holds it rather than on the path alone. -async function runRepublishCycle(protocol: string, version?: number) { +async function runRepublishCycle(protocol: string) { const pair = createMockTransportPair(protocol); const origin = new OriginProducer(); const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client, consume: clientOrigin }), - accept( - pair.server, - url, - version !== undefined ? { publish: origin.consume(), version } : { publish: origin.consume() }, - ), + accept(pair.server, url, { publish: origin.consume() }), ]); const serve = async (broadcast: BroadcastProducer, payload: string) => { @@ -2024,13 +1904,9 @@ test("integration: lite republish on one session swaps the handle every generati await runRepublishCycle(Lite.ALPN_06); }); -test("integration: ietf republish on one session swaps the handle every generation", async () => { - await runRepublishCycle("", Ietf.Version.DRAFT_14); -}); - -// Draft-17+ withdraws by reset, which the replacement request does not wait behind. -test.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_18])( - "integration: ietf republish withdrawn by reset swaps the handle (%#)", +// moq-transport withdraws by reset, which the replacement request does not wait behind. +test.each([Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_22])( + "integration: %s republish withdrawn by reset swaps the handle", async (protocol) => { await runRepublishCycle(protocol); }, @@ -2103,12 +1979,12 @@ test("integration: a blind handle goes offline when the session dies", async () // are not: each subscriber resolves announcements its own way. These mirror the lite cases. test("integration: ietf blind handle picks up a publisher that arrives late", async () => { withoutDiscovery(); - const pair = createMockTransportPair(""); + const pair = createMockTransportPair(Ietf.ALPN.DRAFT_18); const origin = new OriginProducer(); const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client, consume: clientOrigin }), - accept(pair.server, url, { version: Ietf.Version.DRAFT_14, publish: origin.consume() }), + accept(pair.server, url, { publish: origin.consume() }), ]); const watched = clientOrigin.request(Path.from("later"), { announced: true }); @@ -2205,10 +2081,6 @@ test("origin: discovers, consumes, and retracts over lite", async () => { await runOriginFlow(Lite.ALPN_05); }); -test("origin: discovers, consumes, and retracts over ietf", async () => { - await runOriginFlow("", Ietf.Version.DRAFT_14); -}); - test("origin: remote entries retract when the session dies, local ones survive", async () => { const pair = createMockTransportPair(Lite.ALPN_05); const serverOrigin = new OriginProducer(); @@ -2475,7 +2347,7 @@ for (const alpn of [Lite.ALPN_06, Lite.ALPN_07_WIP]) { } } -test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_18])( +test.each([Ietf.ALPN.DRAFT_18])( "%s: closing an announce consumer before the namespace answer ends the peer's stream", async (alpn) => { // The peer never answers, and returns only once the request stream closes. @@ -2821,8 +2693,7 @@ async function runSessionDeath( for (const [name, protocol, version] of [ ["lite draft-03", Lite.ALPN_03, undefined], ["lite draft-05", Lite.ALPN_05, undefined], - ["ietf draft-14", "", Ietf.Version.DRAFT_14], - ["ietf draft-17", Ietf.ALPN.DRAFT_17, undefined], + ["ietf draft-18", Ietf.ALPN.DRAFT_18, undefined], ] as const) { test(`integration: ${name} ends a track with its session's error`, async () => { const [closed, groupEnd] = await runSessionDeath(protocol, version); diff --git a/js/net/src/lite/compat.ts b/js/net/src/lite/compat.ts new file mode 100644 index 00000000000..7702725c5da --- /dev/null +++ b/js/net/src/lite/compat.ts @@ -0,0 +1,95 @@ +import { concat, encodeVarint, Reader, Writer } from "../stream.ts"; +import { StreamId } from "./stream.ts"; +import { Version } from "./version.ts"; + +/** The most versions a CLIENT_SETUP may offer. */ +const MAX_VERSIONS = 128; + +/** An empty parameter list: moq-lite defines none here. */ +const NO_PARAMETERS = new Uint8Array([0]); + +/** Whether `version` is a moq-lite version, each of which negotiates over the `moql` SETUP exchange. */ +export function isNegotiated(version: number): version is Version { + return (Object.values(Version) as number[]).includes(version); +} + +/** Write `id`, then the body `f` writes behind draft-14's u16 size, in one write. */ +async function frame(w: Writer, id: number, f: (w: Writer) => Promise): Promise { + const body = await Writer.collect(w.version, f); + if (body.byteLength > 0xffff) throw new Error(`SETUP too large: ${body.byteLength} bytes`); + await w.write( + concat([encodeVarint(id, w.version), new Uint8Array([body.byteLength >> 8, body.byteLength & 0xff]), body]), + ); +} + +/** Read the type `id`, then a u16-sized body, refusing anything else. */ +async function unframe(r: Reader, id: number): Promise { + const kind = await r.u53(); + if (kind !== id) throw new Error(`unexpected SETUP message type: 0x${kind.toString(16)}`); + const size = await r.u16(); + return new Reader(undefined, await r.read(size), r.version); +} + +/** + * The client half of the `moql` SETUP exchange, offering moq-lite versions. + * + * The framing is moq-transport draft-14's CLIENT_SETUP, which moq-lite borrowed: a u16 + * size, the offered versions, then parameters. + */ +export class ClientSetup { + /** The offered versions, most preferred first. */ + versions: number[]; + + constructor(versions: number[]) { + this.versions = versions; + } + + async encode(w: Writer): Promise { + await frame(w, StreamId.ClientCompat, async (w) => { + await w.u53(this.versions.length); + for (const v of this.versions) await w.u53(v); + await w.write(NO_PARAMETERS); + }); + } + + static async decode(r: Reader): Promise { + const body = await unframe(r, StreamId.ClientCompat); + const count = await body.u53(); + if (count > MAX_VERSIONS) throw new Error(`too many versions: ${count}`); + const versions: number[] = []; + for (let i = 0; i < count; i++) versions.push(await body.u53()); + // Parameters are unparsed: moq-lite defines none here. + await body.readAll(); + return new ClientSetup(versions); + } +} + +/** + * The server half of the `moql` SETUP exchange, selecting a moq-lite version. + * + * The framing is moq-transport draft-14's SERVER_SETUP: a u16 size, the selected version, + * then parameters. + */ +export class ServerSetup { + /** The selected version. */ + version: number; + + constructor(version: number) { + this.version = version; + } + + async encode(w: Writer): Promise { + await frame(w, StreamId.ServerCompat, async (w) => { + await w.u53(this.version); + await w.write(NO_PARAMETERS); + }); + } + + static async decode(r: Reader): Promise { + const body = await unframe(r, StreamId.ServerCompat); + const version = await body.u53(); + // Parameters are unparsed: moq-lite defines none here. + await body.readAll(); + return new ServerSetup(version); + } +} diff --git a/js/net/src/lite/index.ts b/js/net/src/lite/index.ts index 8e588403e91..9d02171212f 100644 --- a/js/net/src/lite/index.ts +++ b/js/net/src/lite/index.ts @@ -1,4 +1,5 @@ export * from "./announce.ts"; +export * from "./compat.ts"; export * from "./connection.ts"; export * from "./datagram.ts"; export * from "./fetch.ts"; diff --git a/js/net/src/mock.ts b/js/net/src/mock.ts index ba6e74ef78b..a10cac22910 100644 --- a/js/net/src/mock.ts +++ b/js/net/src/mock.ts @@ -323,7 +323,7 @@ export interface MockTransportOptions { /** * Creates a pair of connected MockTransport instances. * - * @param protocol - The WebTransport protocol identifier (e.g. "moqt-17", "moql", "") + * @param protocol - The WebTransport protocol identifier (e.g. "moqt-18", "moql", "") * @param options - Optional behavior toggles for datagram availability * @returns An object containing `client` and `server` transports */ diff --git a/js/net/src/net-cleanups.test.ts b/js/net/src/net-cleanups.test.ts index 1f210bc1515..45f9e20ff4c 100644 --- a/js/net/src/net-cleanups.test.ts +++ b/js/net/src/net-cleanups.test.ts @@ -154,7 +154,7 @@ test("malformed SETUP closes the accepted transport", async () => { const writer = stream.writable.getWriter(); try { await writer.write(new Uint8Array([0])); - expect(await pending).toEqual(new Error("unexpected client message type: 0x0")); + expect(await pending).toEqual(new Error("unexpected SETUP message type: 0x0")); expect((await pair.client.closed).closeCode).toBe(0); } finally { writer.releaseLock(); diff --git a/js/net/src/order.test.ts b/js/net/src/order.test.ts index 9b14735aee0..79e1e89bc63 100644 --- a/js/net/src/order.test.ts +++ b/js/net/src/order.test.ts @@ -137,7 +137,7 @@ test("lite-06 serves an ascending subscription newest first", async () => { // moq-transport joins at the live edge, so the groups arrive live, one at a time. test.each(["ascending", "descending"] as const)("ietf serves GROUP_ORDER %s", async (order) => { const origin = new OriginProducer(); - const { client, server, transport } = await session(Ietf.ALPN.DRAFT_17, origin); + const { client, server, transport } = await session(Ietf.ALPN.DRAFT_18, origin); const broadcastPublication = origin.createBroadcast(Path.from("test")); const broadcast = broadcastPublication.broadcast; @@ -201,7 +201,7 @@ function holdFins(transport: MockTransport): { closing: Promise; release: // the groups behind it are promoted to tie with it. test("ietf keeps a closing group stream ranked until it settles", async () => { const origin = new OriginProducer(); - const { client, server, transport } = await session(Ietf.ALPN.DRAFT_17, origin); + const { client, server, transport } = await session(Ietf.ALPN.DRAFT_18, origin); const fins = holdFins(transport); const broadcastPublication = origin.createBroadcast(Path.from("test")); diff --git a/js/net/src/origin-demand.test.ts b/js/net/src/origin-demand.test.ts index f8a947dd9d9..b9caea824d9 100644 --- a/js/net/src/origin-demand.test.ts +++ b/js/net/src/origin-demand.test.ts @@ -2,8 +2,8 @@ import { expect, spyOn, test } from "bun:test"; import { Producer as Broadcast } from "./broadcast.ts"; import * as Epoch from "./epoch.ts"; import { randomHop } from "./hop.ts"; -import { NativeSession } from "./ietf/adapter.ts"; import { Publisher as IetfPublisher } from "./ietf/publisher.ts"; +import { NativeSession } from "./ietf/session.ts"; import { Subscribe as IetfSubscribe } from "./ietf/subscribe.ts"; import { Version as IetfVersion } from "./ietf/version.ts"; import { hooks } from "./internal.ts"; diff --git a/js/net/src/parked-expiry.test.ts b/js/net/src/parked-expiry.test.ts index 91b66955810..30f4dcd12c7 100644 --- a/js/net/src/parked-expiry.test.ts +++ b/js/net/src/parked-expiry.test.ts @@ -2,8 +2,8 @@ import { expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import { StreamCode } from "./error.ts"; import { randomHop } from "./hop.ts"; -import { NativeSession } from "./ietf/adapter.ts"; import { Publisher as IetfPublisher } from "./ietf/publisher.ts"; +import { NativeSession } from "./ietf/session.ts"; import { Subscribe as IetfSubscribe } from "./ietf/subscribe.ts"; import { ALPN, Version as Ietf } from "./ietf/version.ts"; import { Publisher as LitePublisher } from "./lite/publisher.ts"; diff --git a/js/net/src/request-deadline.test.ts b/js/net/src/request-deadline.test.ts index fdbd50301ed..80a1c19c6ed 100644 --- a/js/net/src/request-deadline.test.ts +++ b/js/net/src/request-deadline.test.ts @@ -2,16 +2,12 @@ import { expect, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import { SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; import { HopSchema } from "./hop.ts"; -import { NativeSession } from "./ietf/adapter.ts"; -import { Publisher } from "./ietf/publisher.ts"; -import { SubscribeNamespace } from "./ietf/subscribe_namespace.ts"; +import { NativeSession } from "./ietf/session.ts"; import { Subscriber as IetfSubscriber } from "./ietf/subscriber.ts"; import { Version as Ietf } from "./ietf/version.ts"; import { Subscriber as LiteSubscriber } from "./lite/subscriber.ts"; import { Version as Lite } from "./lite/version.ts"; -import { Producer as Origin } from "./origin.ts"; import * as Path from "./path.ts"; -import { Stream } from "./stream.ts"; // Zero stream credit can be rejected immediately or queued by a transport that ignores // waitUntilAvailable. Once a slot returns, observe both resets and any unwanted bytes. @@ -51,7 +47,7 @@ function limited(mode: "refuse" | "queue" | "open") { }; } -for (const protocol of ["lite", "ietf-17"] as const) { +for (const protocol of ["lite", "ietf"] as const) { for (const mode of ["refuse", "queue", "open"] as const) { test(`${protocol}: ${mode} has one fatal request deadline`, async () => { const clock = new Manual(); @@ -60,7 +56,7 @@ for (const protocol of ["lite", "ietf-17"] as const) { const subscriber = protocol === "lite" ? new LiteSubscriber(transport.quic, Lite.DRAFT_07, HopSchema.parse(1n)) - : new IetfSubscriber({ session: new NativeSession(transport.quic, Ietf.DRAFT_17, true) }); + : new IetfSubscriber({ session: new NativeSession(transport.quic, Ietf.DRAFT_18, true) }); const broadcast = subscriber.consume(Path.from("room")); const track = broadcast.track("video").subscribe(); try { @@ -100,67 +96,6 @@ for (const protocol of ["lite", "ietf-17"] as const) { } } -for (const legacy of [false, true]) { - for (const mode of ["refuse", "queue", "open"] as const) { - test(`${legacy ? "solicited draft-15" : "unsolicited draft-17"} advertisement: ${mode} is terminal`, async () => { - const clock = new Manual(); - const installed = clock.install(); - const transport = limited(mode); - const version = legacy ? Ietf.DRAFT_15 : Ietf.DRAFT_17; - const origin = new Origin(); - const publisher = new Publisher({ - quic: transport.quic, - session: new NativeSession(transport.quic, version, true), - publish: origin.consume(), - requiresSolicitation: legacy, - }); - const subscription = new Stream({ - readable: new ReadableStream(), - writable: new WritableStream(), - version, - }); - origin.createBroadcast(Path.from("room")).announce(); - const loop = legacy - ? publisher.runSubscribeNamespace( - new SubscribeNamespace({ requestId: 0n, namespace: Path.empty() }), - subscription, - ) - : publisher.runPublishNamespaces(); - try { - await transport.created; - await clock.advance(Nano.fromMilli(Milli(9_999))); - let aborted = false; - void transport.aborted.then(() => { - aborted = true; - }); - expect(aborted).toBe(false); - await clock.advance(Nano.fromMilli(Milli(1))); - if (mode === "queue") transport.release(); - if (mode !== "refuse") { - const reset = await transport.aborted; - await transport.cancelled; - if (mode === "open") expect((reset as Error).message).toContain("request timed out after 10000ms"); - else expect(transport.written).toEqual([]); - } - await clock.advance(Nano.fromMilli(Milli(30_000))); - // An unrelated change wakes reconciliation, but cannot revive the failed offer. - origin.createBroadcast(Path.from("another")).announce(); - await clock.advance(Nano.zero); - expect(transport.options).toEqual([ - { sendOrder: undefined, waitUntilAvailable: false }, - { sendOrder: undefined, waitUntilAvailable: false }, - ]); - } finally { - subscription.close(); - origin.close(); - await loop; - publisher.close(); - installed.close(); - } - }); - } -} - for (const operation of ["info", "fetch"] as const) { for (const mode of ["refuse", "queue", "open"] as const) { test(`lite standalone ${operation}: ${mode} has a fatal deadline`, async () => { @@ -199,7 +134,7 @@ for (const operation of ["info", "fetch"] as const) { } } -for (const protocol of ["lite", "ietf-17"] as const) { +for (const protocol of ["lite", "ietf"] as const) { for (const mode of ["refuse", "queue", "open"] as const) { test(`${protocol} discovery: ${mode} bounds only the create`, async () => { const clock = new Manual(); @@ -208,7 +143,7 @@ for (const protocol of ["lite", "ietf-17"] as const) { const subscriber = protocol === "lite" ? new LiteSubscriber(transport.quic, Lite.DRAFT_07, HopSchema.parse(1n)) - : new IetfSubscriber({ session: new NativeSession(transport.quic, Ietf.DRAFT_17, true) }); + : new IetfSubscriber({ session: new NativeSession(transport.quic, Ietf.DRAFT_18, true) }); const announced = subscriber.announced(); try { await transport.created; @@ -234,7 +169,7 @@ for (const protocol of ["lite", "ietf-17"] as const) { } } -for (const protocol of ["lite", "ietf-17"] as const) { +for (const protocol of ["lite", "ietf"] as const) { test(`${protocol}: credit granted in the deadline task cannot send a late request`, async () => { const clock = new Manual(); const installed = clock.install(); @@ -242,7 +177,7 @@ for (const protocol of ["lite", "ietf-17"] as const) { const subscriber = protocol === "lite" ? new LiteSubscriber(transport.quic, Lite.DRAFT_07, HopSchema.parse(1n)) - : new IetfSubscriber({ session: new NativeSession(transport.quic, Ietf.DRAFT_17, true) }); + : new IetfSubscriber({ session: new NativeSession(transport.quic, Ietf.DRAFT_18, true) }); const broadcast = subscriber.consume(Path.from("room")); const track = broadcast.track("video").subscribe(); try { @@ -262,7 +197,7 @@ for (const protocol of ["lite", "ietf-17"] as const) { }); } -for (const protocol of ["lite", "ietf-17"] as const) { +for (const protocol of ["lite", "ietf"] as const) { test(`${protocol}: a create the closed session fails reports the session`, async () => { const quic = { closed: Promise.resolve({ closeCode: SessionCode.Unauthorized, reason: "bye" }), @@ -274,7 +209,7 @@ for (const protocol of ["lite", "ietf-17"] as const) { const subscriber = protocol === "lite" ? new LiteSubscriber(quic, Lite.DRAFT_07, HopSchema.parse(1n)) - : new IetfSubscriber({ quic, session: new NativeSession(quic, Ietf.DRAFT_17, true) }); + : new IetfSubscriber({ quic, session: new NativeSession(quic, Ietf.DRAFT_18, true) }); const broadcast = subscriber.consume(Path.from("room")); const track = broadcast.track("video").subscribe(); try { diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index ace4019787b..24c7b143c0f 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -5,7 +5,7 @@ import * as Epoch from "./epoch.ts"; import { NotFound, SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; import { type Consumer as GroupConsumer, Producer as GroupProducer } from "./group.ts"; import { Route } from "./hop.ts"; -import type { Session as IetfSession } from "./ietf/adapter.ts"; +import type { Session as IetfSession } from "./ietf/session.ts"; import { Subscriber as IetfSubscriber } from "./ietf/subscriber.ts"; import { hooks, MAX_DATAGRAMS } from "./internal.ts"; import type { Lister } from "./listing.ts"; diff --git a/js/net/src/session-limits.test.ts b/js/net/src/session-limits.test.ts index e2b6a455602..91ff5481c7e 100644 --- a/js/net/src/session-limits.test.ts +++ b/js/net/src/session-limits.test.ts @@ -79,7 +79,7 @@ test.each(["lite", "ietf"] as const)("%s closes on the first advertisement beyon version: IetfVersion.DRAFT_22, client: false, nextRequestId: async () => 0n, - spendRequest: () => {}, + checkRequestId: () => {}, openBi: async () => new Stream({ version, readable, writable }), acceptBi: async () => undefined, close: () => pair.server.close(), diff --git a/js/net/src/stream.test.ts b/js/net/src/stream.test.ts index f8d909911c3..2b4a90b1c01 100644 --- a/js/net/src/stream.test.ts +++ b/js/net/src/stream.test.ts @@ -615,30 +615,6 @@ test("stop mid-stream still cancels with the code", async () => { expect((stopped as { streamErrorCode?: number }).streamErrorCode).toBe(StreamCode.Cancel); }); -test("a numeric stop sends only a code the negotiated draft shares", async () => { - const stop = (version: StreamVersion, code: StreamCode) => { - const cancelled = Promise.withResolvers(); - const reader = new Reader( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([1])); - }, - cancel: cancelled.resolve, - }), - undefined, - version, - ); - reader.stop(code); - return cancelled.promise; - }; - - // 0x4 is GOING_AWAY here, but UNKNOWN_OBJECT_STATUS on draft-16. - const draft16 = await stop(Version.DRAFT_16, StreamCode.GoingAway); - expect((draft16 as { streamErrorCode?: number }).streamErrorCode).toBe(StreamCode.Internal); - const draft18 = await stop(Version.DRAFT_18, StreamCode.GoingAway); - expect((draft18 as { streamErrorCode?: number }).streamErrorCode).toBe(StreamCode.GoingAway); -}); - test("Writer reset still reads a bare number as Internal", async () => { const aborted = Promise.withResolvers(); const writer = new Writer(new WritableStream({ abort: aborted.resolve }), Version.DRAFT_18); @@ -836,7 +812,6 @@ test("open waits for a stream slot instead of rejecting once the peer's limit is // arrived in draft-17, and moq-lite's own 48-63 codes are in no draft at all. for (const [version, tooFarBehind] of [ [QUIC, StreamCode.TooFarBehind], - [Version.DRAFT_14, StreamCode.Internal], [Version.DRAFT_18, StreamCode.TooFarBehind], [Version.DRAFT_22, StreamCode.TooFarBehind], ] as const) { @@ -950,12 +925,11 @@ test("the varint range follows the version: 64 bits on lite-07, 62 on lite-06", await expect(written(Lite.DRAFT_06, (w) => w.u62(2n ** 62n))).rejects.toThrow("62-bits"); }); -test("a lite-07 stream decodes reset codes with the moq-lite registry", async () => { - // 0x4 is GOING_AWAY on moq-lite but a foreign code on draft-17. - const reset = async (version: StreamVersion) => { +test("reset codes the registries share decode alike, and the rest as INTERNAL_ERROR", async () => { + const reset = async (version: StreamVersion, code: number) => { const reader = new Reader( new ReadableStream({ - start: (controller) => controller.error(new Reset(4)), + start: (controller) => controller.error(new Reset(code)), }), undefined, version, @@ -966,17 +940,19 @@ test("a lite-07 stream decodes reset codes with the moq-lite registry", async () ); return (err as StreamError).code; }; - expect(await reset(Lite.DRAFT_07)).toBe(StreamCode.GoingAway); - expect(await reset(Version.DRAFT_17)).toBe(StreamCode.Internal); + // 0x4 is GOING_AWAY in both registries; 0x33 is NOT_FOUND on moq-lite only. + expect(await reset(Lite.DRAFT_07, 4)).toBe(StreamCode.GoingAway); + expect(await reset(Version.DRAFT_18, 4)).toBe(StreamCode.GoingAway); + expect(await reset(Lite.DRAFT_07, 0x33)).toBe(StreamCode.NotFound); + expect(await reset(Version.DRAFT_18, 0x33)).toBe(StreamCode.Internal); }); test("Writer and Reader varint round-trip every size in both formats", async () => { const values = [0n, 63n, 64n, 127n, 128n, 2n ** 14n, 2n ** 21n, 2n ** 28n, 2n ** 30n, 2n ** 32n, 2n ** 35n]; values.push(2n ** 42n, 2n ** 49n, 2n ** 53n, 2n ** 56n, 2n ** 62n - 1n); - for (const version of [Lite.DRAFT_06, Lite.DRAFT_07, Version.DRAFT_16, Version.DRAFT_17, Version.DRAFT_18]) { + for (const version of [Lite.DRAFT_06, Lite.DRAFT_07, Version.DRAFT_18]) { // Only leading-ones varints reach past 62 bits. - const all = - version === Lite.DRAFT_06 || version === Version.DRAFT_16 ? values : [...values, 2n ** 62n, 2n ** 64n - 1n]; + const all = version === Lite.DRAFT_06 ? values : [...values, 2n ** 62n, 2n ** 64n - 1n]; const { stream, written } = createTestWritableStream(); const writer = new Writer(stream, version); for (const value of all) await writer.varint(U64.fromBigInt(value)); @@ -989,18 +965,10 @@ test("Writer and Reader varint round-trip every size in both formats", async () } }); -test("Writer varint refuses a QUIC varint past 62 bits before emitting bytes", async () => { - const { stream, written } = createTestWritableStream(); - const writer = new Writer(stream, Version.DRAFT_16); - await expect(writer.varint(U64.fromBigInt(2n ** 62n))).rejects.toThrow(/larger than 62-bits/); - await expect(writer.varint(U64.MAX)).rejects.toThrow(/larger than 62-bits/); - expect(written).toEqual([]); -}); - test("Reader u53 decodes every size in both formats, one byte per chunk", async () => { const values = [0, 63, 64, 127, 128, 16383, 16384, 2 ** 21, 2 ** 28 - 1, 2 ** 28, 2 ** 30 - 1, 2 ** 30]; values.push(2 ** 35, 2 ** 42, 2 ** 49, Number.MAX_SAFE_INTEGER); - for (const version of [Lite.DRAFT_06, Lite.DRAFT_07, Version.DRAFT_17]) { + for (const version of [Lite.DRAFT_06, Lite.DRAFT_07, Version.DRAFT_18]) { const { stream, written } = createTestWritableStream(); const writer = new Writer(stream, version); for (const value of values) await writer.u53(value); diff --git a/js/net/src/stream.ts b/js/net/src/stream.ts index edd8ca25459..28f0f9af413 100644 --- a/js/net/src/stream.ts +++ b/js/net/src/stream.ts @@ -3,7 +3,6 @@ import { race } from "@moq/signals"; import { fromTransport, StreamCode, toStreamCode, toTransport } from "./error.ts"; import { sharedStreamCode } from "./ietf/error.ts"; import type { IetfVersion } from "./ietf/version.ts"; -import { Version } from "./ietf/version.ts"; import { Version as Lite, type Version as LiteVersion } from "./lite/version.ts"; import { decodeError } from "./util/decode_error.ts"; import { TimeoutError, withTimeout } from "./util/timeout.ts"; @@ -39,9 +38,8 @@ function withCode(reason: unknown, stream: StreamVersion): unknown { // withCode, which reads a number as a non-stream error and sends Internal. function stopReason(reason: unknown, stream: StreamVersion): unknown { if (typeof reason !== "number") return withCode(reason, stream); - const version = asIetf(stream); const code = - version === undefined || sharedStreamCode(reason, version) ? (reason as StreamCode) : StreamCode.Internal; + asIetf(stream) === undefined || sharedStreamCode(reason) ? (reason as StreamCode) : StreamCode.Internal; return toTransport(code, "cancel"); } @@ -107,9 +105,6 @@ export function asIetf(version: StreamVersion): IetfVersion | undefined { */ export function isLeadingOnes(version: StreamVersion): boolean { switch (version) { - case Version.DRAFT_14: - case Version.DRAFT_15: - case Version.DRAFT_16: case Lite.DRAFT_01: case Lite.DRAFT_02: case Lite.DRAFT_03: @@ -685,10 +680,7 @@ export class Cursor { let size: number; if (this.#leadingOnes) { size = peekLeadingOnes(b[o]); - // 1111110x is a 7-byte form. Draft-17 rejects it; draft-18+ allows it per #1595. - if (size === 7 && this.version === Version.DRAFT_17) { - throw new Error("invalid leading-ones varint: 1111110x prefix is reserved on draft-17"); - } + // 1111110x is a 7-byte form, which draft-18+ allows per #1595. this.#ensure(size); this.#offset += size; return readLeadingOnes(b, o, size); diff --git a/js/net/src/subscribe_priority.test.ts b/js/net/src/subscribe_priority.test.ts index c2b4ac207c3..00787e37275 100644 --- a/js/net/src/subscribe_priority.test.ts +++ b/js/net/src/subscribe_priority.test.ts @@ -5,7 +5,6 @@ import { expect, spyOn, test } from "bun:test"; import { Manual } from "@moq/clock"; import { accept, connect, type Established } from "./connection/index.ts"; -import { SessionCode, type Session as SessionError } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; import { SubscribeUpdate } from "./ietf/subscribe.ts"; import * as Lite from "./lite/index.ts"; @@ -24,20 +23,13 @@ const path = Path.from("test"); const LOW = -100; const HIGH = 100; -// Dial a session that consumes what `origin` serves. Draft-14's ALPN is the generic one, so -// the server is told the version instead. +// Dial a session that consumes what `origin` serves. async function session(protocol: string, origin: OriginProducer): Promise<[Established, Established]> { - const draft14 = protocol === Ietf.ALPN.DRAFT_14; - const pair = createMockTransportPair(draft14 ? "" : protocol); + const pair = createMockTransportPair(protocol); lateAcks(pair.client); return Promise.all([ connect({ url, transport: pair.client }), - accept({ - transport: pair.server, - url, - publish: origin.consume(), - version: draft14 ? Ietf.Version.DRAFT_14 : undefined, - }), + accept({ transport: pair.server, url, publish: origin.consume() }), ]); } @@ -171,7 +163,6 @@ test.each(Object.values(Ietf.ALPN))("%s sends no update when the last subscriber // An update that fails to encode is a bug, not a publisher that stopped reading, so it ends the // track with the error rather than leaving it at a stale priority. Nothing reaches the wire. -// Drafts 14 and 15 require the next request ID, so the one the update spent ends the session. test.each(Object.values(Ietf.ALPN))("%s ends the track when an update fails to encode", async (protocol) => { const origin = new OriginProducer(); const broadcastPublication = origin.createBroadcast(path); @@ -197,11 +188,7 @@ test.each(Object.values(Ietf.ALPN))("%s ends the track when an update fails to e expect(video.subscription.peek()?.priority).toBe(LOW); await video.subscription.changed(); } - if (protocol === Ietf.ALPN.DRAFT_14 || protocol === Ietf.ALPN.DRAFT_15) { - expect(((await server.closed) as SessionError | null)?.code).toBe(SessionCode.Internal); - } else { - expect(closed).toBeUndefined(); - } + expect(closed).toBeUndefined(); remote.close(); } finally { encode.mockRestore(); diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts index 1e3d8d3ae48..7883d64dcd2 100644 --- a/js/net/src/tail.test.ts +++ b/js/net/src/tail.test.ts @@ -84,31 +84,28 @@ test.each([Lite.ALPN_05, Lite.ALPN_06])( // moq-transport carries the end in an END_OF_TRACK object, so it survives a track that // declared an end past the groups it produced, and the Stream Count lets the subscriber stop // waiting for streams at once. -test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])( - "%s: END_OF_TRACK carries the declared end", - async (alpn) => { - const { video, reader, close } = await session(alpn); - try { - video.writeFrame(textFrame("0")); - expect(await reader.readString()).toBe("0"); - - video.finishAt({ group: 4, frame: 0 }); - video.writeFrame(textFrame("1")); - video.writeFrame(textFrame("2")); - const ended = performance.now(); - video.finish(); - video.close(); - - expect(await readAll(reader)).toEqual(["1", "2"]); - expect(await reader.closed).toBeNull(); - expect(await reader.finished()).toEqual({ group: 4, frame: 0 }); - // Every counted stream arrived, so nothing waited out the grace. - expect(performance.now() - ended).toBeLessThan(TAIL_GRACE_MS); - } finally { - close(); - } - }, -); +test.each([Ietf.ALPN.DRAFT_22])("%s: END_OF_TRACK carries the declared end", async (alpn) => { + const { video, reader, close } = await session(alpn); + try { + video.writeFrame(textFrame("0")); + expect(await reader.readString()).toBe("0"); + + video.finishAt({ group: 4, frame: 0 }); + video.writeFrame(textFrame("1")); + video.writeFrame(textFrame("2")); + const ended = performance.now(); + video.finish(); + video.close(); + + expect(await readAll(reader)).toEqual(["1", "2"]); + expect(await reader.closed).toBeNull(); + expect(await reader.finished()).toEqual({ group: 4, frame: 0 }); + // Every counted stream arrived, so nothing waited out the grace. + expect(performance.now() - ended).toBeLessThan(TAIL_GRACE_MS); + } finally { + close(); + } +}); describe("Tail", () => { const GRACE = Milli(1000); diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index afcd35bb48a..3579fbf1b8d 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -30,10 +30,7 @@ const WIRES: Wire[] = [ { name: "lite-05", protocol: Lite.ALPN_05, declares: false }, { name: "lite-06", protocol: Lite.ALPN_06, declares: false }, { name: "lite-07-wip", protocol: Lite.ALPN_07_WIP, declares: true }, - { name: "ietf-14", protocol: "", version: Ietf.Version.DRAFT_14, declares: false }, - { name: "ietf-16", protocol: Ietf.ALPN.DRAFT_16, declares: false }, - { name: "ietf-17", protocol: Ietf.ALPN.DRAFT_17, declares: true }, - { name: "ietf-20", protocol: Ietf.ALPN.DRAFT_22, declares: true }, + { name: "ietf-22", protocol: Ietf.ALPN.DRAFT_22, declares: true }, ]; /** Serve one group of `timestamps` on a track with `timescale`, and read it back over `wire`. */ diff --git a/js/net/src/util/deadline.ts b/js/net/src/util/deadline.ts index 7b1f0fdd0b5..68c5f37373a 100644 --- a/js/net/src/util/deadline.ts +++ b/js/net/src/util/deadline.ts @@ -16,17 +16,10 @@ function sessionFailure(err: unknown): boolean { return source === "session" || name === "InvalidStateError"; } -// A stream the deadline resets, with the request written on it and the message withdrawing it. -interface Recorded { - stream: Stream; - written?: Promise; - withdraw?: () => Promise; -} - /** One fatal deadline for a request, including every stream its setup creates. @internal */ export class Deadline { #opening = false; - #streams: Recorded[] = []; + #streams: Stream[] = []; #failure?: Error; /** Record a create so deadline failures reset its stream even if it opens late. */ @@ -45,27 +38,15 @@ export class Deadline { stream.abort(this.#failure); throw this.#failure; } - this.#streams.push({ stream }); + this.#streams.push(stream); return stream; } - /** - * Write the request on an opened stream, and what withdraws it if the deadline fails after. - * - * A draft-14-16 request rides the control stream, where a reset tells the peer nothing, so - * one that reached the peer is withdrawn by message first. Without `withdraw` the reset alone ends it. - */ - async send(stream: Stream, write: () => Promise, withdraw?: () => Promise): Promise { + /** Write the request on an opened stream, which a deadline failure after resets. */ + async send(stream: Stream, write: () => Promise): Promise { if (this.#failure) throw this.#failure; - const recorded = this.#streams.find((recorded) => recorded.stream === stream); - if (!recorded) throw new Error("request sent on a stream the deadline did not open"); - const sending = write(); - recorded.written = sending.then( - () => true, - () => false, - ); - recorded.withdraw = withdraw; - await sending; + if (!this.#streams.includes(stream)) throw new Error("request sent on a stream the deadline did not open"); + await write(); } /** Wait for the first answer under the request's single deadline. */ @@ -84,18 +65,11 @@ export class Deadline { : controlTimeout(cause) : error(cause); this.#failure = failure; - // Other failures keep the caller's cancellation ordering, including legacy UNSUBSCRIBE. + // Other failures keep the caller's cancellation ordering. if (cause instanceof TimeoutError || cause instanceof NoStreamCredit) { - await Promise.all(this.#streams.map((recorded) => Deadline.#reset(recorded, failure))); + for (const stream of this.#streams) stream.abort(failure); } throw failure; } } - - static async #reset({ stream, written, withdraw }: Recorded, failure: Error) { - // The withdrawal names a request the peer must already have, so a write still in - // flight finishes first, and one that never landed is not withdrawn. - if (withdraw && written && (await written)) await withdraw().catch(() => undefined); - stream.abort(failure); - } } diff --git a/js/publish/README.md b/js/publish/README.md index a6c9b182719..3462dd54d3a 100644 --- a/js/publish/README.md +++ b/js/publish/README.md @@ -65,7 +65,7 @@ The simplest way to publish a stream: |-------------|---------|----------|---------------------------------| | `url` | string | required | Relay server URL | | `name` | string | required | Broadcast name | -| `versions` | string | stable versions | Comma-separated protocol versions to offer, most preferred first, like `moq-transport-14,moq-lite-06` | +| `versions` | string | stable versions | Comma-separated protocol versions to offer, most preferred first, like `moq-transport-18,moq-lite-06` | | `source` | string | — | `"camera"`, `"screen"`, `"file"` | | `muted` | boolean | false | Mute audio capture | | `invisible` | boolean | false | Disable video capture | diff --git a/js/qmux/src/session.ts b/js/qmux/src/session.ts index 7ae4748a458..7bb85b5fdc7 100644 --- a/js/qmux/src/session.ts +++ b/js/qmux/src/session.ts @@ -295,7 +295,7 @@ export interface SessionOptions extends WebTransportOptions { * {@link SessionOptions.versions} and emits the prefixed wire form * `{version}.{alpn}` (e.g. `"qmux-01.moq-lite-04"`). * - An explicit pair already prefixed with a QMux version - * (e.g. `"qmux-00.moq-transport-17"`). Advertised as-is. + * (e.g. `"qmux-00.moq-transport-18"`). Advertised as-is. * * Bare entries that have no matching `versions` entry throw at * construction time. @@ -473,8 +473,8 @@ export function resolveSubprotocols( * @example * ```ts * const protocol = selectSubprotocol(req.headers.get("sec-websocket-protocol"), { - * protocols: ["moq-transport-17"], - * versions: { "moq-transport-17": null }, + * protocols: ["moq-transport-18"], + * versions: { "moq-transport-18": null }, * }); * if (!protocol) return new Response("no supported protocol", { status: 400 }); * const { socket, response } = Deno.upgradeWebSocket(req, { protocol }); @@ -639,7 +639,7 @@ export default class Session implements WebTransport { * application-level ALPNs in `options.protocols` plus a `versions` * map saying which QMux wire-format version each bare ALPN rides on. The * wire form `{qmux-VV}.{alpn}` is built automatically; entries already in - * pair form (e.g. `"qmux-00.moq-transport-17"`) pass through unchanged. + * pair form (e.g. `"qmux-00.moq-transport-18"`) pass through unchanged. * * Once the handshake completes, the QMux wire-format version is derived * from the negotiated `Sec-WebSocket-Protocol`. `.protocol` exposes the diff --git a/js/qmux/src/subprotocol.test.ts b/js/qmux/src/subprotocol.test.ts index a908371e198..8edd6c9335c 100644 --- a/js/qmux/src/subprotocol.test.ts +++ b/js/qmux/src/subprotocol.test.ts @@ -42,7 +42,7 @@ describe("resolveSubprotocols", () => { }); test("already-prefixed pairs pass through without a versions entry", () => { - expect(resolveSubprotocols(["qmux-00.moq-transport-17"], {}, true)).toEqual(["qmux-00.moq-transport-17"]); + expect(resolveSubprotocols(["qmux-00.moq-transport-18"], {}, true)).toEqual(["qmux-00.moq-transport-18"]); }); test("unsupported mapped versions are refused before negotiating", () => { diff --git a/js/watch/README.md b/js/watch/README.md index 2e77222087b..95700ad266c 100644 --- a/js/watch/README.md +++ b/js/watch/README.md @@ -65,7 +65,7 @@ The simplest way to watch a stream: |------------------|----------------------------|---------------|------------------------------------------| | `url` | string | required | Relay server URL | | `name` | string | required | Broadcast name/path | -| `versions` | comma-separated list | stable versions | Protocol versions to offer, most preferred first, like `moq-transport-14,moq-lite-06` | +| `versions` | comma-separated list | stable versions | Protocol versions to offer, most preferred first, like `moq-transport-18,moq-lite-06` | | `paused` | boolean | false | Pause playback | | `muted` | boolean | false | Mute audio without changing volume | | `visible` | never, distance, or always | `20%` | When to download video (see below) | diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 4bfa7afd239..ad916db34ef 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -159,7 +159,7 @@ client = moq.Client( - `.stats() → ConnectionStats`. Snapshot RTT, bandwidth estimates, and byte/packet counters. - `.state()` and `await .state_changed(after)`. Read one lifecycle snapshot and wait with its revision; first-dial failures and reconnects share the same session. - `.allocator() → Allocator`. Divide the send estimate between encoders and app-owned tracks. - - `.grants() → GrantConsumer`. On `moq-lite-07-wip` and on moq-transport draft-17+ with the MoQ Auth extension, `.grant()` reads the peer's `Grant` and `async for grant in session.grants()` follows it. + - `.grants() → GrantConsumer`. On `moq-lite-07-wip` and on moq-transport draft-18+ with the MoQ Auth extension, `.grant()` reads the peer's `Grant` and `async for grant in session.grants()` follows it. - `.tokens() → Tokens | None`. A client session's credentials: `.set([...])` replaces them without reconnecting and `await .answered()` raises the relay's refusal. ### Publishing diff --git a/quest/m0/test-flakes-2/README.md b/quest/m0/test-flakes-2/README.md index fd53b12013c..7fa84ab1c0f 100644 --- a/quest/m0/test-flakes-2/README.md +++ b/quest/m0/test-flakes-2/README.md @@ -24,7 +24,7 @@ Rules every child keeps: clock, drive the timers with a `moq_clock::Manual` clock or the sim (every crate's timers run on `moq-clock`), or move the test off real sockets. - A mocked clock still flakes when the code jitters its timers with `rand` - (the draft-16 cancel race was a retry sweep jittered past a fixed settle + (one cancel race was a retry sweep jittered past a fixed settle budget). Wait out the jitter's whole window, not a count of small steps. This README's own work, after the children: run `just check --all` several diff --git a/quest/m1/compress/README.md b/quest/m1/compress/README.md index 63bdb35c992..6233c03d579 100644 --- a/quest/m1/compress/README.md +++ b/quest/m1/compress/README.md @@ -46,9 +46,9 @@ Every interview prompt and the selected answer (✅) are in the planning PR. built on first demand, shared across readers, billed to the cache pool, and dropped with its group. A thousand old-lite viewers cost one inflate. - **Signaling:** lite-07 TrackInfo carries the codec id and the frame header the - size; IETF draft-17+ carries them only + size; IETF carries them only after both peers advertise a setup parameter, because unknown track - properties are discarded. Lite-01..06, IETF draft-14/15/16, and IETF peers without the + properties are discarded. Lite-01..06 and IETF peers without the parameter get the inflated copy, with no flag. A sealed compression declaration is preserved, so delivery is refused when plain fallback would remove it. @@ -74,7 +74,7 @@ Every interview prompt and the selected answer (✅) are in the planning PR. This README owns the end-to-end proof and the guide once every child lands: - A Rust publisher's compressed `catalog.json` reaches a JS player over - lite-07 compressed, and a lite-06 and an IETF draft-14 subscriber through + lite-07 compressed, and a lite-06 and a plain IETF subscriber through moq-relay inflated, from one inflate on the relay. - A short guide under `doc/` on when to set `compress`, what each protocol version receives, and the cost on the relay. diff --git a/quest/m1/cut-audit/README.md b/quest/m1/cut-audit/README.md index 1ebd950c120..0542968a292 100644 --- a/quest/m1/cut-audit/README.md +++ b/quest/m1/cut-audit/README.md @@ -48,7 +48,7 @@ redesign evidence went into the quests that already own the mechanism: [subscribe ranges](/quest/m1/subscribe-ranges/README.md), [drop set](/quest/m1/drop-set.md), [per-origin routes](/quest/m1/cluster-routing/routes.md), -[drafts 14-17 removal](/quest/m1/ietf-drop/d14-17.md), and +drafts 14-17 removal (since landed), and [buffered playback](/quest/m1/buffered-ascending.md). The audit recommends leaving JS-only redesigns of `js/net` to [rs2ts](/quest/m2/rs2ts/README.md). diff --git a/quest/m1/ietf-drop/README.md b/quest/m1/ietf-drop/README.md index 2939bd3e920..0871eaebb99 100644 --- a/quest/m1/ietf-drop/README.md +++ b/quest/m1/ietf-drop/README.md @@ -1,4 +1,4 @@ -# moq-transport 18 and 22 only +# [XS] moq-transport 18 and 22 only ## Goal @@ -53,18 +53,15 @@ Why 14 to 16 cost the most, measured on `main` at `28bd16452d`: m1, ranked beside the API audit, because the drop deletes public API (`session::Limits::requests` and the relay's request limit) and belongs with -the other breaks before the cut. The two children are independent; land -either first. +the other breaks before the cut. Drafts 14 to 17 +([moq-dev/moq#6753](https://github.com/moq-dev/moq/pull/6753)) and 19 to 21 +([moq-dev/moq#6754](https://github.com/moq-dev/moq/pull/6754)) are gone. -This README's own work, once both children merge: open a PR on +This README's remaining work: open a PR on `englishm/moq-interop-runner` narrowing the `moq-dev-rs` and `moq-dev-js` entries in `implementations.json` to drafts 18 and 22. The maintainer approved posting there on 2026-10-24. -## Required - -- [Drop moq-transport 14-17](/quest/m1/ietf-drop/d14-17.md) - the legacy control-stream adapter, legacy SETUP negotiation, the request window and its public limit, and the draft-17 varint family are gone in Rust and JS - ## Related - [Interop coverage](/quest/m1/interop-coverage/README.md) - new cross-implementation cases target drafts 18 and 22 only diff --git a/quest/m1/ietf-drop/d14-17.md b/quest/m1/ietf-drop/d14-17.md deleted file mode 100644 index 36f8166ca23..00000000000 --- a/quest/m1/ietf-drop/d14-17.md +++ /dev/null @@ -1,109 +0,0 @@ -# [L] Drop moq-transport 14-17 - -## Goal - -`moq-net` and `@moq/net` no longer offer, accept, or contain code for -moq-transport drafts 14, 15, 16, or 17. A peer that offers only those -drafts fails negotiation loudly. `session::Limits::requests` and the relay's -`limits.*.requests` setting are gone. - -## Plan - -Decided 2026-10-24 with the maintainer; see the -[epic](/quest/m1/ietf-drop/README.md) for which peers this loses and why. -Draft 17 goes with 14-16 because it shares the "14-17" arms -(`SubscribeNamespaceLegacy` and others in `ietf/subscriber.rs` and -`ietf/publisher.rs`), so dropping the four together removes those arms -whole. Estimated deletion: about 2,950 production lines in Rust and 1,750 -in JS, plus their tests. - -Rust (`rs/moq-net`): - -- `ietf::Version` loses `Draft14`..`Draft17`; `version.rs` loses the - `moq-00` and `moqt-15`/`16`/`17` ALPNs and codes. `NEGOTIATED` becomes - `[Lite02, Lite01]`. -- `setup.rs` keeps the codec for `LiteLegacy` only; the `Draft14` - versions-list and `Draft15Plus` paths go. -- Delete `ietf/adapter.rs` and the `ControlStreamAdapter` session path in - `ietf/session.rs`, the request window in `ietf/control.rs`, request IDs - carried in responses, legacy datagrams, absolute parameter keys - (`ietf/parameters.rs`), and the "no Track Properties" branches in - `ietf/properties.rs`. -- Draft 17's own pieces go: the varint family that forbids 7-byte values - (`coding/varint.rs`, `ietf/namespace.rs`), `required_request_id_delta`, and - its `request_stream.rs` branch. -- Collapse every gate that now has one survivor. Fuzz targets - (`fuzz.rs`) drop the four drafts. - -JS (`js/net`): - -- `ietf/version.ts` loses `DRAFT_07` and `DRAFT_14`..`DRAFT_17`; delete - `ControlStreamAdapter` (`ietf/adapter.ts`) and the gated arms. -- SETUP negotiation accepts only lite-01/02. Today `acceptNegotiated` - (`connection/accept.ts`) and `connect.ts` take any value in - `Ietf.Version`, so a peer can select the unsupported 0xff000007 or a - draft that Rust only negotiates by ALPN. Add a test that both sides refuse - an IETF version over SETUP. The SETUP codec's draft-14 encoding that lite - uses stays, named for its role. - -Public API (breaking, `!`): - -- `session::Limits::requests` and the relay's `--limits-client-requests` - and `limits.*.requests` are removed (`rs/moq-relay/src/limits.rs`). They - only sized the 14-16 request window. Migration: delete the setting; an old - config fails loudly instead of being ignored. - Removing it also resizes the draft-18+ request-ID history: `Spent` - (`rs/moq-net/src/ietf/control.rs:200`) keeps every skipped peer request - ID as a run and closes with INTERNAL_ERROR past `with_history(limits.window())` - (`ietf/session.rs:412`, `window() = 2*(announces+requests)`), a constant - whose only effect is a refusal (found by the 2026-10-24 pre-cut audit, - new on main). Decide it here. Request streams can arrive out of - allocation order (`modern_requests_reject_reuse_and_wrong_parity` accepts - 4, 0, then 2), so a plain high-water mark that treats lower IDs as spent - would close legitimate sessions. The real choice is between keeping reuse - detection, with `Spent`'s memory bounded by design rather than a refusal - constant, or dropping reuse detection below the high-water mark, argued - on behavior: on draft 18+ each request owns its stream, and the only state - keyed by request ID is `Publisher::joins`, which a reuse confuses only for - the reusing peer's own joining FETCHes. Either choice keeps GOAWAY's - cutoff: `Control::goaway` names the smallest unprocessed ID from the - missing runs (`goaway_freezes_request_admission_at_current_progress` - accepts 0 and 4 and expects 2), so the lowest gap must still be tracked, - or a coarser cutoff argued on behavior, and that regression kept. -- `--version`/`--connect-version` stop accepting `moq-transport-14`..`17`. - The CLI derives its names from `Version::names()`, so only examples change. -- Wire: four ALPNs and SETUP-negotiated draft-14 are no longer offered. - -Tests and CI: - -- The PR-gate interop runs drafts 18 and 22 instead of 14 and 22 - (`.github/workflows/interop.yml`). Nightly (`test/interop/interop.sh`) drops - 14 and 16, and `test/interop/interop.toml` drops every entry for 14 to 17, - since the harness accepts any version listed there. -- Moxygen moves to draft 18: `test/smoke/moxygen.sh` (rewrite its pin - comment) and `test/ietf-conformance/run.sh`. Cloudflare smoke targets - draft 18. -- Fix the fixtures that use these drafts, such as - `rs/moq-relay/tests/integration/cluster_unknown.rs` and - `rs/moq-cli/src/publish.rs`. - -Docs: "draft-17 and later" becomes "draft-18 and later" for Auth, Flate, and -Epoch in `drafts/draft-lcurley-moq-{auth,flate,epoch}.md` (run -`just drafts check`), `doc/draft/`, `doc/bin/relay/auth.md`, -`doc/bin/obs.md`, every `doc/lib/*/index.md`, and `py/moq-rs/README.md`. -`doc/setup/redundancy.md` pins draft 18 as the minimal epoch draft. Fix the -`moq-transport-14` example strings in `doc/lib/js/{watch,publish}.md` and -`js/{watch,publish}/README.md`, the request limit in -`doc/bin/relay/config.md`, and the supported list in -`doc/concept/standard.md`. The moq-lite draft's informational comparisons -with 14-18 stay. - -Quests: rewrite each quest that `rg -l -i 'draft[- ]?1[4-7]|d1[4-7]\b|14-1[67]' quest` -lists to what remains. For example, the Moxygen smoke quest checks its -filter-type bug at draft 18, the d17 FETCH exception in pipelined requests -goes, the 14/15 and 17 families in the passthrough epic go, and the d14 -audit findings go. - -moq-transport compatibility between our own released and current builds is -out of scope, since they prefer moq-lite, so a released client pinned to -these drafts against a new relay is not a regression to guard. diff --git a/quest/m1/ietf-fill-timeout.md b/quest/m1/ietf-fill-timeout.md index e2a42cd4760..530dd160d2b 100644 --- a/quest/m1/ietf-fill-timeout.md +++ b/quest/m1/ietf-fill-timeout.md @@ -4,8 +4,8 @@ Rust and JS use IETF FILL_TIMEOUT to bound waiting for missing FETCH objects on drafts 18 and 22, and return the draft's timeout-gap disposition -without hanging or discarding objects that are available. Drafts 14-17 -keep their existing wire; local FETCH age enforcement works on all versions. +without hanging or discarding objects that are available. Local FETCH age +enforcement works on all versions. ## Plan diff --git a/quest/m1/ietf-request-cancel.md b/quest/m1/ietf-request-cancel.md index d7dbdc1c801..07d95043c8b 100644 --- a/quest/m1/ietf-request-cancel.md +++ b/quest/m1/ietf-request-cancel.md @@ -19,17 +19,11 @@ lazy discovery that rewrote these writes. What remains is the awaited first encode under send-window pressure: watch for cancellation there and reset the partially written request stream. -Decided 2026-10-25 (maintainer): drop support for the older drafts rather -than handle them here. Drafts 14 and 15 shared one ordered control stream, so -a partial message could not be abandoned; once -[Drop moq-transport 14-17](/quest/m1/ietf-drop/d14-17.md) lands, every -remaining draft gives each request its own stream, and only the reset path -is needed. +Drafts 14 and 15 shared one ordered control stream, so a partial message +could not be abandoned; every remaining draft gives each request its own +stream, so only the reset path is needed. Test with a peer that never grants send credit, on drafts 18 and 22. Public API: none. Wire: none. -## Required - -- [Drop moq-transport 14-17](/quest/m1/ietf-drop/d14-17.md) - leaves only per-request streams, so a cancelled request is always a stream reset diff --git a/quest/m1/js-fetch.md b/quest/m1/js-fetch.md index f762ea1ab5c..86781d9cf5a 100644 --- a/quest/m1/js-fetch.md +++ b/quest/m1/js-fetch.md @@ -39,7 +39,7 @@ SUBSCRIBE_OK's Largest to `{G, 0}`. The two parts below only work together; [moq-dev/moq#6407](https://github.com/moq-dev/moq/pull/6407) made a refused joining FETCH cost the Rust subscriber only that group's head. -1. **Honor Largest Object before draft 22.** `subscribeRange` serves the +1. **Honor Largest Object on draft 18.** `subscribeRange` serves the `nextObject` filter from `edge.next`, as draft 22 already does, and SUBSCRIBE_OK reports the true Largest on every draft. Otherwise the FETCH's head duplicates the subscription's first objects, and the Rust @@ -57,15 +57,13 @@ joining FETCH cost the Rust subscriber only that group's head. FETCH_OK. Then it writes FETCH_OK (end `{G, k + 1}`, not end of track) and a fetch stream named by the FETCH's own request ID, reusing `#writeFillGroup`. It is not one of the subscription's counted streams. - Codec work: `Fetch` keeps the joining fields; a `FetchOk` encoder for - drafts 14 to 18 (request ID through 16, GROUP_ORDER on 14, empty - parameters, Track Properties from 15); `FetchFrame` writes draft-14's - explicit object layout and the status after an empty payload on 14 and 15. + Codec work: `Fetch` keeps the joining fields, and a `FetchOk` encoder for + draft 18 writes empty parameters and Track Properties. Joining tests: unit tests per draft for each refusal and for a served head (bytes the Rust subscriber stitches), and the tail cells `just test interop --tail --publishers js-native-node,js-native-bun --subscribers rust --versions ietf` -plus the browser publisher on drafts before 22, except a track that finishes +plus the browser publisher on draft 18, except a track that finishes right after SUBSCRIBE_OK, which the m2 quest covers. Verify with an in-memory application responder: a browser publisher serves a diff --git a/quest/m1/smoke-moxygen.md b/quest/m1/smoke-moxygen.md index 270a8a35314..5a18b3080db 100644 --- a/quest/m1/smoke-moxygen.md +++ b/quest/m1/smoke-moxygen.md @@ -3,7 +3,7 @@ ## Goal Waiting on upstream Moxygen. test/smoke's Moxygen lane passes all six -cases through moq-relay on WebTransport and raw QUIC at draft-16, and its +cases through moq-relay on WebTransport and raw QUIC at draft-18, and its `continue-on-error` exception is removed. Check by running `just test smoke moxygen`, or by reading the latest @@ -14,13 +14,13 @@ is gone. The failure is Moxygen's, not moq-relay's. The interop client builds every SUBSCRIBE with `SubscribeRequest::make`, whose default `locType` is Moxygen's -`LocationType::LargestGroup` (0xFA). Draft-16 section 5.1.2 registers Filter +`LocationType::LargestGroup` (0xFA). The draft registers Filter Types 0x1-0x4 only and says an endpoint receiving any other "MUST close the session with PROTOCOL_VIOLATION", which moq-relay does. The reduced -reproducer is the draft-16 SUBSCRIBE body -`00 01 0b "nonexistent" 0d "no-such-track" 01 21 02 40 fa`: one +reproducer is the draft-18 SUBSCRIBE body +`00 01 0b "nonexistent" 0d "no-such-track" 01 21 02 80 fa`: one SUBSCRIPTION_FILTER parameter whose Filter Type varint is 250. -`test_subscribe_v16_rejects_unregistered_filter_type` in +`test_subscribe_rejects_unregistered_filter_type` in `rs/moq-net/src/ietf/subscribe.rs` pins that refusal. `subscribe-error` and `subscribe-before-announce` report `ok` only because diff --git a/quest/m1/subscribe-ranges/lite.md b/quest/m1/subscribe-ranges/lite.md index 1827eb7259a..13956fd7d33 100644 --- a/quest/m1/subscribe-ranges/lite.md +++ b/quest/m1/subscribe-ranges/lite.md @@ -60,7 +60,7 @@ first. moq-transport's relative offsets differ by one. Draft-22's relative filter counts back from the next group: `Filter::Relative(0)` is the next group and `Relative(1)` the current one, so an open-ended `Relative(n)..` maps to -`Filter::Relative(n + 1)`, saturating. Drafts 14 to 18 only encode a relative +`Filter::Relative(n + 1)`, saturating. Draft 18 only encodes a relative 0 (NEXT_GROUP), so there it is SUBSCRIBE plus a relative joining FETCH n groups back, as the live join already does. A range with a bounded end resolves both bounds against the Largest Object and goes upstream as an diff --git a/quest/m1/time-addressing/ietf.md b/quest/m1/time-addressing/ietf.md index 601ecddd5dd..fbfb7d8afde 100644 --- a/quest/m1/time-addressing/ietf.md +++ b/quest/m1/time-addressing/ietf.md @@ -18,11 +18,10 @@ Decided 2026-10-25 (maintainer), from the spike: object's PTS rides in TIMESTAMP (0x10), with TIMESCALE (0x08) on the track, the MOQ Properties shared with draft-ietf-moq-loc, so it stays LOC compatible. -- GROUP_END maps to END_OF_GROUP. Only draft-14 lets a status object carry - properties, so only there does it carry the end in its TIMESTAMP; later - drafts refuse them (`check_status` in `rs/moq-net/src/ietf/object.rs`), so - their END_OF_GROUP stays bare and the end needs a negotiated extension or - is lost, as the extension rule below allows. +- GROUP_END maps to END_OF_GROUP. The supported drafts refuse properties on + a status object (`check_status` in `rs/moq-net/src/ietf/object.rs`), so + END_OF_GROUP stays bare and the end needs a negotiated extension or is + lost, as the extension rule below allows. - Rejected: Object ID as a PTS delta, because B-frames run backwards in decode order and open-GOP leading frames present before the group's start. - The properties are optional on both sides: moq-transport may carry them as diff --git a/quest/m2/audit-interop.md b/quest/m2/audit-interop.md index 2bc4138bf34..c79e21a3959 100644 --- a/quest/m2/audit-interop.md +++ b/quest/m2/audit-interop.md @@ -64,14 +64,14 @@ each turned green: WebTransport on that port; a raw-QUIC-only relay stays unsupported, since JS has no raw QUIC. The JS client's red5 WebTransport cells still time out connecting, which m0's one-write control messages target. -- `subscribe-namespace-lifecycle`: released JS withdraws a draft-17+ +- `subscribe-namespace-lifecycle`: released JS withdraws a draft-18+ PUBLISH_NAMESPACE with FIN, which six relays ignore, so it waits for NAMESPACE_DONE. `main` resets the request ([moq-dev/moq#5483](https://github.com/moq-dev/moq/pull/5483)) and passes against Cloudflare draft-18; [moq-dev/moq#6518](https://github.com/moq-dev/moq/pull/6518) backports it to `release`. Released Rust never sees the NAMESPACE itself from quic-zig - or moxygen (docker, and draft-14 over raw QUIC); the client built on + or moxygen (docker); the client built on `main` passes against both, so it gets no backport: the runner client bump fixes it. - Our relay through third-party clients: `rendezvous-timeout` 0x10 @@ -99,9 +99,6 @@ Still red from `main`, ours: end, while moq-dev-rs passes. Not yet traced. - Every `js-web` publisher cell behind a joining FETCH (imquic, MOQtail draft-22, aiomoqt, red5) waits on [JavaScript FETCH](/quest/m1/js-fetch.md). - moxygen draft-14 over raw QUIC discovered nothing, since the CLI asked - draft-14 for no namespace; it passed live once Rust asked for the watched - prefixes ([moq-dev/moq#6530](https://github.com/moq-dev/moq/pull/6530)). Theirs, reported or waiting on others: @@ -122,9 +119,7 @@ Theirs, reported or waiting on others: [JS subgroup data through imquic](/quest/m1/interop-coverage/js-imquic-objects.md). [Eyevinn/moqlivemock#157](https://github.com/Eyevinn/moqlivemock/issues/157) is fixed and green. -- Drafted in this run, posted only if the maintainer approved: Cloudflare's - draft-16 relay forwards END_OF_TRACK as a Normal empty object, so the Rust - subscriber reads an extra object (3, 0); MOQtail draft-18 never forwards +- Drafted in this run, posted only if the maintainer approved: MOQtail draft-18 never forwards NAMESPACE for a later matching PUBLISH_NAMESPACE; moq-go dials only the IPv6 address of `cdn.moq.dev` and needs an explicit port in an `https://` URL; and the runner lists moqxr as not run for lack of a published image. @@ -132,14 +127,14 @@ Theirs, reported or waiting on others: - Not reported, lower value: red5 subscribes the published prefix instead of the requested tuple namespace (no public repo); moqlivemock's relay refuses a SUBSCRIBE under an announced prefix; libquicr answers - SUBSCRIBE_OK for an unpublished namespace; xquic ignores our draft-14 - SUBSCRIBE_ERROR, sends `moq-test/interop` as one tuple field, and its - WebTransport never reaches our relay. + SUBSCRIBE_OK for an unpublished namespace; xquic sends `moq-test/interop` + as one tuple field, and its WebTransport never reaches our relay. Unsupported or down, not worth rechecking unless something changes: PUBLISH and SUBSCRIBE_TRACKS cases (`publish-track-*`, `namespace-discovery`, Cloudflare's PUBLISH cases), relays without namespace discovery, which -moq-net needs before a SUBSCRIBE (Cloudflare draft-14, xquic-draft-18), +moq-net needs before a SUBSCRIBE (xquic-draft-18), peers speaking only +moq-transport drafts 14 to 17 (Cloudflare's draft-14 and draft-16 relays), quiche-moq (connect idle timeout), xquic's docker relay (binds loopback), and moqtopus and xquic-draft-18 over WebTransport (raw QUIC only). diff --git a/quest/m2/ietf-finish.md b/quest/m2/ietf-finish.md index c7a469a609d..bdd9e7f072c 100644 --- a/quest/m2/ietf-finish.md +++ b/quest/m2/ietf-finish.md @@ -15,7 +15,7 @@ skip in `run_finishes` and its README note. Observed on 2026-10-22 with `just test interop --finish --all --versions ietf` (lite passes 45/45): -- Drafts 14 to 18: late readers see the finished catalog end empty, a short +- Draft 18: late readers see the finished catalog end empty, a short tail, or a refused joining FETCH (`publish done: internal error`). This is the pre-draft-22 join after a finish, which [join after a quick finish](/quest/m2/ietf-join-after-finish.md) fixes; rerun @@ -33,4 +33,4 @@ its draft. ## Required -- [Join after a quick finish](/quest/m2/ietf-join-after-finish.md) - the pre-draft-22 half of this, without which drafts 14 to 18 cannot pass +- [Join after a quick finish](/quest/m2/ietf-join-after-finish.md) - the pre-draft-22 half of this, without which draft 18 cannot pass diff --git a/quest/m2/ietf-join-after-finish.md b/quest/m2/ietf-join-after-finish.md index 50e092bb445..f437f5da051 100644 --- a/quest/m2/ietf-join-after-finish.md +++ b/quest/m2/ietf-join-after-finish.md @@ -2,7 +2,7 @@ ## Goal -On drafts 14 to 18, a joining FETCH that reaches our publisher after its +On draft 18, a joining FETCH that reaches our publisher after its subscription finished cleanly is still answered from the subscription's saved Largest group. A track that ends within about one RTT of SUBSCRIBE_OK then finishes cleanly at the subscriber, head included, instead of losing @@ -29,7 +29,7 @@ subscription, so the subscriber loses the Largest group. Short or already-finished tracks hit this often. It flakes the pinned-pass interop `tail` cells with a Rust publisher, about -one run in six on draft 14 (seen 2026-10-22 while landing +one run in six (seen 2026-10-22 while landing [moq-dev/moq#6407](https://github.com/moq-dev/moq/pull/6407)): the tail test finishes its track before SUBSCRIBE_OK, so PUBLISH_DONE follows within milliseconds. The relay's settle grace (about a second) ends long before the @@ -43,22 +43,15 @@ and other publishers may refuse the same way. A finished entry holds a `track::Consumer`, which keeps the track's cache alive, so retaining it costs memory a peer controls. Candidate bounds: -- **The request.** Drafts 17 and 18 give each SUBSCRIBE its own request - stream; keep the entry until the subscriber closes its side, with our - subscriber holding it open until its joining FETCH resolves. Drafts 14 to 16 - share the control stream and have no such signal. -- **The request window.** On drafts 14 to 16 a finished entry keeps its - SUBSCRIBE's request permit, so it counts against the window that - `session::Limits` sizes, released when its FETCH takes it, the peer - cancels, or a new request needs the room (oldest finished entry first). A - peer that never sends the FETCH costs no more than an open subscription. - There is no per-session subscription limit to charge instead: stream credit - bounds subscriptions on later drafts. +- **The request.** Draft 18 gives each SUBSCRIBE its own request stream; + keep the entry until the subscriber closes its side, with our subscriber + holding it open until its joining FETCH resolves. A peer that never sends + the FETCH costs no more than an open subscription, which stream credit + already bounds. - **The session alone.** Grows with every subscription a long-lived relay session ever makes, so probably not bounded enough. -Recommendation: the request close where the draft has one, and the request -window on drafts 14 to 16. Keep only entries that can be answered (`Joined::Group`); an +Recommendation: the request close. Keep only entries that can be answered (`Joined::Group`); an unsubscribed or refused subscription still drops its entry at once. The entry must not outlive what authorized the subscription: when its request token's grant expires or is revoked, or the session grant no longer covers @@ -67,16 +60,13 @@ evicted from the cache meanwhile stays a loud refusal. The existing wait in `joined()` bounds a FETCH whose subscription has not arrived yet; do not stretch it to cover this. -Tests, on mocked time with no sleeps, for each of drafts 14 to 18: +Tests, on mocked time with no sleeps, on draft 18: - A track that finishes right after SUBSCRIBE_OK, with the joining FETCH delivered an RTT after PUBLISH_DONE: the FETCH returns the Largest group's head and the subscriber's track finishes cleanly with it. Today the FETCH is refused and the group is lost. -- More finished subscriptions than the limit with no FETCH: retained entries - stay at the limit, the oldest are dropped, and their late FETCHes are - refused. New subscriptions are still admitted. -- On drafts 17 and 18, closing the subscription's request drops its entry. +- Closing the subscription's request drops its entry. - A request token that expires (by mocked time) or is revoked after the finish drops the entry, and the late FETCH is refused. - An unsubscribed subscription's FETCH is refused, and session close frees diff --git a/quest/m2/ietf-js-joining-fetch.md b/quest/m2/ietf-js-joining-fetch.md index 7b2a5c3bb42..da1ceabfd29 100644 --- a/quest/m2/ietf-js-joining-fetch.md +++ b/quest/m2/ietf-js-joining-fetch.md @@ -2,7 +2,7 @@ ## Goal -Before draft 22, the JS publisher answers a joining FETCH that arrives after +On draft 18, the JS publisher answers a joining FETCH that arrives after its subscription finished cleanly, from the subscription's saved Largest group, as the Rust publisher will. The `tail` interop cells with a native JS or browser publisher pass on a track that finishes right after diff --git a/quest/m2/pipeline-requests/README.md b/quest/m2/pipeline-requests/README.md index c0b38e6c6f1..ff7f9888c83 100644 --- a/quest/m2/pipeline-requests/README.md +++ b/quest/m2/pipeline-requests/README.md @@ -5,9 +5,8 @@ A track request never waits a round trip for the track's info before the request that carries data goes out: SUBSCRIBE and the first FETCH travel with TRACK (lite) or TRACK_STATUS (moq-transport) at every hop, so first data -arrives one round trip sooner per hop. Draft-17's first FETCH stays serial -(see [the FETCH quest](/quest/m2/pipeline-requests/fetch.md)). Peers that send -them serially keep working unchanged. +arrives one round trip sooner per hop. Peers that send them serially keep +working unchanged. ## Plan diff --git a/quest/m2/pipeline-requests/fetch.md b/quest/m2/pipeline-requests/fetch.md index a7abd8a54fd..9429ef0be46 100644 --- a/quest/m2/pipeline-requests/fetch.md +++ b/quest/m2/pipeline-requests/fetch.md @@ -3,8 +3,7 @@ ## Goal A fetch-only reader's first FETCH goes upstream together with the request for -the track's info, on moq-lite and moq-transport (except draft-17, which stays -serial), at every hop, removing a round trip per hop. The fetched group is still handed out only once that +the track's info, on moq-lite and moq-transport, at every hop, removing a round trip per hop. The fetched group is still handed out only once that route's info is known and passes the origin's consistency check. Peers that send them serially keep working unchanged. @@ -44,10 +43,7 @@ What needs the info is decoding the response and approving the group, so: lands (which also covers lite-07 untimed framing). On a failed TRACK_INFO, reject the fetch and reset the stream. - moq-transport: send TRACK_STATUS alongside the FETCH and take timestamps' - units from it (or FETCH_OK properties where present). Draft-17's - TRACK_STATUS answer carries no properties, and its FETCH_OK timescale is - decoded but not surfaced (`ietf/fetch.rs`), so draft-17 keeps #4974's - SUBSCRIBE fallback and is not pipelined. + units from it (or FETCH_OK properties where present). The line's shared decisions apply: early response data stays unread in QUIC, a failed info fails the fetch, and legacy serial peers keep working. diff --git a/quest/m2/probe/padding.md b/quest/m2/probe/padding.md index 97d0c5775c5..a41a5c36937 100644 --- a/quest/m2/probe/padding.md +++ b/quest/m2/probe/padding.md @@ -42,8 +42,7 @@ Decided with the maintainer on 2026-10-17 (see the (Padding Stream 0x2), matching moq-transport's PADDING. Pad only on lite-07 and, on moq-transport, from draft-18 on. On lite-05 and lite-06 the peer resets every padding stream, so the estimate - can never rise, and drafts 14 to 17 close the session on an unknown stream - type (`UniType::classify`). Those sessions have no sender: refuse to pad + can never rise. Those sessions have no sender: refuse to pad and keep `Report`. Update `drafts/draft-lcurley-moq-probe.md` if the sender changes what it says, and run `just drafts check`. - A browser sender can't see whether it is app-limited; settle how to judge diff --git a/quest/m2/probe/viewer-target.md b/quest/m2/probe/viewer-target.md index 82572683a18..695490903f1 100644 --- a/quest/m2/probe/viewer-target.md +++ b/quest/m2/probe/viewer-target.md @@ -59,8 +59,7 @@ Decided with the maintainer on 2026-10-17 (see the - **`Increase` only with a sender.** SETUP advertises `Increase` only when the session has a sender behind the seam the send target defines: native probing on moq-quic, padding in the browser. A browser session on a version - [padding](/quest/m2/probe/padding.md) refuses (lite-05, lite-06, IETF - drafts 14 to 17) has none, so it keeps `Report`. + [padding](/quest/m2/probe/padding.md) refuses (lite-05, lite-06) has none, so it keeps `Report`. - **Automatic, opt-out.** Targets are on by default; an application can override them or disable probing. diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index 230e64e0ac0..068c293f6d1 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -1278,7 +1278,7 @@ pub(crate) mod tests { let input = [&lead[..], &second].concat(); let _ = moq_tokio::crypto::install_default(); - for version in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-14"] { + for version in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-18"] { let fixture = moq_relay::test_relay().await.unwrap(); let ready = fixture.relay.ready(); tokio::spawn(fixture.relay.run()); diff --git a/rs/moq-cli/tests/integration/epochs.rs b/rs/moq-cli/tests/integration/epochs.rs index 01aba9abfcb..ce6539ce8a3 100644 --- a/rs/moq-cli/tests/integration/epochs.rs +++ b/rs/moq-cli/tests/integration/epochs.rs @@ -201,10 +201,7 @@ async fn replacement(version: &str) { (changed(&mut catalog_follow).await, changed(&mut media_follow).await) }) .await; - assert_eq!( - catalog_restart, - version != "moq-lite-06" && version != "moq-transport-14" - ); + assert_eq!(catalog_restart, version != "moq-lite-06"); assert_eq!(media_restart, catalog_restart); println!( "{version}: relay replacement observed in {:?} of {:?} (publisher already connected)", @@ -307,6 +304,5 @@ macro_rules! versions { versions! { lite07: "moq-lite-07-wip", lite06: "moq-lite-06", - ietf14: "moq-transport-14", ietf22: "moq-transport-22", } diff --git a/rs/moq-e2ee/tests/support/harness.rs b/rs/moq-e2ee/tests/support/harness.rs index eb221f97f76..1b22c466208 100644 --- a/rs/moq-e2ee/tests/support/harness.rs +++ b/rs/moq-e2ee/tests/support/harness.rs @@ -82,7 +82,7 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { } // Run both handshakes concurrently and spawn each side's driver - // the moment its handshake resolves: on draft-17+ the server's accept blocks + // the moment its handshake resolves: on moq-transport the server's accept blocks // on the client's SETUP, which only reaches the wire once the client's // machine is polled (and vice versa for the server's own SETUP). let client_fut = async { diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 1cae13b3b35..cfd58354721 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -8008,7 +8008,7 @@ async fn answered(session: &MoqSession) -> Result<(), MoqError> { /// unsupported. #[tokio::test] async fn auth_reads_the_peer_grant() { - for version in ["moq-lite-07-wip", "moq-transport-17"] { + for version in ["moq-lite-07-wip", "moq-transport-18"] { auth_reads_the_peer_grant_on(version).await; } } @@ -8041,11 +8041,11 @@ async fn auth_reads_the_peer_grant_on(version: &str) { server.cancel(); } -/// moq-transport without the Auth extension carries a token only on a request that names it, -/// which no binding can, so the answer is that the session cannot carry it. +/// A live session without AUTH (moq-lite before 07) has nothing to carry a new token on, so the +/// answer is that the session cannot carry it. #[tokio::test] async fn auth_refuses_a_token_nothing_carries() { - let (server, client, accepted) = tokio::time::timeout(TIMEOUT, session_pair("moq-transport-14")) + let (server, client, accepted) = tokio::time::timeout(TIMEOUT, session_pair("moq-lite-06")) .await .expect("pair"); client.tokens().unwrap().set(vec!["extra".into()]).unwrap(); diff --git a/rs/moq-gst/src/connection.rs b/rs/moq-gst/src/connection.rs index 270eb21d079..e2b824c3b3c 100644 --- a/rs/moq-gst/src/connection.rs +++ b/rs/moq-gst/src/connection.rs @@ -128,10 +128,10 @@ mod tests { element.set_property(name, value); assert_eq!(element.property::(name), value); } - element.set_property("connect-version", " moq-transport-14 , moq-lite-06 "); + element.set_property("connect-version", " moq-transport-18 , moq-lite-06 "); assert_eq!( element.property::("connect-version"), - " moq-transport-14 , moq-lite-06 " + " moq-transport-18 , moq-lite-06 " ); } } @@ -140,7 +140,7 @@ mod tests { fn properties_reach_versions_tls_and_quic_config() { let mut settings = Settings::default(); for (name, value) in [ - ("connect-version", " moq-transport-14 , moq-lite-06 ".to_value()), + ("connect-version", " moq-transport-18 , moq-lite-06 ".to_value()), ("tls-disable-verify", true.to_value()), ("quic-idle-timeout", 15_000u64.to_value()), ("quic-keep-alive", 0u64.to_value()), @@ -151,7 +151,7 @@ mod tests { let (connect, quic) = settings.resolve().unwrap(); assert_eq!( connect.version, - ["moq-transport-14".parse().unwrap(), "moq-lite-06".parse().unwrap()] + ["moq-transport-18".parse().unwrap(), "moq-lite-06".parse().unwrap()] ); assert_eq!(connect.tls.insecure, Some(true)); assert_eq!(connect.backoff.timeout, Some(Duration::ZERO.into())); diff --git a/rs/moq-net-tests/tests/integration/ietf_conformance.rs b/rs/moq-net-tests/tests/integration/ietf_conformance.rs index 2f9b0ecdff1..e458c15a3dd 100644 --- a/rs/moq-net-tests/tests/integration/ietf_conformance.rs +++ b/rs/moq-net-tests/tests/integration/ietf_conformance.rs @@ -76,14 +76,14 @@ async fn routing(version: &str, cluster: bool) { #[moq_clock::test] async fn conformance_namespaces_route_through_one_relay() { - for version in ["moq-transport-16", "moq-transport-18"] { + for version in ["moq-transport-18"] { routing(version, false).await; } } #[moq_clock::test] async fn conformance_namespaces_route_through_a_lite_cluster() { - for version in ["moq-transport-16", "moq-transport-18"] { + for version in ["moq-transport-18"] { routing(version, true).await; } } diff --git a/rs/moq-net-tests/tests/integration/restart.rs b/rs/moq-net-tests/tests/integration/restart.rs index 3e15bd3e64a..3d86b8fc6e9 100644 --- a/rs/moq-net-tests/tests/integration/restart.rs +++ b/rs/moq-net-tests/tests/integration/restart.rs @@ -522,10 +522,6 @@ retired_fetch_tests! { retired_fetch_lite05: "moq-lite-05", retired_fetch_lite06: "moq-lite-06", retired_fetch_lite07: "moq-lite-07-wip", - retired_fetch_ietf14: "moq-transport-14", - retired_fetch_ietf15: "moq-transport-15", - retired_fetch_ietf16: "moq-transport-16", - retired_fetch_ietf17: "moq-transport-17", retired_fetch_ietf18: "moq-transport-18", retired_fetch_ietf22: "moq-transport-22", } @@ -595,13 +591,7 @@ async fn retired_fetch_explicit_epoch_control() { #[moq_clock::test] async fn withdrawal_refuses_new_fetches_but_keeps_open_reads() { - for version in [ - "moq-lite-05", - "moq-lite-06", - "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-22", - ] { + for version in ["moq-lite-05", "moq-lite-06", "moq-lite-07-wip", "moq-transport-22"] { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); let viewer = produce_origin(2); @@ -624,13 +614,7 @@ async fn withdrawal_refuses_new_fetches_but_keeps_open_reads() { #[moq_clock::test] async fn a_more_specific_source_cannot_fill_a_held_covering_track() { - for name in [ - "moq-lite-05", - "moq-lite-06", - "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-22", - ] { + for name in ["moq-lite-05", "moq-lite-06", "moq-lite-07-wip", "moq-transport-22"] { let version: Version = name.parse().unwrap(); let publisher = produce_origin(1); let viewer = produce_origin(2); @@ -769,7 +753,6 @@ claim_restart_tests! { claim_restart_lite05: "moq-lite-05", claim_restart_lite06: "moq-lite-06", claim_restart_lite07: "moq-lite-07-wip", - claim_restart_ietf14: "moq-transport-14", - claim_restart_ietf16: "moq-transport-16", + claim_restart_ietf18: "moq-transport-18", claim_restart_ietf22: "moq-transport-22", } diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 120c89c9728..1dfc7b41fc1 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -173,11 +173,6 @@ name = "admission" harness = false required-features = ["async"] -[[bench]] -name = "request-ids" -harness = false -required-features = ["fuzz"] - [[bench]] name = "refresh" harness = false diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 6d9205f22df..508e72fb05e 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### moq-transport drafts 14 through 17 are removed ([#6753](https://github.com/moq-dev/moq/pull/6753)) + +`ietf::Version::Draft14` through `Draft17` are gone, and a peer offering only those versions, by ALPN or in the moql SETUP, fails the handshake. +Peers must speak moq-transport draft 18 or later, or moq-lite. +The moql SETUP (no ALPN, or `moql`) now negotiates only moq-lite versions. +`session::Limits::requests` is removed: stream credit bounds every peer request, so drop the field and keep `announces`. + ### `Path` and `Relative` have no lifetime There is one owned `Path` type: a short path is stored inline and a long one shares a reference-counted buffer, so cloning stays cheap. Every path is at most `Path::MAX_BYTES` (16 KiB). @@ -96,7 +103,7 @@ Path normalization, relative references, and subscription paths retain their sem ### `Version::uses_setup_negotiation` is removed ([#6483](https://github.com/moq-dev/moq/pull/6483)) -Match the version yourself: lite-01, lite-02, and moq-transport draft 14 negotiate the version in SETUP; every other version negotiates it through ALPN. +Match the version yourself: lite-01 and lite-02 negotiate the version in SETUP; every other version negotiates it through ALPN. ### Sessions box their transport (#6176) @@ -502,7 +509,7 @@ Treat both like `NotFound` if you match on errors. ### Sessions cap peer requests (#4820) A peer past the session's announce bound loses the session with `TooManyRequests`. -Stream credit bounds its subscriptions and FETCHes, except on moq-transport drafts 14 to 16, where the request window sized by `announces` and `requests` does. +Stream credit bounds its subscriptions and FETCHes. Raise the defaults for a large relay mesh. ```rust diff --git a/rs/moq-net/benches/request-ids.rs b/rs/moq-net/benches/request-ids.rs deleted file mode 100644 index e540905c7ed..00000000000 --- a/rs/moq-net/benches/request-ids.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Request-ID admission cost across independent sessions and reordered windows. -use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; -use std::{hint::black_box, time::Duration}; - -fn request_ids(c: &mut Criterion) { - let mut group = c.benchmark_group("request-ids"); - group.sample_size(20); - group.warm_up_time(Duration::from_millis(200)); - group.measurement_time(Duration::from_secs(1)); - // Up to the production window, which the session sizes to twice its 120,000 caps. - for mode in ["ordered", "reordered", "scattered"] { - for sessions in [1, 8, 32] { - for window in [8, 128, 240_000] { - let ids = ids(mode, window); - println!( - "admitted,{mode},{sessions},{window},{}", - moq_net::fuzz::request_ids(window, &ids) * sessions - ); - group.bench_function(BenchmarkId::new(mode, format!("{sessions}x{window}")), |b| { - b.iter(|| { - let accepted: usize = (0..sessions) - .map(|_| moq_net::fuzz::request_ids(window, black_box(&ids))) - .sum(); - black_box(accepted) - }); - }); - } - } - } - group.finish(); -} - -/// Draft-17+ admission, which spends IDs without a window but remembers skipped runs. -fn request_history(c: &mut Criterion) { - let mut group = c.benchmark_group("modern-request-ids"); - group.sample_size(20); - group.warm_up_time(Duration::from_millis(200)); - group.measurement_time(Duration::from_secs(1)); - for mode in ["ordered", "reordered", "scattered"] { - for sessions in [1, 8, 32] { - for requests in [8, 128, 240_000] { - let ids = ids(mode, requests); - println!( - "admitted-modern,{mode},{sessions},{requests},{}", - moq_net::fuzz::request_history(&ids) * sessions - ); - group.bench_function(BenchmarkId::new(mode, format!("{sessions}x{requests}")), |b| { - b.iter(|| { - let accepted: usize = (0..sessions) - .map(|_| moq_net::fuzz::request_history(black_box(&ids))) - .sum(); - black_box(accepted) - }); - }); - } - } - } - group.finish(); -} - -/// Every ID in the window: in order, reversed (one skip to the top, then each fill -/// shortens it), or scattered (the top first, then every other ID descending, so each -/// splits the lowest skipped run and the remembered runs grow to half the window). -fn ids(mode: &str, window: u64) -> Vec { - let all = (0..window).map(|id| id * 2); - match mode { - "ordered" => all.collect(), - "reordered" => all.rev().collect(), - "scattered" => { - let below = (0..window - 1).rev().map(|id| id * 2); - let mut ids = vec![2 * (window - 1)]; - ids.extend(below.clone().filter(|id| id / 2 % 2 == 1)); - ids.extend(below.filter(|id| id / 2 % 2 == 0)); - ids - } - _ => unreachable!(), - } -} - -criterion_group!(benches, request_ids, request_history); -criterion_main!(benches); diff --git a/rs/moq-net/benches/session/mod.rs b/rs/moq-net/benches/session/mod.rs index 0af68be2590..603833d728e 100644 --- a/rs/moq-net/benches/session/mod.rs +++ b/rs/moq-net/benches/session/mod.rs @@ -44,9 +44,8 @@ //! Nothing else happens, so it measures the sessions' own work. Paused time skips waiting. //! //! `session_churn_*` times one request opened and closed by a session that already -//! holds others open, swept over sessions and requests held per session. On -//! moq-transport 14 every close is credited back through the MAX_REQUEST_ID window, -//! so a slope here is that bookkeeping growing with the sessions or the requests. +//! holds others open, swept over sessions and requests held per session, so a slope +//! here is per-request bookkeeping growing with the sessions or the requests. //! //! Everything runs on one current-thread runtime, so the only work measured is //! protocol and relay work, never scheduling across threads. @@ -1214,9 +1213,8 @@ fn allocations() { } } -/// Versions for the churn sweep: the one draft with a MAX_REQUEST_ID window, the -/// newest draft without it, and lite. -const CHURN_VERSIONS: [&str; 3] = ["moq-transport-14", "moq-transport-22", "moq-lite-06"]; +/// Versions for the churn sweep: the newest moq-transport draft and lite. +const CHURN_VERSIONS: [&str; 2] = ["moq-transport-22", "moq-lite-06"]; /// Sessions on one relay, each holding `held` subscriptions open while it churns one more. #[derive(Clone, Copy)] @@ -1371,7 +1369,7 @@ impl IdleRoom { pub(crate) fn idle(c: &mut Criterion) { let mut group = c.benchmark_group("session_idle"); - for version in ["moq-lite-06", "moq-transport-16", "moq-transport-22"] { + for version in ["moq-lite-06", "moq-transport-22"] { for sessions in [1, 64, 1024] { for consumers in [0, 1, 8] { group.throughput(Throughput::Elements(sessions as u64)); diff --git a/rs/moq-net/fuzz/README.md b/rs/moq-net/fuzz/README.md index 6c877b91a13..534b6e39762 100644 --- a/rs/moq-net/fuzz/README.md +++ b/rs/moq-net/fuzz/README.md @@ -46,8 +46,7 @@ Beyond "does not panic": same code, so emitting something we would reject is a bug on the wire. - Encoding is stable: encode, decode, encode again yields identical bytes. Skipped where a parameter map reaches the wire, since those encoders walk a `HashMap` and its - iteration order differs per instance (moq-lite SETUP, and draft-14/15, which unlike - draft-16+ do not sort by key first). + iteration order differs per instance (moq-lite SETUP). - A lite-07 announce stream our decoder accepts, recompressed by our encoder, resolves to the same announcements. - Setup Options a client accepts from its server, a server accepts from its client: only diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-goaway-request-id b/rs/moq-net/fuzz/regressions/ietf/draft18-goaway-request-id index ca1272f1f39..2abb874d906 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-goaway-request-id and b/rs/moq-net/fuzz/regressions/ietf/draft18-goaway-request-id differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-0 b/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-0 index 59c326743c0..b87d0aa307b 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-0 and b/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-0 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-255 b/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-255 index ffe8e6b9d81..7e9a314a706 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-255 and b/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-255 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-3 b/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-3 index 12ed52ca628..264b409f721 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-3 and b/rs/moq-net/fuzz/regressions/ietf/draft18-invalid-group-order-3 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-truncated-unknown b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-truncated-unknown index 065e5234095..915c20d41c3 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-truncated-unknown and b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-truncated-unknown differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-unknown-bytes b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-unknown-bytes index 7fc380d97df..96f776f5eee 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-unknown-bytes and b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-unknown-bytes differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-unknown-varint b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-unknown-varint index 9ff874dd9da..8e998087af5 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-unknown-varint and b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-envelope-unknown-varint differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-truncated-unknown b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-truncated-unknown index 489e5c30e6a..69f2fc11640 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-truncated-unknown and b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-truncated-unknown differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-unknown-bytes b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-unknown-bytes index d502a4b6bfe..faf6680ce75 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-unknown-bytes and b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-unknown-bytes differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-unknown-varint b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-unknown-varint index 8fb114a73cc..8e228f345e2 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-setup-unknown-varint and b/rs/moq-net/fuzz/regressions/ietf/draft18-setup-unknown-varint differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-empty-namespace-field b/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-empty-namespace-field index 7f6cb67fee2..639915ac8e4 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-empty-namespace-field and b/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-empty-namespace-field differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-oversized-full-track-name b/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-oversized-full-track-name index 4eeee07dc8a..85cc10f525e 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-oversized-full-track-name and b/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-oversized-full-track-name differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-oversized-namespace b/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-oversized-namespace index e3445ccec24..d5e41124eae 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-oversized-namespace and b/rs/moq-net/fuzz/regressions/ietf/draft18-subscribe-oversized-namespace differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-0 b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-0 index 5f36668a4fa..704ed425b17 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-0 and b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-0 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-255 b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-255 index a6d9a1848a8..df323e8c719 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-255 and b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-255 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-3 b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-3 index 72288ebd31a..d64f866e142 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-3 and b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-fill-group-order-3 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-0 b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-0 index 85fafad94f2..7e24493124c 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-0 and b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-0 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-255 b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-255 index 7f827fc2db1..0feb64599eb 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-255 and b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-255 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-3 b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-3 index 4d8b7677e7d..ad00bb4d1f4 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-3 and b/rs/moq-net/fuzz/regressions/ietf/draft22-invalid-group-order-3 differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-truncated-unknown b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-truncated-unknown index f81fe20c53a..f3973216b35 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-truncated-unknown and b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-truncated-unknown differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-unknown-bytes b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-unknown-bytes index 8f8bde8af08..31d0455005e 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-unknown-bytes and b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-unknown-bytes differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-unknown-varint b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-unknown-varint index ebf87c7e273..55ca1b6d147 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-unknown-varint and b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-envelope-unknown-varint differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-truncated-unknown b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-truncated-unknown index c4eb50843c1..cde0cac89f3 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-truncated-unknown and b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-truncated-unknown differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-unknown-bytes b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-unknown-bytes index 5b9b6efac6b..a2c48ef2d3d 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-unknown-bytes and b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-unknown-bytes differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-unknown-varint b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-unknown-varint index d4785b9d689..bc7a4fa3003 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-setup-unknown-varint and b/rs/moq-net/fuzz/regressions/ietf/draft22-setup-unknown-varint differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-empty-namespace-field b/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-empty-namespace-field index fed2ec60fab..196c5e95c2e 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-empty-namespace-field and b/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-empty-namespace-field differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-oversized-full-track-name b/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-oversized-full-track-name index 5daf36d3f21..29b653c9677 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-oversized-full-track-name and b/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-oversized-full-track-name differ diff --git a/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-oversized-namespace b/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-oversized-namespace index 164f8047c06..4cb4bcc0907 100644 Binary files a/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-oversized-namespace and b/rs/moq-net/fuzz/regressions/ietf/draft22-subscribe-oversized-namespace differ diff --git a/rs/moq-net/fuzz/regressions/ietf/subscribe-absolute-filter b/rs/moq-net/fuzz/regressions/ietf/subscribe-absolute-filter deleted file mode 100644 index a0f7a684dd6..00000000000 Binary files a/rs/moq-net/fuzz/regressions/ietf/subscribe-absolute-filter and /dev/null differ diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 2999046646c..353f54f13a4 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -27,8 +27,8 @@ //! that carried a token is held to that token's grant alone and ends with it; any other //! request is held to the session's scope, which a token carried by a request never joins. //! -//! moq-transport draft-17+ carries the same exchange when both sides negotiate the -//! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no +//! moq-transport carries the same exchange when both sides negotiate the MoQ Auth +//! extension. Older moq-lite versions, and peers that do not negotiate it, carry no //! AUTH exchange: there the grant stays `None`. On moq-transport `add` still succeeds, //! with a grant that stays unknown, since a request that names the auth carries its //! token; on moq-lite without AUTH it fails with [`Error::Unsupported`]. Both wires carry diff --git a/rs/moq-net/src/auth/tokens.rs b/rs/moq-net/src/auth/tokens.rs index c857662c849..6d587d42ee3 100644 --- a/rs/moq-net/src/auth/tokens.rs +++ b/rs/moq-net/src/auth/tokens.rs @@ -892,7 +892,7 @@ mod tests { let url: Url = "https://relay.example.com/room".parse().unwrap(); let (dialed, setup, connection) = credential( &url, - &versions(&["moq-lite-07-wip", "moq-transport-17"]), + &versions(&["moq-lite-07-wip", "moq-transport-18"]), &tokens(&["a", "b"]), ); assert_eq!(dialed, url); diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 75b13fe5721..f365147c8c2 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -3,9 +3,9 @@ use crate::origin; use crate::time::{Deadlines, Instant}; use crate::transport::poll::Session as _; use crate::{ - ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_22, ALPN_LITE, ALPN_LITE_03, ALPN_LITE_04, ALPN_LITE_05, - ALPN_LITE_06, ALPN_LITE_07_WIP, Consume, Error, NEGOTIATED, Session, Version, Versions, - coding::{self, Decode, Encode, Stream}, + ALPN_18, ALPN_22, ALPN_LITE, ALPN_LITE_03, ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07_WIP, Consume, + Error, NEGOTIATED, Session, Version, Versions, + coding::{self, Encode, Stream}, ietf, lite, setup, stats, }; @@ -202,7 +202,7 @@ impl Client { } /// Price this link, in the units the rest of the mesh uses (moq-lite-06+, and - /// `moqt-17`+ via the MoQ Cluster extension). + /// moq-transport via the MoQ Cluster extension). /// /// The dialer is the side that knows what a link costs, because it chose the peer: /// use `0` for a sibling in the same datacenter and something large for another @@ -367,19 +367,16 @@ impl Client { let peer_hop = self.assigned_hop(); let (publish, subscribe) = self.origins(peer_hop); - // If ALPN was used to negotiate the version, use the appropriate encoding. - // Default to IETF 14 if no ALPN was used and we'll negotiate the version later. - let (encoding, supported) = match session.protocol() { - Some(alpn @ (ALPN_22 | ALPN_18 | ALPN_17)) => { + let supported = match session.protocol() { + Some(alpn @ (ALPN_22 | ALPN_18)) => { let draft = match alpn { ALPN_22 => ietf::Version::Draft22, - ALPN_18 => ietf::Version::Draft18, - _ => ietf::Version::Draft17, + _ => ietf::Version::Draft18, }; let v = self.versions.select(Version::Ietf(draft)).ok_or(Error::Version)?; - // Draft-17+: SETUP is exchanged by the connection driver. + // SETUP is exchanged by the connection driver. // We advertise the request path in our SETUP for URL-less transports. // The peer's SETUP decides whether AUTH is negotiated. A handle that does not // offer it keeps the option out of our SETUP and presents no credential. @@ -390,8 +387,6 @@ impl Client { stats: self.stats.clone(), limits: self.limits, session: session.clone(), - setup: None, - request_id_max: None, client: true, publish: publish.clone(), subscribe: subscribe.clone(), @@ -428,27 +423,6 @@ impl Client { }, )); } - Some(ALPN_16) => { - let v = self - .versions - .select(Version::Ietf(ietf::Version::Draft16)) - .ok_or(Error::Version)?; - (v, v.into()) - } - Some(ALPN_15) => { - let v = self - .versions - .select(Version::Ietf(ietf::Version::Draft15)) - .ok_or(Error::Version)?; - (v, v.into()) - } - Some(ALPN_14) => { - let v = self - .versions - .select(Version::Ietf(ietf::Version::Draft14)) - .ok_or(Error::Version)?; - (v, v.into()) - } Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07_WIP)) => { let version = match alpn { ALPN_LITE_07_WIP => lite::Version::Lite07, @@ -470,49 +444,19 @@ impl Client { .ok_or(Error::Version)?; return self.start_lite(runtime, session, lite::Version::Lite03, peer_hop); } - Some(ALPN_LITE) | None => { - let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; - (Version::Ietf(ietf::Version::Draft14), supported) - } + // No ALPN, or `moql`: negotiate moq-lite in a bidi SETUP. + Some(ALPN_LITE) | None => self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?, Some(p) => return Err(Error::UnknownAlpn(p.to_string())), }; - let mut stream = Stream::open(&mut session, encoding).await?.with_stats(&self.stats); - - // The encoding is always an IETF version for SETUP negotiation. - let ietf_encoding = ietf::Version::try_from(encoding).map_err(|_| Error::Version)?; - - let mut parameters = ietf::Parameters::default(); - // The server's requests, admitted up to our limits and granted back as they close. - parameters.set_varint( - ietf::ParameterVarInt::MaxRequestId, - ietf::initial_max_request_id(self.limits.window(), false), - ); - if let Some(implementation) = self.implementation() { - parameters.set_bytes(ietf::ParameterBytes::Implementation, implementation.into_bytes()); - } - // Advertise the request path in-band (draft 14-16), same as the lite-05 SETUP. - if let Some(path) = self.setup_path(session.address()) { - parameters.set_bytes(ietf::ParameterBytes::Path, path.into_bytes()); - } - if let Some(authority) = self.setup_authority(session.address()) { - parameters.set_bytes(ietf::ParameterBytes::Authority, authority.into_bytes()); - } - if let Some(token) = &self.setup_token { - ietf::token::into_setup(&mut parameters, token, ietf_encoding)?; - } - if self.extensions.solicit { - ietf::solicit::into_setup(&mut parameters, ietf_encoding); - } - ietf::hidden::into_setup(&mut parameters, ietf_encoding); - ietf::max_age::into_setup(&mut parameters, ietf_encoding); - ietf::active_count::into_setup(&mut parameters, ietf_encoding); - ietf::flate::into_setup(&mut parameters, ietf_encoding); - let parameters = parameters.encode_bytes(ietf_encoding)?; + // The SETUP keeps lite-01's framing whichever version it offers. + let mut stream = Stream::open(&mut session, Version::Lite(lite::Version::Lite01)) + .await? + .with_stats(&self.stats); let client = setup::Client { versions: supported.clone().into(), - parameters, + parameters: lite::Parameters::default().encode_bytes(lite::Version::Lite01)?, }; stream.writer.encode(&client).await?; @@ -524,122 +468,51 @@ impl Client { .find(|v| coding::Version::from(**v) == server.version) .copied() .ok_or(Error::Version)?; + let Version::Lite(v) = version else { + return Err(Error::Version); + }; // The server ends the stream after its SETUP. Reading to the end first keeps the drop // from stopping a reply the server is still waiting to have acknowledged. - if let Version::Lite(v) = version - && version.hands_off() - { + if version.hands_off() { std::future::poll_fn(|cx| stream.reader.poll_closed(cx)).await?; drop(stream); return self.start_lite(runtime, session, v, peer_hop); } - let (recv_bw, protocol, goaway, auth, credential, estimate, peer_implementation) = match version { - Version::Lite(v) => { - let stream = stream.with_version(v); - let start = lite::start(lite::Config { - runtime: runtime.clone(), - stats: self.stats.clone(), - client: true, - limits: self.limits, - session: session.clone(), - setup_stream: Some(stream), - publish: publish.clone(), - subscribe: subscribe.clone(), - peer_hop: Some(peer_hop), - version: v, - // Lite 01/02 continue on the bidi SETUP stream and have no Setup Stream. - our_setup: lite::Setup::default(), - peer_setup: None, - // Negotiated over the bidi SETUP: lite 01/02, which carry no AUTH. - auth: self.link(v.has_auth(), false)?, - })?; - - ( - start.recv_bandwidth, - crate::driver::Protocol::Lite(Box::new(start.driver)), - start.goaway, - start.auth, - start.credential, - Some(start.estimate), - start.peer_implementation, - ) - } - Version::Ietf(v) => { - // Decode the parameters to get the initial request ID and what the server - // requires of us. - let (parameters, _) = ietf::Parameters::decode_slice(&server.parameters, v)?; - let request_id_max = parameters - .get_varint(ietf::ParameterVarInt::MaxRequestId) - .map(ietf::RequestId); - let peer_declared = ietf::peer::Peer { - solicit: ietf::solicit::from_setup(¶meters, v)?, - hidden: ietf::hidden::from_setup(¶meters, v), - max_age: ietf::max_age::from_setup(¶meters, v), - active_count: ietf::active_count::from_setup(¶meters, v), - flate: ietf::flate::from_setup(¶meters, v)?, - ..Default::default() - }; - let peer_implementation = setup::PeerImplementation::known(setup::decode_implementation( - parameters.get_bytes(ietf::ParameterBytes::Implementation), - )?); - - let stream = stream.with_version(v); - // Draft 14-16 carry no AUTH, but the session is still limited through this handle. - let auth = self.link(false, true)?; - // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. - let (protocol, goaway, credential) = ietf::start(ietf::Config { - runtime: runtime.clone(), - stats: self.stats.clone(), - limits: self.limits, - session: session.clone(), - setup: Some(stream), - request_id_max, - client: true, - publish: publish.clone(), - subscribe: subscribe.clone(), - peer_hop: Some(peer_hop), - cost: self.cost, - version: v, - path: None, - authority: None, - token: None, - peer_setup_stream: None, - peer_declared: Some(peer_declared), - auth: auth.clone(), - early_unis: Vec::new(), - extensions: self.extensions, - // Already sent on the bidi SETUP above. - implementation: None, - peer_implementation: peer_implementation.clone(), - })?; - ( - None, - crate::driver::Protocol::Ietf(protocol), - goaway, - auth, - credential, - None, - peer_implementation, - ) - } - }; + let stream = stream.with_version(v); + let start = lite::start(lite::Config { + runtime: runtime.clone(), + stats: self.stats.clone(), + client: true, + limits: self.limits, + session: session.clone(), + setup_stream: Some(stream), + publish: publish.clone(), + subscribe: subscribe.clone(), + peer_hop: Some(peer_hop), + version: v, + // Lite 01/02 continue on the bidi SETUP stream and have no Setup Stream. + our_setup: lite::Setup::default(), + peer_setup: None, + // Lite 01/02 carry no AUTH. + auth: self.link(v.has_auth(), false)?, + })?; Ok(Session::new( runtime, session, version, - recv_bw, - protocol, + start.recv_bandwidth, + crate::driver::Protocol::Lite(Box::new(start.driver)), crate::session::Handles { stats: self.stats.clone(), - goaway, - auth, - credential, - estimate, + goaway: start.goaway, + auth: start.auth, + credential: start.credential, + estimate: Some(start.estimate), bandwidth: self.allocator(), - peer_implementation, + peer_implementation: start.peer_implementation, }, )) } @@ -931,8 +804,8 @@ mod tests { }; server .encode( - &mut crate::coding::Encoder::new(&mut encoded, (Version::Ietf(ietf::Version::Draft14)).into()), - Version::Ietf(ietf::Version::Draft14), + &mut crate::coding::Encoder::new(&mut encoded, negotiated.into()), + negotiated, ) .unwrap(); @@ -952,7 +825,6 @@ mod tests { Version::Lite(lite::Version::Lite03), Version::Lite(lite::Version::Lite02), Version::Lite(lite::Version::Lite01), - Version::Ietf(ietf::Version::Draft14), ] .into(), ); @@ -961,11 +833,11 @@ mod tests { let (_session, driver) = client.connect(moq_clock::now(), fake.clone()).await.unwrap(); moq_clock::sim::spawn(crate::time::run(driver)); - // Verify the client setup was encoded using Draft14 framing (ALPN_LITE fallback path). + // Verify the client offered every configured moq-lite version over the `moql` SETUP. let mut setup_bytes = Bytes::from(fake.control_writes()); let setup = crate::coding::decode_buf( &mut setup_bytes, - Version::Ietf(ietf::Version::Draft14), + Version::Lite(lite::Version::Lite02), setup::Client::decode, ) .unwrap(); @@ -976,7 +848,6 @@ mod tests { Version::Lite(lite::Version::Lite03), Version::Lite(lite::Version::Lite02), Version::Lite(lite::Version::Lite01), - Version::Ietf(ietf::Version::Draft14), ] ); @@ -1020,46 +891,6 @@ mod tests { .expect("connect failed"); } - /// The client SETUP on the bidi control stream (the pre-draft-17 framing) carries the - /// AUTHORITY next to the PATH. - #[moq_clock::test] - async fn draft14_setup_carries_the_authority_and_token() { - let token = setup::Token { - kind: setup::Token::OUT_OF_BAND, - value: b"connection-credential".to_vec(), - }; - let fake = FakeSession::new(Some(ALPN_LITE), mock_server_setup(Version::Lite(lite::Version::Lite01))); - let client = Client::new() - .with_versions( - [ - Version::Lite(lite::Version::Lite01), - Version::Ietf(ietf::Version::Draft14), - ] - .into(), - ) - .with_url("moqt://relay.example.com:4443/anon".parse().unwrap()) - .with_token(token.clone()); - - let (_session, driver) = client.connect(moq_clock::now(), fake.clone()).await.unwrap(); - moq_clock::sim::spawn(crate::time::run(driver)); - - let (setup, _) = - setup::Client::decode_slice(&fake.control_writes(), Version::Ietf(ietf::Version::Draft14)).unwrap(); - let (parameters, _) = ietf::Parameters::decode_slice(&setup.parameters, ietf::Version::Draft14).unwrap(); - assert_eq!( - parameters.get_bytes(ietf::ParameterBytes::Authority), - Some(b"relay.example.com:4443".as_ref()) - ); - assert_eq!( - parameters.get_bytes(ietf::ParameterBytes::Path), - Some(b"/anon".as_ref()) - ); - assert_eq!( - ietf::token::from_setup(¶meters, ietf::Version::Draft14).unwrap(), - Some(token) - ); - } - fn dialed(url: &str) -> Client { Client::new().with_url(url.parse().unwrap()) } @@ -1135,12 +966,12 @@ mod tests { } #[moq_clock::test] - async fn alpn_lite_falls_back_to_draft14_and_switches_version_post_setup() { + async fn alpn_lite_falls_back_to_moql_setup_and_switches_version_post_setup() { run_alpn_lite_fallback_case(Some(ALPN_LITE)).await; } #[moq_clock::test] - async fn no_alpn_falls_back_to_draft14_and_switches_version_post_setup() { + async fn no_alpn_falls_back_to_moql_setup_and_switches_version_post_setup() { run_alpn_lite_fallback_case(None).await; } diff --git a/rs/moq-net/src/coding/codes.rs b/rs/moq-net/src/coding/codes.rs index fbb0f704ce8..6f2495098dd 100644 --- a/rs/moq-net/src/coding/codes.rs +++ b/rs/moq-net/src/coding/codes.rs @@ -65,14 +65,14 @@ impl StreamCodes for lite::Version { } } -/// moq-transport's registry, which is per draft. +/// moq-transport's registry. impl StreamCodes for ietf::Version { fn encode_stream_code(&self, err: &StreamError) -> u32 { - ietf::error::to_stream_code(err, *self) + ietf::error::to_stream_code(err) } fn decode_stream_code(&self, code: u32) -> StreamError { - ietf::error::from_stream_code(code, *self) + ietf::error::from_stream_code(code) } } @@ -120,16 +120,8 @@ mod tests { assert_eq!(lite.encode_stream_code(&StreamError::Cancel), ietf::error::CANCELLED); assert_eq!(ietf.encode_stream_code(&StreamError::Cancel), ietf::error::CANCELLED); - // GOING_AWAY is moq-lite's 0x4 and draft-22's, but draft-17 gives 0x4 to - // UNKNOWN_OBJECT_STATUS, so it is not sent there and not read back there. - let draft17 = crate::Version::Ietf(ietf::Version::Draft17); assert_eq!(lite.decode_stream_code(0x4), StreamError::GoingAway); assert_eq!(ietf.decode_stream_code(0x4), StreamError::GoingAway); - assert_eq!(draft17.decode_stream_code(0x4), StreamError::Unknown(0x4)); - assert_eq!( - draft17.encode_stream_code(&StreamError::GoingAway), - ietf::error::INTERNAL_ERROR - ); } /// NOT_FETCHABLE is lite-07's: an earlier peer is told NOT_FOUND, the miss it would @@ -178,7 +170,7 @@ mod tests { /// connection closed. Native transport adapters supply that metadata themselves. #[test] fn a_virtual_stream_close_does_not_claim_the_connection_closed() { - let version = ietf::Version::Draft16; + let version = ietf::Version::Draft18; assert!(matches!(version.transport_error(Error::Closed), Error::Transport(_))); assert!(matches!( version.transport_error(Error::IdleTimeout), diff --git a/rs/moq-net/src/coding/encode.rs b/rs/moq-net/src/coding/encode.rs index bbd308a5731..22dc7efe996 100644 --- a/rs/moq-net/src/coding/encode.rs +++ b/rs/moq-net/src/coding/encode.rs @@ -90,7 +90,7 @@ impl<'a> Encoder<'a> { } /// Write a big-endian `u16`. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + #[cfg(all(test, feature = "async"))] pub fn u16(&mut self, v: u16) { self.buf.extend_from_slice(&v.to_be_bytes()); } diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 59a64c59bcd..458bf6695ad 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -421,11 +421,6 @@ impl Reader { Poll::Ready(Ok(bytes)) } - /// Read exactly the given number of bytes from the stream. - pub async fn read_exact(&mut self, size: usize) -> Result { - std::future::poll_fn(|cx| self.poll_read_exact(cx, size)).await - } - /// Poll until the stream is closed, erroring if there are any additional bytes. pub fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { if ready!(self.poll_has_more(cx))? { @@ -782,31 +777,15 @@ mod tests { refuses_frame::(&[0], lite::Version::Lite05, false); refuses_frame::(&[0], lite::Version::Lite05, false); refuses_frame::(&[0, 0], ietf::Version::Draft22, false); - for version in [ - Version::Lite(lite::Version::Lite01), - Version::Lite(lite::Version::Lite02), - Version::Ietf(ietf::Version::Draft14), - ] { - let prefix: &'static [u8] = if version.is_lite() { &[0x20, 0] } else { &[0x20, 0, 0] }; - refuses_frame::(prefix, version, false); - let prefix: &'static [u8] = if version.is_lite() { &[0x21, 0] } else { &[0x21, 0, 0] }; - refuses_frame::(prefix, version, false); + for version in [lite::Version::Lite01, lite::Version::Lite02] { + refuses_frame::(&[0x20, 0, 0], Version::Lite(version), false); + refuses_frame::(&[0x21, 0, 0], Version::Lite(version), false); } } #[test] fn oversized_setup_is_refused_at_the_prefix() { - use crate::{Version, lite, setup}; - for version in [lite::Version::Lite01, lite::Version::Lite02] { - for size in [65536u64, (1 << 40) + 1] { - let mut prefix = vec![0x20]; - Encoder::new(&mut prefix, version.into()).varint(size).unwrap(); - let client: &'static [u8] = prefix.clone().leak(); - refuses_frame::(client, Version::Lite(version), true); - prefix[0] = 0x21; - refuses_frame::(prefix.leak(), Version::Lite(version), true); - } - } + use crate::lite; for version in [lite::Version::Lite05, lite::Version::Lite06] { let mut prefix = Vec::new(); Encoder::new(&mut prefix, version.into()).varint(65536).unwrap(); diff --git a/rs/moq-net/src/coding/varint.rs b/rs/moq-net/src/coding/varint.rs index b7c2b5739e6..df4f65b1c7b 100644 --- a/rs/moq-net/src/coding/varint.rs +++ b/rs/moq-net/src/coding/varint.rs @@ -23,10 +23,7 @@ pub(crate) enum Form { /// QUIC's two-bit length tag, up to [`MAX_QUIC`]. Quic, /// Leading one bits count the length, up to `u64::MAX`. - LeadingOnes { - /// Whether the 7-byte form (`1111110x`) is accepted on decode, which draft-17 forbids. - seven: bool, - }, + LeadingOnes, } impl From for Form { @@ -38,20 +35,16 @@ impl From for Form { | lite::Version::Lite04 | lite::Version::Lite05 | lite::Version::Lite06 => Self::Quic, - // Lite-07 is only reached through its ALPN, so the form is known before the - // first byte of any stream. - _ => Self::LeadingOnes { seven: true }, + // Lite-07 is reached through its ALPN or a SETUP hand-off, so the form is known + // before the first byte of any lite-07 stream. + _ => Self::LeadingOnes, } } } impl From for Form { - fn from(version: ietf::Version) -> Self { - match version { - ietf::Version::Draft14 | ietf::Version::Draft15 | ietf::Version::Draft16 => Self::Quic, - ietf::Version::Draft17 => Self::LeadingOnes { seven: false }, - _ => Self::LeadingOnes { seven: true }, - } + fn from(_: ietf::Version) -> Self { + Self::LeadingOnes } } @@ -83,15 +76,15 @@ pub(crate) fn size(value: u64, form: Form) -> Result { Form::Quic if hi == 0 && lo < 1 << 30 => 4, Form::Quic if hi < 1 << 30 => 8, Form::Quic => return Err(BoundsExceeded), - Form::LeadingOnes { .. } if hi == 0 && lo < 1 << 7 => 1, - Form::LeadingOnes { .. } if hi == 0 && lo < 1 << 14 => 2, - Form::LeadingOnes { .. } if hi == 0 && lo < 1 << 21 => 3, - Form::LeadingOnes { .. } if hi == 0 && lo < 1 << 28 => 4, - Form::LeadingOnes { .. } if hi < 1 << 3 => 5, - Form::LeadingOnes { .. } if hi < 1 << 10 => 6, + Form::LeadingOnes if hi == 0 && lo < 1 << 7 => 1, + Form::LeadingOnes if hi == 0 && lo < 1 << 14 => 2, + Form::LeadingOnes if hi == 0 && lo < 1 << 21 => 3, + Form::LeadingOnes if hi == 0 && lo < 1 << 28 => 4, + Form::LeadingOnes if hi < 1 << 3 => 5, + Form::LeadingOnes if hi < 1 << 10 => 6, // The 7-byte form is skipped: one byte longer, but legal on every draft. - Form::LeadingOnes { .. } if hi < 1 << 24 => 8, - Form::LeadingOnes { .. } => 9, + Form::LeadingOnes if hi < 1 << 24 => 8, + Form::LeadingOnes => 9, }) } @@ -103,7 +96,7 @@ pub(crate) fn size(value: u64, form: Form) -> Result { pub(super) fn write(value: u64, form: Form, out: &mut Vec) -> Result<(), BoundsExceeded> { match form { Form::Quic => write_quic(value, out), - Form::LeadingOnes { .. } => { + Form::LeadingOnes => { write_leading_ones(value, out); Ok(()) } @@ -172,7 +165,7 @@ fn write_leading_ones(value: u64, out: &mut Vec) { pub(super) fn read(buf: &[u8], form: Form) -> Result<(u64, &[u8]), DecodeError> { match form { Form::Quic => read_quic(buf), - Form::LeadingOnes { seven } => read_leading_ones(buf, seven), + Form::LeadingOnes => read_leading_ones(buf), } } @@ -201,7 +194,7 @@ fn read_quic(buf: &[u8]) -> Result<(u64, &[u8]), DecodeError> { #[cfg_attr(target_arch = "wasm32", inline)] #[cfg_attr(not(target_arch = "wasm32"), inline(always))] #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] -fn read_leading_ones(buf: &[u8], seven: bool) -> Result<(u64, &[u8]), DecodeError> { +fn read_leading_ones(buf: &[u8]) -> Result<(u64, &[u8]), DecodeError> { let Some((&first, rest)) = buf.split_first() else { return Err(DecodeError::Short); }; @@ -229,8 +222,6 @@ fn read_leading_ones(buf: &[u8], seven: bool) -> Result<(u64, &[u8]), DecodeErro let ([a, b, lo @ ..], rest) = buf.split_first_chunk::<6>().ok_or(DecodeError::Short)?; (from_halves(be([0, 0, a & 0x03, *b]), be(*lo)), rest) } - // 1111110x: the 7-byte form, which draft-17 forbids. - 6 if !seven => return Err(DecodeError::InvalidValue), 6 => { let ([a, b, c, lo @ ..], rest) = buf.split_first_chunk::<7>().ok_or(DecodeError::Short)?; (from_halves(be([0, a & 0x01, *b, *c]), be(*lo)), rest) @@ -303,8 +294,7 @@ pub(crate) const fn unzigzag(value: u64) -> i64 { mod tests { use super::*; - const DRAFT17: Form = Form::LeadingOnes { seven: false }; - const DRAFT18: Form = Form::LeadingOnes { seven: true }; + const LEADING_ONES: Form = Form::LeadingOnes; fn encode(value: u64, form: Form) -> Result, BoundsExceeded> { let mut buf = Vec::new(); @@ -313,7 +303,7 @@ mod tests { Ok(buf) } - /// Test vectors from the draft-17 spec (Table 2: Example Integer Encodings), + /// Test vectors from the spec (Table 2: Example Integer Encodings), /// excluding the known-buggy example 4 (0xdd7f3e7d). #[test] fn leading_ones_spec_examples() { @@ -335,27 +325,18 @@ mod tests { ]; for (bytes, expected) in cases { - let (decoded, rest) = read(bytes, DRAFT17).expect("decode should succeed"); + let (decoded, rest) = read(bytes, LEADING_ONES).expect("decode should succeed"); assert_eq!(decoded, *expected, "decode mismatch for bytes {bytes:02x?}"); assert!(rest.is_empty(), "all bytes should be consumed for {bytes:02x?}"); // Skip the non-minimal encoding (0x8025 for 37); we only emit the minimal one. if bytes.len() == 1 || *expected != 37 { - let encoded = encode(*expected, DRAFT17).unwrap(); + let encoded = encode(*expected, LEADING_ONES).unwrap(); assert_eq!(&encoded, bytes, "encode mismatch for value {expected}"); } } } - /// 11111100 (0xFC) is an invalid code point on draft-17 (allowed as 7-byte form on draft-18+). - #[test] - fn leading_ones_invalid_0xfc() { - assert!( - matches!(read(&[0xFC], DRAFT17), Err(DecodeError::InvalidValue)), - "0xFC should be rejected as invalid on draft-17" - ); - } - #[test] fn leading_ones_boundaries_round_trip() { let cases = [ @@ -368,14 +349,14 @@ mod tests { ]; for (value, expected_len) in cases { - let encoded = encode(value, DRAFT17).unwrap(); + let encoded = encode(value, LEADING_ONES).unwrap(); assert_eq!( encoded.len(), expected_len, "unexpected encoded length for value {value}" ); - let (decoded, _) = read(&encoded, DRAFT17).expect("leading-ones decode should succeed"); + let (decoded, _) = read(&encoded, LEADING_ONES).expect("leading-ones decode should succeed"); assert_eq!(decoded, value, "round-trip mismatch for value {value}"); } } @@ -386,7 +367,7 @@ mod tests { fn every_length_round_trips() { for bits in 0..64 { for value in [1u64 << bits, (1u64 << bits) - 1, (1u64 << bits) + 1] { - for form in [Form::Quic, DRAFT17, DRAFT18] { + for form in [Form::Quic, LEADING_ONES] { let Ok(encoded) = encode(value, form) else { assert_eq!(form, Form::Quic); assert!(value > MAX_QUIC); @@ -414,19 +395,12 @@ mod tests { } for value in [MAX_QUIC, 1u64 << 62, u64::MAX] { - let encoded = encode(value, DRAFT18).unwrap(); + let encoded = encode(value, LEADING_ONES).unwrap(); assert_eq!(encoded.len(), 9); - assert_eq!(read(&encoded, DRAFT18).unwrap().0, value); + assert_eq!(read(&encoded, LEADING_ONES).unwrap().0, value); } } - #[test] - fn draft17_rejects_7_byte_varint() { - // 1111110x prefix: invalid on draft-17. - let err = read(&[0xFC, 0, 0, 0, 0, 0, 0], DRAFT17).unwrap_err(); - assert!(matches!(err, DecodeError::InvalidValue)); - } - #[test] fn zigzag_roundtrip_small() { for n in [-3i64, -2, -1, 0, 1, 2, 3, 100, -100] { @@ -465,7 +439,7 @@ mod tests { } #[test] - fn draft18_accepts_7_byte_varint() { + fn accepts_7_byte_varint() { // Value 0x1234_5678_9ABC encoded as 7-byte leading-ones (1111110x | hi, +6 bytes). let value: u64 = 0x1234_5678_9ABC; let mut bytes = Vec::new(); @@ -476,7 +450,7 @@ mod tests { for shift in (0..48).step_by(8).rev() { bytes.push(((value >> shift) & 0xFF) as u8); } - let (decoded, _) = read(&bytes, DRAFT18).unwrap(); + let (decoded, _) = read(&bytes, LEADING_ONES).unwrap(); assert_eq!(decoded, value); } diff --git a/rs/moq-net/src/coding/writer.rs b/rs/moq-net/src/coding/writer.rs index 272ebf0cdfd..ee05770cead 100644 --- a/rs/moq-net/src/coding/writer.rs +++ b/rs/moq-net/src/coding/writer.rs @@ -327,10 +327,6 @@ mod tests { #[moq_clock::test] async fn raw_payload_stop_uses_the_negotiated_registry() { for (version, expected) in [ - ( - crate::Version::Ietf(crate::ietf::Version::Draft17), - Error::Stream(crate::StreamError::Unknown(0x4)), - ), ( crate::Version::Ietf(crate::ietf::Version::Draft22), Error::Stream(crate::StreamError::GoingAway), @@ -399,7 +395,7 @@ mod tests { // A control message's type goes with its body: a bare type would make the next // message, or a FIN, a torn one. - let mut writer = Writer::new(SinkSend::new(Log::default()), crate::ietf::Version::Draft17); + let mut writer = Writer::new(SinkSend::new(Log::default()), crate::ietf::Version::Draft18); writer.buffer_varint(5).unwrap(); writer.buffer_message(0x2, &Poison).unwrap_err(); writer.buffer_varint(7).unwrap(); diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index d6aa0ccd520..a1d88afa4c5 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -38,9 +38,8 @@ pub enum SessionError { #[error("key-value formatting error")] KeyValueFormatting, - /// The peer went past what the session allows: more announcements than its - /// `crate::session::Limits`, or on moq-transport drafts 14 to 16, a request ID at or past - /// the MAX_REQUEST_ID we advertised or more live requests than the window. + /// The peer went past what the session allows, such as more announcements than its + /// `crate::session::Limits`. #[error("too many requests")] TooManyRequests, @@ -456,8 +455,8 @@ pub enum Error { #[error(transparent)] Encode(#[from] coding::EncodeError), - /// The peer went past this session's `crate::session::Limits`: too many announcements, or - /// too many requests on moq-transport drafts 14 to 16. + /// The peer went past this session's `crate::session::Limits`, such as too many + /// announcements. #[error("too many requests")] TooManyRequests, diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 37629185218..9d61e7b21ca 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -95,20 +95,13 @@ const LITE_VERSIONS: &[lite::Version] = &[ ]; /// The moq-transport drafts a target decodes at, selected by the input's first byte. -const IETF_VERSIONS: &[ietf::Version] = &[ - ietf::Version::Draft14, - ietf::Version::Draft15, - ietf::Version::Draft16, - ietf::Version::Draft17, - ietf::Version::Draft18, - ietf::Version::Draft22, -]; +const IETF_VERSIONS: &[ietf::Version] = &[ietf::Version::Draft18, ietf::Version::Draft22]; /// How many types [`lite_wire`] dispatches over. -const LITE_KINDS: u8 = 28; +const LITE_KINDS: u8 = 30; /// How many types [`ietf_wire`] dispatches over. -const IETF_KINDS: u8 = 44; +const IETF_KINDS: u8 = 27; /// Split the two selector bytes off the input: a version and a type. fn select(data: &[u8], versions: usize) -> Option<(usize, u8, &[u8])> { @@ -168,7 +161,7 @@ fn datagram(data: &[u8], version: lite::Version) -> bool { let mut any = false; for &negotiated in negotiated { for timescale in [Some(crate::Timescale::MILLI), None] { - for ietf in [None, Some(ietf::Version::Draft14), Some(ietf::Version::Draft22)] { + for ietf in [None, Some(ietf::Version::Draft18), Some(ietf::Version::Draft22)] { for compressed in [false, true] { let framing = lite::Framing { timescale, @@ -236,6 +229,8 @@ pub fn lite_wire(data: &[u8]) -> bool { 25 => roundtrip::(rest, version), 26 => roundtrip::(rest, version), 27 => roundtrip::(rest, version), + 28 => roundtrip::(rest, crate::Version::Lite(version)), + 29 => roundtrip::(rest, crate::Version::Lite(version)), _ => unreachable!("kind is taken modulo LITE_KINDS"), } } @@ -635,56 +630,30 @@ pub fn ietf_wire(data: &[u8]) -> bool { 0 => roundtrip::, _>(rest, version), 1 => roundtrip::, _>(rest, version), 2 => roundtrip::(rest, version), - 3 => roundtrip::, _>(rest, version), - 4 => roundtrip::(rest, version), - 5 => roundtrip::(rest, version), - 6 => roundtrip::, _>(rest, version), - 7 => roundtrip::(rest, version), - 8 => roundtrip::(rest, version), - 9 => roundtrip::, _>(rest, version), - 10 => roundtrip::(rest, version), - 11 => roundtrip::, _>(rest, version), - 12 => roundtrip::, _>(rest, version), - 13 => roundtrip::, _>(rest, version), - 14 => roundtrip::(rest, version), - 15 => roundtrip::, _>(rest, version), - 16 => roundtrip::, _>(rest, version), - 17 => roundtrip::, _>(rest, version), - 18 => roundtrip::, _>(rest, version), - 19 => roundtrip::(rest, version), - 20 => roundtrip::, _>(rest, version), - 21 => roundtrip::(rest, version), - 22 => roundtrip::(rest, version), - 23 => roundtrip::(rest, version), - 24 => roundtrip::(rest, version), - 25 => roundtrip::(rest, version), - 26 => roundtrip::, _>(rest, version), - 27 => roundtrip::(rest, version), - 28 => roundtrip::(rest, version), - 29 => roundtrip::(rest, version), - 30 => roundtrip::(rest, version), - 31 => roundtrip::(rest, version), - 32 => roundtrip::(rest, version), - 33 => roundtrip::, _>(rest, version), - 34 => roundtrip::(rest, version), - 35 => roundtrip::(rest, version), - 36 => roundtrip::(rest, version), - 37 => roundtrip::(rest, version), - 38 => roundtrip::(rest, version), - 39 => roundtrip::(rest, version), - 40 => roundtrip::(rest, version), - // The unified SETUP replaced the client/server pair at draft-17. The pair refuses a - // later draft itself, but the unified one treats an earlier draft as unreachable. - 41 if matches!( - version, - ietf::Version::Draft14 | ietf::Version::Draft15 | ietf::Version::Draft16 - ) => - { - false - } - 41 => setup::(rest, version, |setup| &setup.parameters), - 42 => setup::(rest, version, |setup| &setup.parameters), - 43 => setup::(rest, version, |setup| &setup.parameters), + 3 => roundtrip::(rest, version), + 4 => roundtrip::, _>(rest, version), + 5 => roundtrip::(rest, version), + 6 => roundtrip::, _>(rest, version), + 7 => roundtrip::, _>(rest, version), + 8 => roundtrip::(rest, version), + 9 => roundtrip::, _>(rest, version), + 10 => roundtrip::, _>(rest, version), + 11 => roundtrip::, _>(rest, version), + 12 => roundtrip::(rest, version), + 13 => roundtrip::(rest, version), + 14 => roundtrip::(rest, version), + 15 => roundtrip::(rest, version), + 16 => roundtrip::(rest, version), + 17 => roundtrip::(rest, version), + 18 => roundtrip::, _>(rest, version), + 19 => roundtrip::(rest, version), + 20 => roundtrip::(rest, version), + 21 => roundtrip::(rest, version), + 22 => roundtrip::(rest, version), + 23 => roundtrip::(rest, version), + 24 => roundtrip::(rest, version), + 25 => roundtrip::(rest, version), + 26 => setup::(rest, version, |setup| &setup.parameters), _ => unreachable!("kind is taken modulo IETF_KINDS"), } } @@ -713,9 +682,9 @@ where /// Decode a varint with whichever codec the selected version uses. /// -/// Byte 0 picks the version, which is the whole point: lite-01 to lite-06 and drafts -/// 14-16 use the QUIC two-bit length tag, while lite-07 and draft-17+ count leading -/// ones, and the two disagree about which byte sequences are even legal. +/// Byte 0 picks the version, which is the whole point: lite-01 to lite-06 use the QUIC +/// two-bit length tag, while lite-07 and the moq-transport drafts count leading ones, and +/// the two disagree about which byte sequences are even legal. /// /// The leading-ones form spans the full `u64`, while the QUIC form stops at /// `2^62 - 1`; a value always re-encodes in the form it was read in. @@ -826,9 +795,7 @@ impl Default for Messages { authorization_token: None, epoch: None, }, - // Draft-17+ carries the request id in the control message framing instead. ietf_ok: ietf::SubscribeOk { - request_id: None, track_alias: 5, largest: Some(ietf::Location { group: 1_000, @@ -1177,29 +1144,7 @@ pub fn seeds() -> Vec { for (index, version) in IETF_VERSIONS.iter().enumerate() { let outer = crate::Version::Ietf(*version); let parameters = ietf::Parameters::default().encode_bytes(*version).unwrap(); - let messages = match version { - ietf::Version::Draft14 | ietf::Version::Draft15 | ietf::Version::Draft16 => vec![ - ( - 42, - crate::setup::Client { - versions: coding::Versions::from([outer.into()]), - parameters: parameters.clone(), - } - .encode_bytes(outer) - .unwrap(), - ), - ( - 43, - crate::setup::Server { - version: outer.into(), - parameters, - } - .encode_bytes(outer) - .unwrap(), - ), - ], - _ => vec![(41, crate::setup::Setup { parameters }.encode_bytes(outer).unwrap())], - }; + let messages = vec![(26, crate::setup::Setup { parameters }.encode_bytes(outer).unwrap())]; for (kind, message) in messages { let mut data = vec![index as u8, kind]; data.extend(message); @@ -1211,6 +1156,31 @@ pub fn seeds() -> Vec { } } + // The `moql` SETUP pair, framed as lite-01/02, also has its own type prefix. + for (index, version) in LITE_VERSIONS.iter().enumerate() { + let outer = crate::Version::Lite(*version); + let client = crate::setup::Client { + versions: coding::Versions::from([outer.into()]), + parameters: Default::default(), + }; + let server = crate::setup::Server { + version: outer.into(), + parameters: Default::default(), + }; + let (Ok(client), Ok(server)) = (client.encode_bytes(outer), server.encode_bytes(outer)) else { + continue; + }; + for (kind, message) in [(28, client), (29, server)] { + let mut data = vec![index as u8, kind]; + data.extend(message); + seeds.push(Seed { + target: "lite", + kind, + data, + }); + } + } + // FETCH is the one arm the sweep above cannot reach: its body ends in a parameter // count, and a uniform fill never lands a zero there. Build it from the encoder // instead, which also means a field change breaks the build rather than the seed. @@ -1252,16 +1222,11 @@ pub fn seeds() -> Vec { }); } - // A SUBSCRIBE_OK carrying LARGEST_OBJECT, whose value encoding changed at draft-17 - // from a length-prefixed Location to two bare varints. A uniform fill never lands a - // valid parameter count followed by a Location, so build it from the encoder. + // A SUBSCRIBE_OK carrying LARGEST_OBJECT, whose value is two bare varints. A uniform + // fill never lands a valid parameter count followed by a Location, so build it from + // the encoder. for (index, version) in IETF_VERSIONS.iter().enumerate() { let subscribe_ok = ietf::SubscribeOk { - request_id: matches!( - version, - ietf::Version::Draft14 | ietf::Version::Draft15 | ietf::Version::Draft16 - ) - .then_some(ietf::RequestId(0)), track_alias: 0, largest: Some(ietf::Location { group: 427, object: 0 }), properties: Default::default(), @@ -1271,11 +1236,11 @@ pub fn seeds() -> Vec { continue; }; - let mut data = vec![index as u8, 19]; + let mut data = vec![index as u8, 12]; data.extend_from_slice(&encoded); seeds.push(Seed { target: "ietf", - kind: 19, + kind: 12, data, }); } @@ -1502,16 +1467,6 @@ impl crate::transport::Error for NoError { } } -/// Admit modern request IDs for the history benchmark, returning the accepted count. -pub fn request_history(ids: &[u64]) -> usize { - ietf::request_history(ids) -} - -/// Admit a draft-16 window for the request-ID benchmark, returning the accepted count. -pub fn request_ids(window: u64, ids: &[u64]) -> usize { - ietf::request_ids(window, ids) -} - /// A source-selection watch for the private request-lifetime benchmark. pub struct RequestValidity(crate::model::broadcast::RequestValidity); diff --git a/rs/moq-net/src/goaway.rs b/rs/moq-net/src/goaway.rs index 6fa14f4c988..f5e2cb12c5c 100644 --- a/rs/moq-net/src/goaway.rs +++ b/rs/moq-net/src/goaway.rs @@ -39,7 +39,7 @@ pub struct Goaway { /// How long the sender waits before force-closing with /// [`Error::GoawayTimeout`]. `None` means no deadline. /// - /// Only moq-transport draft-17+ carries this on the wire. Elsewhere it still + /// Only moq-transport carries this on the wire. Elsewhere it still /// applies locally, so the sender enforces a deadline the peer cannot see. pub(crate) timeout: Option, } diff --git a/rs/moq-net/src/ietf/active_count.rs b/rs/moq-net/src/ietf/active_count.rs index 24b55d1a096..2b37334c255 100644 --- a/rs/moq-net/src/ietf/active_count.rs +++ b/rs/moq-net/src/ietf/active_count.rs @@ -6,8 +6,6 @@ //! quiet. When both endpoints declare the ACTIVE_COUNT option, the publisher puts on its //! REQUEST_OK how many NAMESPACE messages come before the subscription is synced. -use super::Version; - /// ACTIVE_COUNT Setup Option: the sender counts the NAMESPACE messages before a /// subscription is synced, and reads the peer's count. Even, so the value is a bare varint. pub const ACTIVE_COUNT: u64 = 0x40B64; @@ -17,23 +15,15 @@ pub const ACTIVE_COUNT: u64 = 0x40B64; /// Even, so the value is a bare varint. pub const ACTIVE_COUNT_PARAM: u64 = 0x40B66; -/// Whether this version answers SUBSCRIBE_NAMESPACE with NAMESPACE messages. Draft-14/15 -/// answer with PUBLISH_NAMESPACE requests instead, which the stream cannot count. -fn supported(version: Version) -> bool { - !matches!(version, Version::Draft14 | Version::Draft15) -} - /// Whether the peer declared the option. We always declare it, so this alone decides /// whether the extension is negotiated. -pub fn from_setup(params: &super::Parameters, version: Version) -> bool { - supported(version) && params.get_varint(super::ParameterVarInt::ActiveCount).is_some() +pub fn from_setup(params: &super::Parameters) -> bool { + params.get_varint(super::ParameterVarInt::ActiveCount).is_some() } /// Declare that we count and read the peer's count. -pub fn into_setup(params: &mut super::Parameters, version: Version) { - if supported(version) { - params.set_varint(super::ParameterVarInt::ActiveCount, 1); - } +pub fn into_setup(params: &mut super::Parameters) { + params.set_varint(super::ParameterVarInt::ActiveCount, 1); } #[cfg(test)] @@ -41,12 +31,10 @@ mod tests { use super::*; #[test] - fn only_versions_with_namespace_negotiate() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft22] { - let mut params = super::super::Parameters::default(); - assert!(!from_setup(¶ms, version)); - into_setup(&mut params, version); - assert_eq!(from_setup(¶ms, version), supported(version), "{version:?}"); - } + fn declared_option_negotiates() { + let mut params = super::super::Parameters::default(); + assert!(!from_setup(¶ms)); + into_setup(&mut params); + assert!(from_setup(¶ms)); } } diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs deleted file mode 100644 index 43a02812e08..00000000000 --- a/rs/moq-net/src/ietf/adapter.rs +++ /dev/null @@ -1,2701 +0,0 @@ -use crate::driver::Transport; -use crate::transport::poll::Session as _; -use std::{ - collections::{HashMap, HashSet, VecDeque}, - sync::{Arc, Mutex, Weak}, - task::{Context, Poll}, -}; - -use bytes::{Buf, Bytes, BytesMut}; - -use crate::{ - Error, Path, - coding::{Decode, Decoder, Encoder, Reader, Writer}, - ietf::{self, RequestId}, -}; - -use super::{Control, Message, Version, control::RequestPermit}; - -// === Message Queues === - -struct QueueState { - messages: VecDeque, - closed: bool, -} - -/// An unbounded FIFO over [`kio::Shared`]: any clone can push or pop. -/// -/// Closure is a plain flag rather than handle liveness: [`close`](Self::close) -/// (or dropping a [`QueueWriter`]) stops future pushes, and the reader drains -/// what's buffered before seeing `None`. -struct Queue(kio::Shared>); - -impl Queue { - fn new() -> Self { - Self(kio::Shared::new(QueueState { - messages: VecDeque::new(), - closed: false, - })) - } - - /// Enqueue a message. Returns false once the queue is closed (the message is - /// dropped), matching a write to a stream whose reader is gone. - fn push(&self, item: T) -> bool { - self.offer(item).is_none() - } - - /// Enqueue a message, handing it back once the queue is closed, for a caller that - /// must not drop it where it is. - fn offer(&self, item: T) -> Option { - let mut state = self.0.lock(); - if state.closed { - return Some(item); - } - state.messages.push_back(item); - None - } - - /// Dequeue the next message, waiting while the queue is open and empty. - /// Returns `None` once the queue is closed and drained. - #[cfg(test)] - async fn pop(&self) -> Option { - kio::wait(|waiter| self.poll_pop(waiter)).await - } - - /// Poll for the next message; ready with `None` once closed and drained. - fn poll_pop(&self, waiter: &kio::Waiter) -> Poll> { - self.0 - .poll(waiter, |state| { - if state.messages.is_empty() && !state.closed { - Poll::Pending - } else { - Poll::Ready(()) - } - }) - .map(|mut state| state.messages.pop_front()) - } - - /// Stop future pushes; buffered messages still drain, then `poll_pop` returns `None`. - fn close(&self) { - self.0.lock().closed = true; - } - - /// Mint the single owning push handle whose `Drop` closes the queue. - fn writer(&self) -> QueueWriter { - QueueWriter(Self(self.0.clone())) - } -} - -// Manual impls: derives would demand `T: Clone`/`T: Default`, which the queued -// stream pairs don't satisfy. -impl Clone for Queue { - fn clone(&self) -> Self { - Self(self.0.clone()) - } -} - -impl Default for Queue { - fn default() -> Self { - Self::new() - } -} - -/// Single-owner push handle: dropping it closes the queue, which the reading -/// [`VirtualRecvStream`] observes as a FIN after draining. -struct QueueWriter(Queue); - -impl QueueWriter { - /// Enqueue a message, returning false if the reader already closed its side. - fn push(&self, item: T) -> bool { - self.0.push(item) - } -} - -impl Drop for QueueWriter { - fn drop(&mut self) { - self.0.close(); - } -} - -// === Virtual Streams === - -/// A virtual receive stream backed by an initial message buffer and a queue of follow-up messages. -pub struct VirtualRecvStream { - buffer: Bytes, - rx: Queue, - closed: bool, - park: kio::Park, - shared: Weak, - /// The request this stream serves, known up front when the peer opened it and - /// filled in by the first write when we did. - request_id: Arc>>, - /// Holds the peer's request open against our MAX_REQUEST_ID window until the - /// handler drops this stream. Only on a stream the peer opened. - _permit: Option, -} - -impl VirtualRecvStream { - fn new(initial: Bytes, rx: Queue, shared: Arc, request_id: Arc>>) -> Self { - Self { - buffer: initial, - rx, - closed: false, - park: kio::Park::default(), - shared: Arc::downgrade(&shared), - request_id, - _permit: None, - } - } - - /// Fill the buffer from the queue if empty; `self.closed` marks the FIN. - fn poll_fill(&mut self, cx: &mut Context<'_>) -> Poll<()> { - if !self.buffer.is_empty() || self.closed { - return Poll::Ready(()); - } - - let waiter = self.park.hold(cx); - match self.rx.poll_pop(waiter) { - Poll::Ready(Some(data)) => { - self.buffer = data; - Poll::Ready(()) - } - Poll::Ready(None) => { - self.closed = true; - Poll::Ready(()) - } - Poll::Pending => Poll::Pending, - } - } -} - -impl crate::transport::poll::Chunks for VirtualRecvStream { - type Error = crate::Error; - - fn poll_read_chunk(&mut self, cx: &mut Context<'_>, max: usize) -> Poll, Self::Error>> { - if max == 0 { - return Poll::Ready(Ok(Some(Bytes::new()))); - } - - std::task::ready!(self.poll_fill(cx)); - if self.buffer.is_empty() { - return Poll::Ready(Ok(None)); - } - - let n = max.min(self.buffer.len()); - Poll::Ready(Ok(Some(self.buffer.split_to(n)))) - } - - fn stop(&mut self, _code: u32) { - self.rx.close(); - } -} - -impl Drop for VirtualRecvStream { - fn drop(&mut self) { - // The reader is gone: close the queue so routed follow-ups are discarded - // instead of buffering forever while the map entry lingers. - self.rx.close(); - - // Release the routing entries too, so a request torn down locally gives its - // namespace back and the same one can be advertised again. - if let Some(request_id) = *self.request_id.lock().unwrap() - && let Some(shared) = self.shared.upgrade() - { - shared.forget(request_id); - } - } -} - -/// A virtual send stream that forwards writes to the shared control stream writer. -/// -/// For streams created by `open_bi` (outgoing requests), this also parses -/// the first outgoing message to extract the request_id and register the -/// stream for response routing. -pub struct VirtualSendStream { - control_tx: Queue, - /// Present only for outgoing requests (from open_bi). - /// Accumulates bytes until the request_id can be parsed, - /// then registers the stream and flushes. - pending: Option, - /// Draft-16 only, once registered: what follows the request, held until whole so - /// each REQUEST_UPDATE is routed back to its request before it leaves. - updates: Option, -} - -struct OutgoingRegistration { - follow_tx: QueueWriter, - shared: Arc, - version: Version, - buf: BytesMut, - /// Shared with the matching [`VirtualRecvStream`] so it can release this request's - /// routing entries once the reader is dropped. - request_id: Arc>>, -} - -impl OutgoingRegistration { - /// Try to parse the request_id (and optionally namespace) from the accumulated bytes, - /// with the length of the message that carried it. - /// Returns Ok(None) if not enough data yet, Err if the message is malformed. - fn try_parse(&self) -> Result, crate::Error> { - let Some((type_id, len, mut body)) = whole_message(&self.buf, self.version) else { - return Ok(None); - }; - - // We have enough bytes for the full message; decoding must succeed. - let request_id = RequestId::decode(&mut body, self.version)?; - - // For PublishNamespace, also extract the namespace for reverse lookup. - if type_id == ietf::PublishNamespace::ID { - if self.version == Version::Draft17 { - // v17 has required_request_id_delta after request_id - let _ = body.varint(); - } - if let Ok(ns) = crate::ietf::namespace::decode_namespace(&mut body) { - self.shared.namespaces.register(Direction::Outgoing, ns, request_id); - } - } - - Ok(Some((request_id, len))) - } - - fn register(self, request_id: RequestId) { - // Serialize publication with receive-half cleanup, including a drop before - // the first write supplied the request id. - let mut registered = self.request_id.lock().unwrap(); - *registered = Some(request_id); - let closed = self.follow_tx.0.0.lock().closed; - if closed { - self.shared.forget(request_id); - } else { - self.shared.streams.lock().unwrap().insert(request_id, self.follow_tx); - } - } -} - -impl VirtualSendStream { - fn new(control_tx: Queue) -> Self { - Self { - control_tx, - pending: None, - updates: None, - } - } - - fn with_registration(control_tx: Queue, pending: OutgoingRegistration) -> Self { - Self { - control_tx, - pending: Some(pending), - updates: None, - } - } -} - -impl VirtualSendStream { - /// Route a chunk: accumulate while the request_id is still unknown, then - /// register and flush; forward directly afterwards. Never blocks, since the - /// control queue is unbounded. - fn push(&mut self, chunk: Bytes) -> Result<(), crate::Error> { - if let Some(pending) = &mut self.pending { - pending.buf.extend_from_slice(&chunk); - - if let Some((request_id, len)) = pending.try_parse()? { - let mut pending = self.pending.take().unwrap(); - let mut buf = std::mem::take(&mut pending.buf); - let rest = buf.split_off(len); - if pending.version == Version::Draft16 { - self.updates = Some(UpdateRouting { - shared: Arc::clone(&pending.shared), - version: pending.version, - request_id, - buf: BytesMut::new(), - }); - } - pending.register(request_id); - if !self.control_tx.push(buf.freeze()) { - return Err(crate::Error::Closed); - } - if !rest.is_empty() { - return self.push(rest.freeze()); - } - } - } else if let Some(updates) = &mut self.updates { - updates.buf.extend_from_slice(&chunk); - while let Some(message) = updates.next()? { - if !self.control_tx.push(message) { - return Err(crate::Error::Closed); - } - } - } else if !self.control_tx.push(chunk) { - return Err(crate::Error::Closed); - } - - Ok(()) - } -} - -/// Draft-16 answers a REQUEST_UPDATE on the control stream by the update's own Request -/// ID, which names no stream, so each update we send is recorded against the request it -/// rides, in [`Shared::updates`], before it leaves. Drafts 14 and 15 answer no update of -/// ours that anything reads. -struct UpdateRouting { - shared: Arc, - version: Version, - /// The request whose stream carries the updates. - request_id: RequestId, - buf: BytesMut, -} - -impl UpdateRouting { - /// The next whole message, recorded first when it is an update. - fn next(&mut self) -> Result, crate::Error> { - let Some((type_id, len, mut body)) = whole_message(&self.buf, self.version) else { - return Ok(None); - }; - if type_id == ietf::SubscribeUpdate::ID { - let own = RequestId::decode(&mut body, self.version)?; - self.shared.route_update(own, self.request_id); - } - Ok(Some(self.buf.split_to(len).freeze())) - } -} - -/// The type, whole length, and body of the first message in `buf`, once all of it is there. -fn whole_message(buf: &[u8], version: Version) -> Option<(u64, usize, Decoder<'_>)> { - let mut r = Decoder::new(buf, version.into()); - let type_id = r.varint().ok()?; - let size = r.u16().ok()? as usize; - let len = buf.len() - r.remaining() + size; - let body = r.sub(size).ok()?; - Some((type_id, len, body)) -} - -impl crate::transport::poll::SendStream for VirtualSendStream { - type Error = crate::Error; - type Group = crate::transport::poll::Ungrouped; - - fn poll_write(&mut self, _cx: &mut Context<'_>, buf: &[u8]) -> Poll> { - let len = buf.len(); - Poll::Ready(self.push(Bytes::copy_from_slice(buf)).map(|()| len)) - } - - fn poll_write_buf(&mut self, _cx: &mut Context<'_>, buf: &mut B) -> Poll> { - // Taking the whole buffer is safe because this never returns Pending; a - // `Bytes` source hands its chunk over without a copy. - let len = buf.remaining(); - let chunk = buf.copy_to_bytes(len); - Poll::Ready(self.push(chunk).map(|()| len)) - } - - fn set_priority(&mut self, _order: i64) {} - - fn set_group(&mut self, group: &Self::Group) { - match *group {} - } - - fn finish(&mut self) -> Result<(), Self::Error> { - // Flush any remaining buffered data (e.g. if registration never completed). - if let Some(pending) = self.pending.take() - && !pending.buf.is_empty() - { - let _ = self.control_tx.push(pending.buf.freeze()); - } - if let Some(updates) = self.updates.take() - && !updates.buf.is_empty() - { - let _ = self.control_tx.push(updates.buf.freeze()); - } - Ok(()) - } - - fn reset(&mut self, _code: u32) {} - - fn poll_closed(&mut self, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } -} - -// === Adapter Send/Recv Enums === - -/// A failure on an adapter stream. A real stream keeps its transport's error, so the negotiated -/// registry decodes a peer's reset rather than mistaking it for a lost connection. -/// Virtual streams have only local errors. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum AdapterError { - #[error(transparent)] - Real(E), - #[error(transparent)] - Virtual(crate::Error), -} - -impl crate::transport::Error for AdapterError { - fn close_kind(&self) -> crate::transport::CloseKind { - match self { - Self::Real(err) => err.close_kind(), - Self::Virtual(err) => err.close_kind(), - } - } - - fn session_error(&self) -> Option<(u32, String)> { - match self { - Self::Real(err) => err.session_error(), - Self::Virtual(err) => err.session_error(), - } - } - - fn stream_error(&self) -> Option { - match self { - Self::Real(err) => err.stream_error(), - Self::Virtual(err) => err.stream_error(), - } - } -} - -pub(crate) enum AdapterSend { - Real(crate::transport::boxed::SendStream), - Virtual(VirtualSendStream), -} - -impl crate::transport::poll::SendStream for AdapterSend { - type Error = AdapterError<::Error>; - type Group = ::Group; - - fn poll_write(&mut self, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { - match self { - Self::Real(s) => s.poll_write(cx, buf).map_err(AdapterError::Real), - Self::Virtual(s) => s.poll_write(cx, buf).map_err(AdapterError::Virtual), - } - } - - fn poll_write_buf(&mut self, cx: &mut Context<'_>, buf: &mut B) -> Poll> { - match self { - Self::Real(s) => s.poll_write_buf(cx, buf).map_err(AdapterError::Real), - Self::Virtual(s) => s.poll_write_buf(cx, buf).map_err(AdapterError::Virtual), - } - } - - fn set_priority(&mut self, order: i64) { - match self { - Self::Real(s) => s.set_priority(order), - Self::Virtual(s) => s.set_priority(order), - } - } - - fn set_group(&mut self, group: &Self::Group) { - match self { - Self::Real(s) => s.set_group(group), - // Rides the control stream, which no group schedules. - Self::Virtual(_) => {} - } - } - - fn finish(&mut self) -> Result<(), Self::Error> { - match self { - Self::Real(s) => s.finish().map_err(AdapterError::Real), - Self::Virtual(s) => s.finish().map_err(AdapterError::Virtual), - } - } - - fn reset(&mut self, code: u32) { - match self { - Self::Real(s) => s.reset(code), - Self::Virtual(s) => s.reset(code), - } - } - - fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { - match self { - Self::Real(s) => s.poll_closed(cx).map_err(AdapterError::Real), - Self::Virtual(s) => s.poll_closed(cx).map_err(AdapterError::Virtual), - } - } -} - -#[cfg_attr( - target_pointer_width = "64", - expect( - clippy::large_enum_variant, - reason = "one per stream, so boxing a variant would add an allocation per stream to save bytes nothing holds in bulk" - ) -)] -pub(crate) enum AdapterRecv { - Real(crate::transport::boxed::RecvStream), - Virtual(crate::transport::poll::Buffered), -} - -impl crate::transport::poll::RecvStream for AdapterRecv { - type Error = AdapterError<::Error>; - type Lease<'a> = AdapterLease<'a>; - - fn poll_lease(&mut self, cx: &mut Context<'_>, want: usize) -> Poll, Self::Error>> { - match self { - Self::Real(s) => s - .poll_lease(cx, want) - .map(|r| r.map(AdapterLease::Real).map_err(AdapterError::Real)), - Self::Virtual(s) => s - .poll_lease(cx, want) - .map(|r| r.map(AdapterLease::Virtual).map_err(AdapterError::Virtual)), - } - } - - fn set_priority(&mut self, priority: i64) { - match self { - Self::Real(s) => s.set_priority(priority), - // Rides the control stream, which carries its credit. - Self::Virtual(_) => {} - } - } - - fn stop(&mut self, code: u32) { - match self { - Self::Real(s) => s.stop(code), - Self::Virtual(s) => s.stop(code), - } - } -} - -/// A [`Lease`](crate::transport::poll::Lease) of an [`AdapterRecv`]. -pub(crate) enum AdapterLease<'a> { - Real(::Lease<'a>), - Virtual(crate::transport::poll::BufferedLease<'a>), -} - -impl Buf for AdapterLease<'_> { - fn remaining(&self) -> usize { - match self { - Self::Real(l) => l.remaining(), - Self::Virtual(l) => l.remaining(), - } - } - - fn chunk(&self) -> &[u8] { - match self { - Self::Real(l) => l.chunk(), - Self::Virtual(l) => l.chunk(), - } - } - - fn advance(&mut self, n: usize) { - match self { - Self::Real(l) => l.advance(n), - Self::Virtual(l) => l.advance(n), - } - } - - fn copy_to_bytes(&mut self, n: usize) -> Bytes { - match self { - Self::Real(l) => l.copy_to_bytes(n), - Self::Virtual(l) => l.copy_to_bytes(n), - } - } -} - -impl crate::transport::poll::Lease for AdapterLease<'_> { - fn is_fin(&self) -> bool { - match self { - Self::Real(l) => l.is_fin(), - Self::Virtual(l) => l.is_fin(), - } - } - - fn chunks(&self) -> impl Iterator { - let (real, virt) = match self { - Self::Real(l) => (Some(l.chunks()), None), - Self::Virtual(l) => (None, Some(l.chunks())), - }; - real.into_iter().flatten().chain(virt.into_iter().flatten()) - } - - fn release(self) { - match self { - Self::Real(l) => l.release(), - Self::Virtual(l) => l.release(), - } - } -} - -// === Control Stream Adapter === - -/// Which request a namespace names: an advertisement, by the side that made it, or the -/// peer's namespace subscription. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Direction { - /// We advertised it to the peer, on a stream from `open_bi`. - Outgoing, - /// The peer advertised it to us, on a stream handed to `accept_bi`. - Incoming, - /// The peer subscribed to it with SUBSCRIBE_NAMESPACE, on a stream handed to - /// `accept_bi`. Only draft-14's UNSUBSCRIBE_NAMESPACE names one. - Subscribed, -} - -/// Namespace → request_id reverse lookup for the v14/v15 messages that name a -/// namespace instead of a request id. -/// -/// Direction is part of the key because a cluster mesh has both endpoints -/// advertising the same namespace to each other. One map would let the second -/// advertisement overwrite the first, and a terminal message would then tear -/// down the stream travelling the other way while its real target stayed open. -/// A subscription keeps a map of its own for the same reason: a peer may both -/// advertise a namespace and subscribe to it. -#[derive(Default)] -struct Namespaces { - state: Mutex, -} - -#[derive(Default)] -struct NamespacesState { - outgoing: HashMap, - incoming: HashMap, - subscribed: HashMap, - - /// request_id → the namespace that request advertised, one entry per request - /// including queued duplicates, so [`Namespaces::forget`] removes only that - /// request from its namespace. The two directions share this map because - /// moq-transport gives each peer its own request id space. - by_request: HashMap, -} - -struct NamespaceRequests { - first: RequestId, - last: RequestId, -} - -struct NamespaceRegistration { - direction: Direction, - namespace: Path, - previous: Option, - next: Option, -} - -impl NamespacesState { - fn map(&mut self, direction: Direction) -> &mut HashMap { - match direction { - Direction::Outgoing => &mut self.outgoing, - Direction::Incoming => &mut self.incoming, - Direction::Subscribed => &mut self.subscribed, - } - } -} - -impl Namespaces { - /// Record a request's namespace, leaving a live advertisement of the same name alone. - /// - /// The first request in a direction owns the name until it ends. A peer that - /// advertises the same namespace twice would otherwise move the name onto the second - /// request, and the withdrawal it eventually names would close that one while the - /// first stayed open, advertised for the life of the session. - fn register(&self, direction: Direction, namespace: Path, request_id: RequestId) { - let mut state = self.state.lock().unwrap(); - if state.by_request.contains_key(&request_id) { - return; - } - let previous = match state.map(direction).entry(namespace.clone()) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - Some(std::mem::replace(&mut entry.get_mut().last, request_id)) - } - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert(NamespaceRequests { - first: request_id, - last: request_id, - }); - None - } - }; - if let Some(previous) = previous { - state.by_request.get_mut(&previous).unwrap().next = Some(request_id); - } - state.by_request.insert( - request_id, - NamespaceRegistration { - direction, - namespace, - previous, - next: None, - }, - ); - } - - /// The namespace the peer's request `request_id` advertises, if it is a PUBLISH_NAMESPACE. - fn incoming(&self, request_id: RequestId) -> Option { - let state = self.state.lock().unwrap(); - let registration = state.by_request.get(&request_id)?; - (registration.direction == Direction::Incoming).then(|| registration.namespace.clone()) - } - - fn get(&self, direction: Direction, namespace: &Path) -> Option { - self.state - .lock() - .unwrap() - .map(direction) - .get(namespace) - .map(|requests| requests.first) - } - - /// Release whatever a finished request holds, so its namespace can be advertised again. - fn forget(&self, request_id: RequestId) { - let mut state = self.state.lock().unwrap(); - let Some(registration) = state.by_request.remove(&request_id) else { - return; - }; - - // Link around the removed request without scanning a burst of duplicates. - if let Some(previous) = registration.previous { - state.by_request.get_mut(&previous).unwrap().next = registration.next; - } - if let Some(next) = registration.next { - state.by_request.get_mut(&next).unwrap().previous = registration.previous; - } - let map = state.map(registration.direction); - if registration.previous.is_none() && registration.next.is_none() { - map.remove(®istration.namespace); - } else { - let requests = map.get_mut(®istration.namespace).unwrap(); - if requests.first == request_id { - requests.first = registration.next.unwrap(); - } - if requests.last == request_id { - requests.last = registration.previous.unwrap(); - } - } - } -} - -#[derive(Default)] -struct Shared { - /// New virtual streams for accept_bi; any caller can pop directly. - incoming: Queue<(VirtualSendStream, VirtualRecvStream)>, - - /// Messages every VirtualSendStream writes; the run_write task drains this - /// onto the real control stream. - control: Queue, - - /// Active virtual streams keyed by request_id. Removing an entry drops its - /// writer, which FINs the matching VirtualRecvStream. - streams: Mutex>>, - - /// Draft-16: our REQUEST_UPDATEs' own Request IDs → the open request each updates, so - /// the one answer each is owed reaches that request's stream. Bounded by what we sent, - /// and an entry goes with its answer or its request. Locked after `streams`. - updates: Mutex>, - - /// Namespace → request_id reverse lookup (for v14/v15 namespace-keyed messages). - namespaces: Namespaces, - - /// The peer's advertisements held back behind a withdrawal still being handled. - readvertised: Mutex, -} - -/// Keeps the peer's withdrawal and re-advertisement of a namespace in wire order. -/// -/// Each request is served by its own handler, so a PUBLISH_NAMESPACE that follows a -/// PUBLISH_NAMESPACE_DONE for the same namespace could otherwise be handled first. It -/// would then read as a second advertisement of a namespace still advertised, and the -/// withdrawal as the end of the first, so the route never ends and starts again. The -/// re-advertisement reaches `accept_bi` only once the withdrawn request's handler drops -/// its stream, which it does after releasing the route. Bounded by the peer's requests. -#[derive(Default)] -struct Readvertised { - namespaces: HashMap, - /// Each withdrawn request a handler still holds, to the namespace it advertised. - withdrawn: HashMap, -} - -#[derive(Default)] -struct Withdrawing { - /// Withdrawn requests whose handlers still hold their streams. - handling: HashSet, - /// The advertisements behind them, in wire order. - held: VecDeque, -} - -struct HeldRequest { - request_id: RequestId, - stream: (VirtualSendStream, VirtualRecvStream), - /// Withdrawn while held, so the advertisements behind it wait for its handler too. - withdrawn: bool, -} - -impl Shared { - /// Mint the local half of a request we're about to send. Registration waits - /// until the first write reveals the request_id. - fn open_outgoing(self: &Arc, version: Version) -> (VirtualSendStream, VirtualRecvStream) { - let follow = Queue::new(); - let request_id = Arc::new(Mutex::new(None)); - let recv = VirtualRecvStream::new(Bytes::new(), follow.clone(), Arc::clone(self), Arc::clone(&request_id)); - let send = VirtualSendStream::with_registration( - self.control.clone(), - OutgoingRegistration { - follow_tx: follow.writer(), - shared: Arc::clone(self), - version, - buf: BytesMut::new(), - request_id, - }, - ); - (send, recv) - } - - /// Register the peer's new request and queue its stream for accept_bi. - fn open_incoming( - self: &Arc, - request_id: RequestId, - raw: Bytes, - permit: Option, - ) -> Result<(), Error> { - let follow = Queue::new(); - let mut recv = VirtualRecvStream::new( - raw, - follow.clone(), - Arc::clone(self), - Arc::new(Mutex::new(Some(request_id))), - ); - recv._permit = permit; - let send = VirtualSendStream::new(self.control.clone()); - self.streams.lock().unwrap().insert(request_id, follow.writer()); - if let Some(namespace) = self.namespaces.incoming(request_id) { - let mut readvertised = self.readvertised.lock().unwrap(); - if let Some(withdrawing) = readvertised.namespaces.get_mut(&namespace) { - withdrawing.held.push_back(HeldRequest { - request_id, - stream: (send, recv), - withdrawn: false, - }); - return Ok(()); - } - } - if !self.incoming.push((send, recv)) { - return Err(Error::Closed); - } - Ok(()) - } - - /// Deliver a message to an open virtual stream, dropping it if the stream is gone. - /// An answer to one of our updates goes to the request it updated. - /// Returns whether a stream took it. - fn push(&self, request_id: RequestId, raw: Bytes) -> bool { - let streams = self.streams.lock().unwrap(); - let request_id = self.updates.lock().unwrap().remove(&request_id).unwrap_or(request_id); - match streams.get(&request_id) { - Some(tx) => tx.push(raw), - None => false, - } - } - - /// Deliver a final message and FIN the stream: the removed writer drops after it. - /// A refusal of one of our updates is only its answer, which leaves the request open. - fn close(&self, request_id: RequestId, raw: Bytes) { - if self.updates.lock().unwrap().contains_key(&request_id) { - self.push(request_id, raw); - return; - } - let tx = self.streams.lock().unwrap().remove(&request_id); - let namespace = self.namespaces.incoming(request_id); - self.namespaces.forget(request_id); - let Some(tx) = tx else { return }; - let Some(namespace) = namespace else { - tx.push(raw); - return; - }; - - // Under the lock, so a handler dropping its stream either refuses the push or - // finds the withdrawal recorded. - let mut readvertised = self.readvertised.lock().unwrap(); - if !tx.push(raw) { - return; - } - let withdrawing = readvertised.namespaces.entry(namespace.clone()).or_default(); - match withdrawing.held.iter_mut().find(|held| held.request_id == request_id) { - Some(held) => held.withdrawn = true, - None => { - withdrawing.handling.insert(request_id); - readvertised.withdrawn.insert(request_id, namespace); - } - } - } - - /// Expect the answer to our update `own` on `request_id`'s stream, while it is open. - fn route_update(&self, own: RequestId, request_id: RequestId) { - let streams = self.streams.lock().unwrap(); - if streams.contains_key(&request_id) { - self.updates.lock().unwrap().insert(own, request_id); - } - } - - /// Drop every routing entry a request owns, whether or not its stream is still around. - fn forget(&self, request_id: RequestId) { - let mut streams = self.streams.lock().unwrap(); - streams.remove(&request_id); - let mut updates = self.updates.lock().unwrap(); - if !updates.is_empty() { - updates.retain(|_, updated| *updated != request_id); - } - drop((updates, streams)); - self.namespaces.forget(request_id); - self.handled(request_id); - } - - /// A withdrawn request's handler is done with it: once none of the namespace's - /// withdrawals is still being handled, hand over what was held behind them, up to and - /// including the next one withdrawn. - fn handled(&self, request_id: RequestId) { - // What a closed session refused, dropped only once the lock is released, since - // dropping a stream lands back here. - let mut refused = Vec::new(); - let mut readvertised = self.readvertised.lock().unwrap(); - let Some(namespace) = readvertised.withdrawn.remove(&request_id) else { - return; - }; - let Readvertised { namespaces, withdrawn } = &mut *readvertised; - let Some(withdrawing) = namespaces.get_mut(&namespace) else { - return; - }; - withdrawing.handling.remove(&request_id); - while withdrawing.handling.is_empty() { - let Some(held) = withdrawing.held.pop_front() else { - namespaces.remove(&namespace); - break; - }; - if let Some(stream) = self.incoming.offer(held.stream) { - // The session is gone, so nothing held behind this one is accepted either. - refused.push(stream); - refused.extend(withdrawing.held.drain(..).map(|held| held.stream)); - namespaces.remove(&namespace); - break; - } - if held.withdrawn { - withdrawing.handling.insert(held.request_id); - withdrawn.insert(held.request_id, namespace.clone()); - } - } - drop(readvertised); - drop(refused); - } -} - -#[derive(Clone)] -pub(crate) struct ControlStreamAdapter { - stats: crate::stats::Session, - inner: Transport, - shared: Arc, - control: Control, - version: Version, - // Bridges Context-based polls onto the kio queues; empty on clone. - park: kio::Park, -} - -impl ControlStreamAdapter { - pub fn new(inner: Transport, control: Control, version: Version) -> Self { - Self { - stats: Default::default(), - inner, - shared: Arc::new(Shared::default()), - control, - version, - park: kio::Park::default(), - } - } - - pub(super) fn with_stats(mut self, stats: &crate::stats::Session) -> Self { - self.stats = stats.clone(); - self - } - - /// Open a real (non-virtual) bidi stream, bypassing control stream multiplexing. - /// Used for v16 SubscribeNamespace which moved to its own bidi stream. - pub async fn open_native_bi(&mut self) -> Result<(AdapterSend, AdapterRecv), crate::Error> { - let (send, recv) = kio::wait(|waiter| self.inner.poll_open_request(&mut waiter.context(), Default::default())) - .await - .map_err(crate::Error::from_transport)? - .ok_or(crate::Error::NoStreamCredit)?; - Ok((AdapterSend::Real(send), AdapterRecv::Real(recv))) - } - - /// Run the control stream read + write tasks. - /// This reads from the control stream and routes messages to virtual streams, - /// and also drains the write queue to the control stream writer. A decoded - /// GOAWAY is surfaced through `goaway` so [`crate::Session::recv_goaway`] resolves - /// instead of the session tearing down. - pub async fn run( - &self, - reader: Reader, - writer: Writer, - goaway: crate::goaway::Protocol, - ) -> Result<(), Error> { - let res = { - let mut read = std::pin::pin!(self.run_read(reader, goaway)); - let mut write = std::pin::pin!(self.run_write(writer)); - kio::wait(|waiter| { - if let Poll::Ready(res) = waiter.poll_future(read.as_mut()) { - return Poll::Ready(res); - } - // Queued before the write drains, so a grant goes out in the same turn. - while let Poll::Ready(max) = self.control.poll_grant(waiter) { - self.send_max_request_id(max); - } - if let Poll::Ready(res) = waiter.poll_future(write.as_mut()) { - return Poll::Ready(res); - } - Poll::Pending - }) - .await - }; - - // The control stream is dead, so the mux is too: fail future virtual - // writes fast and end accept_bi instead of buffering into a queue nobody - // will ever drain. - self.shared.control.close(); - self.shared.incoming.close(); - - res - } - - /// Queue a GOAWAY message onto the shared control stream (draft-14-16). - /// - /// Framed as `[type_id varint][size u16][body]` like every other control - /// message. Best-effort: an encode failure or a closed control stream is - /// logged and dropped, since the session is tearing down anyway. - pub(super) fn send_goaway(&self, uri: &str, timeout_ms: u64, version: Version) { - let msg = crate::ietf::GoAway { - new_session_uri: std::borrow::Cow::Borrowed(uri), - timeout: timeout_ms, - request_id: None, - }; - - if let Err(err) = self.queue_message(&msg, version) { - tracing::warn!(%err, "failed to encode goaway"); - } - } - - /// Refuse an update no request here can apply, naming its own Request ID. - fn refuse_update(&self, request_id: RequestId) { - if self.version == Version::Draft14 { - return; - } - let msg = ietf::RequestError { - request_id: Some(request_id), - error_code: super::error::request::to_code( - &crate::Error::Unsupported, - super::error::request::Kind::Subscribe, - self.version, - ), - reason_phrase: "update not supported".into(), - retry_interval: 0, - }; - if let Err(err) = self.queue_message(&msg, self.version) { - tracing::warn!(%err, "failed to encode REQUEST_ERROR"); - } - } - - /// Queue a MAX_REQUEST_ID granting the peer room up to `max`. - fn send_max_request_id(&self, max: RequestId) { - if let Err(err) = self.queue_message(&ietf::MaxRequestId { request_id: max }, self.version) { - tracing::warn!(%err, "failed to encode MAX_REQUEST_ID"); - } - } - - fn queue_message(&self, msg: &T, version: Version) -> Result<(), Error> { - use crate::coding::Encode; - let mut raw = Vec::new(); - let mut encoder = Encoder::new(&mut raw, version.into()); - encoder.varint(T::ID)?; - msg.encode(&mut encoder, version)?; - let selector = crate::coding::varint::size(T::ID, version.into()).map_err(crate::coding::EncodeError::from)?; - self.stats - .control(super::message::control(T::ID, version), raw.len() - selector, false); - if !self.shared.control.push(raw.into()) { - tracing::debug!("control stream closed; message not sent"); - } - Ok(()) - } - - /// Writer task: drains the queue and writes to the control stream. The peer's - /// STOP_SENDING, seen while idle or as a failed write, is a protocol violation. - async fn run_write(&self, mut writer: Writer) -> Result<(), Error> { - loop { - let msg = kio::wait(|waiter| { - // Never finished, so any end is the peer's or the session's. - if let Poll::Ready(res) = writer.poll_closed(&mut waiter.context()) { - let err = res - .err() - .map_or(Error::ProtocolViolation, super::session::control_stream_error); - return Poll::Ready(Err(err)); - } - self.shared.control.poll_pop(waiter).map(Ok) - }) - .await?; - let Some(msg) = msg else { break }; - let mut buf = std::io::Cursor::new(msg); - writer - .write_all(&mut buf) - .await - .map_err(super::session::control_stream_error)?; - } - Ok(()) - } - - /// Dispatcher loop that reads control stream messages and routes them. - async fn run_read( - &self, - mut reader: Reader, - goaway: crate::goaway::Protocol, - ) -> Result<(), Error> { - loop { - let type_id = match reader - .varint_maybe() - .await - .map_err(super::session::control_stream_error)? - { - Some(id) => id, - None => return Err(Error::ProtocolViolation), - }; - - let body = reader - .decode::() - .await - .map_err(super::session::control_stream_error)? - .0; - - self.stats - .control(super::message::control(type_id, self.version), body.len() + 2, true); - - // Reconstruct raw message bytes: [type_id][size][body] - let raw = encode_raw(type_id, &body, self.version); - - // Classify and route - match classify(type_id, &body, self.version, &self.shared.namespaces)? { - Route::NewRequest(request_id) => { - let permit = self.control.accept(request_id)?; - self.shared.open_incoming(request_id, raw, permit)? - } - Route::Response(request_id) => { - self.shared.push(request_id, raw); - } - Route::FollowUp { own, target } => { - // SUBSCRIBE_UPDATE takes a request ID of its own (draft-14 section 9.1), - // done as soon as it is delivered, so it is granted straight back. - drop(self.control.accept(own)?); - // An update whose request is gone, or rides a stream of its own like draft 16's - // SUBSCRIBE_NAMESPACE, is still owed its one answer. - if !self.shared.push(target, raw) { - self.refuse_update(own); - } - } - Route::CloseStream(request_id) => self.shared.close(request_id, raw), - Route::MaxRequestId(max) => self.control.max_request_id(max), - Route::Ignore => {} - Route::GoAway => { - let mut data = Decoder::new(&body, self.version.into()); - let msg = crate::ietf::GoAway::decode_msg(&mut data, self.version)?; - tracing::info!(message = ?msg, "received GOAWAY"); - - // Draft-14-16 have no timeout field. - // A second GOAWAY is a protocol violation the draft requires we - // close over, so let it propagate and end the session. - goaway.record(crate::goaway::Goaway { - uri: msg.new_session_uri.into_owned(), - timeout: None, - })?; - // Keep processing control messages: existing subscriptions - // flow until the session actually closes. - } - } - } - } -} - -/// Classify a control message and extract its request_id for routing. -/// -/// Takes the namespace map rather than reading it off the adapter so the routing -/// rules can be tested without a session behind them. -fn classify(type_id: u64, body: &Bytes, version: Version, namespaces: &Namespaces) -> Result { - match type_id { - // New requests: these create new virtual streams - ietf::Subscribe::ID => { - let id = decode_request_id(body, version)?; - Ok(Route::NewRequest(id)) - } - ietf::Fetch::ID => { - let id = decode_request_id(body, version)?; - Ok(Route::NewRequest(id)) - } - ietf::Publish::ID => { - let id = decode_request_id(body, version)?; - Ok(Route::NewRequest(id)) - } - ietf::PublishNamespace::ID => { - let id = decode_request_id(body, version)?; - // Decode the namespace and store the mapping for v14/v15 reverse lookup - if let Ok(ns) = decode_publish_namespace_body(body, version) { - namespaces.register(Direction::Incoming, ns, id); - } - Ok(Route::NewRequest(id)) - } - ietf::TrackStatus::ID => { - let id = decode_request_id(body, version)?; - Ok(Route::NewRequest(id)) - } - // SubscribeNamespace on control stream (v14/v15 only) - ietf::SubscribeNamespaceLegacy::ID => match version { - // Draft-14's UNSUBSCRIBE_NAMESPACE names the prefix rather than the request. - Version::Draft14 => { - let mut r = Decoder::new(body, version.into()); - let id = RequestId::decode(&mut r, version)?; - if let Ok(prefix) = crate::ietf::namespace::decode_namespace(&mut r) { - namespaces.register(Direction::Subscribed, prefix, id); - } - Ok(Route::NewRequest(id)) - } - Version::Draft15 => { - let id = decode_request_id(body, version)?; - Ok(Route::NewRequest(id)) - } - _ => Err(Error::UnexpectedMessage), - }, - - // 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::SubscribeTracks::ID => { - tracing::error!( - version = ?version, - "received SUBSCRIBE_TRACKS (0x51); not supported in moq-lite (no PUBLISH replication)" - ); - Err(Error::Unsupported) - } - - // Responses: route to the virtual stream waiting for a reply - ietf::SubscribeOk::ID => { - let id = decode_response_request_id(body, version)?; - Ok(Route::Response(id)) - } - // 0x05: SubscribeError in v14, RequestError in v15+ - ietf::SubscribeError::ID => { - let id = decode_response_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - ietf::FetchOk::ID => { - let id = decode_response_request_id(body, version)?; - Ok(Route::Response(id)) - } - // 0x19: FetchError in v14 only - ietf::FetchError::ID => match version { - Version::Draft14 => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - _ => Err(Error::UnexpectedMessage), - }, - // TRACK_STATUS_OK and TRACK_STATUS_ERROR (v14 only): the one answer the request gets. - ietf::TrackStatusOk::ID_14 | ietf::TRACK_STATUS_ERROR_14 => match version { - Version::Draft14 => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - _ => Err(Error::UnexpectedMessage), - }, - // PublishOk (0x1E) - ietf::PublishOk::ID => { - let id = decode_response_request_id(body, version)?; - Ok(Route::Response(id)) - } - // PublishError (0x1F) - v14 only - ietf::PublishError::ID => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - // 0x07: PublishNamespaceOk in v14, RequestOk in v15+ - ietf::PublishNamespaceOk::ID => match version { - Version::Draft14 => { - let id = decode_request_id(body, version)?; - Ok(Route::Response(id)) - } - Version::Draft15 | Version::Draft16 => { - // RequestOk - route to stream - let id = decode_response_request_id(body, version)?; - Ok(Route::Response(id)) - } - _ => Err(Error::UnexpectedMessage), - }, - // 0x08: PublishNamespaceError in v14 only - ietf::PublishNamespaceError::ID => match version { - Version::Draft14 => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - _ => Err(Error::UnexpectedMessage), - }, - // SubscribeNamespaceOk (v14 only) - ietf::SubscribeNamespaceOk::ID => match version { - Version::Draft14 => { - let id = decode_request_id(body, version)?; - Ok(Route::Response(id)) - } - _ => Err(Error::UnexpectedMessage), - }, - // SubscribeNamespaceError (v14 only) - ietf::SubscribeNamespaceError::ID => match version { - Version::Draft14 => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - _ => Err(Error::UnexpectedMessage), - }, - - // Follow-up messages: route to the request being updated, not the update's own id. - ietf::SubscribeUpdate::ID => { - let own = decode_request_id(body, version)?; - let target = decode_update_target(body, version)?; - Ok(Route::FollowUp { own, target }) - } - - // Close stream messages - ietf::Unsubscribe::ID => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - ietf::PublishDone::ID => { - let id = decode_response_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - ietf::FetchCancel::ID => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - ietf::PublishNamespaceDone::ID => match version { - Version::Draft16 => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - // v14/v15: namespace-keyed, so decode namespace and look up request_id. - // DONE withdraws the advertisement its sender made, so it names an - // inbound one. - Version::Draft14 | Version::Draft15 => { - match lookup_namespace_request_id(body, version, namespaces, Direction::Incoming)? { - Some(id) => Ok(Route::CloseStream(id)), - None => Ok(Route::Ignore), - } - } - _ => Err(Error::UnexpectedMessage), - }, - ietf::PublishNamespaceCancel::ID => match version { - Version::Draft16 => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - // v14/v15: namespace-keyed. CANCEL rejects an advertisement its sender - // received, so it names an outbound one. - Version::Draft14 | Version::Draft15 => { - match lookup_namespace_request_id(body, version, namespaces, Direction::Outgoing)? { - Some(id) => Ok(Route::CloseStream(id)), - None => Ok(Route::Ignore), - } - } - _ => Err(Error::UnexpectedMessage), - }, - ietf::UnsubscribeNamespace::ID => match version { - Version::Draft14 => match lookup_namespace_request_id(body, version, namespaces, Direction::Subscribed)? { - Some(id) => Ok(Route::CloseStream(id)), - None => Ok(Route::Ignore), - }, - Version::Draft15 => { - let id = decode_request_id(body, version)?; - Ok(Route::CloseStream(id)) - } - _ => Err(Error::UnexpectedMessage), - }, - - // Utility - ietf::MaxRequestId::ID => { - let id = decode_request_id(body, version)?; - Ok(Route::MaxRequestId(id)) - } - // The peer reached the MAX_REQUEST_ID we advertised and waits for a grant, which - // follows on its own as its requests close. - ietf::RequestsBlocked::ID => Ok(Route::Ignore), - - // Terminal - ietf::GoAway::ID => Ok(Route::GoAway), - - _ => Err(Error::UnexpectedMessage), - } -} - -/// Decode the namespace from a v14/v15 namespace-keyed message body and look up the -/// request_id of the advertisement it terminates, travelling in the given direction. -/// -/// `None` when nothing holds that name: the request it named is already gone. There is -/// nothing left to close, so the message is dropped rather than ending the session. -fn lookup_namespace_request_id( - body: &Bytes, - version: Version, - namespaces: &Namespaces, - direction: Direction, -) -> Result, Error> { - let mut r = Decoder::new(body, version.into()); - let ns = crate::ietf::namespace::decode_namespace(&mut r)?; - Ok(namespaces.get(direction, &ns)) -} - -impl crate::transport::poll::Session for ControlStreamAdapter { - type SendStream = AdapterSend; - type RecvStream = AdapterRecv; - type Error = crate::Error; - - fn poll_accept_bi( - &mut self, - cx: &mut Context<'_>, - ) -> Poll> { - // v16: SubscribeNamespace uses real bidi streams, so poll both sources. - // Native first: real bidi streams are rare and transport-paced, so they - // can't starve the queue, while a control-message flood could starve a - // native SubscribeNamespace under queue-first order. - if self.version == Version::Draft16 { - match self.inner.poll_accept_bi(cx) { - Poll::Ready(Ok((send, recv))) => { - return Poll::Ready(Ok((AdapterSend::Real(send), AdapterRecv::Real(recv)))); - } - Poll::Ready(Err(_)) => return Poll::Ready(Err(crate::Error::Closed)), - Poll::Pending => {} - } - } - - let waiter = self.park.hold(cx); - match std::task::ready!(self.shared.incoming.poll_pop(waiter)) { - Some((send, recv)) => Poll::Ready(Ok(( - AdapterSend::Virtual(send), - AdapterRecv::Virtual(crate::transport::poll::Buffered::new(recv)), - ))), - None => Poll::Ready(Err(crate::Error::Closed)), - } - } - - fn poll_open_bi( - &mut self, - _cx: &mut Context<'_>, - // Rides the control stream, which no group schedules. - _: crate::transport::poll::SendOptions<'_, ::Group>, - ) -> Poll> { - let (send, recv) = self.shared.open_outgoing(self.version); - Poll::Ready(Ok(( - AdapterSend::Virtual(send), - AdapterRecv::Virtual(crate::transport::poll::Buffered::new(recv)), - ))) - } - - fn poll_open_request( - &mut self, - cx: &mut std::task::Context<'_>, - options: crate::transport::poll::SendOptions< - '_, - ::Group, - >, - ) -> std::task::Poll>, Self::Error>> { - self.poll_open_bi(cx, options).map_ok(Some) - } - - fn poll_open_uni( - &mut self, - cx: &mut Context<'_>, - options: crate::transport::poll::SendOptions< - '_, - ::Group, - >, - ) -> Poll> { - self.inner - .poll_open_uni(cx, options) - .map(|r| r.map(AdapterSend::Real).map_err(|_| crate::Error::Closed)) - } - - fn poll_accept_uni(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner - .poll_accept_uni(cx) - .map(|r| r.map(AdapterRecv::Real).map_err(|_| crate::Error::Closed)) - } - - fn send_group(&mut self, priority: i64) -> Option<::Group> { - self.inner.send_group(priority) - } - - fn send_datagram( - &mut self, - payload: Bytes, - options: crate::transport::poll::SendOptions< - '_, - ::Group, - >, - ) -> Result<(), Self::Error> { - self.inner - .send_datagram(payload, options) - .map_err(|_| crate::Error::Closed) - } - - fn poll_recv_datagram(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner - .poll_recv_datagram(cx) - .map(|r| r.map_err(|_| crate::Error::Closed)) - } - - fn max_datagram_size(&self) -> usize { - self.inner.max_datagram_size() - } - - fn address(&self) -> crate::transport::Address { - self.inner.address() - } - fn protocol(&self) -> Option<&str> { - self.inner.protocol() - } - - fn close(&mut self, code: u32, reason: &str) { - self.inner.close(code, reason) - } - - fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { - self.inner.poll_closed(cx).map(|_| crate::Error::Closed) - } - - fn metrics(&self) -> impl crate::transport::Metrics { - self.inner.metrics() - } -} - -// === Message Classification === - -#[derive(Debug)] -enum Route { - NewRequest(RequestId), - Response(RequestId), - /// An update: its own request ID counts against the window, and it goes to `target`. - FollowUp { - own: RequestId, - target: RequestId, - }, - CloseStream(RequestId), - MaxRequestId(RequestId), - /// Nothing to route: the message named a request that is already gone. - Ignore, - GoAway, -} - -/// Encode raw message bytes as [type_id varint][size u16][body]. -fn encode_raw(type_id: u64, body: &Bytes, version: Version) -> Bytes { - let mut buf = Vec::new(); - let mut w = Encoder::new(&mut buf, version.into()); - w.varint(type_id).expect("type_id was read from the same wire"); - w.u16(u16::try_from(body.len()).expect("body was read with a u16 size")); - w.slice(body); - buf.into() -} - -/// Decode just the request_id from the beginning of a message body. -fn decode_request_id(body: &Bytes, version: Version) -> Result { - let (request_id, _) = RequestId::decode_slice(body, version)?; - Ok(request_id) -} - -/// The request a SUBSCRIBE_UPDATE or REQUEST_UPDATE applies to. -/// -/// Drafts 14 and 15 name it in Subscription Request ID, and draft 16 in Existing -/// Request ID. Both are the second field; the first is the update's own new -/// Request ID. Later drafts send updates on the request's own stream, not here. -fn decode_update_target(body: &Bytes, version: Version) -> Result { - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => { - let mut r = Decoder::new(body, version.into()); - let _own = RequestId::decode(&mut r, version)?; - Ok(RequestId::decode(&mut r, version)?) - } - _ => Err(Error::UnexpectedMessage), - } -} - -/// Decode request_id for response messages that have Option in v14-16. -fn decode_response_request_id(body: &Bytes, version: Version) -> Result { - // In v14-16, response messages always have request_id present - decode_request_id(body, version) -} - -/// Decode the namespace from a PublishNamespace message body (after the request_id). -fn decode_publish_namespace_body(body: &Bytes, version: Version) -> Result { - let mut r = Decoder::new(body, version.into()); - // Skip request_id - let _request_id = RequestId::decode(&mut r, version)?; - // v17 has required_request_id_delta - if version == Version::Draft17 { - r.varint()?; - } - let ns = crate::ietf::namespace::decode_namespace(&mut r)?; - Ok(ns) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::coding::Encode; - use crate::transport::poll::{RecvStream as _, SendStream as _}; - use futures::FutureExt as _; - - fn make_body_with_request_id(id: u64, version: Version) -> Bytes { - RequestId(id).encode_bytes(version).unwrap() - } - - /// Classify against an empty namespace map, for the messages that don't use it. - fn classify_msg(version: Version, type_id: u64, body: &Bytes) -> Result { - classify(type_id, body, version, &Namespaces::default()) - } - - /// A receive stream with no request behind it, for exercising the reader alone. - fn detached_recv(initial: Bytes, rx: Queue) -> crate::transport::poll::Buffered { - crate::transport::poll::Buffered::new(VirtualRecvStream::new( - initial, - rx, - Arc::new(Shared::default()), - Arc::new(Mutex::new(None)), - )) - } - - #[test] - fn test_classify_subscribe_new_request() { - let body = make_body_with_request_id(42, Version::Draft15); - let route = classify_msg(Version::Draft15, ietf::Subscribe::ID, &body).unwrap(); - assert!(matches!(route, Route::NewRequest(RequestId(42)))); - } - - #[test] - fn test_classify_fetch_new_request() { - let body = make_body_with_request_id(10, Version::Draft14); - let route = classify_msg(Version::Draft14, ietf::Fetch::ID, &body).unwrap(); - assert!(matches!(route, Route::NewRequest(RequestId(10)))); - } - - #[test] - fn test_classify_publish_new_request() { - let body = make_body_with_request_id(5, Version::Draft16); - let route = classify_msg(Version::Draft16, ietf::Publish::ID, &body).unwrap(); - assert!(matches!(route, Route::NewRequest(RequestId(5)))); - } - - #[test] - fn test_classify_subscribe_ok_response() { - let body = make_body_with_request_id(42, Version::Draft15); - let route = classify_msg(Version::Draft15, ietf::SubscribeOk::ID, &body).unwrap(); - assert!(matches!(route, Route::Response(RequestId(42)))); - } - - #[test] - fn test_classify_request_error_v15_closes_stream() { - let body = make_body_with_request_id(7, Version::Draft15); - let route = classify_msg(Version::Draft15, ietf::SubscribeError::ID, &body).unwrap(); - assert!(matches!(route, Route::CloseStream(RequestId(7)))); - } - - #[test] - fn test_classify_request_ok_v15_response() { - let body = make_body_with_request_id(3, Version::Draft15); - let route = classify_msg(Version::Draft15, ietf::PublishNamespaceOk::ID, &body).unwrap(); - assert!(matches!(route, Route::Response(RequestId(3)))); - } - - #[test] - fn test_classify_unsubscribe_closes_stream() { - let body = make_body_with_request_id(99, Version::Draft14); - let route = classify_msg(Version::Draft14, ietf::Unsubscribe::ID, &body).unwrap(); - assert!(matches!(route, Route::CloseStream(RequestId(99)))); - } - - /// Drafts 14 and 15 name the subscription in the second field; draft 16 names - /// the existing request there. The first field is the update's own Request ID, - /// so a body that carries only that ID cannot tell the two apart. - #[moq_clock::test] - async fn test_subscribe_update_reaches_its_target() { - let update_id = RequestId(10); - let target_id = RequestId(4); - - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let update = ietf::SubscribeUpdate { - max_age: None, - new_group: None, - request_id: update_id, - subscription_request_id: Some(target_id), - start_location: ietf::Location { group: 1, object: 2 }, - end_group: 100, - subscriber_priority: Some(200), - forward: Some(false), - filter: Some(ietf::Filter::NextObject), - authorization_token: None, - fill: None, - }; - let body = encode_body(&update, version); - let raw = encode_raw(ietf::SubscribeUpdate::ID, &body, version); - let Route::FollowUp { own, target: routed } = - classify_msg(version, ietf::SubscribeUpdate::ID, &body).unwrap() - else { - panic!("{version} did not route the update as a follow-up"); - }; - assert_eq!(own, update_id, "{version}"); - - let shared = Arc::new(Shared::default()); - shared - .open_incoming(target_id, Bytes::from_static(b"request"), None) - .unwrap(); - let (_, mut recv) = shared.incoming.pop().await.unwrap(); - assert_eq!( - read_virtual(&mut recv).await.unwrap(), - Some(Bytes::from_static(b"request")) - ); - - assert_eq!(routed, target_id, "{version}: update {update_id} routed to {routed}"); - shared.push(routed, raw.clone()); - assert_eq!( - read_virtual(&mut recv).await.unwrap(), - Some(raw), - "{version}: update {update_id} did not reach {target_id}" - ); - } - } - - /// Draft-16 answers our REQUEST_UPDATE by the update's own Request ID. The answer - /// reaches the request it updated, a refusal leaves that request open, and an update - /// written in pieces is recorded all the same. - #[moq_clock::test] - async fn our_update_answers_reach_their_request() { - const VERSION: Version = Version::Draft16; - let shared = Arc::new(Shared::default()); - let (mut send, mut recv) = shared.open_outgoing(VERSION); - let update = |request_id| { - encode_msg( - &ietf::PublishNamespaceUpdate { - request_id: RequestId(request_id), - namespace_request_id: Some(RequestId(4)), - hops: None, - cost: None, - authorization_token: None, - }, - VERSION, - ) - }; - - // The request and the first byte of its update in one write, the rest in another. - let first = update(6); - let request = encode_msg(&publish_namespace(RequestId(4), "ns"), VERSION); - send.write_chunk([request.clone(), first.slice(..1)].concat().into()) - .await - .unwrap(); - send.write_chunk(first.slice(1..)).await.unwrap(); - assert_eq!(shared.control.pop().await.unwrap(), request); - assert_eq!(shared.control.pop().await.unwrap(), first); - - let ok = ietf::RequestOk { - largest: None, - request_id: Some(RequestId(6)), - active: None, - }; - assert!(matches!( - classify_msg(VERSION, ietf::RequestOk::ID, &encode_body(&ok, VERSION)).unwrap(), - Route::Response(RequestId(6)) - )); - let ok = encode_msg(&ok, VERSION); - assert!(shared.push(RequestId(6), ok.clone()), "the answer found no stream"); - assert_eq!(read_virtual(&mut recv).await.unwrap(), Some(ok.clone())); - assert!(!shared.push(RequestId(6), ok), "a second answer was routed"); - - send.write_chunk(update(8)).await.unwrap(); - assert_eq!(shared.control.pop().await.unwrap(), update(8)); - let error = encode_msg( - &ietf::RequestError { - request_id: Some(RequestId(8)), - error_code: 0, - reason_phrase: "refused".into(), - retry_interval: 0, - }, - VERSION, - ); - shared.close(RequestId(8), error.clone()); - assert_eq!(read_virtual(&mut recv).await.unwrap(), Some(error)); - assert!( - shared.push(RequestId(4), Bytes::from_static(b"later")), - "the refusal closed the request" - ); - - // An update outstanding when its request goes leaves nothing behind. - send.write_chunk(update(10)).await.unwrap(); - assert_eq!(shared.updates.lock().unwrap().len(), 1); - drop(recv); - assert!(shared.updates.lock().unwrap().is_empty()); - } - - /// Before draft-16 an answer to our update names nothing we route. - #[moq_clock::test] - async fn draft_15_update_answers_are_not_routed() { - const VERSION: Version = Version::Draft15; - let shared = Arc::new(Shared::default()); - let (mut send, _recv) = shared.open_outgoing(VERSION); - send.write_chunk(encode_msg(&publish_namespace(RequestId(4), "ns"), VERSION)) - .await - .unwrap(); - // A SUBSCRIBE_UPDATE's header and its own Request ID 6, all that routing reads. - send.write_chunk(Bytes::from_static(&[0x02, 0x00, 0x01, 0x06])) - .await - .unwrap(); - assert!(!shared.push(RequestId(6), Bytes::from_static(b"ok"))); - assert!(shared.updates.lock().unwrap().is_empty()); - } - - #[test] - fn test_classify_goaway() { - let body = Bytes::new(); - let route = classify_msg(Version::Draft14, ietf::GoAway::ID, &body).unwrap(); - assert!(matches!(route, Route::GoAway)); - } - - #[test] - fn test_classify_max_request_id() { - let body = make_body_with_request_id(100, Version::Draft14); - let route = classify_msg(Version::Draft14, ietf::MaxRequestId::ID, &body).unwrap(); - assert!(matches!(route, Route::MaxRequestId(RequestId(100)))); - } - - #[test] - fn test_classify_subscribe_namespace_v14_new_request() { - let body = make_body_with_request_id(20, Version::Draft14); - let route = classify_msg(Version::Draft14, ietf::SubscribeNamespaceLegacy::ID, &body).unwrap(); - assert!(matches!(route, Route::NewRequest(RequestId(20)))); - } - - #[test] - fn test_classify_subscribe_namespace_v16_errors() { - let body = make_body_with_request_id(20, Version::Draft16); - let result = classify_msg(Version::Draft16, ietf::SubscribeNamespaceLegacy::ID, &body); - assert!(result.is_err()); - } - - #[test] - fn test_classify_unknown_message() { - let body = Bytes::new(); - let result = classify_msg(Version::Draft14, 0xFF, &body); - assert!(result.is_err()); - } - - #[test] - fn test_encode_raw_roundtrip() { - let version = Version::Draft15; - let body = Bytes::from_static(b"hello"); - let raw = encode_raw(0x03, &body, version); - - // Decode the raw bytes - let mut r = Decoder::new(&raw, version.into()); - assert_eq!(r.varint().unwrap(), 0x03); - assert_eq!(r.u16().unwrap(), 5); - assert_eq!(r.rest(), b"hello"); - } - - #[moq_clock::test] - async fn test_virtual_recv_stream_reads_initial_then_followup() { - let initial = Bytes::from_static(b"initial"); - let follow = Queue::new(); - let tx = follow.writer(); - let mut stream = detached_recv(initial, follow); - - // Read initial data - let chunk = stream.read_chunk(32).await.unwrap().unwrap(); - assert_eq!(&chunk[..], b"initial"); - - // Send follow-up - tx.push(Bytes::from_static(b"followup")); - let chunk = stream.read_chunk(32).await.unwrap().unwrap(); - assert_eq!(&chunk[..], b"followup"); - - // Drop the writer → FIN - drop(tx); - let result = stream.read_chunk(32).await.unwrap(); - assert_eq!(result, None); - } - - #[moq_clock::test] - async fn test_virtual_recv_stream_partial_reads() { - let initial = Bytes::from_static(b"hello world"); - let mut stream = detached_recv(initial, Queue::new()); - - // Read small chunks - let chunk = stream.read_chunk(5).await.unwrap().unwrap(); - assert_eq!(&chunk[..], b"hello"); - - let chunk = stream.read_chunk(5).await.unwrap().unwrap(); - assert_eq!(&chunk[..], b" worl"); - - let chunk = stream.read_chunk(1).await.unwrap().unwrap(); - assert_eq!(&chunk[..], b"d"); - } - - #[moq_clock::test] - async fn test_virtual_send_stream_errors_once_control_closes() { - // The adapter closes the control queue when its run() exits; writes must - // fail fast instead of buffering into a queue nobody drains. - let control = Queue::new(); - let mut stream = VirtualSendStream::new(control.clone()); - control.close(); - assert!(matches!(stream.write(b"hello").await, Err(Error::Closed))); - } - - #[test] - fn test_recv_stream_drop_discards_followups() { - // Dropping the reader closes the queue, so late routed messages are - // discarded instead of accumulating while the map entry lingers. - let follow = Queue::new(); - let stream = detached_recv(Bytes::new(), follow.clone()); - drop(stream); - assert!(!follow.push(Bytes::from_static(b"late"))); - } - - #[moq_clock::test] - async fn test_virtual_send_stream_writes_to_channel() { - let control = Queue::new(); - let mut stream = VirtualSendStream::new(control.clone()); - - let n = stream.write(b"hello").await.unwrap(); - assert_eq!(n, 5); - - let data = control.pop().await.unwrap(); - assert_eq!(data, &b"hello"[..]); - } - - /// Encode a message body (no type_id/size header). - fn encode_body(msg: &M, version: Version) -> Bytes { - let mut buf = Vec::new(); - msg.encode_msg(&mut Encoder::new(&mut buf, version.into()), version) - .unwrap(); - bytes::Bytes::from(buf) - } - - /// Encode a full control message: [type_id][size][body]. - fn encode_msg(msg: &M, version: Version) -> Bytes { - let body = encode_body(msg, version); - encode_raw(M::ID, &body, version) - } - - fn publish_namespace(request_id: RequestId, namespace: &str) -> ietf::PublishNamespace { - ietf::PublishNamespace { - request_id, - track_namespace: crate::Path::new(namespace).unwrap(), - params: Default::default(), - authorization_token: None, - } - } - - /// Advertise `namespace` to the peer on `request_id`, as `open_bi` + a write would. - async fn advertise( - shared: &Arc, - namespace: &str, - version: Version, - request_id: RequestId, - ) -> VirtualRecvStream { - let (mut send, recv) = shared.open_outgoing(version); - send.write_chunk(encode_msg(&publish_namespace(request_id, namespace), version)) - .await - .unwrap(); - recv - } - - /// Receive the peer's advertisement of `namespace` on `request_id`, as the read loop would. - async fn receive( - shared: &Arc, - namespace: &str, - version: Version, - request_id: RequestId, - ) -> VirtualRecvStream { - let msg = publish_namespace(request_id, namespace); - let route = classify( - ietf::PublishNamespace::ID, - &encode_body(&msg, version), - version, - &shared.namespaces, - ) - .unwrap(); - assert!(matches!(route, Route::NewRequest(id) if id == request_id)); - shared - .open_incoming(request_id, encode_msg(&msg, version), None) - .unwrap(); - - let (_, mut recv) = shared.incoming.pop().await.unwrap(); - - // Drain the advertisement the stream opened with, so both halves are quiescent - // and a later read only sees what the terminal message routes. - assert_eq!(read_virtual(&mut recv).await.unwrap(), Some(encode_msg(&msg, version))); - recv - } - - /// Two relays meshed together, each advertising `namespace` to the other: ours on - /// request 4, theirs on request 7. Returns their receive halves. - /// - /// `last` is the advertisement registered second, which is the one a single map - /// would leave as the sole entry. Each test passes the direction it must *not* - /// match, so a direction-blind lookup lands on the wrong stream. - async fn mesh( - namespace: &str, - version: Version, - last: Direction, - ) -> (Arc, VirtualRecvStream, VirtualRecvStream) { - let shared = Arc::new(Shared::default()); - - let (ours, theirs) = match last { - Direction::Outgoing => { - let theirs = receive(&shared, namespace, version, RequestId(7)).await; - (advertise(&shared, namespace, version, RequestId(4)).await, theirs) - } - Direction::Incoming => { - let ours = advertise(&shared, namespace, version, RequestId(4)).await; - (ours, receive(&shared, namespace, version, RequestId(7)).await) - } - Direction::Subscribed => unreachable!("a mesh only advertises"), - }; - - (shared, ours, theirs) - } - - /// The next chunk of a virtual stream, read without a lease. - fn read_virtual(stream: &mut VirtualRecvStream) -> impl Future, crate::Error>> + '_ { - std::future::poll_fn(|cx| crate::transport::poll::Chunks::poll_read_chunk(stream, cx, usize::MAX)) - } - - /// Read until the stream FINs, returning whether it did so within `limit` reads. - async fn drained(stream: &mut VirtualRecvStream, limit: usize) -> bool { - for _ in 0..limit { - if read_virtual(stream).await.unwrap().is_none() { - return true; - } - } - false - } - - #[test] - fn test_namespace_reverse_lookup_v14() { - let namespaces = Namespaces::default(); - namespaces.register(Direction::Incoming, crate::Path::from_static("test/ns"), RequestId(42)); - - let body = encode_body( - &ietf::PublishNamespaceDone { - track_namespace: crate::Path::from_static("test/ns"), - request_id: RequestId(0), - }, - Version::Draft14, - ); - - let route = classify(ietf::PublishNamespaceDone::ID, &body, Version::Draft14, &namespaces).unwrap(); - assert!(matches!(route, Route::CloseStream(RequestId(42)))); - } - - #[moq_clock::test] - async fn test_publish_namespace_done_closes_only_inbound() { - // The peer withdraws the advertisement it made, so its own stream closes and - // ours keeps running. - let version = Version::Draft14; - let (shared, mut ours, mut theirs) = mesh("cluster/ns", version, Direction::Outgoing).await; - - let done = ietf::PublishNamespaceDone { - track_namespace: crate::Path::from_static("cluster/ns"), - request_id: RequestId(0), - }; - let route = classify( - ietf::PublishNamespaceDone::ID, - &encode_body(&done, version), - version, - &shared.namespaces, - ) - .unwrap(); - assert!(matches!(route, Route::CloseStream(RequestId(7)))); - - shared.close(RequestId(7), encode_msg(&done, version)); - assert!(drained(&mut theirs, 4).await); - assert!(shared.streams.lock().unwrap().contains_key(&RequestId(4))); - assert!(read_virtual(&mut ours).now_or_never().is_none()); - } - - #[moq_clock::test] - async fn test_publish_namespace_cancel_closes_only_outbound() { - // The peer rejects the advertisement we made, so ours closes and the one it - // sent us keeps running. - let version = Version::Draft14; - let (shared, mut ours, mut theirs) = mesh("cluster/ns", version, Direction::Incoming).await; - - let cancel = ietf::PublishNamespaceCancel { - track_namespace: crate::Path::from_static("cluster/ns"), - request_id: RequestId(0), - error_code: 0, - reason_phrase: "".into(), - }; - let route = classify( - ietf::PublishNamespaceCancel::ID, - &encode_body(&cancel, version), - version, - &shared.namespaces, - ) - .unwrap(); - assert!(matches!(route, Route::CloseStream(RequestId(4)))); - - shared.close(RequestId(4), encode_msg(&cancel, version)); - assert!(drained(&mut ours, 4).await); - assert!(shared.streams.lock().unwrap().contains_key(&RequestId(7))); - assert!(read_virtual(&mut theirs).now_or_never().is_none()); - } - - /// Classify the peer withdrawing its own advertisement of `namespace`. - fn done(shared: &Arc, namespace: &str, version: Version) -> Route { - let msg = ietf::PublishNamespaceDone { - track_namespace: crate::Path::new(namespace).unwrap(), - request_id: RequestId(0), - }; - classify( - ietf::PublishNamespaceDone::ID, - &encode_body(&msg, version), - version, - &shared.namespaces, - ) - .unwrap() - } - - /// Classify the peer rejecting our advertisement of `namespace`. - fn cancel(shared: &Arc, namespace: &str, version: Version) -> Route { - let msg = ietf::PublishNamespaceCancel { - track_namespace: crate::Path::new(namespace).unwrap(), - request_id: RequestId(0), - error_code: 0, - reason_phrase: "".into(), - }; - classify( - ietf::PublishNamespaceCancel::ID, - &encode_body(&msg, version), - version, - &shared.namespaces, - ) - .unwrap() - } - - #[moq_clock::test] - async fn test_duplicate_publish_namespace_keeps_the_first() { - // The peer advertises the same namespace twice. The session refuses the second, so - // the first still owns the name and its withdrawal must reach the first's stream. - let version = Version::Draft14; - let shared = Arc::new(Shared::default()); - let first = receive(&shared, "cluster/ns", version, RequestId(7)).await; - let duplicate = receive(&shared, "cluster/ns", version, RequestId(11)).await; - - assert!(matches!( - done(&shared, "cluster/ns", version), - Route::CloseStream(RequestId(7)) - )); - - // Tearing down the refused duplicate releases only what it owns, which is nothing. - drop(duplicate); - assert!(matches!( - done(&shared, "cluster/ns", version), - Route::CloseStream(RequestId(7)) - )); - - // The winner going away is what frees the name. - drop(first); - assert!(matches!(done(&shared, "cluster/ns", version), Route::Ignore)); - } - - #[moq_clock::test] - async fn test_withdrawal_releases_the_namespace() { - // A namespace the peer withdraws must be advertisable again on a new request. - let version = Version::Draft14; - let shared = Arc::new(Shared::default()); - let mut theirs = receive(&shared, "cluster/ns", version, RequestId(7)).await; - - let msg = ietf::PublishNamespaceDone { - track_namespace: crate::Path::from_static("cluster/ns"), - request_id: RequestId(0), - }; - shared.close(RequestId(7), encode_msg(&msg, version)); - assert!(drained(&mut theirs, 4).await); - assert!(!shared.streams.lock().unwrap().contains_key(&RequestId(7))); - drop(theirs); - - let _again = receive(&shared, "cluster/ns", version, RequestId(11)).await; - assert!(matches!( - done(&shared, "cluster/ns", version), - Route::CloseStream(RequestId(11)) - )); - } - - /// The peer's next request on `namespace`, advertising it, as the read loop routes it. - fn readvertise(shared: &Arc, namespace: &str, version: Version, request_id: RequestId) { - let msg = publish_namespace(request_id, namespace); - let route = classify( - ietf::PublishNamespace::ID, - &encode_body(&msg, version), - version, - &shared.namespaces, - ) - .unwrap(); - assert!(matches!(route, Route::NewRequest(id) if id == request_id)); - shared - .open_incoming(request_id, encode_msg(&msg, version), None) - .unwrap(); - } - - /// The peer withdrawing `namespace` on `request_id`, as the read loop routes it. - fn withdraw(shared: &Arc, namespace: &str, version: Version, request_id: RequestId) { - let msg = ietf::PublishNamespaceDone { - track_namespace: crate::Path::new(namespace).unwrap(), - request_id, - }; - let route = classify( - ietf::PublishNamespaceDone::ID, - &encode_body(&msg, version), - version, - &shared.namespaces, - ) - .unwrap(); - assert!(matches!(route, Route::CloseStream(id) if id == request_id)); - shared.close(request_id, encode_msg(&msg, version)); - } - - /// The request of the next stream `accept_bi` would hand out, if one is ready. - fn accepted(shared: &Arc) -> Option<(RequestId, VirtualRecvStream)> { - let (_, recv) = shared.incoming.pop().now_or_never()??; - let request_id = recv.request_id.lock().unwrap().unwrap(); - Some((request_id, recv)) - } - - #[moq_clock::test] - async fn a_readvertisement_waits_for_the_withdrawal_to_be_handled() { - // Each request has its own handler, so a re-advertisement handed out beside the - // withdrawal before it could be handled first and read as a second advertisement. - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let shared = Arc::new(Shared::default()); - let first = receive(&shared, "live/media", version, RequestId(3)).await; - let other = receive(&shared, "live/other", version, RequestId(5)).await; - - withdraw(&shared, "live/media", version, RequestId(3)); - readvertise(&shared, "live/media", version, RequestId(7)); - withdraw(&shared, "live/media", version, RequestId(7)); - readvertise(&shared, "live/media", version, RequestId(9)); - // Another namespace is not held up. - withdraw(&shared, "live/other", version, RequestId(5)); - drop(other); - readvertise(&shared, "live/other", version, RequestId(11)); - assert!(matches!(accepted(&shared), Some((RequestId(11), _))), "{version:?}"); - assert!(accepted(&shared).is_none(), "{version:?}"); - - // Handling the first withdrawal hands over the next advertisement, which was - // withdrawn too, so the one after it waits for that withdrawal in turn. - drop(first); - let (id, mut second) = accepted(&shared).expect("released"); - assert_eq!(id, RequestId(7), "{version:?}"); - assert!(accepted(&shared).is_none(), "{version:?}"); - assert!(drained(&mut second, 4).await, "{version:?}"); - drop(second); - assert!(matches!(accepted(&shared), Some((RequestId(9), _))), "{version:?}"); - assert!(shared.readvertised.lock().unwrap().namespaces.is_empty(), "{version:?}"); - } - } - - #[moq_clock::test] - async fn a_session_closed_behind_a_withdrawal_drops_what_it_held() { - // Dropping a refused stream re-enters the release, which must not still hold its lock. - let version = Version::Draft14; - let shared = Arc::new(Shared::default()); - let first = receive(&shared, "live/media", version, RequestId(3)).await; - withdraw(&shared, "live/media", version, RequestId(3)); - readvertise(&shared, "live/media", version, RequestId(7)); - withdraw(&shared, "live/media", version, RequestId(7)); - readvertise(&shared, "live/media", version, RequestId(9)); - shared.incoming.close(); - - drop(first); - let readvertised = shared.readvertised.lock().unwrap(); - assert!(readvertised.namespaces.is_empty() && readvertised.withdrawn.is_empty()); - drop(readvertised); - assert!(shared.streams.lock().unwrap().is_empty()); - } - - #[moq_clock::test] - async fn test_local_withdrawal_releases_the_namespace() { - // We withdraw our own advertisement by dropping the request that carried it, so a - // later CANCEL must name the re-advertisement rather than the request that is gone. - let version = Version::Draft14; - let shared = Arc::new(Shared::default()); - - let ours = advertise(&shared, "cluster/ns", version, RequestId(4)).await; - drop(ours); - assert!(matches!(cancel(&shared, "cluster/ns", version), Route::Ignore)); - - let _again = advertise(&shared, "cluster/ns", version, RequestId(6)).await; - assert!(matches!( - cancel(&shared, "cluster/ns", version), - Route::CloseStream(RequestId(6)) - )); - } - - #[moq_clock::test] - async fn surviving_duplicate_remains_reachable_after_owner_closes() { - for version in [Version::Draft14, Version::Draft15] { - let shared = Arc::new(Shared::default()); - let first = receive(&shared, "cluster/ns", version, RequestId(7)).await; - let second = receive(&shared, "cluster/ns", version, RequestId(11)).await; - let third = receive(&shared, "cluster/ns", version, RequestId(15)).await; - drop(first); - assert!(matches!( - done(&shared, "cluster/ns", version), - Route::CloseStream(RequestId(11)) - )); - drop(second); - assert!(matches!( - done(&shared, "cluster/ns", version), - Route::CloseStream(RequestId(15)) - )); - drop(third); - assert!(matches!(done(&shared, "cluster/ns", version), Route::Ignore)); - } - } - - #[test] - fn namespace_removal_preserves_links_in_every_order() { - for direction in [Direction::Incoming, Direction::Outgoing, Direction::Subscribed] { - for first in 0..4 { - for second in 0..4 { - for third in 0..4 { - if first == second || first == third || second == third { - continue; - } - let fourth = (0..4).find(|id| *id != first && *id != second && *id != third).unwrap(); - let namespaces = Namespaces::default(); - let name = crate::Path::from_static("ns"); - for id in 0..4 { - namespaces.register(direction, name.clone(), RequestId(id)); - } - let mut live = [true; 4]; - for id in [first, second, third, fourth] { - namespaces.forget(RequestId(id)); - live[id as usize] = false; - let expected = live.iter().position(|live| *live).map(|id| RequestId(id as u64)); - assert_eq!(namespaces.get(direction, &name), expected); - } - let state = namespaces.state.lock().unwrap(); - assert!(state.by_request.is_empty()); - assert!(state.incoming.is_empty() && state.outgoing.is_empty() && state.subscribed.is_empty()); - } - } - } - } - } - - /// A peer's control stream carrying a bare SUBSCRIBE for each request ID: the - /// adapter routes on the ID alone, so the rest of the body is never read. - async fn windowed( - window: u64, - ids: &[u64], - then: &[u8], - ) -> ( - ControlStreamAdapter, - Reader, - Writer, - crate::lite::test_transport::Log, - ) { - use crate::lite::test_transport::{Log, ScriptedSession, SinkSend}; - use crate::transport::poll::Session as _; - - const VERSION: Version = Version::Draft14; - let mut script = Vec::new(); - for id in ids { - let body = make_body_with_request_id(*id, VERSION); - script.extend_from_slice(&encode_raw(ietf::Subscribe::ID, &body, VERSION)); - } - script.extend_from_slice(then); - let mut session = ScriptedSession::new(script); - let (_, recv) = session.open_bi(Default::default()).await.unwrap(); - // We are the server, so the client's request IDs are even. - let control = Control::new(None, false).with_window(window, false, VERSION); - let adapter = ControlStreamAdapter::new(Transport::new(session.clone()), control, VERSION); - let log = Log::default(); - let writer = Writer::new( - crate::transport::boxed::SendStream::new(SinkSend::new(log.clone())), - VERSION, - ); - ( - adapter, - Reader::new(crate::transport::boxed::RecvStream::new(recv), VERSION), - writer, - log, - ) - } - - #[moq_clock::test] - async fn legacy_multiplexing_counts_a_control_body_once() { - use crate::{ - coding::{Encode, Stream}, - lite::test_transport::{Log, ScriptedSession, SinkSend}, - stats::{Message as Class, Registry, Tier, Totals}, - }; - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let registry = Registry::new(Default::default()); - let stats = registry.tier(Tier::default()).session(crate::Path::from_static("")); - let msg = ietf::FetchCancel { - request_id: RequestId(0), - }; - let body = msg.encode_bytes(version).unwrap(); - let raw_response = encode_raw(ietf::FetchCancel::ID, &body.slice(2..), version); - let mut session = ScriptedSession::new(raw_response.to_vec()); - let (_, recv) = crate::transport::poll::Session::open_bi(&mut session, Default::default()) - .await - .unwrap(); - let adapter = ControlStreamAdapter::new(Transport::new(session.clone()), Control::new(None, true), version) - .with_stats(&stats); - let mut virtual_stream = super::super::request_stream::with_stats( - Stream::open(&mut crate::driver::Transport::new(adapter.clone()), version) - .await - .unwrap(), - &stats, - version, - ); - let msg = ietf::FetchCancel { - request_id: RequestId(0), - }; - let size = msg.encode_bytes(version).unwrap().len() as u64; - virtual_stream.writer.encode_message(&msg).await.unwrap(); - let raw = adapter.shared.control.pop().await.unwrap(); - let log = Log::default(); - let mut physical = Writer::new( - crate::transport::boxed::SendStream::new(SinkSend::new(log.clone())), - version, - ); - physical.write_all(&mut raw.clone()).await.unwrap(); - assert_eq!( - registry.snapshot().control(), - vec![(Tier::default(), Class::Fetch, Totals { tx: size, rx: 0 })], - "{version:?}" - ); - assert_eq!(*log.writes.lock().unwrap(), raw); - let (_, goaway) = crate::goaway::Handle::new(false); - let mut reading = std::pin::pin!(adapter.run_read( - Reader::new(crate::transport::boxed::RecvStream::new(recv), version), - goaway - )); - assert!(futures::poll!(reading.as_mut()).is_pending()); - assert_eq!(virtual_stream.reader.varint().await.unwrap(), ietf::FetchCancel::ID); - virtual_stream.reader.decode::().await.unwrap(); - assert_eq!( - registry.snapshot().control(), - vec![(Tier::default(), Class::Fetch, Totals { tx: size, rx: size })], - "virtual decode counted a physical frame twice: {version:?}" - ); - adapter.send_max_request_id(RequestId(123)); - assert!( - registry - .snapshot() - .control() - .iter() - .any(|(_, class, bytes)| *class == Class::Other && bytes.tx > 0), - "adapter-generated messages bypassed the typed writer" - ); - } - } - - #[moq_clock::test] - async fn a_legacy_control_stream_fin_or_reset_is_a_protocol_violation() { - use crate::lite::test_transport::{DeadStreamSession, ScriptedSession}; - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let session = ScriptedSession::eof(Vec::new()); - let stream = crate::coding::Stream::open(&mut Transport::new(session.clone()), version) - .await - .unwrap(); - let adapter = ControlStreamAdapter::new(Transport::new(session.clone()), Control::new(None, true), version); - let (_handle, protocol) = crate::goaway::Handle::new(false); - let err = adapter.run(stream.reader, stream.writer, protocol).await.unwrap_err(); - assert!(matches!(err, Error::ProtocolViolation), "{version:?}: {err:?}"); - - let session = DeadStreamSession::bis(0); - let stream = crate::coding::Stream::open(&mut Transport::new(session.clone()), version) - .await - .unwrap(); - let adapter = ControlStreamAdapter::new(Transport::new(session.clone()), Control::new(None, true), version); - let (_handle, protocol) = crate::goaway::Handle::new(false); - let err = adapter.run(stream.reader, stream.writer, protocol).await.unwrap_err(); - assert!(matches!(err, Error::ProtocolViolation), "{version:?}: {err:?}"); - } - } - - /// A STOP_SENDING on a legacy control stream ends the session even with nothing queued - /// to write, while the peer keeps its own half open. - #[moq_clock::test] - async fn a_legacy_control_stream_stopped_while_idle_is_a_protocol_violation() { - use crate::transport::memory; - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let (session, mut peer) = memory::pair(memory::Config::default()); - let stream = crate::coding::Stream::open(&mut Transport::new(session.clone()), version) - .await - .unwrap(); - let mut remote = crate::coding::Stream::accept(&mut peer, version).await.unwrap(); - remote.reader.abort(&Error::Cancel); - let adapter = ControlStreamAdapter::new(Transport::new(session.clone()), Control::new(None, true), version); - let (_handle, protocol) = crate::goaway::Handle::new(false); - let res = moq_clock::timeout( - std::time::Duration::from_secs(10), - adapter.run(stream.reader, stream.writer, protocol), - ) - .await - .expect("an idle writer missed the STOP_SENDING"); - assert!(matches!(res, Err(Error::ProtocolViolation)), "{version:?}: {res:?}"); - } - } - - /// A request ID at the MAX_REQUEST_ID we advertised closes the session with the - /// draft's TOO_MANY_REQUESTS, before the request is queued. - #[moq_clock::test] - async fn a_request_past_the_window_closes_the_session() { - let (adapter, reader, writer, _) = windowed(1, &[0, 2], &[]).await; - let (_, goaway) = crate::goaway::Handle::new(true); - let err = adapter.run(reader, writer, goaway).await.unwrap_err(); - assert!( - matches!(err, Error::Session(crate::SessionError::TooManyRequests)), - "{err:?}" - ); - } - - /// An update's own request ID counts against the window, not the subscription it targets. - #[moq_clock::test] - async fn an_update_past_the_window_closes_the_session() { - const VERSION: Version = Version::Draft14; - let update = ietf::SubscribeUpdate { - max_age: None, - new_group: None, - request_id: RequestId(4), - subscription_request_id: Some(RequestId(0)), - start_location: ietf::Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority: Some(1), - forward: Some(true), - filter: Some(ietf::Filter::NextObject), - authorization_token: None, - fill: None, - }; - let update = encode_raw(ietf::SubscribeUpdate::ID, &encode_body(&update, VERSION), VERSION); - // The target is in the window and the update's own ID is past it. - let (adapter, reader, writer, _) = windowed(2, &[0], &update).await; - let (_, goaway) = crate::goaway::Handle::new(true); - let err = adapter.run(reader, writer, goaway).await.unwrap_err(); - assert!( - matches!(err, Error::Session(crate::SessionError::TooManyRequests)), - "{err:?}" - ); - } - - /// An update aimed at no open request, such as draft 16's SUBSCRIBE_NAMESPACE riding a - /// stream of its own, still gets its one answer on drafts 15 and 16: a REQUEST_ERROR - /// naming the update. Draft 14 answers no update. - #[moq_clock::test] - async fn an_unroutable_update_is_refused() { - use crate::lite::test_transport::{Log, ScriptedSession, SinkSend}; - use crate::transport::poll::Session as _; - - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - // Own Request ID 2, aimed at request 0, which nothing here holds. Draft 14 carries - // its fixed start, end group, priority and forward before the parameters. - let body: &[u8] = match version { - Version::Draft14 => &[2, 0, 0, 0, 0, 128, 1, 0], - _ => &[2, 0, 0], - }; - let script = encode_raw(ietf::SubscribeUpdate::ID, &Bytes::copy_from_slice(body), version); - let mut session = ScriptedSession::new(script.to_vec()); - let (_, recv) = session.open_bi(Default::default()).await.unwrap(); - let adapter = - ControlStreamAdapter::new(Transport::new(session.clone()), Control::new(None, false), version); - let log = Log::default(); - let writer = Writer::new( - crate::transport::boxed::SendStream::new(SinkSend::new(log.clone())), - version, - ); - let (_, goaway) = crate::goaway::Handle::new(true); - let mut run = std::pin::pin!(adapter.run( - Reader::new(crate::transport::boxed::RecvStream::new(recv), version), - writer, - goaway - )); - assert!(futures::poll!(run.as_mut()).is_pending(), "{version}"); - - let written = log.writes.lock().unwrap().clone(); - match version { - Version::Draft14 => assert!(written.is_empty(), "{version}: {written:?}"), - // [type, length (2), Request ID, NOT_SUPPORTED, ...] - _ => assert_eq!( - (written[0], written[3], written[4]), - (ietf::RequestError::ID as u8, 2, 0x3), - "{version}: {written:?}" - ), - } - } - } - - /// Requests that end are granted back with MAX_REQUEST_ID, once half the window has. - #[moq_clock::test] - async fn retired_requests_are_granted_back() { - use crate::transport::poll::Session as _; - - const VERSION: Version = Version::Draft14; - let (mut adapter, reader, writer, log) = windowed(2, &[0, 2], &[]).await; - let (_, goaway) = crate::goaway::Handle::new(true); - let runner = adapter.clone(); - let mut run = std::pin::pin!(runner.run(reader, writer, goaway)); - assert!(futures::poll!(run.as_mut()).is_pending()); - - let first = adapter.accept_bi().await.unwrap(); - let _second = adapter.accept_bi().await.unwrap(); - assert!(log.writes.lock().unwrap().is_empty(), "nothing has ended yet"); - - drop(first); - assert!(futures::poll!(run.as_mut()).is_pending()); - - let mut expected = Vec::new(); - crate::coding::Encoder::new(&mut expected, VERSION.into()) - .varint(ietf::MaxRequestId::ID) - .unwrap(); - ietf::MaxRequestId { - request_id: RequestId(6), - } - .encode(&mut crate::coding::Encoder::new(&mut expected, VERSION.into()), VERSION) - .unwrap(); - assert_eq!(*log.writes.lock().unwrap(), expected.to_vec()); - } - - #[test] - fn queued_incoming_stream_does_not_keep_shared_alive() { - let shared = Arc::new(Shared::default()); - let weak = Arc::downgrade(&shared); - shared.open_incoming(RequestId(7), Bytes::new(), None).unwrap(); - drop(shared); - assert!(weak.upgrade().is_none()); - } - - #[moq_clock::test] - async fn receive_drop_before_registration_releases_namespace() { - for version in [Version::Draft14, Version::Draft15] { - let shared = Arc::new(Shared::default()); - let (mut send, recv) = shared.open_outgoing(version); - drop(recv); - send.write_chunk(encode_msg(&publish_namespace(RequestId(4), "cluster/ns"), version)) - .await - .unwrap(); - assert!(shared.streams.lock().unwrap().is_empty()); - assert!(matches!(cancel(&shared, "cluster/ns", version), Route::Ignore)); - let _again = advertise(&shared, "cluster/ns", version, RequestId(6)).await; - assert!(matches!( - cancel(&shared, "cluster/ns", version), - Route::CloseStream(RequestId(6)) - )); - } - } - - #[test] - fn test_unresolvable_withdrawal_is_dropped() { - // v14/v15 name their withdrawals, and a name we hold no advertisement for belongs - // to a request that is already gone. Dropping it beats ending the session. - for version in [Version::Draft14, Version::Draft15] { - let shared = Arc::new(Shared::default()); - assert!(matches!(done(&shared, "cluster/ns", version), Route::Ignore)); - assert!(matches!(cancel(&shared, "cluster/ns", version), Route::Ignore)); - } - } -} diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 7f142f9adc0..189b9c19afe 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -2,8 +2,7 @@ //! //! The moq-transport binding of the lite Auth Stream (see [`crate::auth`]): each //! token rides a request stream of its own, answered with the patterns it grants, -//! encoded as lite's AUTH_OK encodes them. Negotiated with the AUTH Setup Option on -//! draft-17+ only, where SETUP is a Key-Value-Pair block. +//! encoded as lite's AUTH_OK encodes them. Negotiated with the AUTH Setup Option. #[cfg(feature = "async")] use crate::driver::Transport; @@ -18,7 +17,7 @@ use crate::{Error, Patterns, SessionError}; #[cfg(feature = "async")] use super::Control; -use super::{Message, RequestId, Version, cluster, peer}; +use super::{Message, RequestId, Version, peer}; #[cfg(feature = "async")] use crate::coding::Stream; @@ -27,33 +26,15 @@ use crate::coding::Stream; /// negotiates it. pub const AUTH: u64 = 0x40B70; -/// Whether a version negotiates this extension: draft-17+, like MoQ Cluster. -pub fn supported(version: Version) -> bool { - cluster::supported(version) -} - /// What the peer declared: `None` for no option, otherwise whether it offered the /// extension. Only an explicit 1 negotiates it. -pub fn from_setup(params: &super::Parameters, version: Version) -> Option { - if !supported(version) { - return None; - } +pub fn from_setup(params: &super::Parameters) -> Option { params.get_varint(super::ParameterVarInt::Auth).map(|value| value == 1) } -/// Offer the extension, on the versions that negotiate it. -pub fn into_setup(params: &mut super::Parameters, version: Version) { - if supported(version) { - params.set_varint(super::ParameterVarInt::Auth, 1); - } -} - -/// Refuse a message on a version that cannot negotiate the extension. -fn check_version(version: Version) -> Result<(), DecodeError> { - match supported(version) { - true => Ok(()), - false => Err(DecodeError::Version), - } +/// Offer the extension. +pub fn into_setup(params: &mut super::Parameters) { + params.set_varint(super::ParameterVarInt::Auth, 1); } /// AUTH: the first message on an Auth request stream, presenting a token. @@ -68,13 +49,11 @@ impl Message for Auth { const ID: u64 = 0x40B61; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - check_version(version).map_err(|_| EncodeError::Version)?; self.request_id.encode(w, version)?; w.bytes(&self.token) } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - check_version(version)?; Ok(Self { request_id: RequestId::decode(r, version)?, token: Bytes::copy_from_slice(r.bytes()?), @@ -100,7 +79,6 @@ impl Message for AuthOk { const ID: u64 = 0x40B62; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - check_version(version).map_err(|_| EncodeError::Version)?; self.publish.encode(w, version)?; self.subscribe.encode(w, version)?; // 0 means never, so a grant that has already lapsed rounds up to the smallest @@ -113,7 +91,6 @@ impl Message for AuthOk { } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - check_version(version)?; let publish = Patterns::decode(r, version)?; let subscribe = Patterns::decode(r, version)?; let expires = match r.varint()? { @@ -144,8 +121,7 @@ const MAX_REASON: usize = 8192; impl Message for AuthError { const ID: u64 = 0x40B63; - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - check_version(version).map_err(|_| EncodeError::Version)?; + fn encode_msg(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { if self.reason.len() > MAX_REASON { return Err(EncodeError::TooLarge); } @@ -153,8 +129,7 @@ impl Message for AuthError { w.string(&self.reason) } - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - check_version(version)?; + fn decode_msg(r: &mut Decoder<'_>, _: Version) -> Result { let code = r.varint()?; if code > u32::MAX as u64 { return Err(DecodeError::InvalidValue); @@ -173,7 +148,6 @@ const UNAUTHORIZED: u64 = 0x1; const NOT_SUPPORTED: u64 = 0x3; const MALFORMED_AUTH_TOKEN: u64 = 0x4; const EXPIRED_AUTH_TOKEN: u64 = 0x5; -/// Draft-17 assigned it, the first draft this extension negotiates on. const GOING_AWAY: u64 = 0x6; /// The AUTH_ERROR code for a refusal. The public API speaks session codes, and the @@ -349,11 +323,8 @@ impl Present { Ok(stream) => stream, Err(err) => return err, }; - stream = super::request_stream::with_stats(stream, &self.stats, self.version); - let request_id = match self.control.next_request_id(&self.runtime).await { - Ok(id) => id, - Err(err) => return err, - }; + stream = stream.with_stats(&self.stats); + let request_id = self.control.next_request_id(); if let Err(err) = stream.writer.encode_message(&Auth { request_id, token }).await { return err; } @@ -626,10 +597,10 @@ mod tests { #[cfg(feature = "async")] #[moq_clock::test] async fn auth_refused_without_credit_spends_no_request_id() { - for version in [Version::Draft17, Version::Draft18] { + for version in [Version::Draft18] { for client in [false, true] { let first = if client { 0 } else { 1 }; - let control = Control::new(Some(RequestId(first + 1)), client); + let control = Control::new(client); let present = Present { runtime: crate::time::sim(), stats: Default::default(), @@ -645,15 +616,12 @@ mod tests { present.run(Bytes::new(), &mut false).await, Error::NoStreamCredit )); - assert_eq!( - control.poll_next_request_id(&kio::Waiter::noop()), - Poll::Ready(RequestId(first)) - ); + assert_eq!(control.next_request_id(), RequestId(first)); } } } - const VERSION: Version = Version::Draft17; + const VERSION: Version = Version::Draft18; fn patterns(texts: &[&str]) -> Patterns { texts.iter().map(|text| Pattern::try_from(*text).unwrap()).collect() @@ -673,21 +641,14 @@ mod tests { got } - /// Every draft that negotiates the extension round-trips the option; the drafts - /// before the unified SETUP carry none. + /// Every draft that negotiates the extension round-trips the option. #[test] fn setup_option_round_trips_on_supported_drafts() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { - let mut params = super::super::Parameters::default(); - assert_eq!(from_setup(¶ms, version), None); - into_setup(&mut params, version); - assert_eq!(from_setup(¶ms, version), Some(true), "{version:?}"); - } - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + for version in [Version::Draft18, Version::Draft22] { let mut params = super::super::Parameters::default(); - into_setup(&mut params, version); - assert_eq!(params.get_varint(super::super::ParameterVarInt::Auth), None); - assert_eq!(from_setup(¶ms, version), None, "{version:?}"); + assert_eq!(from_setup(¶ms), None); + into_setup(&mut params); + assert_eq!(from_setup(¶ms), Some(true), "{version:?}"); } } @@ -697,7 +658,7 @@ mod tests { fn only_one_negotiates() { let mut params = super::super::Parameters::default(); params.set_varint(super::super::ParameterVarInt::Auth, 0); - assert_eq!(from_setup(¶ms, VERSION), Some(false)); + assert_eq!(from_setup(¶ms), Some(false)); } #[test] @@ -766,21 +727,6 @@ mod tests { assert!(matches!(err, DecodeError::InvalidValue), "{err:?}"); } - #[test] - fn older_drafts_have_no_auth() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let msg = Auth { - request_id: RequestId(0), - token: Bytes::new(), - }; - assert!(matches!(encode_msg(&msg, version), Err(EncodeError::Version))); - assert!(matches!( - Auth::decode_msg(&mut Decoder::new(&[0, 0], version.into()), version), - Err(DecodeError::Version) - )); - } - } - /// A reply with bytes past its message is refused, like any other framed message. #[test] fn replies_with_trailing_bytes_are_refused() { diff --git a/rs/moq-net/src/ietf/cluster.rs b/rs/moq-net/src/ietf/cluster.rs index 602cf45f80d..f9362630a80 100644 --- a/rs/moq-net/src/ietf/cluster.rs +++ b/rs/moq-net/src/ietf/cluster.rs @@ -13,7 +13,6 @@ //! //! The semantics are the same ones moq-lite carries natively (see //! [`crate::origin::Route`]); this module is only the moq-transport binding. -//! Negotiated on draft-17+ only, where SETUP is a Key-Value-Pair block. use crate::coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}; use crate::{Hop, Hops}; @@ -44,15 +43,6 @@ pub const ROUTE_COST: u64 = 0x40B58; /// same datacenter. pub const DEFAULT_COST: u64 = 1; -/// Whether a version negotiates this extension. -/// -/// Draft-17 is the first with the unified SETUP: a bare Key-Value-Pair block that a -/// new option slots into without touching the message shape. Earlier drafts exchange -/// SETUP over the legacy control stream, which this extension does not cover. -pub fn supported(version: Version) -> bool { - !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) -} - /// The ordered list of Hop IDs an advertisement has traversed, from the original /// publisher to the relay immediately upstream of the receiver. /// @@ -221,11 +211,7 @@ pub fn link_cost(local: Option, peer: &Peer) -> u64 { } /// Read the cluster Setup Options out of a decoded SETUP parameter block. -pub fn peer_from_setup(params: &super::Parameters, version: Version) -> Result { - if !supported(version) { - return Ok(Peer::default()); - } - +pub fn peer_from_setup(params: &super::Parameters) -> Result { // 0 is legal here: the peer speaks the extension but withholds its identity. let hop = params.get_varint(super::ParameterVarInt::HopId).map(Hop::from_wire); @@ -238,11 +224,7 @@ pub fn peer_from_setup(params: &super::Parameters, version: Version) -> Result

, version: Version) { - if !supported(version) { - return; - } - +pub fn peer_into_setup(params: &mut super::Parameters, self_hop: Hop, cost: Option) { params.set_varint(super::ParameterVarInt::HopId, self_hop.id()); if let Some(cost) = cost { @@ -495,7 +477,7 @@ mod tests { (Hop::UNKNOWN, None), ] { let mut params = super::super::Parameters::default(); - peer_into_setup(&mut params, self_hop, cost, VERSION); + peer_into_setup(&mut params, self_hop, cost); let mut buf = Vec::new(); params @@ -504,7 +486,7 @@ mod tests { let mut bytes = bytes::Bytes::from(buf); let decoded = crate::coding::decode_buf(&mut bytes, VERSION, super::super::Parameters::decode).unwrap(); - let peer = peer_from_setup(&decoded, VERSION).unwrap(); + let peer = peer_from_setup(&decoded).unwrap(); assert_eq!(peer.hop, Some(self_hop)); assert_eq!(peer.cost, cost); } @@ -515,7 +497,7 @@ mod tests { // The key sits at delta 0x40B54 from the start of the block and the value follows // it directly: a length byte here would make us unreadable to every -01 peer. let mut params = super::super::Parameters::default(); - peer_into_setup(&mut params, hop(42), None, VERSION); + peer_into_setup(&mut params, hop(42), None); let mut buf = Vec::new(); params @@ -527,7 +509,7 @@ mod tests { #[test] fn setup_without_hop_id_is_not_negotiated() { let params = super::super::Parameters::default(); - let peer = peer_from_setup(¶ms, VERSION).unwrap(); + let peer = peer_from_setup(¶ms).unwrap(); assert!(!peer.negotiated()); } @@ -546,17 +528,7 @@ mod tests { let mut bytes = bytes::Bytes::from(buf); let decoded = crate::coding::decode_buf(&mut bytes, VERSION, super::super::Parameters::decode).unwrap(); - let peer = peer_from_setup(&decoded, VERSION).unwrap(); + let peer = peer_from_setup(&decoded).unwrap(); assert!(!peer.negotiated()); } - - #[test] - fn setup_options_skipped_before_draft17() { - // Draft-14..16 exchange SETUP over the legacy control stream, which this - // extension does not cover; we neither send nor read the options there. - let mut params = super::super::Parameters::default(); - peer_into_setup(&mut params, hop(42), Some(3), Version::Draft16); - assert!(params.get_varint(super::super::ParameterVarInt::HopId).is_none()); - assert!(!peer_from_setup(¶ms, Version::Draft16).unwrap().negotiated()); - } } diff --git a/rs/moq-net/src/ietf/control.rs b/rs/moq-net/src/ietf/control.rs index e83e0591efc..5e2d3f1ad18 100644 --- a/rs/moq-net/src/ietf/control.rs +++ b/rs/moq-net/src/ietf/control.rs @@ -1,142 +1,33 @@ -use std::{collections::BTreeMap, task::Poll}; - -use crate::{ - Error, SessionError, - ietf::{RequestId, Version}, -}; - -/// The peer's spent request IDs: every ID of its parity below `next`, except the skipped -/// `start..end` runs in `missing`. -/// -/// Independent streams can reorder, so a peer's IDs need not arrive in sequence. A jump -/// costs one run however far it skips, and only a request landing inside a run splits it, -/// so the runs never outnumber the requests received. -struct Spent { - next: u64, - missing: BTreeMap, -} - -impl Spent { - fn new(first: u64) -> Self { - Self { - next: first, - missing: BTreeMap::new(), - } - } - - /// The skipped run holding `id`, if any. - fn run(&self, id: u64) -> Option<(u64, u64)> { - let (&start, &end) = self.missing.range(..=id).next_back()?; - (id < end).then_some((start, end)) - } - - /// Whether `id` has the peer's parity and was never spent. - /// - /// No peer reaches 2^62 requests, and refusing the IDs past it keeps `next` from - /// overflowing on a draft-17+ varint, which can carry a full 64 bits. - fn unused(&self, id: u64) -> bool { - id < 1 << 62 && id % 2 == self.next % 2 && (id >= self.next || self.run(id).is_some()) - } - - /// Whether spending `id` would record one more run. - fn splits(&self, id: u64) -> bool { - match self.run(id) { - Some((start, end)) => start < id && id + 2 < end, - None => id > self.next, - } - } - - /// Spend an [unused](Self::unused) `id`. - fn spend(&mut self, id: u64) { - if let Some((start, end)) = self.run(id) { - self.missing.remove(&start); - if start < id { - self.missing.insert(start, id); - } - if id + 2 < end { - self.missing.insert(id + 2, end); - } - } else if id > self.next { - self.missing.insert(self.next, id); - } - self.next = self.next.max(id + 2); - } -} - -/// The MAX_REQUEST_ID that first admits `window` requests from a peer. -/// -/// A client's request IDs are even from 0 and a server's odd from 1, each stepping by 2. -pub(crate) fn initial_max_request_id(window: u64, peer_client: bool) -> u64 { - let parity = if peer_client { 0 } else { 1 }; - window.saturating_mul(2).saturating_add(parity).min((1 << 62) - 1) -} +use crate::{Error, SessionError, ietf::RequestId}; struct ControlState { /// Our own role. client: bool, request_id_next: RequestId, - /// Legacy request headers that must finish before a later ID can be allocated. - /// A count, since two tasks can allocate before either takes its hold. - request_id_sending: usize, - /// None means no flow control (draft17 removed MaxRequestId). - request_id_max: Option, - /// The peer's requests, flow controlled on drafts 14 to 16 only. - incoming: Option, - /// The peer's spent IDs on draft 17 and later, which has no window to bound the runs. - modern: Spent, - /// The most runs `modern` may hold. Forgetting one would make its IDs usable again. - history: u64, - /// A draft-18 GOAWAY has frozen admission of new peer requests. + /// One past the highest ID the peer has used, of its parity. + /// + /// Request streams can arrive out of order, so any lower ID is still accepted, and a + /// reused one too: each request owns its stream, so a reuse only confuses the reusing + /// peer's own joining FETCHes, and remembering every skipped ID would cost memory the + /// peer chooses. + peer_next: u64, + /// A GOAWAY has frozen admission at `peer_next`. going_away: bool, } -/// The MAX_REQUEST_ID window we grant the peer. -struct Incoming { - /// How many requests the peer may hold open at once. - window: u64, - /// The MAX_REQUEST_ID in force: every request ID the peer uses must be below it. - max: u64, - /// The last MAX_REQUEST_ID the peer was told, so each grant goes out once. - sent: u64, - /// Requests accepted and not yet retired. - live: u64, - /// Requests retired since the last grant. - retired: u64, - /// Every accepted ID, preserving IDs whose requests retired. - spent: Spent, - /// Draft 16 can reorder across streams; drafts 14 and 15 require the next ID. - reorder: bool, -} - #[derive(Clone)] pub(super) struct Control { state: kio::Shared, } -/// The session must keep a committed legacy header alive until this hold ends. -pub(super) struct RequestSend(Control); - -impl Drop for RequestSend { - fn drop(&mut self) { - self.0.state.lock().request_id_sending -= 1; - } -} - impl Control { - /// How long a request waits for MAX_REQUEST_ID credit before giving up. - pub const REQUEST_ID_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - - pub fn new(request_id_max: Option, client: bool) -> Self { + pub fn new(client: bool) -> Self { Self { state: kio::Shared::new(ControlState { client, request_id_next: if client { RequestId(0) } else { RequestId(1) }, - request_id_sending: 0, - request_id_max, - incoming: None, // The peer takes the other parity: a client's IDs are even, a server's odd. - modern: Spent::new(u64::from(client)), - history: crate::session::Limits::default().window(), + peer_next: u64::from(client), going_away: false, }), } @@ -147,183 +38,42 @@ impl Control { self.state.lock().client } - /// Flow control the peer's requests (drafts 14 to 16), admitting `window` at once. + /// Freeze admission and name the first peer ID past every one it has used. /// - /// `client` is our own role, as passed to [`Self::new`]; the window starts at what - /// [`initial_max_request_id`] advertised in our SETUP. - pub fn with_window(self, window: u64, client: bool, version: Version) -> Self { - let max = initial_max_request_id(window, !client); - self.state.lock().incoming = Some(Incoming { - window, - max, - sent: max, - live: 0, - retired: 0, - spent: Spent::new(u64::from(client)), - reorder: version == Version::Draft16, - }); - self - } - - /// Bound the runs of skipped IDs a draft-17+ peer may leave, closing the session with - /// INTERNAL_ERROR past `limit` rather than forgetting any. - pub fn with_history(self, limit: u64) -> Self { - self.state.lock().history = limit; - self - } - - /// Freeze draft-18 admission and name the smallest peer ID not admitted: the first one - /// a reordered stream skipped, or else the next. + /// A lower ID still in flight on a reordered stream is processed as the cutoff promises. pub fn goaway(&self) -> Result { let mut state = self.state.lock(); - let modern = &state.modern; - let cutoff = modern.missing.keys().next().copied().unwrap_or(modern.next); - if cutoff >= 1 << 62 { + if state.peer_next >= 1 << 62 { return Err(SessionError::Internal.into()); } state.going_away = true; - Ok(RequestId(cutoff)) - } - - pub fn max_request_id(&self, max: RequestId) { - // Mutating through the guard wakes any `next_request_id` waiting on the limit. - self.state.lock().request_id_max = Some(max); + Ok(RequestId(state.peer_next)) } - /// Admit a request the peer opened, held open until the returned permit drops. + /// Admit a request the peer opened. /// - /// Closes the session with TOO_MANY_REQUESTS when the ID is at or past the - /// MAX_REQUEST_ID we advertised, or its live requests exceed the window. - /// Drafts 14/15 require the next ID; draft 16 permits cross-stream reordering. - /// Used IDs close with INVALID_REQUEST_ID even after retirement, on every draft. - /// `None` when the peer's requests are not flow controlled (draft 17 and later). - pub fn accept(&self, id: RequestId) -> Result, Error> { + /// A wrong-parity ID closes with INVALID_REQUEST_ID, and once GOAWAY froze admission, an + /// ID at or past its cutoff is refused with [`Error::GoingAway`]. + pub fn accept(&self, id: RequestId) -> Result<(), Error> { let mut state = self.state.lock(); - let state = &mut *state; - let Some(incoming) = &mut state.incoming else { - let modern = &mut state.modern; - if !modern.unused(id.0) { - return Err(SessionError::InvalidRequestId.into()); - } - if modern.splits(id.0) && modern.missing.len() as u64 >= state.history { - tracing::warn!(limit = state.history, "request ID history exhausted"); - return Err(SessionError::Internal.into()); - } - modern.spend(id.0); - return if state.going_away { - Err(Error::GoingAway) - } else { - Ok(None) - }; - }; - if id.0 >= incoming.max { - tracing::warn!(id = %id, max = incoming.max, live = incoming.live, "peer exceeded MAX_REQUEST_ID"); - return Err(SessionError::TooManyRequests.into()); - } - let unused = match incoming.reorder { - true => incoming.spent.unused(id.0), - false => id.0 == incoming.spent.next, - }; - if !unused { + // No peer reaches 2^62 requests, and refusing the IDs past it keeps `peer_next` from + // overflowing on a varint that can carry a full 64 bits. + if id.0 >= 1 << 62 || id.0 % 2 != state.peer_next % 2 { return Err(SessionError::InvalidRequestId.into()); } - if incoming.live >= incoming.window { - return Err(SessionError::TooManyRequests.into()); - } - incoming.spent.spend(id.0); - incoming.live += 1; - Ok(Some(RequestPermit(self.clone()))) - } - - /// Retire one accepted request, growing the window once half of it has retired. - /// - /// Batching keeps the MAX_REQUEST_ID traffic a fraction of the requests it grants. - fn retire(&self) { - let mut state = self.state.lock(); - let Some(incoming) = &mut state.incoming else { - return; - }; - incoming.live -= 1; - incoming.retired += 1; - if incoming.retired.saturating_mul(2) >= incoming.window { - let grant = incoming.retired.saturating_mul(2); - incoming.max = incoming.max.saturating_add(grant).min((1 << 62) - 1); - incoming.retired = 0; - } - } - - /// Poll for a MAX_REQUEST_ID the peer has not been told yet. - /// - /// Pending forever when the peer's requests are not flow controlled. - pub fn poll_grant(&self, waiter: &kio::Waiter) -> Poll { - let ready = self.state.poll(waiter, |state| match &state.incoming { - Some(incoming) if incoming.max > incoming.sent => Poll::Ready(()), - _ => Poll::Pending, - }); - let mut state = std::task::ready!(ready); - let incoming = state.incoming.as_mut().expect("checked above"); - incoming.sent = incoming.max; - Poll::Ready(RequestId(incoming.max)) - } - - /// Hold later allocations until an already allocated legacy header is sent. - pub fn sending(&self) -> RequestSend { - self.state.lock().request_id_sending += 1; - RequestSend(self.clone()) - } - - /// Allocate the next request_id once credit and the committed header allow it, without - /// giving up: for a caller that waits on credit beside its other work. - pub fn poll_next_request_id(&self, waiter: &kio::Waiter) -> Poll { - let allowed = self.state.poll(waiter, |state| { - let allowed = match state.request_id_max { - None => true, - Some(max) => state.request_id_next < max, - }; - match allowed && state.request_id_sending == 0 { - true => Poll::Ready(()), - false => Poll::Pending, - } - }); - let mut state = std::task::ready!(allowed); - Poll::Ready(state.request_id_next.increment()) - } - - /// Allocate the next request_id once credit and the committed header allow it, or give - /// up with [`Error::Cancel`] once `timeout` passes. - /// - /// Arm `timeout` with [`Self::REQUEST_ID_TIMEOUT`] on the caller's clock. - pub fn poll_credit( - &self, - waiter: &kio::Waiter, - timeout: &mut crate::time::Deadline, - ) -> Poll> { - if let Poll::Ready(id) = self.poll_next_request_id(waiter) { - return Poll::Ready(Ok(id)); + if id.0 < state.peer_next { + return Ok(()); } - if timeout.poll(waiter).is_ready() { - tracing::warn!("timed out waiting for MAX_REQUEST_ID"); - return Poll::Ready(Err(Error::Cancel)); + if state.going_away { + return Err(Error::GoingAway); } - Poll::Pending + state.peer_next = id.0 + 2; + Ok(()) } - /// Allocate the next request_id once credit and the committed header allow it. - /// - /// `runtime` arms the give-up timer, so the wait cannot outlive the caller's - /// clock. - pub async fn next_request_id(&self, runtime: &crate::time::Deadlines) -> Result { - let mut timeout = crate::time::Deadline::after(runtime, Self::REQUEST_ID_TIMEOUT); - kio::wait(|waiter| self.poll_credit(waiter, &mut timeout)).await - } -} - -/// One request the peer holds open, retired (and eventually granted back) on drop. -pub(super) struct RequestPermit(Control); - -impl Drop for RequestPermit { - fn drop(&mut self) { - self.0.retire(); + /// Allocate the next request ID. + pub fn next_request_id(&self) -> RequestId { + self.state.lock().request_id_next.increment() } } @@ -334,272 +84,61 @@ mod tests { #[test] fn goaway_freezes_request_admission_at_current_progress() { for client in [false, true] { - let control = Control::new(None, client); + let control = Control::new(client); let first = u64::from(client); assert_eq!(control.goaway().unwrap(), RequestId(first)); assert!(matches!(control.accept(RequestId(first)), Err(Error::GoingAway))); - let control = Control::new(None, client); + let control = Control::new(client); control.accept(RequestId(first)).unwrap(); control.accept(RequestId(first + 2)).unwrap(); assert_eq!(control.goaway().unwrap(), RequestId(first + 4)); - // A reordered stream skipped first + 2, which is the smallest ID not processed. - let control = Control::new(None, client); + // A reordered stream skipped first + 2, which the cutoff still covers. + let control = Control::new(client); control.accept(RequestId(first)).unwrap(); control.accept(RequestId(first + 4)).unwrap(); - assert_eq!(control.goaway().unwrap(), RequestId(first + 2)); - // The skipped ID may still arrive after GOAWAY, and is past the cutoff too. - for id in [first + 2, first + 6, first + 8] { + assert_eq!(control.goaway().unwrap(), RequestId(first + 6)); + control.accept(RequestId(first + 2)).unwrap(); + for id in [first + 6, first + 8] { assert!(matches!(control.accept(RequestId(id)), Err(Error::GoingAway))); } - assert!(matches!( - control.accept(RequestId(first)), - Err(Error::Session(SessionError::InvalidRequestId)) - )); - } - } - - fn granted(control: &Control) -> Option { - match control.poll_grant(&kio::Waiter::noop()) { - Poll::Ready(max) => Some(max.0), - Poll::Pending => None, - } - } - - /// The window admits exactly its size, refuses the next ID with TOO_MANY_REQUESTS, - /// and grants room back in batches as requests retire. - #[test] - fn window_refills_as_requests_retire() { - // We are the server, so the client's IDs are even: 0, 2, 4, 6. - let control = Control::new(None, false).with_window(4, false, Version::Draft15); - assert_eq!(initial_max_request_id(4, true), 8); - - let mut open: Vec<_> = (0..4) - .map(|i| control.accept(RequestId(i * 2)).unwrap().unwrap()) - .collect(); - assert!(matches!( - control.accept(RequestId(8)), - Err(Error::Session(SessionError::TooManyRequests)) - )); - assert_eq!(granted(&control), None, "nothing retired yet"); - - // One retired is below half the window, so nothing is granted yet. - open.pop(); - assert_eq!(granted(&control), None); - - // Half the window retired: two more IDs. - open.pop(); - assert_eq!(granted(&control), Some(12)); - assert_eq!(granted(&control), None, "each grant goes out once"); - - let _a = control.accept(RequestId(8)).unwrap(); - let _b = control.accept(RequestId(10)).unwrap(); - assert!(control.accept(RequestId(12)).is_err()); - } - - /// An ID remains spent after its request ends, across every request type. - #[test] - fn retired_or_lower_request_ids_are_refused() { - for later in [0, 2] { - let control = Control::new(None, false).with_window(8, false, Version::Draft15); - for id in [0, 2, 4] { - drop(control.accept(RequestId(id)).unwrap()); - } - let err = control.accept(RequestId(later)).err().expect("spent ID refused"); - assert_eq!(SessionError::from(&err).to_code(), 0x4); + assert_eq!(control.goaway().unwrap(), RequestId(first + 6)); } } #[test] - fn a_duplicate_is_invalid_even_when_the_window_is_full() { - for client in [false, true] { - let control = Control::new(None, client).with_window(1, client, Version::Draft15); - let id = RequestId(u64::from(client)); - let _held = control.accept(id).unwrap(); - assert!(matches!( - control.accept(id), - Err(Error::Session(SessionError::InvalidRequestId)) - )); - } - } - - /// The first ID and every subsequent request must be exactly the next in sequence. - #[test] - fn skipped_and_wrong_parity_request_ids_are_refused() { + fn requests_need_the_peers_parity() { for client in [false, true] { + let control = Control::new(client); let first = u64::from(client); - for prefix in [0, 1, 2] { - for gap in [2, 4, 1] { - let control = Control::new(None, client).with_window(8, client, Version::Draft15); - for index in 0..prefix { - drop(control.accept(RequestId(first + 2 * index)).unwrap()); - } - let err = control - .accept(RequestId(first + 2 * prefix + gap)) - .err() - .expect("non-sequential ID refused"); - assert_eq!(SessionError::from(&err), SessionError::InvalidRequestId); - } - } - } - } - - #[test] - fn draft16_independent_streams_can_arrive_out_of_order() { - for client in [false, true] { - let control = Control::new(None, client).with_window(8, client, Version::Draft16); - let first = u64::from(client); - for id in [first + 2, first, first + 6, first + 4] { - drop(control.accept(RequestId(id)).expect("independent stream arrival order")); - } - for id in [first, first + 2, first + 4, first + 6] { - assert!(matches!( - control.accept(RequestId(id)), - Err(Error::Session(SessionError::InvalidRequestId)) - )); - } - } - } - - #[test] - fn draft16_remembers_only_missing_ids_while_other_requests_retire() { - let control = Control::new(None, false).with_window(4, false, Version::Draft16); - for id in 1..1024 { - drop(control.accept(RequestId(id * 2)).unwrap()); - let state = control.state.lock(); - let missing = &state.incoming.as_ref().unwrap().spent.missing; - assert_eq!(missing.iter().collect::>(), [(&0, &2)]); - } - drop(control.accept(RequestId(0)).unwrap()); - assert!(control.state.lock().incoming.as_ref().unwrap().spent.missing.is_empty()); - for id in [0, 2, 2046] { - assert!(matches!( - control.accept(RequestId(id)), - Err(Error::Session(SessionError::InvalidRequestId)) - )); - } - } - - /// Skipping to the top of a production-sized window records one run, not every ID. - #[test] - fn draft16_a_jump_is_one_run_split_only_by_requests_inside_it() { - let window = crate::session::Limits::default().window(); - let control = Control::new(None, false).with_window(window, false, Version::Draft16); - let top = 2 * (window - 1); - let runs = |control: &Control| { - let state = control.state.lock(); - let missing = &state.incoming.as_ref().unwrap().spent.missing; - missing.iter().map(|(&start, &end)| (start, end)).collect::>() - }; - - drop(control.accept(RequestId(top)).unwrap()); - assert_eq!(runs(&control), [(0, top)]); - - for id in [100, 0, top - 2] { - drop(control.accept(RequestId(id)).unwrap()); - } - assert_eq!(runs(&control), [(2, 100), (102, top - 2)]); - - for id in [0, 100, top - 2, top] { - assert!(matches!( - control.accept(RequestId(id)), - Err(Error::Session(SessionError::InvalidRequestId)) - )); - } - } - - #[test] - fn modern_requests_reject_reuse_and_wrong_parity() { - for client in [false, true] { - let control = Control::new(None, client); - let first = u64::from(client); - for id in [first + 4, first, first + 2] { - assert!(control.accept(RequestId(id)).unwrap().is_none()); + for id in [first + 4, first, first + 2, first + 4] { + control.accept(RequestId(id)).unwrap(); } - for id in [first, first + 2, first + 4, first ^ 1] { + for id in [first ^ 1, (first + 4) ^ 1] { assert!( matches!( control.accept(RequestId(id)), Err(Error::Session(SessionError::InvalidRequestId)) ), - "accepted spent or wrong-parity ID {id}" + "accepted wrong-parity ID {id}" ); } } } - /// Contiguous arrival in either order leaves no runs, and a full history refuses a new - /// gap rather than forgetting an old one. - #[test] - fn modern_history_coalesces_and_never_forgets() { - for reversed in [false, true] { - let control = Control::new(None, false).with_history(1); - for n in 0..4096 { - let id = if reversed { 4095 - n } else { n } * 2; - control.accept(RequestId(id)).unwrap(); - } - assert!(control.state.lock().modern.missing.is_empty()); - assert!(matches!( - control.accept(RequestId(0)), - Err(Error::Session(SessionError::InvalidRequestId)) - )); - } - let control = Control::new(None, false).with_history(1); - control.accept(RequestId(0)).unwrap(); - control.accept(RequestId(4)).unwrap(); - assert!(matches!( - control.accept(RequestId(8)), - Err(Error::Session(SessionError::Internal)) - )); - control.accept(RequestId(2)).unwrap(); - control.accept(RequestId(8)).unwrap(); - for id in [0, 2, 4, 8] { - assert!(matches!( - control.accept(RequestId(id)), - Err(Error::Session(SessionError::InvalidRequestId)) - )); - } - } - #[test] - fn modern_sparse_ids_do_not_allocate_the_gap() { - let control = Control::new(None, false).with_history(1); + fn ids_past_2_62_are_refused() { + let control = Control::new(false); let top = (1 << 62) - 2; control.accept(RequestId(top)).unwrap(); control.accept(RequestId(0)).unwrap(); - let runs: Vec<_> = control.state.lock().modern.missing.clone().into_iter().collect(); - assert_eq!(runs, [(2, top)]); - for id in [top, 1 << 62, u64::MAX - 1] { + for id in [1 << 62, u64::MAX - 1] { assert!(matches!( control.accept(RequestId(id)), Err(Error::Session(SessionError::InvalidRequestId)) )); } - } - - /// Draft-17+ has no MAX_REQUEST_ID: nothing is granted. - #[test] - fn unlimited_without_a_window() { - let control = Control::new(None, true); - assert!(control.accept(RequestId(1)).unwrap().is_none()); - assert_eq!(granted(&control), None); - } - - /// Allocation waits until every overlapping legacy header hold has ended. - #[moq_clock::test] - async fn overlapping_send_holds_block_allocation_until_all_end() { - let control = Control::new(None, false); - let clock = crate::time::sim(); - let a = control.sending(); - let b = control.sending(); - drop(a); - let mut next = std::pin::pin!(control.next_request_id(&clock)); - assert!(kio::Waiter::noop().poll_future(next.as_mut()).is_pending()); - drop(b); - assert!(matches!( - kio::Waiter::noop().poll_future(next.as_mut()), - Poll::Ready(Ok(RequestId(1))) - )); + assert!(matches!(control.goaway(), Err(Error::Session(SessionError::Internal)))); } } diff --git a/rs/moq-net/src/ietf/datagram.rs b/rs/moq-net/src/ietf/datagram.rs index 1f2f0178c43..145de6a9ffe 100644 --- a/rs/moq-net/src/ietf/datagram.rs +++ b/rs/moq-net/src/ietf/datagram.rs @@ -1,9 +1,7 @@ -//! OBJECT_DATAGRAM: one Object carried in a QUIC datagram (draft-14 section 10.3.1 through -//! draft-22 section 11.2.1). +//! OBJECT_DATAGRAM: one Object carried in a QUIC datagram (draft-22 section 11.2.1). //! //! Public [`crate::Datagram`] writers emit Object ID 0; private relay metadata preserves -//! other IDs, properties, and status. The Type is a set of flags; draft-14 lacks the -//! DEFAULT_PRIORITY bit and a status with an omitted Object ID. +//! other IDs, properties, and status. The Type is a set of flags. use bytes::Bytes; @@ -38,7 +36,7 @@ pub struct ObjectDatagram { pub group_id: u64, /// The Object ID, or `None` when the ZERO_OBJECT_ID bit omits it (Object 0). pub object_id: Option, - /// The Publisher Priority, or `None` to inherit the subscription's (draft-15+). + /// The Publisher Priority, or `None` to inherit the subscription's. pub publisher_priority: Option, /// No Object past this one exists in the group. pub end_of_group: bool, @@ -48,13 +46,9 @@ pub struct ObjectDatagram { } impl ObjectDatagram { - /// Whether `kind` is a Type this draft defines. - fn valid(kind: u64, version: Version) -> bool { - match version { - Version::Draft14 => kind <= 0x07 || kind == 0x20 || kind == 0x21, - // A status cannot also end the group. - _ => kind & !flag::ALL == 0 && !(kind & flag::STATUS != 0 && kind & flag::END_OF_GROUP != 0), - } + /// Whether `kind` is a defined Type. A status cannot also end the group. + fn valid(kind: u64) -> bool { + kind & !flag::ALL == 0 && !(kind & flag::STATUS != 0 && kind & flag::END_OF_GROUP != 0) } /// The Type bits this datagram's fields set. @@ -80,10 +74,9 @@ impl ObjectDatagram { } impl crate::model::datagram::Wire { - /// Whether `version`, the draft its track's publisher spoke, defines a datagram of this - /// shape, as its ingress checks one. Egress resolves the Object ID and priority each - /// draft needs. - pub(crate) fn defined(&self, version: Version) -> bool { + /// Whether a datagram of this shape is defined, as ingress checks one. Egress resolves + /// the Object ID and priority each draft needs. + pub(crate) fn defined(&self) -> bool { let shape = ObjectDatagram { track_alias: 0, group_id: 0, @@ -96,24 +89,17 @@ impl crate::model::datagram::Wire { None => DatagramBody::Payload(Bytes::new()), }, }; - ObjectDatagram::valid(shape.kind(), version) + ObjectDatagram::valid(shape.kind()) && self.status.is_none_or(|status| { - super::object::status_defined(status, version) - && (self.properties.block.is_none() || carries_properties(status, version)) + super::object::status_defined(status) && (self.properties.block.is_none() || status == 0) }) } } -/// Whether `version` lets a datagram of Object Status `status` carry properties: from -/// draft-17 on, only a Normal Object may. -pub(super) fn carries_properties(status: u64, version: Version) -> bool { - status == 0 || matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) -} - impl Encode for ObjectDatagram { - fn encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + fn encode(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { let kind = self.kind(); - if !Self::valid(kind, version) { + if !Self::valid(kind) { return Err(EncodeError::InvalidState); } @@ -136,7 +122,8 @@ impl Encode for ObjectDatagram { match &self.body { DatagramBody::Status(status) => { - if self.properties.is_some() && !carries_properties(*status, version) { + // Only a Normal Object may carry properties. + if self.properties.is_some() && *status != 0 { return Err(EncodeError::InvalidState); } w.varint(*status)? @@ -151,9 +138,9 @@ impl Encode for ObjectDatagram { } impl Decode for ObjectDatagram { - fn decode(r: &mut Decoder<'_>, version: Version) -> Result { + fn decode(r: &mut Decoder<'_>, _: Version) -> Result { let kind = r.varint()?; - if !Self::valid(kind, version) { + if !Self::valid(kind) { return Err(DecodeError::InvalidValue); } @@ -181,7 +168,7 @@ impl Decode for ObjectDatagram { let body = match kind & flag::STATUS != 0 { true => { let status = r.varint()?; - if properties.is_some() && !carries_properties(status, version) { + if properties.is_some() && status != 0 { return Err(DecodeError::InvalidValue); } if !r.is_empty() { @@ -214,14 +201,7 @@ mod tests { Ok(datagram) } - const ALL: [Version; 6] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; + const ALL: [Version; 2] = [Version::Draft18, Version::Draft22]; fn single(priority: Option) -> ObjectDatagram { ObjectDatagram { @@ -245,19 +225,6 @@ mod tests { } } - #[test] - fn default_priority_needs_draft15() { - assert!(matches!( - single(None).encode_bytes(Version::Draft14), - Err(EncodeError::InvalidState) - )); - assert!(decode(&[0x0C, 0x01, 0x02], Version::Draft14).is_err()); - - let buf = single(None).encode_bytes(Version::Draft16).unwrap(); - assert_eq!(buf[0], 0x0F); - assert_eq!(decode(&buf, Version::Draft16).unwrap(), single(None)); - } - #[test] fn explicit_object_id_and_status() { let datagram = ObjectDatagram { @@ -305,17 +272,8 @@ mod tests { body: DatagramBody::Status(3), }; for version in ALL { - let legacy = matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16); - match datagram.encode_bytes(version) { - Ok(buf) => { - assert!(legacy, "{version}"); - assert_eq!(decode(&buf, version).unwrap(), datagram); - } - Err(err) => { - assert!(!legacy, "{version}"); - assert!(matches!(err, EncodeError::InvalidState)); - } - } + let err = datagram.encode_bytes(version).unwrap_err(); + assert!(matches!(err, EncodeError::InvalidState), "{version}"); } } @@ -324,7 +282,7 @@ mod tests { // PROPERTIES and ZERO_OBJECT_ID, alias 1, group 2, priority 0, empty block. let bytes = [0x05, 0x01, 0x02, 0x00, 0x00, b'x']; assert!(matches!( - ObjectDatagram::decode_slice(&bytes, Version::Draft16), + ObjectDatagram::decode_slice(&bytes, Version::Draft18), Err(DecodeError::InvalidValue) )); } diff --git a/rs/moq-net/src/ietf/epoch.rs b/rs/moq-net/src/ietf/epoch.rs index 9500bae5700..34fbe717a93 100644 --- a/rs/moq-net/src/ietf/epoch.rs +++ b/rs/moq-net/src/ietf/epoch.rs @@ -8,8 +8,7 @@ //! TRACK_STATUS). A namespace and an EPOCH name one broadcast; a request carrying one //! is refused rather than served by another instance. //! -//! Negotiated on draft-17+ only, where SETUP is a Key-Value-Pair block, and only when -//! both endpoints declare the option. +//! Negotiated only when both endpoints declare the option. use crate::Epoch; use crate::coding::{DecodeError, Decoder, EncodeError, Encoder}; @@ -24,21 +23,14 @@ pub const EPOCH: u64 = 0x40B68; /// value is length-prefixed. pub const EPOCH_PARAM: u64 = 0x40B69; -/// Whether a version negotiates this extension: draft-17+, like MoQ Cluster. -pub fn supported(version: Version) -> bool { - !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) -} - /// Whether the peer declared the option. -pub fn from_setup(params: &super::Parameters, version: Version) -> bool { - supported(version) && params.get_varint(super::ParameterVarInt::Epoch).is_some() +pub fn from_setup(params: &super::Parameters) -> bool { + params.get_varint(super::ParameterVarInt::Epoch).is_some() } /// Declare that we understand the EPOCH parameter. -pub fn into_setup(params: &mut super::Parameters, version: Version) { - if supported(version) { - params.set_varint(super::ParameterVarInt::Epoch, 1); - } +pub fn into_setup(params: &mut super::Parameters) { + params.set_varint(super::ParameterVarInt::Epoch, 1); } impl Param for Epoch { @@ -66,13 +58,11 @@ mod tests { } #[test] - fn only_draft17_and_later_negotiate() { - for version in [Version::Draft14, Version::Draft16, Version::Draft17, Version::Draft22] { - let mut params = super::super::Parameters::default(); - assert!(!from_setup(¶ms, version)); - into_setup(&mut params, version); - assert_eq!(from_setup(¶ms, version), supported(version), "{version:?}"); - } + fn declared_option_negotiates() { + let mut params = super::super::Parameters::default(); + assert!(!from_setup(¶ms)); + into_setup(&mut params); + assert!(from_setup(¶ms)); } #[test] diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index d6853a1ce5c..9a7c86e362e 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -8,33 +8,16 @@ //! //! Sent on RESET_STREAM and STOP_SENDING, and read back off both. The values are the //! draft's, not [`StreamError::to_code`]'s: the two registries agree on most of what they -//! both assign, but not all of it, and this one grew (and moved a value) across the drafts -//! we negotiate, so a code only means something once you know which draft carried it. -//! -//! | Code | draft-14/15 | draft-16 | draft-17 | draft-18+ | -//! |------|-------------|----------|----------|-----------| -//! | 0x0 | INTERNAL_ERROR | INTERNAL_ERROR | INTERNAL_ERROR | INTERNAL_ERROR | -//! | 0x1 | CANCELLED | CANCELLED | CANCELLED | CANCELLED | -//! | 0x2 | DELIVERY_TIMEOUT | DELIVERY_TIMEOUT | DELIVERY_TIMEOUT | DELIVERY_TIMEOUT | -//! | 0x3 | SESSION_CLOSED | SESSION_CLOSED | SESSION_CLOSED | SESSION_CLOSED | -//! | 0x4 | - | UNKNOWN_OBJECT_STATUS | UNKNOWN_OBJECT_STATUS | GOING_AWAY | -//! | 0x5 | - | - | TOO_FAR_BEHIND | TOO_FAR_BEHIND | -//! | 0x12 | - | MALFORMED_TRACK | MALFORMED_TRACK | MALFORMED_TRACK | -//! -//! Encoding and decoding therefore move together and both take the negotiated version: -//! GOING_AWAY sent to a draft-17 peer reads as UNKNOWN_OBJECT_STATUS, and a draft-17 -//! peer's UNKNOWN_OBJECT_STATUS read as GOING_AWAY would retire a session that is not -//! going anywhere. +//! both assign, but not all of it. use super::Version; use crate::{SessionError, StreamError}; /// An implementation-specific error: the stream died on our side, with no registry entry -/// for why. Assigned by every draft we negotiate. +/// for why. pub const INTERNAL_ERROR: u32 = 0x0; /// The stream was cancelled by either endpoint. A routine unsubscribe, not a failure. -/// Assigned by every draft we negotiate. pub const CANCELLED: u32 = 0x1; /// The content missed its delivery deadline. @@ -43,40 +26,21 @@ const DELIVERY_TIMEOUT: u32 = 0x2; /// The session is closing, taking this stream with it. const SESSION_CLOSED: u32 = 0x3; -/// A GOAWAY was sent or received. Draft-18 and later; draft-16 and 17 gave 0x4 to -/// UNKNOWN_OBJECT_STATUS instead. +/// A GOAWAY was sent or received. const GOING_AWAY: u32 = 0x4; -/// The subscription outran the publisher's resource limits. Draft-17 and later. +/// The subscription outran the publisher's resource limits. const TOO_FAR_BEHIND: u32 = 0x5; -/// The track's content could not be parsed. Draft-16 and later. +/// The track's content could not be parsed. const MALFORMED_TRACK: u32 = 0x12; -/// Whether the draft assigns 0x4 to GOING_AWAY. -/// -/// Draft-16 and 17 assign it to UNKNOWN_OBJECT_STATUS, which draft-18 moved to 0x6 when it -/// took 0x4 for this. Draft-14 and 15 assign it nothing. -fn has_going_away(version: Version) -> bool { - matches!(version, Version::Draft18 | Version::Draft22) -} - -/// Whether the draft assigns TOO_FAR_BEHIND. Added in draft-17. -fn has_too_far_behind(version: Version) -> bool { - !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) -} - -/// Whether the draft assigns MALFORMED_TRACK. Added in draft-16. -fn has_malformed_track(version: Version) -> bool { - !matches!(version, Version::Draft14 | Version::Draft15) -} - -/// The code to reset a stream, or send STOP_SENDING, with on the negotiated draft. +/// The code to reset a stream, or send STOP_SENDING, with. /// /// Conditions without a matching reset code use INTERNAL_ERROR. Request rejection has /// its own registry: a missing track belongs in a request error response, not a reset. /// moq-lite's own 48-63 range and its application offset have no corresponding ranges here. -pub fn to_stream_code(err: &StreamError, version: Version) -> u32 { +pub fn to_stream_code(err: &StreamError) -> u32 { match err { StreamError::Internal => INTERNAL_ERROR, StreamError::Cancel => CANCELLED, @@ -87,30 +51,30 @@ pub fn to_stream_code(err: &StreamError, version: Version) -> u32 { // Flattened, as on the moq-lite wire: the session registry is disjoint, so the // specific reason travels on the session close instead. StreamError::Session(_) => SESSION_CLOSED, - StreamError::GoingAway if has_going_away(version) => GOING_AWAY, - StreamError::TooFarBehind if has_too_far_behind(version) => TOO_FAR_BEHIND, - StreamError::MalformedTrack if has_malformed_track(version) => MALFORMED_TRACK, + StreamError::GoingAway => GOING_AWAY, + StreamError::TooFarBehind => TOO_FAR_BEHIND, + StreamError::MalformedTrack => MALFORMED_TRACK, _ => INTERNAL_ERROR, } } -/// Read a stream reset (or STOP_SENDING) code received on the negotiated draft. +/// Read a stream reset (or STOP_SENDING) code. /// -/// A code the draft does not assign stays [`StreamError::Unknown`], which surfaces as +/// A code the drafts do not assign stays [`StreamError::Unknown`], which surfaces as /// [`Error::Stream`](crate::Error::Stream): an error, but never one given a meaning it did /// not carry. That includes the codes this crate has no local counterpart for /// (UNKNOWN_OBJECT_STATUS, EXCESSIVE_LOAD) and every value a later /// draft may add. -pub fn from_stream_code(code: u32, version: Version) -> StreamError { +pub fn from_stream_code(code: u32) -> StreamError { match code { INTERNAL_ERROR => StreamError::Internal, CANCELLED => StreamError::Cancel, DELIVERY_TIMEOUT => StreamError::DeliveryTimeout, // The peer's session code is not on this stream, so the reason is unknown here. SESSION_CLOSED => StreamError::Session(SessionError::Internal), - GOING_AWAY if has_going_away(version) => StreamError::GoingAway, - TOO_FAR_BEHIND if has_too_far_behind(version) => StreamError::TooFarBehind, - MALFORMED_TRACK if has_malformed_track(version) => StreamError::MalformedTrack, + GOING_AWAY => StreamError::GoingAway, + TOO_FAR_BEHIND => StreamError::TooFarBehind, + MALFORMED_TRACK => StreamError::MalformedTrack, code => StreamError::Unknown(code), } } @@ -128,55 +92,34 @@ pub(crate) fn to_session_code(error: &SessionError) -> u32 { /// The moq-transport request error registry, per negotiated draft. /// -/// Sent in a rejection response to a request, and read back off one. Draft-14 gives each -/// error message its own registry and they disagree about 0x4; draft-15 folded them all -/// into REQUEST_ERROR with a single registry that renumbered almost everything. So a -/// request code only means something once you know both the draft and, on draft-14, which -/// request it answers. -/// -/// | Condition | 14 SUBSCRIBE | 14 FETCH | 14 PUBLISH | 14 ANNOUNCE | 14 SUB_NS | 15+ | -/// |-----------|--------------|----------|------------|-------------|-----------|-----| -/// | INTERNAL_ERROR | 0x0 | 0x0 | 0x0 | 0x0 | 0x0 | 0x0 | -/// | UNAUTHORIZED | 0x1 | 0x1 | 0x1 | 0x1 | 0x1 | 0x1 | -/// | TIMEOUT | 0x2 | 0x2 | 0x2 | 0x2 | 0x2 | 0x2 | -/// | NOT_SUPPORTED | 0x3 | 0x3 | 0x3 | 0x3 | 0x3 | 0x3 | -/// | DOES_NOT_EXIST | 0x4 | 0x4 | - | - | - | 0x10 | -/// | UNINTERESTED | - | - | 0x4 | 0x4 | - | 0x20 | -/// | MALFORMED_TRACK | - | 0x9 | - | - | - | 0x12 | -/// | EXPIRED_AUTH_TOKEN | 0x12 | 0x12 | - | 0x12 | 0x12 | 0x5 | -/// | GOING_AWAY | - | - | - | - | - | 0x6 (17+) | -/// | UNSUPPORTED_EXTENSION | - | - | - | - | - | 0x33 (18+) | -/// -/// The renumbering is the trap: draft-14 SUBSCRIBE_ERROR gives 0x4 to TRACK_DOES_NOT_EXIST -/// and 0x10 to MALFORMED_AUTH_TOKEN, and draft-15 swaps the two. Sending either number -/// without the draft in hand tells the peer the opposite of what happened. +/// Sent in a rejection response to a request, and read back off one. The registry is +/// disjoint from the stream reset one, so the same integer means something else there. pub(crate) mod request { use super::Version; use crate::{DecodeError, Error, SessionError, StreamError}; - /// Which request the error answers, so draft-14 picks the right registry. + /// Which request the error answers. /// - /// Draft-15 and later carry one REQUEST_ERROR registry for every request, so from there - /// on the kind only decides what a refused route says: not here to a request for - /// content, uninterested to an offer of it. + /// Every request shares one REQUEST_ERROR registry, so the kind only decides what a + /// refused route says (not here to a request for content, uninterested to an offer of + /// it) and which codes only FETCH carries. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Kind { - /// SUBSCRIBE_ERROR, draft-14 section 13.1.2. + /// SUBSCRIBE. Subscribe, - /// FETCH_ERROR, draft-14 section 13.1.5. + /// FETCH. Fetch, - /// PUBLISH_ERROR, draft-14 section 13.1.4. + /// PUBLISH. Publish, - /// ANNOUNCE_ERROR, draft-14 section 13.1.6. + /// PUBLISH_NAMESPACE. PublishNamespace, - /// SUBSCRIBE_NAMESPACE_ERROR, draft-14 section 13.1.7. + /// SUBSCRIBE_NAMESPACE. SubscribeNamespace, - /// TRACK_STATUS_ERROR on draft-14, REQUEST_ERROR later. + /// TRACK_STATUS. TrackStatus, } - /// An implementation-specific error, with no registry entry for why. Assigned by every - /// draft, for every request. + /// An implementation-specific error, with no registry entry for why. const INTERNAL_ERROR: u64 = 0x0; /// The peer's credentials do not cover the request. @@ -188,86 +131,47 @@ pub(crate) mod request { /// The endpoint does not implement this request at all, as opposed to refusing this one. pub(crate) const NOT_SUPPORTED: u64 = 0x3; - /// A second subscription to a track the peer already subscribes to. Draft-16 and later. + /// A second subscription to a track the peer already subscribes to. pub(crate) const DUPLICATE_SUBSCRIPTION: u64 = 0x19; - /// The endpoint is overloaded and cannot process the request. Draft-18 and later. + /// The endpoint is overloaded and cannot process the request. pub(crate) const EXCESSIVE_LOAD: u64 = 0x9; /// 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. + /// A GOAWAY is draining the session, so no new request is accepted. const GOING_AWAY: u64 = 0x6; - /// The requested broadcast or track is not here, as draft-14 numbers it under the name - /// TRACK_DOES_NOT_EXIST. - const DOES_NOT_EXIST_14: u64 = 0x4; - - /// An invalid FETCH range: draft-14 FETCH_ERROR and draft-15+ REQUEST_ERROR. - const INVALID_RANGE_14: u64 = 0x5; + /// An invalid FETCH range. const INVALID_RANGE: u64 = 0x11; - /// The track holds a Mandatory Track Property the endpoint does not understand. Draft-18 - /// and later; draft-15 gave 0x33 another meaning. + /// The track holds a Mandatory Track Property the endpoint does not understand. const UNSUPPORTED_EXTENSION: u64 = 0x33; /// A Range Filter exceeds MAX_FILTER_RANGES (draft-22 and later). pub(crate) const INVALID_FILTER: u64 = 0x36; /// A joining FETCH named no active subscription. Removed from the registry in draft-22. - const INVALID_JOINING_REQUEST_ID_14: u64 = 0x7; const INVALID_JOINING_REQUEST_ID: u64 = 0x32; - /// The same condition from draft-15 on, renamed DOES_NOT_EXIST and moved off 0x4. + /// The requested broadcast or track is not here. const DOES_NOT_EXIST: u64 = 0x10; - /// The offered content is not wanted here, as draft-14 numbers it. - const UNINTERESTED_14: u64 = 0x4; - - /// The same condition from draft-15 on. + /// The offered content is not wanted here. const UNINTERESTED: u64 = 0x20; - /// The track's content could not be parsed, as draft-14 numbers it on FETCH_ERROR. - const MALFORMED_TRACK_14: u64 = 0x9; - - /// The same condition from draft-15 on. + /// The track's content could not be parsed. const MALFORMED_TRACK: u64 = 0x12; - /// A well-formed AUTHORIZATION TOKEN this endpoint cannot use, as draft-14 numbers it. - const MALFORMED_AUTH_TOKEN_14: u64 = 0x10; - - /// The same condition from draft-15 on, where 0x10 became DOES_NOT_EXIST. + /// A well-formed AUTHORIZATION TOKEN this endpoint cannot use. const MALFORMED_AUTH_TOKEN: u64 = 0x4; - /// An expired AUTHORIZATION TOKEN, as draft-14 numbers it. - const EXPIRED_AUTH_TOKEN_14: u64 = 0x12; - - /// The same condition from draft-15 on, where 0x12 became MALFORMED_TRACK. + /// An expired AUTHORIZATION TOKEN. const EXPIRED_AUTH_TOKEN: u64 = 0x5; - /// The value for "the thing you asked for is not here", or `None` where the draft and - /// request assign none. - fn does_not_exist(kind: Kind, version: Version) -> Option { - match version { - Version::Draft14 => match kind { - Kind::Subscribe | Kind::Fetch | Kind::TrackStatus => Some(DOES_NOT_EXIST_14), - _ => None, - }, - _ => Some(DOES_NOT_EXIST), - } - } - - fn invalid_range(kind: Kind, version: Version) -> Option { - if kind != Kind::Fetch { - return None; - } - Some(if version == Version::Draft14 { - INVALID_RANGE_14 - } else { - INVALID_RANGE - }) + fn invalid_range(kind: Kind) -> Option { + (kind == Kind::Fetch).then_some(INVALID_RANGE) } fn invalid_joining_request_id(kind: Kind, version: Version) -> Option { @@ -275,75 +179,11 @@ pub(crate) mod request { return None; } match version { - Version::Draft14 => Some(INVALID_JOINING_REQUEST_ID_14), - Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 => { - Some(INVALID_JOINING_REQUEST_ID) - } + Version::Draft18 => Some(INVALID_JOINING_REQUEST_ID), _ => None, } } - /// The value for "we do not want this", or `None` where the draft and request assign - /// none. Draft-14 registers it only on the two requests that offer content. - fn uninterested(kind: Kind, version: Version) -> Option { - match version { - Version::Draft14 => match kind { - Kind::Publish | Kind::PublishNamespace => Some(UNINTERESTED_14), - _ => None, - }, - _ => Some(UNINTERESTED), - } - } - - /// The value for a track we could not parse, or `None` where the draft and request - /// assign none. - fn malformed_track(kind: Kind, version: Version) -> Option { - match version { - Version::Draft14 => match kind { - Kind::Fetch => Some(MALFORMED_TRACK_14), - _ => None, - }, - _ => Some(MALFORMED_TRACK), - } - } - - /// The value for an unusable token, or `None` where the draft and request assign none. - /// Draft-14 registers none on PUBLISH_ERROR. - fn malformed_auth_token(kind: Kind, version: Version) -> Option { - match (version, kind) { - (Version::Draft14, Kind::Publish) => None, - (Version::Draft14, _) => Some(MALFORMED_AUTH_TOKEN_14), - _ => Some(MALFORMED_AUTH_TOKEN), - } - } - - /// The value for an expired token, or `None` where the draft and request assign none. - /// Draft-14 registers none on PUBLISH_ERROR. - fn expired_auth_token(kind: Kind, version: Version) -> Option { - match (version, kind) { - (Version::Draft14, Kind::Publish) => None, - (Version::Draft14, _) => Some(EXPIRED_AUTH_TOKEN_14), - _ => Some(EXPIRED_AUTH_TOKEN), - } - } - - /// The value for a track needing a property we do not understand, or `None` before - /// draft-18 registered one. - fn unsupported_extension(version: Version) -> Option { - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 => None, - _ => Some(UNSUPPORTED_EXTENSION), - } - } - - /// The value for a draining session, or `None` before draft-17 registered one. - fn going_away(version: Version) -> Option { - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => None, - _ => Some(GOING_AWAY), - } - } - /// The code to reject a request with, on the negotiated draft. /// /// Lossy on purpose, like [`to_stream_code`](super::to_stream_code): an error says what went @@ -356,7 +196,7 @@ pub(crate) mod request { Error::Unauthorized | Error::Session(SessionError::Unauthorized) | Error::Stream(StreamError::Unauthorized) => return UNAUTHORIZED, - Error::Expired | Error::Session(SessionError::Expired) => expired_auth_token(kind, version), + Error::Expired | Error::Session(SessionError::Expired) => return EXPIRED_AUTH_TOKEN, Error::Timeout | Error::Stream(StreamError::DeliveryTimeout) | Error::Session(SessionError::Timeout) => { return TIMEOUT; } @@ -371,26 +211,27 @@ pub(crate) mod request { // does not implement FETCH at all. Error::NotFound | Error::NotFetchable - | Error::Stream(StreamError::NotFound | StreamError::NotFetchable) => does_not_exist(kind, version), - Error::InvalidRange => invalid_range(kind, version), + | Error::Stream(StreamError::NotFound | StreamError::NotFetchable) => return DOES_NOT_EXIST, + Error::InvalidRange => invalid_range(kind), Error::InvalidJoiningRequestId => invalid_joining_request_id(kind, version), // A path with no route is one we will not carry. A subscriber that asked for it // cannot act on "we do not want it": what it needs to know is that we do not have // it, which is the same refusal from its side. Only the requests that offer content // say UNINTERESTED. - Error::Unroutable | Error::Stream(StreamError::Unroutable) => match kind { - Kind::Subscribe | Kind::Fetch | Kind::TrackStatus => does_not_exist(kind, version), - Kind::Publish | Kind::PublishNamespace | Kind::SubscribeNamespace => uninterested(kind, version), - }, - Error::Decode(DecodeError::MalformedAuthToken) => malformed_auth_token(kind, version), + Error::Unroutable | Error::Stream(StreamError::Unroutable) => { + return match kind { + Kind::Subscribe | Kind::Fetch | Kind::TrackStatus => DOES_NOT_EXIST, + Kind::Publish | Kind::PublishNamespace | Kind::SubscribeNamespace => UNINTERESTED, + }; + } + Error::Decode(DecodeError::MalformedAuthToken) => return MALFORMED_AUTH_TOKEN, // Our own parse failure is, from the peer's side, a malformed track. Error::Decode(_) | Error::BoundsExceeded(_) | Error::MalformedTrack - | Error::Stream(StreamError::MalformedTrack) => malformed_track(kind, version), - Error::GoingAway | Error::Stream(StreamError::GoingAway) => going_away(version), - // Before its code, the closest is that we do not implement what the track needs. - Error::UnsupportedExtension => unsupported_extension(version).or(Some(NOT_SUPPORTED)), + | Error::Stream(StreamError::MalformedTrack) => return MALFORMED_TRACK, + Error::GoingAway | Error::Stream(StreamError::GoingAway) => return GOING_AWAY, + Error::UnsupportedExtension => return UNSUPPORTED_EXTENSION, // Everything else, a duplicate included: no draft assigns it a value, and // INTERNAL_ERROR is how a peer reads an unregistered code anyway. _ => return INTERNAL_ERROR, @@ -408,14 +249,14 @@ pub(crate) mod request { UNAUTHORIZED => Error::Unauthorized, TIMEOUT => Error::Timeout, NOT_SUPPORTED => Error::Unsupported, - code if Some(code) == expired_auth_token(kind, version) => Error::Expired, - code if Some(code) == does_not_exist(kind, version) => Error::NotFound, - code if Some(code) == invalid_range(kind, version) => Error::InvalidRange, + EXPIRED_AUTH_TOKEN => Error::Expired, + DOES_NOT_EXIST => Error::NotFound, + code if Some(code) == invalid_range(kind) => Error::InvalidRange, code if Some(code) == invalid_joining_request_id(kind, version) => Error::InvalidJoiningRequestId, - code if Some(code) == uninterested(kind, version) => Error::Unroutable, - code if Some(code) == malformed_track(kind, version) => Error::MalformedTrack, - code if Some(code) == going_away(version) => Error::GoingAway, - code if Some(code) == unsupported_extension(version) => Error::UnsupportedExtension, + UNINTERESTED => Error::Unroutable, + MALFORMED_TRACK => Error::MalformedTrack, + GOING_AWAY => Error::GoingAway, + UNSUPPORTED_EXTENSION => Error::UnsupportedExtension, code => Error::Remote(u32::try_from(code).unwrap_or(u32::MAX)), } } @@ -424,14 +265,7 @@ pub(crate) mod request { mod tests { use super::*; - const ALL: [Version; 6] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; + const ALL: [Version; 2] = [Version::Draft18, Version::Draft22]; const KINDS: [Kind; 6] = [ Kind::Subscribe, @@ -477,20 +311,15 @@ pub(crate) mod request { } } - /// Draft-14 numbers a missing track 0x4 and draft-15 moved it to 0x10, which is - /// draft-14's MALFORMED_AUTH_TOKEN. Getting this backwards tells a peer its token is - /// broken when the broadcast simply is not here, so it re-authenticates instead of - /// waiting for the announcement. + /// A missing broadcast is DOES_NOT_EXIST, never 0x4, which is MALFORMED_AUTH_TOKEN: + /// getting this backwards tells a peer its token is broken when the broadcast simply + /// is not here, so it re-authenticates instead of waiting for the announcement. #[test] - fn a_missing_broadcast_uses_the_draft_s_own_number() { + fn a_missing_broadcast_is_not_here() { for kind in [Kind::Subscribe, Kind::Fetch] { - assert_eq!(to_code(&Error::NotFound, kind, Version::Draft14), 0x4); - assert!(matches!(from_code(0x4, kind, Version::Draft14), Error::NotFound)); - - for version in ALL.into_iter().skip(1) { + for version in ALL { assert_eq!(to_code(&Error::NotFound, kind, version), 0x10); assert!(matches!(from_code(0x10, kind, version), Error::NotFound)); - // 0x4 is MALFORMED_AUTH_TOKEN from draft-15 on, which is not ours to claim. assert!(matches!(from_code(0x4, kind, version), Error::Remote(0x4))); } } @@ -500,7 +329,6 @@ pub(crate) mod request { // So is a datagram group: never cached, so a FETCH reaching one finds nothing, and // NOT_SUPPORTED would say the endpoint has no FETCH at all. - assert_eq!(to_code(&Error::NotFetchable, Kind::Fetch, Version::Draft14), 0x4); assert_eq!(to_code(&Error::NotFetchable, Kind::Fetch, Version::Draft22), 0x10); // Including a lite-07 upstream's NOT_FETCHABLE, bridged by a relay. assert_eq!( @@ -509,92 +337,35 @@ pub(crate) mod request { ); } - /// Draft-14 gives 0x4 to UNINTERESTED on the requests that offer content and to - /// TRACK_DOES_NOT_EXIST on the ones that ask for it, so the same integer on the same - /// draft says two different things. + /// A refused route says UNINTERESTED to the requests that offer content. #[test] - fn draft_14_reads_0x4_per_request() { - for kind in [Kind::Publish, Kind::PublishNamespace] { - assert_eq!(to_code(&Error::Unroutable, kind, Version::Draft14), 0x4); - assert!(matches!(from_code(0x4, kind, Version::Draft14), Error::Unroutable)); - // Those requests offer content, so "it is not here" is not a rejection they - // can carry. - assert_eq!(to_code(&Error::NotFound, kind, Version::Draft14), INTERNAL_ERROR); + fn an_unroutable_offer_is_uninterested() { + for kind in [Kind::Publish, Kind::PublishNamespace, Kind::SubscribeNamespace] { + assert_eq!(to_code(&Error::Unroutable, kind, Version::Draft22), 0x20); + assert!(matches!(from_code(0x20, kind, Version::Draft22), Error::Unroutable)); } - - // And neither meaning reaches a request draft-14 gives neither to. - assert!(matches!( - from_code(0x4, Kind::SubscribeNamespace, Version::Draft14), - Error::Remote(0x4) - )); } - /// Draft-15 swapped MALFORMED_AUTH_TOKEN and the missing-track code, and draft-14 - /// gives PUBLISH_ERROR no value for it. #[test] - fn a_malformed_token_uses_the_draft_s_own_number() { + fn token_refusals_use_their_codes() { let err = Error::Decode(DecodeError::MalformedAuthToken); - for kind in KINDS { - let draft14 = if kind == Kind::Publish { INTERNAL_ERROR } else { 0x10 }; - assert_eq!(to_code(&err, kind, Version::Draft14), draft14); - for version in ALL.into_iter().skip(1) { + for version in ALL { + for kind in KINDS { assert_eq!(to_code(&err, kind, version), 0x4); - } - } - } - - /// Draft-15 moved EXPIRED_AUTH_TOKEN from 0x12 to 0x5, and draft-14 gives PUBLISH_ERROR - /// no value for it. - #[test] - fn an_expired_token_uses_the_draft_s_own_number() { - for kind in KINDS { - if kind == Kind::Publish { - assert_eq!(to_code(&Error::Expired, kind, Version::Draft14), INTERNAL_ERROR); - } else { - assert_eq!(to_code(&Error::Expired, kind, Version::Draft14), 0x12); - assert!(matches!(from_code(0x12, kind, Version::Draft14), Error::Expired)); - } - for version in ALL.into_iter().skip(1) { assert_eq!(to_code(&Error::Expired, kind, version), 0x5); assert!(matches!(from_code(0x5, kind, version), Error::Expired)); } } } - /// GOING_AWAY arrived in draft-17. Sent to an earlier peer, 0x6 is unassigned there, - /// so say INTERNAL_ERROR outright rather than a number that means nothing. #[test] - fn going_away_only_exists_from_draft_17() { - for kind in KINDS { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - assert_eq!(to_code(&Error::GoingAway, kind, version), INTERNAL_ERROR); - assert!(matches!(from_code(GOING_AWAY, kind, version), Error::Remote(0x6))); - } - - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + fn going_away_and_unsupported_extension_round_trip() { + for version in ALL { + for kind in KINDS { assert_eq!(to_code(&Error::GoingAway, kind, version), GOING_AWAY); assert!(matches!(from_code(GOING_AWAY, kind, version), Error::GoingAway)); - } - } - } - - /// UNSUPPORTED_EXTENSION arrived in draft-18. Draft-15 gave its 0x33 to - /// UNKNOWN_STATUS_IN_RANGE, so earlier drafts say NOT_SUPPORTED instead. - #[test] - fn unsupported_extension_only_exists_from_draft_18() { - for kind in KINDS { - for version in ALL { - let code = to_code(&Error::UnsupportedExtension, kind, version); - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 => { - assert_eq!(code, NOT_SUPPORTED, "{version}"); - assert!(!matches!(from_code(0x33, kind, version), Error::UnsupportedExtension)); - } - _ => { - assert_eq!(code, 0x33, "{version}"); - assert!(matches!(from_code(code, kind, version), Error::UnsupportedExtension)); - } - } + assert_eq!(to_code(&Error::UnsupportedExtension, kind, version), 0x33); + assert!(matches!(from_code(0x33, kind, version), Error::UnsupportedExtension)); } } } @@ -602,12 +373,10 @@ pub(crate) mod request { #[test] fn fetch_refusals_follow_each_drafts_registry() { for version in ALL { - let range = if version == Version::Draft14 { 0x5 } else { 0x11 }; - assert_eq!(to_code(&Error::InvalidRange, Kind::Fetch, version), range); - assert!(matches!(from_code(range, Kind::Fetch, version), Error::InvalidRange)); + assert_eq!(to_code(&Error::InvalidRange, Kind::Fetch, version), 0x11); + assert!(matches!(from_code(0x11, Kind::Fetch, version), Error::InvalidRange)); let joining = match version { - Version::Draft14 => Some(0x7), - Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 => Some(0x32), + Version::Draft18 => Some(0x32), _ => None, }; assert_eq!( @@ -645,29 +414,16 @@ pub(crate) mod request { } /// Every code we put in a rejection has to be one the negotiated draft registers for - /// that request. The table is transcribed from the drafts (draft-14 section 13.1 - /// through draft-22 section 16.11.2), not derived from the mapping, so a mistake in - /// the mapping cannot talk the assertion into agreeing with it. + /// that request. The table is transcribed from the drafts (draft-18 section 15.11.2 + /// on), not derived from the mapping, so a mistake in the mapping cannot talk the + /// assertion into agreeing with it. #[test] fn only_registered_codes_reach_the_wire() { - fn registered(kind: Kind, version: Version) -> &'static [u64] { - match (version, kind) { - (Version::Draft14, Kind::Subscribe) => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x12], - (Version::Draft14, Kind::Fetch) => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x10, 0x12], - (Version::Draft14, Kind::Publish) => &[0x0, 0x1, 0x2, 0x3, 0x4], - (Version::Draft14, Kind::PublishNamespace) => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x10, 0x12], - (Version::Draft14, Kind::TrackStatus) => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x12], - (Version::Draft14, Kind::SubscribeNamespace) => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x12], - (Version::Draft15, _) => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x11, 0x12, 0x20, 0x30, 0x32, 0x33], - (Version::Draft16, _) => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x11, 0x12, 0x19, 0x20, 0x30, 0x32], - (Version::Draft17, _) => &[ - 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x9, 0x10, 0x11, 0x12, 0x19, 0x20, 0x30, 0x31, 0x32, - ], - _ => &[ - 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x9, 0x10, 0x11, 0x12, 0x19, 0x20, 0x30, 0x31, 0x32, 0x33, - 0x34, 0x35, 0x36, - ], - } + fn registered() -> &'static [u64] { + &[ + 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x9, 0x10, 0x11, 0x12, 0x19, 0x20, 0x30, 0x31, 0x32, 0x33, 0x34, + 0x35, 0x36, + ] } // Sent but never read back as itself: a peer's MALFORMED_AUTH_TOKEN stays remote. @@ -677,7 +433,7 @@ pub(crate) mod request { for err in EVERY_ERROR.iter().chain([&malformed]) { let code = to_code(err, kind, version); assert!( - registered(kind, version).contains(&code), + registered().contains(&code), "{err:?} sends {code:#x}, which {version} does not register for {kind:?}" ); } @@ -712,28 +468,17 @@ mod tests { use super::*; use crate::Error; - const ALL: [Version; 6] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; - /// A routine unsubscribe must not read as a fault on our side. moq-lite's own error /// enum encodes a cancellation as 0, which is this wire's INTERNAL_ERROR, so the codes /// come from here instead. #[test] fn a_cancellation_is_not_an_internal_error() { - for version in ALL { - assert_eq!(to_stream_code(&StreamError::Cancel, version), CANCELLED); - assert_eq!(from_stream_code(CANCELLED, version), StreamError::Cancel); - assert!(matches!( - Error::from(from_stream_code(CANCELLED, version)), - Error::Stream(StreamError::Cancel) - )); - } + assert_eq!(to_stream_code(&StreamError::Cancel), CANCELLED); + assert_eq!(from_stream_code(CANCELLED), StreamError::Cancel); + assert!(matches!( + Error::from(from_stream_code(CANCELLED)), + Error::Stream(StreamError::Cancel) + )); assert_ne!( CANCELLED, @@ -763,8 +508,8 @@ mod tests { assert_eq!(to_session_code(&SessionError::App(7)), 64 + 7); } - /// Every code we send must decode back to what we meant on the same draft, or two - /// moq-net peers disagree about what a stream reset said. + /// Every code we send must decode back to what we meant, or two moq-net peers disagree + /// about what a stream reset said. #[test] fn every_emitted_code_round_trips() { let errors = [ @@ -781,79 +526,17 @@ mod tests { StreamError::App(7), ]; - for version in ALL { - for err in &errors { - let code = to_stream_code(err, version); - let decoded = from_stream_code(code, version); - assert_eq!( - to_stream_code(&decoded, version), - code, - "{err:?} on {version:?} did not survive a round trip" - ); - } - - // A session teardown flattens to SESSION_CLOSED and comes back as one, rather - // than as the session's own reason, which the stream never carried. - let code = to_stream_code(&StreamError::Session(SessionError::Unauthorized), version); - assert_eq!(code, SESSION_CLOSED); - assert_eq!( - from_stream_code(code, version), - StreamError::Session(SessionError::Internal) - ); - } - } - - /// Draft-18 took 0x4 for GOING_AWAY from UNKNOWN_OBJECT_STATUS, so the same integer - /// means different things on two drafts we both negotiate. Sending it to draft-17 would - /// claim the next object's status is unknowable; reading theirs as GOING_AWAY would - /// start draining a session that is not going anywhere. - #[test] - fn going_away_only_exists_from_draft_18() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft17] { - assert_eq!(to_stream_code(&StreamError::GoingAway, version), INTERNAL_ERROR); - assert_eq!(from_stream_code(GOING_AWAY, version), StreamError::Unknown(GOING_AWAY)); - } - - for version in [Version::Draft18, Version::Draft22] { - assert_eq!(to_stream_code(&StreamError::GoingAway, version), GOING_AWAY); - assert_eq!(from_stream_code(GOING_AWAY, version), StreamError::GoingAway); - } - } - - /// The rest of the per-draft registry: TOO_FAR_BEHIND arrived in draft-17 and - /// MALFORMED_TRACK in draft-16, so an older peer must be told neither. - #[test] - fn later_codes_are_not_sent_to_earlier_drafts() { - for version in ALL { - let too_far_behind = to_stream_code(&StreamError::TooFarBehind, version); - let malformed = to_stream_code(&StreamError::MalformedTrack, version); - - assert_eq!( - too_far_behind, - match has_too_far_behind(version) { - true => TOO_FAR_BEHIND, - false => INTERNAL_ERROR, - }, - "{version:?} disagrees about TOO_FAR_BEHIND" - ); - assert_eq!( - malformed, - match has_malformed_track(version) { - true => MALFORMED_TRACK, - false => INTERNAL_ERROR, - }, - "{version:?} disagrees about MALFORMED_TRACK" - ); + for err in &errors { + let code = to_stream_code(err); + let decoded = from_stream_code(code); + assert_eq!(to_stream_code(&decoded), code, "{err:?} did not survive a round trip"); } - assert_eq!( - from_stream_code(TOO_FAR_BEHIND, Version::Draft16), - StreamError::Unknown(TOO_FAR_BEHIND) - ); - assert_eq!( - from_stream_code(MALFORMED_TRACK, Version::Draft15), - StreamError::Unknown(MALFORMED_TRACK) - ); + // A session teardown flattens to SESSION_CLOSED and comes back as one, rather + // than as the session's own reason, which the stream never carried. + let code = to_stream_code(&StreamError::Session(SessionError::Unauthorized)); + assert_eq!(code, SESSION_CLOSED); + assert_eq!(from_stream_code(code), StreamError::Session(SessionError::Internal)); } /// Conditions this registry has no value for say INTERNAL_ERROR rather than borrowing @@ -878,7 +561,7 @@ mod tests { StreamError::Unknown(0x1234), ] { assert_eq!( - to_stream_code(&err, Version::Draft22), + to_stream_code(&err), INTERNAL_ERROR, "{err:?} has no value in this registry" ); @@ -903,9 +586,9 @@ mod tests { 0x3b, 64 + 7, ] { - assert_eq!(from_stream_code(code, Version::Draft22), StreamError::Unknown(code)); + assert_eq!(from_stream_code(code), StreamError::Unknown(code)); assert!(matches!( - Error::from(from_stream_code(code, Version::Draft22)), + Error::from(from_stream_code(code)), Error::Stream(StreamError::Unknown(remote)) if remote == code )); } @@ -941,44 +624,28 @@ mod tests { /// application codes past 64, neither of which this registry has a range for, /// and it assigns 0x4 and 0x5 meanings the earlier drafts give to something else. /// - /// The table is transcribed from the drafts (draft-14 section 13.1.8 through draft-22 - /// section 15.11.4), not derived from the mapping, so a mistake in the mapping cannot - /// talk the assertion into agreeing with it. + /// The table is transcribed from the drafts (draft-18 section 15.11.4 on), not derived + /// from the mapping, so a mistake in the mapping cannot talk the assertion into + /// agreeing with it. #[test] fn only_registered_codes_reach_the_wire() { - fn registered(version: Version) -> &'static [u32] { - match version { - Version::Draft14 | Version::Draft15 => &[0x0, 0x1, 0x2, 0x3], - Version::Draft16 => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x12], - Version::Draft17 => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x9, 0x12], - _ => &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x9, 0x12], - } - } - - for version in ALL { - for err in EVERY_ERROR { - let code = to_stream_code(&err, version); - assert!( - registered(version).contains(&code), - "{err:?} sends {code:#x}, which {version} does not register" - ); - } + const REGISTERED: &[u32] = &[0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x9, 0x12]; + for err in EVERY_ERROR { + let code = to_stream_code(&err); + assert!( + REGISTERED.contains(&code), + "{err:?} sends {code:#x}, which is not registered" + ); } } /// A relay decodes a peer's code and re-encodes it onto the stream it tears down in - /// response. That hop must not change what the code says, on the same draft. + /// response. That hop must not change what the code says. #[test] fn relaying_a_code_does_not_change_its_meaning() { - for version in ALL { - for code in [INTERNAL_ERROR, CANCELLED, DELIVERY_TIMEOUT, SESSION_CLOSED] { - let relayed = StreamError::from(&Error::from(from_stream_code(code, version))); - assert_eq!( - to_stream_code(&relayed, version), - code, - "{code:#x} changed across a relay on {version:?}" - ); - } + for code in [INTERNAL_ERROR, CANCELLED, DELIVERY_TIMEOUT, SESSION_CLOSED] { + let relayed = StreamError::from(&Error::from(from_stream_code(code))); + assert_eq!(to_stream_code(&relayed), code, "{code:#x} changed across a relay"); } } } diff --git a/rs/moq-net/src/ietf/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index 4df2cb34974..ba0b12238e9 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -4,7 +4,7 @@ use crate::{ Path, coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}, ietf::{ - Filter, GroupOrder, Location, Opaque, Parameters, RequestId, + Filter, GroupOrder, Location, Opaque, RequestId, namespace::{decode_track, encode_namespace}, }, }; @@ -136,7 +136,7 @@ pub struct Fetch<'a> { /// Whether the requester wants Track Properties in FETCH_OK (INCLUDE_PROPERTIES, /// draft-22). Always `true` before draft-22, which has no way to opt out. pub properties_wanted: bool, - /// The publisher instance the requester expects (MoQ Epoch, draft-17+). Only on a + /// The publisher instance the requester expects (MoQ Epoch). Only on a /// request that names its track: a joining FETCH takes its subscription's. pub epoch: Option, } @@ -150,20 +150,11 @@ impl Message for Fetch<'_> { let group_order = (self.group_order != GroupOrder::Any).then_some(self.group_order); self.request_id.encode(w, version)?; - if version == Version::Draft17 { - w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) - } match version { - Version::Draft14 => { - w.u8(self.subscriber_priority); - self.group_order.encode(w, version)?; - self.fetch_type.encode(w, version)?; - w.u8(0); // no parameters - } - Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 => { + Version::Draft18 => { self.fetch_type.encode(w, version)?; - let epoch = self.epoch.clone().filter(|_| epoch::supported(version)); + let epoch = self.epoch.clone(); encode_params!(w, version, 0x20 => self.subscriber_priority, 0x22 => group_order, @@ -198,36 +189,18 @@ impl Message for Fetch<'_> { fn decode_msg(buf: &mut Decoder<'_>, version: Version) -> Result { let request_id = RequestId::decode(buf, version)?; - if version == Version::Draft17 { - let _required_request_id_delta = buf.varint()?; - } // The token decodes but grants nothing: the session's grant is what authorizes the request. let (fetch_type, subscriber_priority, group_order, range_filters, fill_timeout, properties_wanted, epoch) = match version { - Version::Draft14 => { - let subscriber_priority = buf.u8()?; - let group_order = GroupOrder::decode(buf, version)?; - let fetch_type = FetchType::decode(buf, version)?; - super::token::single(Parameters::skip_request(buf)?)?; - ( - fetch_type, - Some(subscriber_priority), - Some(group_order), - false, - false, - true, - None, - ) - } - Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 => { + Version::Draft18 => { let fetch_type = FetchType::decode(buf, version)?; decode_params!(buf, version, 0x03 => authorization_token: Vec, - 0x0A => fill_timeout: Option where !matches!(version, Version::Draft15 | Version::Draft16 | Version::Draft17), + 0x0A => fill_timeout: Option, 0x20 => subscriber_priority: Option, 0x22 => group_order: Option, - epoch::EPOCH_PARAM => epoch: Option where epoch::supported(version), + epoch::EPOCH_PARAM => epoch: Option, ); super::token::single(authorization_token)?; @@ -312,150 +285,44 @@ impl Message for Fetch<'_> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct FetchOk { - pub request_id: Option, - pub group_order: GroupOrder, pub end_of_track: bool, - /// Drafts 14-18 use the last object plus one, or Object 0 for the whole End Group; + /// Draft 18 uses the last object plus one, or Object 0 for the whole End Group; /// draft 22 and later use an inclusive End Location. pub end_location: Location, - /// The track's properties, as SUBSCRIBE_OK carries them: MAX_CACHE_DURATION from the - /// draft-14/15 parameters, the Track Properties block from draft-16 on. + /// The track's properties, as SUBSCRIBE_OK carries them. pub properties: super::Properties, } impl Message for FetchOk { const ID: u64 = 0x18; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - self.request_id - .expect("request_id required for draft14-16") - .encode(w, version)?; - } else { - assert!(self.request_id.is_none(), "request_id must be None for draft17+"); - } - - match version { - Version::Draft14 => { - self.group_order.encode(w, version)?; - w.bool(self.end_of_track); - self.end_location.encode(w, version)?; - w.u8(0); // no parameters - } - _ => { - // GROUP_ORDER is not a legal FETCH_OK parameter in any draft after 14; the order - // of the response is whatever the FETCH asked for. - w.bool(self.end_of_track); - self.end_location.encode(w, version)?; - encode_params!(w, version,); - // Track Properties are the final field, so nothing may follow. - self.properties.encode(w, version)?; - } - } + // GROUP_ORDER is not a legal FETCH_OK parameter; the order of the response is + // whatever the FETCH asked for. + w.bool(self.end_of_track); + self.end_location.encode(w, version)?; + encode_params!(w, version,); + // Track Properties are the final field, so nothing may follow. + self.properties.encode(w, version)?; Ok(()) } fn decode_msg(buf: &mut Decoder<'_>, version: Version) -> Result { - let request_id = if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - Some(RequestId::decode(buf, version)?) - } else { - None - }; + let end_of_track = buf.bool()?; + let end_location = Location::decode(buf, version)?; + // FETCH_OK lists no parameters, so any closes the session. + decode_params!(buf, version,); + // The timescale is read but not surfaced yet: a fetched object without its + // own units arrives untimed. + let properties = super::Properties::decode(buf, version)?; - match version { - Version::Draft14 => { - let group_order = GroupOrder::decode(buf, version)?; - let end_of_track = buf.bool()?; - let end_location = Location::decode(buf, version)?; - let properties = super::Properties { - max_cache_duration: Parameters::skip_response(buf)?.map(std::time::Duration::from_millis), - ..Default::default() - }; - Ok(Self { - request_id, - group_order, - end_of_track, - end_location, - properties, - }) - } - _ => { - let end_of_track = buf.bool()?; - let end_location = Location::decode(buf, version)?; - // MAX_CACHE_DURATION and GROUP_ORDER are legal on FETCH_OK only in draft-15. - // Draft-16 still knows GROUP_ORDER, so one here is ignored. From draft-17 - // it closes the session. - decode_params!(buf, version, - 0x04 => max_cache_duration: Option where version == Version::Draft15, - 0x22 => group_order: Option where version == Version::Draft15, - ); - // The timescale is read but not surfaced yet: a fetched object without its - // own units arrives untimed. - let mut properties = super::Properties::decode(buf, version)?; - if version == Version::Draft15 { - properties.max_cache_duration = max_cache_duration.map(std::time::Duration::from_millis); - } - - let group_order = group_order.unwrap_or(GroupOrder::Descending); - - Ok(Self { - request_id, - group_order, - end_of_track, - end_location, - properties, - }) - } - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FetchError<'a> { - pub request_id: RequestId, - pub error_code: u64, - pub reason_phrase: Cow<'a, str>, -} - -impl Message for FetchError<'_> { - const ID: u64 = 0x19; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - w.varint(self.error_code)?; - w.string(&self.reason_phrase)?; - Ok(()) - } - - fn decode_msg(buf: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(buf, version)?; - let error_code = buf.varint()?; - let reason_phrase = Cow::Owned(buf.string()?); Ok(Self { - request_id, - error_code, - reason_phrase, + end_of_track, + end_location, + properties, }) } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FetchCancel { - pub request_id: RequestId, -} -impl Message for FetchCancel { - const ID: u64 = 0x17; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - Ok(()) - } - - fn decode_msg(buf: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(buf, version)?; - Ok(Self { request_id }) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct FetchHeader { pub request_id: RequestId, @@ -695,178 +562,6 @@ mod tests { crate::coding::decode_buf(&mut buf, version, M::decode_msg) } - #[test] - fn test_fetch_v14_round_trip() { - let msg = Fetch { - request_id: RequestId(1), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - fetch_type: FetchType::Standalone { - namespace: Path::from_static("test"), - track: "video".into(), - start: Location { group: 0, object: 0 }, - end: Location { group: 10, object: 5 }, - }, - range_filters: false, - fill_timeout: false, - properties_wanted: true, - epoch: None, - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: Fetch = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.subscriber_priority, 128); - } - - #[test] - fn test_fetch_v15_round_trip() { - let msg = Fetch { - request_id: RequestId(1), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - fetch_type: FetchType::Standalone { - namespace: Path::from_static("test"), - track: "video".into(), - start: Location { group: 0, object: 0 }, - end: Location { group: 10, object: 5 }, - }, - range_filters: false, - fill_timeout: false, - properties_wanted: true, - epoch: None, - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: Fetch = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.subscriber_priority, 128); - } - - #[test] - fn test_fetch_ok_v14_round_trip() { - let msg = FetchOk { - request_id: Some(RequestId(2)), - group_order: GroupOrder::Descending, - end_of_track: false, - end_location: Location { group: 5, object: 3 }, - properties: Default::default(), - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: FetchOk = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(2))); - assert!(!decoded.end_of_track); - assert_eq!(decoded.end_location, Location { group: 5, object: 3 }); - } - - #[test] - fn test_fetch_v16_round_trip() { - let msg = Fetch { - request_id: RequestId(1), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - fetch_type: FetchType::Standalone { - namespace: Path::from_static("test"), - track: "video".into(), - start: Location { group: 0, object: 0 }, - end: Location { group: 10, object: 5 }, - }, - range_filters: false, - fill_timeout: false, - properties_wanted: true, - epoch: None, - }; - - let encoded = encode_message(&msg, Version::Draft16); - let decoded: Fetch = decode_message(&encoded, Version::Draft16).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.subscriber_priority, 128); - } - - #[test] - fn test_fetch_v17_round_trip() { - let msg = Fetch { - request_id: RequestId(1), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - fetch_type: FetchType::Standalone { - namespace: Path::from_static("test"), - track: "video".into(), - start: Location { group: 0, object: 0 }, - end: Location { group: 10, object: 5 }, - }, - range_filters: false, - fill_timeout: false, - properties_wanted: true, - epoch: None, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: Fetch = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.subscriber_priority, 128); - } - - #[test] - fn test_fetch_ok_v15_round_trip() { - let msg = FetchOk { - request_id: Some(RequestId(2)), - group_order: GroupOrder::Descending, - end_of_track: false, - end_location: Location { group: 5, object: 3 }, - properties: Default::default(), - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: FetchOk = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(2))); - assert!(!decoded.end_of_track); - assert_eq!(decoded.end_location, Location { group: 5, object: 3 }); - } - - #[test] - fn test_fetch_ok_v16_round_trip() { - let msg = FetchOk { - request_id: Some(RequestId(2)), - group_order: GroupOrder::Descending, - end_of_track: false, - end_location: Location { group: 5, object: 3 }, - properties: Default::default(), - }; - - let encoded = encode_message(&msg, Version::Draft16); - let decoded: FetchOk = decode_message(&encoded, Version::Draft16).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(2))); - assert!(!decoded.end_of_track); - assert_eq!(decoded.end_location, Location { group: 5, object: 3 }); - } - - #[test] - fn test_fetch_ok_v17_round_trip() { - let msg = FetchOk { - request_id: None, - group_order: GroupOrder::Descending, - end_of_track: false, - end_location: Location { group: 5, object: 3 }, - properties: Default::default(), - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: FetchOk = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, None); - assert!(!decoded.end_of_track); - assert_eq!(decoded.end_location, Location { group: 5, object: 3 }); - } - #[test] fn test_fetch_v18_round_trip() { let msg = Fetch { @@ -895,8 +590,6 @@ mod tests { #[test] fn test_fetch_ok_v18_round_trip() { let msg = FetchOk { - request_id: None, - group_order: GroupOrder::Descending, end_of_track: false, end_location: Location { group: 5, object: 3 }, properties: Default::default(), @@ -905,19 +598,15 @@ mod tests { let encoded = encode_message(&msg, Version::Draft18); let decoded: FetchOk = decode_message(&encoded, Version::Draft18).unwrap(); - assert_eq!(decoded.request_id, None); assert!(!decoded.end_of_track); assert_eq!(decoded.end_location, Location { group: 5, object: 3 }); } - /// GROUP_ORDER (0x22) has never been a legal FETCH_OK parameter outside draft-14, where - /// it was a plain field. A draft-15+ peer closes the session with PROTOCOL_VIOLATION when - /// it sees one, so the response carries no parameters at all. + /// GROUP_ORDER (0x22) is not a legal FETCH_OK parameter; a peer closes the session with + /// PROTOCOL_VIOLATION when it sees one, so the response carries no parameters at all. #[test] fn test_fetch_ok_v18_omits_group_order() { let msg = FetchOk { - request_id: None, - group_order: GroupOrder::Descending, end_of_track: false, end_location: Location { group: 5, object: 3 }, properties: Default::default(), @@ -1032,8 +721,7 @@ mod tests { ); } - /// FILL_TIMEOUT arrived in draft-18, and the Range Filters are still an unknown - /// parameter on a draft-18 FETCH. + /// The Range Filters arrived in draft-22, so they are still unknown parameters on draft 18. #[test] fn test_fetch_parameters_follow_their_draft() { #[rustfmt::skip] @@ -1047,8 +735,7 @@ mod tests { let range_filter = joining(&[0x01, 0x25, 0x00]); for (version, body, ok) in [ - (Version::Draft15, &token, true), - (Version::Draft16, &fill_timeout, false), + (Version::Draft18, &token, true), (Version::Draft18, &fill_timeout, true), (Version::Draft18, &range_filter, false), ] { @@ -1070,7 +757,7 @@ mod tests { 0x02, 0x03, 0x00, // Relative Joining: subscription 3, offset 0 0x00, // no parameters ]; - let fetch = decode_message::(&body, Version::Draft16).unwrap(); + let fetch = decode_message::(&body, Version::Draft18).unwrap(); assert_eq!(fetch.subscriber_priority, 128); } } @@ -1197,7 +884,7 @@ mod object_tests { /// desync the rest of the stream. #[test] fn an_undefined_value_is_refused() { - // 0x8D, one past End of Non-Existent Range, in the draft-17+ leading-ones form. + // 0x8D, one past End of Non-Existent Range, in the leading-ones form. let mut bytes = bytes::Bytes::from_static(&[0x80, 0x8D, 0x00, 0x00]); assert!(crate::coding::decode_buf(&mut bytes, VERSION, FetchObject::decode).is_err()); } @@ -1212,21 +899,9 @@ mod cache_duration_tests { /// subscription still learns its retention window. #[test] fn max_cache_duration_is_read_in_each_drafts_field() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { for age in [0u64, 30_000] { - let mut payload = match version { - Version::Draft14 => vec![0, 1, 0, 0, 0, 1, 4], - Version::Draft15 => vec![0, 0, 0, 0, 1, 4], - Version::Draft16 => vec![0, 0, 0, 0, 0, 4], - _ => vec![0, 0, 0, 0, 4], - }; + let mut payload = vec![0, 0, 0, 0, 4]; Encoder::new(&mut payload, version.into()).varint(age).unwrap(); let got = FetchOk::decode_msg(&mut Decoder::new(&payload, version.into()), version).unwrap(); assert_eq!( diff --git a/rs/moq-net/src/ietf/filter.rs b/rs/moq-net/src/ietf/filter.rs index 5fcc2a0c1af..d5079983374 100644 --- a/rs/moq-net/src/ietf/filter.rs +++ b/rs/moq-net/src/ietf/filter.rs @@ -74,10 +74,7 @@ impl Filter { /// fields that follow it, and added the parameters that ride alongside it, so this /// gates them too. pub(crate) fn is_draft22(version: Version) -> bool { - !matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 - ) + !matches!(version, Version::Draft18) } /// The absolute end group, given the start, or an error when the range runs backwards. @@ -206,20 +203,6 @@ impl Filter { } } -/// The inline form, used by draft-14 where the filter is message fields rather than a -/// parameter. Later drafts go through [`Param`] instead. -impl Encode for Filter { - fn encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.encode_tag(w, version) - } -} - -impl Decode for Filter { - fn decode(r: &mut Decoder<'_>, version: Version) -> Result { - Self::decode_tag(r, version) - } -} - impl Param for Filter { /// An unfiltered subscription is the default, so it goes on the wire as an absent /// parameter rather than an empty one. @@ -228,19 +211,10 @@ impl Param for Filter { } fn param_encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - // Inner varints use the draft-15 leading-ones encoding on the drafts that predate - // the switch, matching the other length-prefixed parameters. - let sv = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Version::Draft15, - _ => version, - }; - let mut buf = Vec::new(); - let mut inner = Encoder::new(&mut buf, sv.into()); + let mut inner = Encoder::new(&mut buf, version.into()); match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 => { - self.encode_tag(&mut inner, sv)? - } + Version::Draft18 => self.encode_tag(&mut inner, version)?, // Draft-22 drops the Length, since the Location Filter Type says which fields follow. _ => return self.encode_typed(w), } @@ -249,14 +223,13 @@ impl Param for Filter { } fn param_decode(r: &mut Decoder<'_>, version: Version) -> Result { - let sv = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Version::Draft15, - Version::Draft17 | Version::Draft18 => version, + match version { + Version::Draft18 => {} _ => return Self::decode_typed(r), - }; + } - let mut buf = Decoder::new(r.bytes()?, sv.into()); - let filter = Self::decode_tag(&mut buf, sv)?; + let mut buf = Decoder::new(r.bytes()?, version.into()); + let filter = Self::decode_tag(&mut buf, version)?; if !buf.is_empty() { return Err(DecodeError::TrailingBytes); } @@ -509,9 +482,7 @@ impl Fill { impl Param for Fill { fn param_encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if self.group_order == Some(super::GroupOrder::Any) - && !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - { + if self.group_order == Some(super::GroupOrder::Any) { return Err(EncodeError::Unsupported); } let mut buf = Vec::new(); @@ -612,7 +583,7 @@ mod fill_tests { #[test] fn fill_rejects_invalid_group_order() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let fill = Fill { group_order: Some(super::super::GroupOrder::Any), filter: None, diff --git a/rs/moq-net/src/ietf/flate.rs b/rs/moq-net/src/ietf/flate.rs index 1c4ac1c833f..71aa7b38121 100644 --- a/rs/moq-net/src/ietf/flate.rs +++ b/rs/moq-net/src/ietf/flate.rs @@ -11,20 +11,11 @@ pub(super) const COMPRESSION: u64 = 0x40B6C; /// Inflated Size object property, in the MOQ Properties registry. pub(super) const INFLATED: u64 = 0x40B6E; -pub(crate) fn supported(version: Version) -> bool { - !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) +pub(crate) fn into_setup(parameters: &mut Parameters) { + parameters.set_varint(ParameterVarInt::Flate, 1); } -pub(crate) fn into_setup(parameters: &mut Parameters, version: Version) { - if supported(version) { - parameters.set_varint(ParameterVarInt::Flate, 1); - } -} - -pub(crate) fn from_setup(parameters: &Parameters, version: Version) -> Result { - if !supported(version) { - return Ok(false); - } +pub(crate) fn from_setup(parameters: &Parameters) -> Result { match parameters.get_varint(ParameterVarInt::Flate) { None => Ok(false), Some(1) => Ok(true), @@ -63,36 +54,24 @@ mod tests { use super::*; #[test] - fn negotiation_requires_an_offer_on_draft17_or_later() { + fn negotiation_requires_an_offer() { for version in crate::Versions::all().iter().filter_map(|v| Version::try_from(*v).ok()) { let mut parameters = Parameters::default(); - assert!(!from_setup(¶meters, version).unwrap()); - into_setup(&mut parameters, version); - assert_eq!(from_setup(¶meters, version).unwrap(), supported(version)); + assert!(!from_setup(¶meters).unwrap()); + into_setup(&mut parameters); + assert!(from_setup(¶meters).unwrap()); use crate::coding::{Decode, Encode}; let encoded = parameters.encode_bytes(version).unwrap(); let (decoded, consumed) = Parameters::decode_slice(&encoded, version).unwrap(); assert_eq!(consumed, encoded.len()); - assert_eq!(from_setup(&decoded, version).unwrap(), supported(version)); + assert!(from_setup(&decoded).unwrap()); for invalid in [0, 2, u64::MAX >> 2] { parameters.set_varint(ParameterVarInt::Flate, invalid); - assert_eq!(from_setup(¶meters, version).is_err(), supported(version)); + assert!(from_setup(¶meters).is_err()); } } } - #[test] - fn legacy_setup_is_unchanged() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let mut parameters = Parameters::default(); - into_setup(&mut parameters, version); - assert_eq!(parameters.get_varint(ParameterVarInt::Flate), None); - } - let mut parameters = Parameters::default(); - into_setup(&mut parameters, Version::Draft17); - assert_eq!(parameters.get_varint(ParameterVarInt::Flate), Some(1)); - } - #[test] fn setup_code_point_is_even_and_not_grease() { assert_eq!(OPTION % 2, 0); diff --git a/rs/moq-net/src/ietf/goaway.rs b/rs/moq-net/src/ietf/goaway.rs index 6a33e3f3c95..f89116b2a3c 100644 --- a/rs/moq-net/src/ietf/goaway.rs +++ b/rs/moq-net/src/ietf/goaway.rs @@ -1,4 +1,4 @@ -//! IETF moq-transport-14 goaway message +//! The moq-transport GOAWAY message. use std::borrow::Cow; @@ -12,7 +12,7 @@ use super::Version; #[derive(Clone, Debug)] pub struct GoAway<'a> { pub new_session_uri: Cow<'a, str>, - /// Draft-17: timeout in milliseconds before closing the session + /// Timeout in milliseconds before closing the session. pub timeout: u64, /// Draft-18 only: the smallest of the receiver's Request IDs the sender has not /// processed. Required on the control stream and absent on a request stream. @@ -24,10 +24,7 @@ impl Message for GoAway<'_> { fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { w.string(&self.new_session_uri)?; - // Draft-17+ adds a timeout field. - if !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - w.varint(self.timeout)?; - } + w.varint(self.timeout)?; // Draft-18 (#1559) added the Request ID; draft-22 removed it again (#1623). if version == Version::Draft18 && let Some(request_id) = self.request_id @@ -45,7 +42,6 @@ impl Message for GoAway<'_> { return Err(DecodeError::InvalidValue); } let (timeout, request_id) = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => (0, None), // The Request ID is present only on the control stream; each caller checks it // against the stream it read from. Version::Draft18 => { @@ -72,14 +68,14 @@ mod tests { fn encode_message(msg: &M) -> Vec { let mut buf = Vec::new(); - msg.encode_msg(&mut Encoder::new(&mut buf, Version::Draft14.into()), Version::Draft14) + msg.encode_msg(&mut Encoder::new(&mut buf, Version::Draft22.into()), Version::Draft22) .unwrap(); buf.to_vec() } fn decode_message(bytes: &[u8]) -> Result { let mut buf = bytes::Bytes::from(bytes.to_vec()); - crate::coding::decode_buf(&mut buf, Version::Draft14, M::decode_msg) + crate::coding::decode_buf(&mut buf, Version::Draft22, M::decode_msg) } #[test] @@ -110,25 +106,6 @@ mod tests { assert_eq!(decoded.new_session_uri, ""); } - #[test] - fn test_goaway_v17_timeout() { - let msg = GoAway { - new_session_uri: "https://example.com/new".into(), - timeout: 5000, - request_id: None, - }; - - let mut buf = Vec::new(); - msg.encode_msg(&mut Encoder::new(&mut buf, Version::Draft17.into()), Version::Draft17) - .unwrap(); - - let mut bytes = bytes::Bytes::from(buf.to_vec()); - let decoded: GoAway = crate::coding::decode_buf(&mut bytes, Version::Draft17, GoAway::decode_msg).unwrap(); - - assert_eq!(decoded.new_session_uri, "https://example.com/new"); - assert_eq!(decoded.timeout, 5000); - } - #[test] fn test_goaway_v18_timeout() { let msg = GoAway { @@ -142,11 +119,11 @@ mod tests { .unwrap(); // Draft-18 requires a trailing Request ID on the control stream, so the - // v18 body must be exactly one varint longer than the v17 body. - let mut buf17 = Vec::new(); - msg.encode_msg(&mut Encoder::new(&mut buf17, Version::Draft17.into()), Version::Draft17) + // v18 body must be exactly one varint longer than the v19 body. + let mut buf19 = Vec::new(); + msg.encode_msg(&mut Encoder::new(&mut buf19, Version::Draft22.into()), Version::Draft22) .unwrap(); - assert_eq!(buf.len(), buf17.len() + 1, "v18 must append the Request ID varint"); + assert_eq!(buf.len(), buf19.len() + 1, "v18 must append the Request ID varint"); let mut bytes = bytes::Bytes::from(buf.to_vec()); let decoded: GoAway = crate::coding::decode_buf(&mut bytes, Version::Draft18, GoAway::decode_msg).unwrap(); diff --git a/rs/moq-net/src/ietf/group.rs b/rs/moq-net/src/ietf/group.rs index 97f1411190c..11cbc4bb40b 100644 --- a/rs/moq-net/src/ietf/group.rs +++ b/rs/moq-net/src/ietf/group.rs @@ -37,34 +37,21 @@ impl Decode for ObjectExtensionsLength { /// Encode a frame's presentation timestamp as a moq-transport Object Property. /// /// Matches the LOC encoding of the same registry id so a relay or LOC-aware peer -/// reads the same bytes on drafts that delta-encode KVP type ids. The Timestamp -/// value is always absolute. Writes the raw KVP bytes +/// reads the same bytes. The Timestamp value is always absolute. Writes the raw KVP bytes /// (no outer length prefix); the caller frames the block with its byte length. /// /// The units come from the track's TIMESCALE property, not from here: repeating the /// timescale on every object would cost bytes per frame to restate something fixed for /// the track's lifetime. `timescale` is what the track advertised, and the timestamp is /// converted into it so the value on the wire matches the declared units. -pub fn encode_object_time( - w: &mut Encoder<'_>, - timestamp: Timestamp, - timescale: Timescale, - version: Version, -) -> Result<(), EncodeError> { +pub fn encode_object_time(w: &mut Encoder<'_>, timestamp: Timestamp, timescale: Timescale) -> Result<(), EncodeError> { let timestamp = timestamp.convert(timescale).map_err(|_| EncodeError::BoundsExceeded)?; - encode_object_property_type(w, PROP_TIMESTAMP, 0, version)?; + // The first property's type is a delta from zero, so it is written as is. + w.varint(PROP_TIMESTAMP)?; w.varint(timestamp.value())?; Ok(()) } -fn encode_object_property_type(w: &mut Encoder<'_>, kind: u64, prev: u64, version: Version) -> Result<(), EncodeError> { - let encoded = match version { - Version::Draft14 | Version::Draft15 => kind, - _ => kind.checked_sub(prev).ok_or(EncodeError::BoundsExceeded)?, - }; - w.varint(encoded) -} - /// Decode the same property list as ingest when checking timestamps in wire tests. #[cfg(test)] pub fn decode_object_time( @@ -136,16 +123,10 @@ impl Param for GroupOrder { } fn param_decode(r: &mut Decoder<'_>, version: Version) -> Result { - let v = u8::param_decode(r, version)?; - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Ok(GroupOrder::try_from(v) - .unwrap_or(GroupOrder::Descending) - .any_to_descending()), - _ => match v { - 1 => Ok(GroupOrder::Ascending), - 2 => Ok(GroupOrder::Descending), - _ => Err(DecodeError::InvalidValue), - }, + match u8::param_decode(r, version)? { + 1 => Ok(GroupOrder::Ascending), + 2 => Ok(GroupOrder::Descending), + _ => Err(DecodeError::InvalidValue), } } } @@ -189,7 +170,7 @@ impl GroupFlags { // moq-lite always sets this bit on emit because every subgroup starts at object 0. pub const FIRST_OBJECT_BIT: u64 = 0x40; - pub fn encode(&self, version: Version) -> Result { + pub fn encode(&self) -> Result { if self.has_subgroup && self.has_subgroup_object { return Err(EncodeError::InvalidState); } @@ -214,27 +195,18 @@ impl GroupFlags { } // Draft-18+: moq-lite always starts subgroups at object 0 and never has gaps, so // the publisher side sets this on everything it produces. - if self.first_object - && !matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) { + if self.first_object { id |= Self::FIRST_OBJECT_BIT; } Ok(id) } - pub fn decode(id: u64, version: Version) -> Result { - // Draft-18+ allows bit 0x40 (FIRST_OBJECT), which says the stream carries the - // subgroup from its first published object. Strip it before the range check, but - // keep the value: it is the only signal that a subgroup starts partway through. - // The drafts that predate it carry no such signal, so they are taken at their word. - let legacy = matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ); - let first_object = legacy || (id & Self::FIRST_OBJECT_BIT) != 0; - let id = if legacy { id } else { id & !Self::FIRST_OBJECT_BIT }; + pub fn decode(id: u64) -> Result { + // Bit 0x40 (FIRST_OBJECT) says the stream carries the subgroup from its first + // published object. Strip it before the range check, but keep the value: it is the + // only signal that a subgroup starts partway through. + let first_object = (id & Self::FIRST_OBJECT_BIT) != 0; + let id = id & !Self::FIRST_OBJECT_BIT; let (has_priority, base_id) = if (Self::START..=Self::END).contains(&id) { (true, id) @@ -287,9 +259,9 @@ pub struct GroupHeader { } impl Encode for GroupHeader { - fn encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + fn encode(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { tracing::trace!(?self, "encoding group header"); - w.varint(self.flags.encode(version)?)?; + w.varint(self.flags.encode()?)?; w.varint(self.track_alias)?; w.varint(self.group_id)?; @@ -310,8 +282,8 @@ impl Encode for GroupHeader { } impl Decode for GroupHeader { - fn decode(r: &mut Decoder<'_>, version: Version) -> Result { - let flags = GroupFlags::decode(r.varint()?, version)?; + fn decode(r: &mut Decoder<'_>, _: Version) -> Result { + let flags = GroupFlags::decode(r.varint()?)?; let track_alias = r.varint()?; let group_id = r.varint()?; @@ -346,7 +318,7 @@ mod tests { /// Encode an object's properties at `version`. fn encode_time(ts: Timestamp, timescale: Timescale, version: Version) -> Vec { let mut buf = Vec::new(); - encode_object_time(&mut Encoder::new(&mut buf, version.into()), ts, timescale, version).unwrap(); + encode_object_time(&mut Encoder::new(&mut buf, version.into()), ts, timescale).unwrap(); buf } @@ -397,22 +369,6 @@ mod tests { assert_eq!(decoded.scale(), Timescale::MICRO); } - /// No Timescale rides along with the object; the track declared it once. - #[test] - fn test_object_time_omits_the_timescale() { - let ts = Timestamp::new(96_000, Timescale::MILLI).unwrap(); - let buf = encode_time(ts, Timescale::MILLI, Version::Draft16); - assert_eq!(varints(&buf, Version::Draft16), [PROP_TIMESTAMP, 96_000]); - } - - /// Draft-14/15 write absolute property types rather than deltas. - #[test] - fn test_object_time_legacy_uses_absolute_types() { - let ts = Timestamp::new(96_000, Timescale::MILLI).unwrap(); - let buf = encode_time(ts, Timescale::MILLI, Version::Draft15); - assert_eq!(varints(&buf, Version::Draft15), [PROP_TIMESTAMP, 96_000]); - } - /// An object-scope Timescale (which LOC permits) is ignored: the track's units apply. #[test] fn test_object_time_ignores_an_object_scope_timescale() { @@ -451,150 +407,130 @@ mod tests { #[test] fn test_group_flags_spec_table() { // Type 0x10: No subgroup field, Subgroup ID = 0, No extensions, No end - let flags = GroupFlags::decode(0x10, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x10).unwrap(); assert!(!flags.has_subgroup); assert!(!flags.has_subgroup_object); assert!(!flags.has_extensions); assert!(!flags.has_end); assert!(flags.has_priority); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x10); + assert_eq!(flags.encode().unwrap(), 0x10); // Type 0x11: No subgroup field, Subgroup ID = 0, Extensions, No end - let flags = GroupFlags::decode(0x11, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x11).unwrap(); assert!(!flags.has_subgroup); assert!(!flags.has_subgroup_object); assert!(flags.has_extensions); assert!(!flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x11); + assert_eq!(flags.encode().unwrap(), 0x11); // Type 0x12: No subgroup field, Subgroup ID = First Object ID, No extensions, No end - let flags = GroupFlags::decode(0x12, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x12).unwrap(); assert!(!flags.has_subgroup); assert!(flags.has_subgroup_object); assert!(!flags.has_extensions); assert!(!flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x12); + assert_eq!(flags.encode().unwrap(), 0x12); // Type 0x13: No subgroup field, Subgroup ID = First Object ID, Extensions, No end - let flags = GroupFlags::decode(0x13, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x13).unwrap(); assert!(!flags.has_subgroup); assert!(flags.has_subgroup_object); assert!(flags.has_extensions); assert!(!flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x13); + assert_eq!(flags.encode().unwrap(), 0x13); // Type 0x14: Subgroup field present, No extensions, No end - let flags = GroupFlags::decode(0x14, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x14).unwrap(); assert!(flags.has_subgroup); assert!(!flags.has_subgroup_object); assert!(!flags.has_extensions); assert!(!flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x14); + assert_eq!(flags.encode().unwrap(), 0x14); // Type 0x15: Subgroup field present, Extensions, No end - let flags = GroupFlags::decode(0x15, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x15).unwrap(); assert!(flags.has_subgroup); assert!(!flags.has_subgroup_object); assert!(flags.has_extensions); assert!(!flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x15); + assert_eq!(flags.encode().unwrap(), 0x15); // Type 0x18: No subgroup field, Subgroup ID = 0, No extensions, End of group - let flags = GroupFlags::decode(0x18, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x18).unwrap(); assert!(!flags.has_subgroup); assert!(!flags.has_subgroup_object); assert!(!flags.has_extensions); assert!(flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x18); + assert_eq!(flags.encode().unwrap(), 0x18); // Type 0x19: No subgroup field, Subgroup ID = 0, Extensions, End of group - let flags = GroupFlags::decode(0x19, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x19).unwrap(); assert!(!flags.has_subgroup); assert!(!flags.has_subgroup_object); assert!(flags.has_extensions); assert!(flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x19); + assert_eq!(flags.encode().unwrap(), 0x19); // Type 0x1A: No subgroup field, Subgroup ID = First Object ID, No extensions, End of group - let flags = GroupFlags::decode(0x1A, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x1A).unwrap(); assert!(!flags.has_subgroup); assert!(flags.has_subgroup_object); assert!(!flags.has_extensions); assert!(flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x1A); + assert_eq!(flags.encode().unwrap(), 0x1A); // Type 0x1B: No subgroup field, Subgroup ID = First Object ID, Extensions, End of group - let flags = GroupFlags::decode(0x1B, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x1B).unwrap(); assert!(!flags.has_subgroup); assert!(flags.has_subgroup_object); assert!(flags.has_extensions); assert!(flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x1B); + assert_eq!(flags.encode().unwrap(), 0x1B); // Type 0x1C: Subgroup field present, No extensions, End of group - let flags = GroupFlags::decode(0x1C, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x1C).unwrap(); assert!(flags.has_subgroup); assert!(!flags.has_subgroup_object); assert!(!flags.has_extensions); assert!(flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x1C); + assert_eq!(flags.encode().unwrap(), 0x1C); // Type 0x1D: Subgroup field present, Extensions, End of group - let flags = GroupFlags::decode(0x1D, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x1D).unwrap(); assert!(flags.has_subgroup); assert!(!flags.has_subgroup_object); assert!(flags.has_extensions); assert!(flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x1D); + assert_eq!(flags.encode().unwrap(), 0x1D); // Invalid: Both has_subgroup and has_subgroup_object (would be 0x16) - assert!(GroupFlags::decode(0x16, Version::Draft14).is_err()); + assert!(GroupFlags::decode(0x16).is_err()); } #[test] fn test_group_flags_no_priority_range() { // v15: 0x30 range = same flags as 0x10 range but no priority - let flags = GroupFlags::decode(0x30, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x30).unwrap(); assert!(!flags.has_priority); assert!(!flags.has_subgroup); assert!(!flags.has_extensions); assert!(!flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x30); + assert_eq!(flags.encode().unwrap(), 0x30); - let flags = GroupFlags::decode(0x38, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x38).unwrap(); assert!(!flags.has_priority); assert!(flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x38); + assert_eq!(flags.encode().unwrap(), 0x38); - let flags = GroupFlags::decode(0x3D, Version::Draft14).unwrap(); + let flags = GroupFlags::decode(0x3D).unwrap(); assert!(!flags.has_priority); assert!(flags.has_subgroup); assert!(flags.has_extensions); assert!(flags.has_end); - assert_eq!(flags.encode(Version::Draft14).unwrap(), 0x3D); + assert_eq!(flags.encode().unwrap(), 0x3D); // Invalid: Both has_subgroup and has_subgroup_object in no-priority range - assert!(GroupFlags::decode(0x36, Version::Draft14).is_err()); - } - - /// Draft-18 introduces the FIRST_OBJECT bit (0x40) per spec §11.4.2. - /// Plain groups set it; relayed groups preserve the publisher's claim. - #[test] - fn test_first_object_bit_draft18() { - // Encoding sets bit 0x40 for default flags. - let flags = GroupFlags::default(); - let encoded = flags.encode(Version::Draft18).unwrap(); - assert_eq!(encoded & GroupFlags::FIRST_OBJECT_BIT, GroupFlags::FIRST_OBJECT_BIT); - // The base value is what draft-17 would have produced. - let v17 = flags.encode(Version::Draft17).unwrap(); - assert_eq!(encoded, v17 | GroupFlags::FIRST_OBJECT_BIT); - - // Decoding accepts and discards the bit. - let decoded = GroupFlags::decode(v17 | GroupFlags::FIRST_OBJECT_BIT, Version::Draft18).unwrap(); - assert_eq!(decoded, flags); - - // Draft-17 rejects the FIRST_OBJECT bit (it's outside the 0x10-0x1d / 0x30-0x3d ranges). - assert!(GroupFlags::decode(v17 | GroupFlags::FIRST_OBJECT_BIT, Version::Draft17).is_err()); + assert!(GroupFlags::decode(0x36).is_err()); } /// FIRST_OBJECT says the stream carries the subgroup from its first published object. @@ -608,44 +544,29 @@ mod tests { first_object, ..Default::default() }; - let encoded = flags.encode(version).unwrap(); + let encoded = flags.encode().unwrap(); assert_eq!( (encoded & GroupFlags::FIRST_OBJECT_BIT) != 0, first_object, "{version} {first_object}" ); - assert_eq!(GroupFlags::decode(encoded, version).unwrap().first_object, first_object); + assert_eq!(GroupFlags::decode(encoded).unwrap().first_object, first_object); } } } - /// The bit arrived in draft-18. Earlier drafts carry no such signal, so a group there - /// is taken at its word rather than read as starting partway through. - #[test] - fn older_drafts_have_no_first_object_signal() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft17] { - let flags = GroupFlags { - first_object: false, - ..Default::default() - }; - let encoded = flags.encode(version).unwrap(); - assert_eq!(encoded & GroupFlags::FIRST_OBJECT_BIT, 0, "{version}"); - assert!(GroupFlags::decode(encoded, version).unwrap().first_object, "{version}"); - } - } - /// Draft-18 byte 0x70..=0x7D should decode to the same flags as 0x30..=0x3D. #[test] fn test_draft18_extended_range() { // 0x70 = 0x30 (no-priority, no flags) + 0x40 (FIRST_OBJECT) - let flags = GroupFlags::decode(0x70, Version::Draft18).unwrap(); + let flags = GroupFlags::decode(0x70).unwrap(); assert!(!flags.has_priority); assert!(!flags.has_subgroup); assert!(!flags.has_extensions); assert!(!flags.has_end); // 0x7D = 0x3D + 0x40 - let flags = GroupFlags::decode(0x7D, Version::Draft18).unwrap(); + let flags = GroupFlags::decode(0x7D).unwrap(); assert!(!flags.has_priority); assert!(flags.has_subgroup); assert!(flags.has_extensions); @@ -679,7 +600,7 @@ mod tests { ); // The check in session.rs::UniType::classify. assert!( - GroupFlags::decode(type_byte, Version::Draft18).is_ok(), + GroupFlags::decode(type_byte).is_ok(), "draft-18 SUBGROUP_HEADER type 0x{type_byte:02x} not recognized by uni-stream classifier", ); } diff --git a/rs/moq-net/src/ietf/hidden.rs b/rs/moq-net/src/ietf/hidden.rs index 388334a516d..4336c0ea100 100644 --- a/rs/moq-net/src/ietf/hidden.rs +++ b/rs/moq-net/src/ietf/hidden.rs @@ -34,11 +34,10 @@ mod tests { #[test] fn every_version_round_trips() { - for version in [Version::Draft14, Version::Draft16, Version::Draft17, Version::Draft22] { - let mut params = super::super::Parameters::default(); - assert!(!from_setup(¶ms, version)); - into_setup(&mut params, version); - assert!(from_setup(¶ms, version)); - } + let version = Version::Draft22; + let mut params = super::super::Parameters::default(); + assert!(!from_setup(¶ms, version)); + into_setup(&mut params, version); + assert!(from_setup(¶ms, version)); } } diff --git a/rs/moq-net/src/ietf/max_age.rs b/rs/moq-net/src/ietf/max_age.rs index 672c0850bb9..f463ac46737 100644 --- a/rs/moq-net/src/ietf/max_age.rs +++ b/rs/moq-net/src/ietf/max_age.rs @@ -30,12 +30,11 @@ mod tests { use super::*; #[test] fn negotiated_separately_and_bounded() { - for version in [Version::Draft14, Version::Draft16, Version::Draft22] { - let mut params = Parameters::default(); - assert!(!from_setup(¶ms, version)); - into_setup(&mut params, version); - assert!(from_setup(¶ms, version)); - } + let version = Version::Draft22; + let mut params = Parameters::default(); + assert!(!from_setup(¶ms, version)); + into_setup(&mut params, version); + assert!(from_setup(¶ms, version)); assert_eq!(validate(None).unwrap(), None); assert_eq!(validate(Some(0)).unwrap(), Some(0)); assert!(validate(Some(MAX_MILLIS + 1)).is_err()); diff --git a/rs/moq-net/src/ietf/message.rs b/rs/moq-net/src/ietf/message.rs index 5458b65945f..08985cb5098 100644 --- a/rs/moq-net/src/ietf/message.rs +++ b/rs/moq-net/src/ietf/message.rs @@ -2,37 +2,14 @@ use crate::coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}; use super::Version; -pub(super) fn control(id: u64, version: Version) -> crate::stats::Message { +pub(super) fn control(id: u64) -> crate::stats::Message { use super::*; use crate::stats::Message as Class; - // Draft 14 reuses later drafts' NAMESPACE_DONE type for TRACK_STATUS_OK. - if version == Version::Draft14 && (id == TrackStatusOk::ID_14 || id == TRACK_STATUS_ERROR_14) { - return Class::TrackInfo; - } - // Before draft 16, 0x02 updates only a subscription; later it is the shared REQUEST_UPDATE. - if matches!(version, Version::Draft14 | Version::Draft15) && id == SubscribeUpdate::ID { - return Class::Subscribe; - } match id { - PublishNamespace::ID - | PublishNamespaceError::ID - | PublishNamespaceDone::ID - | PublishNamespaceCancel::ID - | SubscribeNamespace::ID - | SubscribeNamespaceLegacy::ID - | SubscribeNamespaceOk::ID - | SubscribeNamespaceError::ID - | UnsubscribeNamespace::ID - | NamespaceDone::ID => Class::Announce, + PublishNamespace::ID | SubscribeNamespace::ID | Namespace::ID | NamespaceDone::ID => Class::Announce, TrackStatus::ID | SubscribeTracks::ID | PublishSkipped::ID => Class::TrackInfo, - Subscribe::ID - | SubscribeOk::ID - | Unsubscribe::ID - | Publish::ID - | PublishOk::ID - | PublishError::ID - | PublishDone::ID => Class::Subscribe, - Fetch::ID | FetchOk::ID | FetchError::ID | FetchCancel::ID => Class::Fetch, + Subscribe::ID | SubscribeOk::ID | Publish::ID | PublishOk::ID | PublishDone::ID => Class::Subscribe, + Fetch::ID | FetchOk::ID => Class::Fetch, auth::Auth::ID | auth::AuthOk::ID | auth::AuthError::ID => Class::Auth, GoAway::ID => Class::Goaway, _ => Class::Other, @@ -58,7 +35,7 @@ impl Encode for T { let prefix = w.prefix_u16(); self.encode_msg(w, version)?; w.fill(prefix)?; - w.control = Some(control(T::ID, version)); + w.control = Some(control(T::ID)); Ok(()) } } @@ -112,7 +89,7 @@ impl Decode for T { Err(err) => tracing::warn!(%err, "decode failed"), } if result.is_ok() { - r.control = Some(control(T::ID, version)); + r.control = Some(control(T::ID)); } result } @@ -123,23 +100,11 @@ mod tests { use super::*; use crate::stats::Message as Class; - /// Draft 14's track-status replies reuse type IDs later drafts give other classes. #[test] - fn draft14_track_status_ids_are_track_info() { - for id in [super::super::TrackStatusOk::ID_14, super::super::TRACK_STATUS_ERROR_14] { - assert_eq!(control(id, Version::Draft14), Class::TrackInfo); - } - assert_eq!( - control(super::super::NamespaceDone::ID, Version::Draft16), - Class::Announce - ); - assert_eq!( - control(super::super::SubscribeUpdate::ID, Version::Draft15), - Class::Subscribe - ); - // Shared replies and updates identify no request class. + fn shared_replies_identify_no_request_class() { + assert_eq!(control(super::super::NamespaceDone::ID), Class::Announce); for id in [super::super::RequestOk::ID, super::super::SubscribeUpdate::ID] { - assert_eq!(control(id, Version::Draft16), Class::Other); + assert_eq!(control(id), Class::Other); } } } diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 674c3db6830..dd187e8f0bb 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -16,8 +16,6 @@ #[macro_use] mod parameters; pub mod active_count; -#[cfg(feature = "async")] -mod adapter; pub(crate) mod auth; pub mod cluster; #[cfg(feature = "async")] @@ -61,8 +59,6 @@ mod version; #[cfg(feature = "async")] use control::Control; -#[cfg(feature = "async")] -pub(crate) use control::initial_max_request_id; pub use datagram::*; pub use fetch::*; pub use filter::*; @@ -87,22 +83,6 @@ use subscriber::*; pub use track::*; pub use version::Version; -#[cfg(all(feature = "async", any(test, feature = "fuzz")))] -pub(crate) fn request_ids(window: u64, ids: &[u64]) -> usize { - let control = Control::new(None, false).with_window(window, false, Version::Draft16); - let held: Vec<_> = ids - .iter() - .filter_map(|id| control.accept(RequestId(*id)).ok()) - .collect(); - held.len() -} - -#[cfg(all(feature = "async", any(test, feature = "fuzz")))] -pub(crate) fn request_history(ids: &[u64]) -> usize { - let control = Control::new(None, false); - ids.iter().filter(|id| control.accept(RequestId(**id)).is_ok()).count() -} - #[cfg(test)] mod tests { use super::*; diff --git a/rs/moq-net/src/ietf/namespace.rs b/rs/moq-net/src/ietf/namespace.rs index 686d5373489..f464793fa5e 100644 --- a/rs/moq-net/src/ietf/namespace.rs +++ b/rs/moq-net/src/ietf/namespace.rs @@ -116,14 +116,7 @@ pub fn decode_track(r: &mut Decoder<'_>) -> Result<(Path, String), DecodeError> /// Session namespaces belong to the transport, and none of our extensions defines one. pub(super) fn refusal(namespace: &Path, version: super::Version) -> Option> { - if matches!( - version, - super::Version::Draft14 - | super::Version::Draft15 - | super::Version::Draft16 - | super::Version::Draft17 - | super::Version::Draft18 - ) { + if matches!(version, super::Version::Draft18) { return None; } // Reserved literals contain no escapes. Comparing the first escaped field keeps @@ -132,7 +125,6 @@ pub(super) fn refusal(namespace: &Path, version: super::Version) -> Option Vec { encode_path(&crate::Path::try_from(path.to_string()).unwrap()) diff --git a/rs/moq-net/src/ietf/object.rs b/rs/moq-net/src/ietf/object.rs index 9419b09b6cb..7db2e973ab0 100644 --- a/rs/moq-net/src/ietf/object.rs +++ b/rs/moq-net/src/ietf/object.rs @@ -3,43 +3,23 @@ use bytes::Bytes; use super::Version; use crate::{ Error, Timescale, Timestamp, - coding::{Decoder, Encoder, Form}, + coding::{Decoder, Encoder}, model::object::{Properties, Property, Value}, }; -/// Whether `version` serializes property IDs as they are, rather than as deltas. -fn absolute(version: Version) -> bool { - matches!(version, Version::Draft14 | Version::Draft15) -} - -/// Whether lists from drafts `a` and `b` are the same bytes: the same keys and varints. -pub(crate) fn same_bytes(a: Version, b: Version) -> bool { - let form = |version| (absolute(version), Form::from(version)); - form(a) == form(b) -} - /// Draft-22 assigns property 0x06 to SUBGROUP_DELIVERY_TIMEOUT. pub(crate) fn has_delivery_timeout(version: Version) -> bool { - !matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 - ) + !matches!(version, Version::Draft18) } -/// Object status: the object does not exist, defined by drafts 14 and 15 only. -pub(crate) const DOES_NOT_EXIST: u64 = 0x1; /// Object status: no object at or past this location in the group exists. pub(crate) const END_OF_GROUP: u64 = 0x3; /// Object status: no object at or past this location exists (every implemented draft). pub(crate) const END_OF_TRACK: u64 = 0x4; -/// Whether `version` defines the Object Status `status`, 0 being Normal. -pub(crate) fn status_defined(status: u64, version: Version) -> bool { - match status { - 0 | END_OF_GROUP | END_OF_TRACK => true, - DOES_NOT_EXIST => matches!(version, Version::Draft14 | Version::Draft15), - _ => false, - } +/// Whether the Object Status `status` is defined, 0 being Normal. +pub(crate) fn status_defined(status: u64) -> bool { + matches!(status, 0 | END_OF_GROUP | END_OF_TRACK) } /// Refuse the properties of an object with a frame on a track in `timescale` if they @@ -52,16 +32,10 @@ pub(crate) fn check_frame(properties: &Properties, timescale: Option) } } -/// Only draft-14 lets a status object, such as END_OF_GROUP, carry properties. +/// Refuse properties on a status object, such as END_OF_GROUP, which may not carry any. #[cfg(feature = "async")] -pub(crate) fn status_has_properties(version: Version) -> bool { - version == Version::Draft14 -} - -/// Refuse properties on a status object where its draft forbids them. -#[cfg(feature = "async")] -pub(super) fn check_status(version: Version, properties: &Properties) -> Result<(), Error> { - match status_has_properties(version) || properties.is_empty() { +pub(super) fn check_status(properties: &Properties) -> Result<(), Error> { + match properties.is_empty() { true => Ok(()), false => Err(Error::Session(crate::SessionError::ProtocolViolation)), } @@ -88,7 +62,7 @@ pub(crate) fn max_block(timescale: Option) -> usize { } // A Timestamp's value is a QUIC varint at most, and its key, a delta of at most 0x10, // only shrinks the key of the property after it. - let largest = [Version::Draft14, Version::Draft16, Version::Draft17, Version::Draft18] + let largest = [Version::Draft18] .into_iter() .map(|version| { let form = version.into(); @@ -128,10 +102,7 @@ fn list(data: &Bytes, version: Version, immutable: bool, lift: bool) -> Result key, - false => previous.checked_add(key).ok_or(Error::MalformedTrack)?, - }; + let id = previous.checked_add(key).ok_or(Error::MalformedTrack)?; previous = id; let value = if id % 2 == 0 { let value = decoder.varint()?; @@ -191,10 +162,7 @@ fn serialize<'a>(values: impl IntoIterator, version: Versio let mut encoder = Encoder::new(&mut out, version.into()); let mut previous = 0; for property in sorted { - encoder.varint(match absolute(version) { - true => property.id, - false => property.id - previous, - })?; + encoder.varint(property.id - previous)?; previous = property.id; match &property.value { Value::Int(value) => encoder.varint(*value)?, @@ -317,8 +285,7 @@ fn values(block: &crate::model::object::Block, version: Version) -> Result) -> Result { decode(data, version, timescale, true) } @@ -408,14 +375,11 @@ pub(crate) fn write( return serialize(×tamp, version); }; let source = source.ok_or(Error::MalformedTrack)?; - if block.immutable && !same_bytes(source, version) { - return Err(Error::Unsupported); - } // Between draft-18 and draft-22, forwarding 0x06 would give it a different meaning. // A mutable one is dropped; a sealed one is refused. let flipped = has_delivery_timeout(source) != has_delivery_timeout(version); // The common case: the block is already what this draft would send. - if timestamp.is_none() && !flipped && same_bytes(source, version) { + if timestamp.is_none() && !flipped { return Ok(block.bytes.to_vec()); } let values = values(block, source)?; @@ -455,14 +419,7 @@ mod tests { #[test] fn key_six_is_opaque_on_every_draft() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { for bytes in [ Bytes::from_static(&[0x06, 42]), Bytes::from_static(&[0x0b, 2, 0x06, 42]), @@ -543,16 +500,6 @@ mod tests { .unwrap(), bytes ); - assert!(matches!( - write( - &decoded.properties, - Some(Version::Draft22), - decoded.timestamp, - Version::Draft17, - milli - ), - Err(Error::Unsupported) - )); // Its units cannot follow the egress's, or vanish with them, without rewriting the block. for timescale in [Some(Timescale::MICRO), None] { assert!(matches!( @@ -719,30 +666,18 @@ mod tests { fn a_status_object_keeps_its_timestamp() { let milli = Some(Timescale::MILLI); let list = Bytes::from_static(&[0x10, 7, 0x3e, 1]); - let framed = read(&list, Version::Draft14, milli).unwrap(); + let framed = read(&list, Version::Draft18, milli).unwrap(); assert_eq!(framed.timestamp, Some(Timestamp::new(7, Timescale::MILLI).unwrap())); - let status = unframed(&list, Version::Draft14, milli).unwrap(); + let status = unframed(&list, Version::Draft18, milli).unwrap(); assert_eq!( - write(&status, Some(Version::Draft14), None, Version::Draft14, None).unwrap(), + write(&status, Some(Version::Draft18), None, Version::Draft18, None).unwrap(), list.to_vec() ); // Over the lite hop, only a status block may hold it. let block = status.block.unwrap().bytes; - assert!(check(&block, Version::Draft14, milli, false).is_ok()); - assert!(check(&block, Version::Draft14, milli, true).is_err()); - } - - /// A draft-14 block crosses the lite hop in ascending order, as ingress sorts it, so - /// one whose absolute IDs descend is malformed. Its Immutable Properties may not ascend. - #[test] - fn a_draft14_block_ascends() { - let milli = Some(Timescale::MILLI); - assert!(check(&Bytes::from_static(&[0x02, 1, 0x20, 1]), Version::Draft14, milli, true).is_ok()); - assert!(check(&Bytes::from_static(&[0x02, 1, 0x02, 2]), Version::Draft14, milli, true).is_ok()); - assert!(check(&Bytes::from_static(&[0x20, 1, 0x02, 1]), Version::Draft14, milli, true).is_err()); - let sealed = Bytes::from_static(&[0x02, 1, 0x0b, 4, 0x20, 1, 0x02, 1]); - assert!(check(&sealed, Version::Draft14, milli, true).is_ok()); + assert!(check(&block, Version::Draft18, milli, false).is_ok()); + assert!(check(&block, Version::Draft18, milli, true).is_err()); } /// Draft-22 assigns 0x06 to SUBGROUP_DELIVERY_TIMEOUT, so it cannot cross that boundary. @@ -835,7 +770,7 @@ mod tests { for bytes in [&[0x0b, 2, 0x0b, 0][..], &[0x0b, 0, 0, 0][..], &[0x0b, 1, 2][..]] { assert!(read(&Bytes::copy_from_slice(bytes), Version::Draft22, None).is_err()); } - // Draft-18 permits this nonminimal seven-byte value; draft-17 cannot read it. + // Draft-18 permits this nonminimal seven-byte value. let bytes = Bytes::from_static(&[0x0b, 8, 2, 0xfc, 0, 0, 0, 0, 0, 1]); let decoded = read(&bytes, Version::Draft18, None).unwrap(); assert_eq!( @@ -849,17 +784,6 @@ mod tests { .unwrap(), bytes ); - assert!(read(&bytes, Version::Draft17, None).is_err()); - assert!(matches!( - write( - &decoded.properties, - Some(Version::Draft18), - decoded.timestamp, - Version::Draft17, - None - ), - Err(Error::Unsupported) - )); } } @@ -892,7 +816,7 @@ mod compression_tests { } #[test] fn sizes_are_known_and_unknown_properties_survive() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let unknown = property(0x8800, 7); let wire = compression(serialize([&unknown], version).unwrap(), version, Some(123)).unwrap(); let decoded = read(&wire.into(), version, None).unwrap(); @@ -905,7 +829,7 @@ mod compression_tests { } #[test] fn a_status_cannot_declare_an_inflated_size() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let size = property(super::super::flate::INFLATED, 0); for value in [ size.clone(), @@ -925,7 +849,7 @@ mod compression_tests { #[test] fn sealed_sizes_are_forwarded_or_refused_without_rewriting() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let sealed = Property { id: 0x0b, value: Value::Bytes( diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index 98b75bed10d..d9445729874 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -14,8 +14,6 @@ const MAX_KVP_VALUE_LEN: usize = (1 << 16) - 1; #[derive(Debug, Copy, Clone, FromPrimitive, IntoPrimitive, Eq, Hash, PartialEq)] #[repr(u64)] pub enum ParameterVarInt { - /// Removed in draft-17; only used in draft-14/15/16. - MaxRequestId = 2, MaxAuthTokenCacheSize = 4, /// MAX_REQUEST_UPDATES, added in draft-22. MaxRequestUpdates = super::request_update::OPTION, @@ -65,27 +63,18 @@ impl Decode for Parameters { fn decode(r: &mut Decoder<'_>, version: Version) -> Result { let mut params = Self::default(); - // Draft-14/15/16 count the pairs; draft-17+ reads them until the buffer is empty. - let count = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(r.varint()?), - _ => None, - }; - if count.is_some_and(|count| count > MAX_PARAMS) { - return Err(DecodeError::TooMany); - } - - // Draft-16+ delta-encodes the types; even is a varint value, odd is length-prefixed bytes. - let delta = !matches!(version, Version::Draft14 | Version::Draft15); + // No count: the pairs run to the end of the buffer. The types are delta-encoded; + // even is a varint value, odd is length-prefixed bytes. let mut prev = 0u64; let mut i = 0u64; - while count.map_or(!r.is_empty(), |count| i < count) { + while !r.is_empty() { if i >= MAX_PARAMS { return Err(DecodeError::TooMany); } let kind = r.varint()?; - let kind = match delta && i > 0 { + let kind = match i > 0 { true => prev.checked_add(kind).ok_or(DecodeError::BoundsExceeded)?, false => kind, }; @@ -100,7 +89,6 @@ impl Decode for Parameters { ParameterVarInt::MaxRequestUpdates if !super::request_update::supported(version) => { ParameterVarInt::Unknown(kind) } - ParameterVarInt::Flate if !super::flate::supported(version) => ParameterVarInt::Unknown(kind), kind => kind, }; if !matches!(kind, ParameterVarInt::Unknown(_)) && params.get_varint(kind).is_some() { @@ -125,7 +113,7 @@ impl Decode for Parameters { } impl Encode for Parameters { - fn encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + fn encode(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { let count = self.vars.len() + self.bytes.len(); if count as u64 > MAX_PARAMS { return Err(EncodeError::TooMany); @@ -134,46 +122,24 @@ impl Encode for Parameters { return Err(EncodeError::BoundsExceeded); } - match version { - Version::Draft14 | Version::Draft15 => { - w.varint(count as u64)?; - - for (kind, value) in &self.vars { - w.varint(u64::from(*kind))?; - w.varint(*value)?; - } + // No count prefix; the types are delta-encoded in ascending order. + enum ParamRef<'a> { + Var(u64), + Bytes(&'a [u8]), + } + let mut all: Vec<(u64, ParamRef)> = Vec::with_capacity(count); + all.extend(self.vars.iter().map(|(k, v)| (u64::from(*k), ParamRef::Var(*v)))); + all.extend(self.bytes.iter().map(|(k, v)| (u64::from(*k), ParamRef::Bytes(v)))); + all.sort_by_key(|(k, _)| *k); - for (kind, value) in &self.bytes { - w.varint(u64::from(*kind))?; - w.bytes(value)?; - } - } - _ => { - // Draft16: count prefix + delta encoding - // Draft17+: NO count prefix + delta encoding - if matches!(version, Version::Draft16) { - w.varint(count as u64)?; - } + let mut prev = 0u64; + for (kind, value) in all { + w.varint(kind - prev)?; + prev = kind; - enum ParamRef<'a> { - Var(u64), - Bytes(&'a [u8]), - } - let mut all: Vec<(u64, ParamRef)> = Vec::with_capacity(count); - all.extend(self.vars.iter().map(|(k, v)| (u64::from(*k), ParamRef::Var(*v)))); - all.extend(self.bytes.iter().map(|(k, v)| (u64::from(*k), ParamRef::Bytes(v)))); - all.sort_by_key(|(k, _)| *k); - - let mut prev = 0u64; - for (kind, value) in all { - w.varint(kind - prev)?; - prev = kind; - - match value { - ParamRef::Var(v) => w.varint(v)?, - ParamRef::Bytes(v) => w.bytes(v)?, - } - } + match value { + ParamRef::Var(v) => w.varint(v)?, + ParamRef::Bytes(v) => w.bytes(v)?, } } @@ -182,63 +148,6 @@ impl Encode for Parameters { } impl Parameters { - /// Consume a draft-14 request's parameter block, returning every AUTHORIZATION TOKEN - /// (0x03), each decoded by [`super::token::RequestToken`]'s rules. - pub fn skip_request(r: &mut Decoder<'_>) -> Result, DecodeError> { - let mut tokens = Vec::new(); - Self::skip(r, Some(&mut tokens))?; - Ok(tokens) - } - - /// Consume a draft-14 response's parameter block, returning its first - /// MAX_CACHE_DURATION (0x04), the one parameter we act on. - pub fn skip_response(r: &mut Decoder<'_>) -> Result, DecodeError> { - Self::skip(r, None) - } - - /// Draft-14 section 9.2 has a receiver ignore unrecognized parameters and allow their - /// duplicates, and lets AUTHORIZATION TOKEN repeat, so unlike [`Parameters::decode`], - /// which SETUP uses, a repeat is not refused. A request collects its tokens into `tokens`; - /// on a message that does not list them, a response, they are ignored like any other - /// parameter. - fn skip( - r: &mut Decoder<'_>, - mut tokens: Option<&mut Vec>, - ) -> Result, DecodeError> { - let count = r.varint()?; - if count > MAX_PARAMS { - return Err(DecodeError::TooMany); - } - - let mut cache_duration = None; - for _ in 0..count { - // Parity frames a Key-Value-Pair: even is one varint, odd is length prefixed. - let kind = r.varint()?; - match kind % 2 { - 0 => { - let value = r.varint()?; - if kind == 0x04 { - cache_duration.get_or_insert(value); - } - } - _ if let Some(tokens) = tokens.as_deref_mut() - && kind == u64::from(ParameterBytes::AuthorizationToken) => - { - tokens.push(super::token::RequestToken::param_decode(r, Version::Draft14)?); - } - _ => { - let len = usize::try_from(r.varint()?).map_err(|_| DecodeError::BoundsExceeded)?; - if len > MAX_KVP_VALUE_LEN { - return Err(DecodeError::BoundsExceeded); - } - r.slice(len)?; - } - } - } - - Ok(cache_duration) - } - pub fn get_varint(&self, kind: ParameterVarInt) -> Option { self.vars.iter().find(|(k, _)| *k == kind).map(|(_, v)| *v) } @@ -264,13 +173,8 @@ impl Parameters { // ---- Message Parameter Value Encoding ---- -/// Trait for encoding/decoding parameter values with version-specific formats. -/// -/// Parameter encoding differs from field encoding: -/// - Draft-14/15/16: u8 and bool are encoded as varints (cast to u64) -/// - Draft-17: type-specific encoding (u8 as raw byte, bool as raw byte, etc.) -/// -/// Use `_ =>` for the newest draft behavior so future versions default forward. +/// Encodes and decodes a Message Parameter value, whose format is type-specific +/// (a u8 or bool is one raw byte, a Location two bare varints). pub trait Param: Sized { fn param_encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError>; fn param_decode(r: &mut Decoder<'_>, version: Version) -> Result; @@ -290,44 +194,24 @@ pub trait Param: Sized { } impl Param for u8 { - fn param_encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - match version { - // Draft-14/15/16: u8 encoded as varint - Version::Draft14 | Version::Draft15 | Version::Draft16 => w.varint(u64::from(*self))?, - _ => w.u8(*self), - } + fn param_encode(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { + w.u8(*self); Ok(()) } - fn param_decode(r: &mut Decoder<'_>, version: Version) -> Result { - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => { - u8::try_from(r.varint()?).map_err(|_| DecodeError::InvalidValue) - } - _ => r.u8(), - } + fn param_decode(r: &mut Decoder<'_>, _: Version) -> Result { + r.u8() } } impl Param for bool { - fn param_encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - match version { - // Draft-14/15/16: bool encoded as varint - Version::Draft14 | Version::Draft15 | Version::Draft16 => w.varint(u8::from(*self).into())?, - _ => w.bool(*self), - } + fn param_encode(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { + w.bool(*self); Ok(()) } - fn param_decode(r: &mut Decoder<'_>, version: Version) -> Result { - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => match r.varint()? { - 0 => Ok(false), - 1 => Ok(true), - _ => Err(DecodeError::InvalidValue), - }, - _ => r.bool(), - } + fn param_decode(r: &mut Decoder<'_>, _: Version) -> Result { + r.bool() } } @@ -342,51 +226,19 @@ impl Param for u64 { } } -/// A Location parameter value, such as LARGEST_OBJECT (0x09). -/// -/// Draft-16 section 9.2 serializes every Message Parameter as a Key-Value-Pair, and section -/// 9.2.2.7 calls LARGEST_OBJECT "a length-prefixed Location structure", so the two varints -/// sit inside a byte string there. Draft-17 section 9.3, and section 10.2 from draft-18 on, -/// redefines a Message Parameter as `{ Type Delta (vi64), Value (..) }` with no Length at -/// all, and lists "Location: Two consecutive varints (Group, Object)" as its own value -/// encoding beside Length-prefixed. So from draft-17 the two varints are written bare. +/// A Location parameter value, such as LARGEST_OBJECT (0x09): two bare varints, as a +/// Message Parameter has no Length (draft-18 section 10.2). impl Param for Location { - fn param_encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => { - // The drafts before 17 pin the inner varints to the draft-15 encoding, - // matching the other length-prefixed parameters. - let mut buf = Vec::new(); - let mut inner = Encoder::new(&mut buf, Version::Draft15.into()); - inner.varint(self.group)?; - inner.varint(self.object)?; - w.bytes(&buf) - } - _ => { - w.varint(self.group)?; - w.varint(self.object)?; - Ok(()) - } - } + fn param_encode(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { + w.varint(self.group)?; + w.varint(self.object)?; + Ok(()) } - fn param_decode(r: &mut Decoder<'_>, version: Version) -> Result { - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => { - let mut inner = Decoder::new(r.bytes()?, Version::Draft15.into()); - let group = inner.varint()?; - let object = inner.varint()?; - if !inner.is_empty() { - return Err(DecodeError::TrailingBytes); - } - Ok(Location { group, object }) - } - _ => { - let group = r.varint()?; - let object = r.varint()?; - Ok(Location { group, object }) - } - } + fn param_decode(r: &mut Decoder<'_>, _: Version) -> Result { + let group = r.varint()?; + let object = r.varint()?; + Ok(Location { group, object }) } } @@ -453,43 +305,6 @@ impl Param for Opaque { } } -/// Message parameter ids defined in draft-16. Sorted for `binary_search`. -/// -/// A known id on a message that does not list it is ignored. An id outside this set -/// is unknown and closes the session. -const DRAFT16_MESSAGE_PARAMS: &[u64] = &[0x02, 0x03, 0x08, 0x09, 0x10, 0x20, 0x21, 0x22, 0x32]; - -fn skip_kvp(r: &mut Decoder<'_>, key: u64) -> Result<(), DecodeError> { - if key.is_multiple_of(2) { - r.varint()?; - } else { - let value = r.bytes()?; - if value.len() > MAX_KVP_VALUE_LEN { - return Err(DecodeError::BoundsExceeded); - } - } - Ok(()) -} - -/// Consumes a parameter the message does not list, when that draft says to ignore it. -/// -/// Draft-14 and draft-15 ignore an unrecognized parameter, including one defined for a -/// different message. Draft-16 ignores a known parameter on the wrong message and closes -/// on an unknown id. From draft-17 on both close the session, and a parameter value has -/// no length to skip by, so this returns false and the caller fails the message. -pub(crate) fn skip_unlisted(r: &mut Decoder<'_>, version: Version, key: u64) -> Result { - let ignore = match version { - Version::Draft14 | Version::Draft15 => true, - Version::Draft16 => DRAFT16_MESSAGE_PARAMS.binary_search(&key).is_ok(), - _ => false, - }; - if !ignore { - return Ok(false); - } - skip_kvp(r, key)?; - Ok(true) -} - /// Encode message parameters with compile-time sorted keys. /// /// Keys must be listed in ascending order (enforced at compile time). @@ -527,11 +342,7 @@ macro_rules! encode_params { $( if $crate::ietf::Param::param_present(&$val) { let _key: u64 = $key; - let _wire = match _version { - $crate::ietf::Version::Draft14 | $crate::ietf::Version::Draft15 => _key, - _ if _first => _key, - _ => _key - _prev_key, - }; + let _wire = if _first { _key } else { _key - _prev_key }; $w.varint(_wire)?; _prev_key = _key; _first = false; @@ -548,9 +359,7 @@ macro_rules! encode_params { /// for parameters where `T::default()` is an acceptable fallback. /// /// A `where` gate takes the key off the list on versions where the expression is false. -/// What remains unlisted is ignored on draft-14 and draft-15, ignored on draft-16 when -/// the id is a message parameter of that draft used on the wrong message, and a -/// `DecodeError::InvalidValue` otherwise (unknown id, or any unlisted id from draft-17 on). +/// An unlisted key is a `DecodeError::InvalidValue`: a value has no length to skip by. /// Duplicate parameters cause `DecodeError::Duplicate`, unless the type allows a repeat /// (see [`Param::param_repeat`]), such as `Vec`. /// @@ -588,17 +397,17 @@ macro_rules! decode_params { let mut _prev_key: u64 = 0; for _i in 0.._count { let _wire = $r.varint()?; - let _key: u64 = match _version { - $crate::ietf::Version::Draft14 | $crate::ietf::Version::Draft15 => _wire, - _ if _i == 0 => _wire, - _ => _prev_key.checked_add(_wire).ok_or($crate::coding::DecodeError::BoundsExceeded)?, + let _key: u64 = if _i == 0 { + _wire + } else { + _prev_key.checked_add(_wire).ok_or($crate::coding::DecodeError::BoundsExceeded)? }; _prev_key = _key; // An if-chain rather than a `match`, so a key can be a named constant: // the macro captures it as an expression, which is not a legal pattern. - // A false `where` gate falls through, so the draft's ignore-or-close rule applies - // instead of validating a value the message is not allowed to carry. + // A false `where` gate falls through to the refusal instead of validating a + // value the message is not allowed to carry. $( if _key == $key $( && ($gate) )? { let _value = <$ty as $crate::ietf::Param>::param_decode($r, _version)?; @@ -609,9 +418,6 @@ macro_rules! decode_params { continue; } )* - if $crate::ietf::parameters::skip_unlisted($r, _version, _key)? { - continue; - } return Err($crate::coding::DecodeError::InvalidValue); } } @@ -644,29 +450,14 @@ mod tests { #[test] fn setup_allows_repeated_unknown_options() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { for kind in [0x20, 0x21, 0x9d, 0x11c] { let mut buf = Vec::new(); let mut w = Encoder::new(&mut buf, version.into()); - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - w.varint(2).unwrap(); - } w.varint(kind).unwrap(); for delta in [None, Some(0)] { if let Some(delta) = delta { - w.varint(if matches!(version, Version::Draft14 | Version::Draft15) { - kind - } else { - delta - }) - .unwrap(); + w.varint(delta).unwrap(); } if kind % 2 == 0 { w.varint(1).unwrap(); @@ -700,7 +491,7 @@ mod tests { #[test] fn group_order_parameter_rejects_values_outside_one_and_two() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { for value in [0u8, 3, 255] { let bytes = [value]; let mut r = Decoder::new(&bytes, version.into()); @@ -724,61 +515,7 @@ mod tests { } #[test] - fn legacy_group_order_zero_keeps_the_publisher_preference() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let mut r = Decoder::new(&[0], version.into()); - assert_eq!( - super::super::GroupOrder::param_decode(&mut r, version).unwrap(), - super::super::GroupOrder::Descending - ); - } - } - - // ---- Setup Parameters tests (unchanged) ---- - - #[test] - fn test_parameters_v16_delta_round_trip() { - let mut params = Parameters::default(); - params.set_bytes(ParameterBytes::Path, b"/test".to_vec()); - params.set_varint(ParameterVarInt::MaxRequestId, 100); - params.set_bytes(ParameterBytes::Implementation, b"test-impl".to_vec()); - - let mut buf = Vec::new(); - params - .encode(&mut Encoder::new(&mut buf, Version::Draft16.into()), Version::Draft16) - .unwrap(); - - let mut bytes = bytes::Bytes::from(buf); - let decoded = crate::coding::decode_buf(&mut bytes, Version::Draft16, Parameters::decode).unwrap(); - - assert_eq!(decoded.get_bytes(ParameterBytes::Path), Some(b"/test".as_ref())); - assert_eq!(decoded.get_varint(ParameterVarInt::MaxRequestId), Some(100)); - assert_eq!( - decoded.get_bytes(ParameterBytes::Implementation), - Some(b"test-impl".as_ref()) - ); - } - - #[test] - fn test_parameters_v15_round_trip() { - let mut params = Parameters::default(); - params.set_bytes(ParameterBytes::Path, b"/test".to_vec()); - params.set_varint(ParameterVarInt::MaxRequestId, 100); - - let mut buf = Vec::new(); - params - .encode(&mut Encoder::new(&mut buf, Version::Draft15.into()), Version::Draft15) - .unwrap(); - - let mut bytes = bytes::Bytes::from(buf); - let decoded = crate::coding::decode_buf(&mut bytes, Version::Draft15, Parameters::decode).unwrap(); - - assert_eq!(decoded.get_bytes(ParameterBytes::Path), Some(b"/test".as_ref())); - assert_eq!(decoded.get_varint(ParameterVarInt::MaxRequestId), Some(100)); - } - - #[test] - fn test_parameters_v17_round_trip() { + fn setup_parameters_round_trip() { let mut params = Parameters::default(); params.set_bytes(ParameterBytes::Path, b"/test".to_vec()); params.set_varint(ParameterVarInt::MaxAuthTokenCacheSize, 4096); @@ -786,37 +523,18 @@ mod tests { let mut buf = Vec::new(); params - .encode(&mut Encoder::new(&mut buf, Version::Draft17.into()), Version::Draft17) + .encode(&mut Encoder::new(&mut buf, Version::Draft18.into()), Version::Draft18) .unwrap(); + // No count prefix: the first byte is the first type, PATH (0x01). + assert_eq!(buf[0], 0x01); - let mut bytes = bytes::Bytes::from(buf); - let decoded = crate::coding::decode_buf(&mut bytes, Version::Draft17, Parameters::decode).unwrap(); - + let (decoded, _) = Parameters::decode_slice(&buf, Version::Draft18).unwrap(); assert_eq!(decoded.get_bytes(ParameterBytes::Path), Some(b"/test".as_ref())); assert_eq!(decoded.get_varint(ParameterVarInt::MaxAuthTokenCacheSize), Some(4096)); assert_eq!( decoded.get_bytes(ParameterBytes::Implementation), Some(b"test-impl".as_ref()) ); - assert!(bytes.is_empty()); - } - - #[test] - fn test_parameters_v17_no_count_prefix() { - let mut params = Parameters::default(); - params.set_bytes(ParameterBytes::Path, b"/x".to_vec()); - - let mut buf15 = Vec::new(); - params - .encode(&mut Encoder::new(&mut buf15, Version::Draft15.into()), Version::Draft15) - .unwrap(); - - let mut buf17 = Vec::new(); - params - .encode(&mut Encoder::new(&mut buf17, Version::Draft17.into()), Version::Draft17) - .unwrap(); - - assert!(buf17.len() < buf15.len()); } // ---- Message Parameter (encode_params!/decode_params!) tests ---- @@ -835,98 +553,73 @@ mod tests { #[test] fn test_param_u8_all_versions() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - encode_params!(w, v, 0x20 => 200u8); - Ok(()) - }, - |r, v| { - decode_params!(r, v, 0x20 => val: Option); - assert_eq!(val, Some(200)); - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + encode_params!(w, v, 0x20 => 200u8); + Ok(()) + }, + |r, v| { + decode_params!(r, v, 0x20 => val: Option); + assert_eq!(val, Some(200)); + Ok(()) + }, + ); } #[test] fn test_param_u8_wire_vectors() -> Result<(), EncodeError> { - for (version, expected) in [ - (Version::Draft16, &[0x01, 0x20, 0x40, 0xff][..]), - (Version::Draft17, &[0x01, 0x20, 0xff][..]), - (Version::Draft18, &[0x01, 0x20, 0xff][..]), - ] { - let mut buf = Vec::new(); - encode_params!(&mut Encoder::new(&mut buf, version.into()), version, 0x20 => u8::MAX); - assert_eq!(&buf[..], expected, "{version}"); - - let mut encoded = Decoder::new(expected, version.into()); - let decoded = (|| -> Result, DecodeError> { - decode_params!(&mut encoded, version, 0x20 => value: Option); - Ok(value) - })() - .expect("fixed uint8 vector should decode"); - assert_eq!(decoded, Some(u8::MAX), "{version}"); - assert!(encoded.is_empty(), "{version}"); - } + let version = Version::Draft18; + let expected = &[0x01, 0x20, 0xff][..]; + let mut buf = Vec::new(); + encode_params!(&mut Encoder::new(&mut buf, version.into()), version, 0x20 => u8::MAX); + assert_eq!(&buf[..], expected, "{version}"); + + let mut encoded = Decoder::new(expected, version.into()); + let decoded = (|| -> Result, DecodeError> { + decode_params!(&mut encoded, version, 0x20 => value: Option); + Ok(value) + })() + .expect("fixed uint8 vector should decode"); + assert_eq!(decoded, Some(u8::MAX), "{version}"); + assert!(encoded.is_empty(), "{version}"); Ok(()) } #[test] fn test_param_bool_all_versions() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - encode_params!(w, v, 0x10 => true); - Ok(()) - }, - |r, v| { - decode_params!(r, v, 0x10 => val: Option); - assert_eq!(val, Some(true)); - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + encode_params!(w, v, 0x10 => true); + Ok(()) + }, + |r, v| { + decode_params!(r, v, 0x10 => val: Option); + assert_eq!(val, Some(true)); + Ok(()) + }, + ); } #[test] fn test_param_location_all_versions() { let loc = Location { group: 5, object: 3 }; - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - encode_params!(w, v, 0x09 => loc.clone()); - Ok(()) - }, - |r, v| { - decode_params!(r, v, 0x09 => val: Option); - assert_eq!(val, Some(Location { group: 5, object: 3 })); - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + encode_params!(w, v, 0x09 => loc.clone()); + Ok(()) + }, + |r, v| { + decode_params!(r, v, 0x09 => val: Option); + assert_eq!(val, Some(Location { group: 5, object: 3 })); + Ok(()) + }, + ); } #[test] @@ -935,12 +628,8 @@ mod tests { group: 255, object: 128, }; - // Draft-16 wraps the two varints in a Key-Value-Pair Length; from draft-17 a - // Message Parameter has no Length and a Location value is two bare varints. The - // inner format differs too: QUIC-style before draft-17, leading-ones after. + // A Message Parameter has no Length, so a Location value is two bare varints. for (version, expected) in [ - (Version::Draft16, &[0x01, 0x09, 0x04, 0x40, 0xff, 0x40, 0x80][..]), - (Version::Draft17, &[0x01, 0x09, 0x80, 0xff, 0x80, 0x80][..]), (Version::Draft18, &[0x01, 0x09, 0x80, 0xff, 0x80, 0x80][..]), (Version::Draft22, &[0x01, 0x09, 0x80, 0xff, 0x80, 0x80][..]), ] { @@ -962,289 +651,206 @@ mod tests { #[test] fn test_param_filter_all_versions() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - encode_params!(w, v, 0x21 => Filter::NextObject); - Ok(()) - }, - |r, v| { - decode_params!(r, v, 0x21 => val: Option); - assert_eq!(val, Some(Filter::NextObject)); - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + encode_params!(w, v, 0x21 => Filter::NextObject); + Ok(()) + }, + |r, v| { + decode_params!(r, v, 0x21 => val: Option); + assert_eq!(val, Some(Filter::NextObject)); + Ok(()) + }, + ); } #[test] fn test_param_multiple_delta_encoding() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - encode_params!(w, v, - 0x10 => true, - 0x20 => 200u8, - 0x21 => Filter::NextObject, - 0x22 => 2u8, - ); - Ok(()) - }, - |r, v| { - decode_params!(r, v, - 0x10 => forward: Option, - 0x20 => sub_pri: Option, - 0x21 => filter: Option, - 0x22 => group_order: Option, - ); - assert_eq!(forward, Some(true)); - assert_eq!(sub_pri, Some(200)); - assert_eq!(filter, Some(Filter::NextObject)); - assert_eq!(group_order, Some(2)); - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + encode_params!(w, v, + 0x10 => true, + 0x20 => 200u8, + 0x21 => Filter::NextObject, + 0x22 => 2u8, + ); + Ok(()) + }, + |r, v| { + decode_params!(r, v, + 0x10 => forward: Option, + 0x20 => sub_pri: Option, + 0x21 => filter: Option, + 0x22 => group_order: Option, + ); + assert_eq!(forward, Some(true)); + assert_eq!(sub_pri, Some(200)); + assert_eq!(filter, Some(Filter::NextObject)); + assert_eq!(group_order, Some(2)); + Ok(()) + }, + ); } #[test] fn test_param_empty_set() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - encode_params!(w, v,); - Ok(()) - }, - |r, v| { - decode_params!(r, v,); - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + encode_params!(w, v,); + Ok(()) + }, + |r, v| { + decode_params!(r, v,); + Ok(()) + }, + ); } #[test] fn test_param_option_skip_none() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - let loc: Option = None; - encode_params!(w, v, - 0x09 => loc, - 0x10 => true, - ); - Ok(()) - }, - |r, v| { - decode_params!(r, v, - 0x09 => loc: Option, - 0x10 => forward: Option, - ); - assert_eq!(loc, None); - assert_eq!(forward, Some(true)); - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + let loc: Option = None; + encode_params!(w, v, + 0x09 => loc, + 0x10 => true, + ); + Ok(()) + }, + |r, v| { + decode_params!(r, v, + 0x09 => loc: Option, + 0x10 => forward: Option, + ); + assert_eq!(loc, None); + assert_eq!(forward, Some(true)); + Ok(()) + }, + ); } #[test] fn test_param_option_encode_some() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - let loc = Some(Location { group: 10, object: 5 }); - encode_params!(w, v, - 0x09 => loc, - 0x10 => true, - ); - Ok(()) - }, - |r, v| { - decode_params!(r, v, - 0x09 => loc: Option, - 0x10 => forward: Option, - ); - assert_eq!(loc, Some(Location { group: 10, object: 5 })); - assert_eq!(forward, Some(true)); - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + let loc = Some(Location { group: 10, object: 5 }); + encode_params!(w, v, + 0x09 => loc, + 0x10 => true, + ); + Ok(()) + }, + |r, v| { + decode_params!(r, v, + 0x09 => loc: Option, + 0x10 => forward: Option, + ); + assert_eq!(loc, Some(Location { group: 10, object: 5 })); + assert_eq!(forward, Some(true)); + Ok(()) + }, + ); } #[test] fn test_param_bare_type_defaults() { // Bare types use T::default() when the parameter is absent - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - round_trip_params( - version, - |w, v| { - // Encode only 0x10, not 0x20 - encode_params!(w, v, 0x10 => true); - Ok(()) - }, - |r, v| { - decode_params!(r, v, - 0x10 => forward: bool, - 0x20 => priority: u8, - ); - assert!(forward); - assert_eq!(priority, 0); // u8::default() - Ok(()) - }, - ); - } + let version = Version::Draft18; + round_trip_params( + version, + |w, v| { + // Encode only 0x10, not 0x20 + encode_params!(w, v, 0x10 => true); + Ok(()) + }, + |r, v| { + decode_params!(r, v, + 0x10 => forward: bool, + 0x20 => priority: u8, + ); + assert!(forward); + assert_eq!(priority, 0); // u8::default() + Ok(()) + }, + ); } #[test] fn test_param_unknown_rejected() { - // 0x3E is not a message parameter in any of these drafts. Draft-14 and draft-15 - // ignore it. Draft-16 on closes the session. - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - let mut buf = Vec::new(); - let mut w = Encoder::new(&mut buf, version.into()); - w.varint(1).unwrap(); - w.varint(0x3Eu64).unwrap(); - 1u64.param_encode(&mut w, version).unwrap(); - - let mut bytes = Decoder::new(&buf, version.into()); - let result: Result, DecodeError> = (|| { - decode_params!(&mut bytes, version, 0x20 => val: Option); - Ok(val) - })(); - match version { - Version::Draft14 | Version::Draft15 => { - assert_eq!(result.unwrap(), None, "{version} ignores an unrecognized parameter"); - assert!(bytes.is_empty(), "{version}"); - } - _ => assert!( - matches!(result, Err(DecodeError::InvalidValue)), - "expected InvalidValue for unknown param in {version}" - ), - } - } + // 0x3E is not a message parameter, which closes the session. + let version = Version::Draft18; + let mut buf = Vec::new(); + let mut w = Encoder::new(&mut buf, version.into()); + w.varint(1).unwrap(); + w.varint(0x3Eu64).unwrap(); + 1u64.param_encode(&mut w, version).unwrap(); + + let mut bytes = Decoder::new(&buf, version.into()); + let result: Result, DecodeError> = (|| { + decode_params!(&mut bytes, version, 0x20 => val: Option); + Ok(val) + })(); + assert!( + matches!(result, Err(DecodeError::InvalidValue)), + "expected InvalidValue for unknown param in {version}" + ); } - /// EXPIRES (0x08) is a draft-16 message parameter, but not for a message whose list is - /// only SUBSCRIBER_PRIORITY. Draft-16 ignores it and still reads the parameter after it. - /// Draft-18 closes the session on the same bytes. + /// EXPIRES (0x08) is a message parameter, but not for a message whose list is only + /// SUBSCRIBER_PRIORITY, so it closes the session. #[test] fn known_param_on_the_wrong_message() { - for version in [Version::Draft16, Version::Draft18] { - let mut buf = Vec::new(); - let mut w = Encoder::new(&mut buf, version.into()); - w.varint(2).unwrap(); - w.varint(0x08u64).unwrap(); - 5u64.param_encode(&mut w, version).unwrap(); - w.varint(0x18u64).unwrap(); // delta from 0x08 to 0x20 - 7u8.param_encode(&mut w, version).unwrap(); - - let mut bytes = Decoder::new(&buf, version.into()); - let result: Result, DecodeError> = (|| { - decode_params!(&mut bytes, version, 0x20 => val: Option); - Ok(val) - })(); - match version { - Version::Draft16 => { - assert_eq!(result.unwrap(), Some(7)); - assert!(bytes.is_empty()); - } - Version::Draft18 => assert!(matches!(result, Err(DecodeError::InvalidValue))), - _ => unreachable!(), - } - } + let version = Version::Draft18; + let mut buf = Vec::new(); + let mut w = Encoder::new(&mut buf, version.into()); + w.varint(2).unwrap(); + w.varint(0x08u64).unwrap(); + 5u64.param_encode(&mut w, version).unwrap(); + w.varint(0x18u64).unwrap(); // delta from 0x08 to 0x20 + 7u8.param_encode(&mut w, version).unwrap(); + + let mut bytes = Decoder::new(&buf, version.into()); + let result: Result, DecodeError> = (|| { + decode_params!(&mut bytes, version, 0x20 => val: Option); + Ok(val) + })(); + assert!(matches!(result, Err(DecodeError::InvalidValue))); } #[test] fn test_param_duplicate_rejected() { // Manually construct a buffer with duplicate key 0x20 - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - let mut buf = Vec::new(); - let mut w = Encoder::new(&mut buf, version.into()); - // Encode count = 2 - w.varint(2).unwrap(); - match version { - Version::Draft14 | Version::Draft15 => { - // Plain (non-delta) keys: first key=0x20, second key=0x20 - w.varint(0x20u64).unwrap(); - 100u8.param_encode(&mut w, version).unwrap(); - w.varint(0x20u64).unwrap(); - 200u8.param_encode(&mut w, version).unwrap(); - } - _ => { - // Delta-encoded: first delta=0x20 (abs=0x20), second delta=0 (abs=0x20) - w.varint(0x20u64).unwrap(); - 100u8.param_encode(&mut w, version).unwrap(); - w.varint(0).unwrap(); - 200u8.param_encode(&mut w, version).unwrap(); - } - } - - let mut bytes = Decoder::new(&buf, version.into()); - let result: Result<(), DecodeError> = (|| { - decode_params!(&mut bytes, version, 0x20 => val: Option); - let _ = val; - Ok(()) - })(); - assert!( - matches!(result, Err(DecodeError::Duplicate)), - "expected Duplicate for {version}" - ); - } + let version = Version::Draft18; + let mut buf = Vec::new(); + let mut w = Encoder::new(&mut buf, version.into()); + // Encode count = 2 + w.varint(2).unwrap(); + // Delta-encoded: first delta=0x20 (abs=0x20), second delta=0 (abs=0x20) + w.varint(0x20u64).unwrap(); + 100u8.param_encode(&mut w, version).unwrap(); + w.varint(0).unwrap(); + 200u8.param_encode(&mut w, version).unwrap(); + + let mut bytes = Decoder::new(&buf, version.into()); + let result: Result<(), DecodeError> = (|| { + decode_params!(&mut bytes, version, 0x20 => val: Option); + let _ = val; + Ok(()) + })(); + assert!( + matches!(result, Err(DecodeError::Duplicate)), + "expected Duplicate for {version}" + ); } /// A parameter the draft lets repeat, such as AUTHORIZATION TOKEN, keeps every @@ -1260,66 +866,22 @@ mod tests { value: vec![0xAA], }) }; - for version in [Version::Draft15, Version::Draft16, Version::Draft17, Version::Draft22] { - let mut buf = Vec::new(); - let mut w = Encoder::new(&mut buf, version.into()); - w.varint(2).unwrap(); - w.varint(0x03).unwrap(); - token(0).param_encode(&mut w, version).unwrap(); - // The second key: absolute before draft-16, a zero delta after. - let second: u64 = if version == Version::Draft15 { 0x03 } else { 0 }; - w.varint(second).unwrap(); - token(1).param_encode(&mut w, version).unwrap(); - - let mut r = Decoder::new(&buf, version.into()); - let tokens = (|| -> Result, DecodeError> { - decode_params!(&mut r, version, 0x03 => tokens: Vec); - Ok(tokens) - })() - .unwrap_or_else(|e| panic!("{version}: {e}")); - assert_eq!(tokens, vec![token(0), token(1)], "{version}"); - assert!(r.is_empty(), "{version}"); - } - } - - /// Draft-14 lets AUTHORIZATION TOKEN repeat and has unknown parameters, duplicates - /// included, ignored. The block is consumed whole either way. - #[test] - fn test_skip_allows_draft14_repeats() { - #[rustfmt::skip] - let block = [ - 0x04, // Number of Parameters - 0x03, 0x03, 0x03, 0x00, 0xAA, // AUTHORIZATION TOKEN: USE_VALUE, type 0 - 0x03, 0x03, 0x03, 0x01, 0xBB, // and again, type 1 - 0x3E, 0x05, // an unknown varint parameter - 0x3E, 0x06, // and again - ]; - let mut r = Decoder::new(&block, Version::Draft14.into()); - let tokens = Parameters::skip_request(&mut r).unwrap(); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[1].0.kind, 1); - assert!(r.is_empty()); - } - - /// A draft-14 request decodes every token; a response ignores one, as section 9.2 has - /// for a parameter on a message that does not list it. - #[test] - fn test_skip_decodes_draft14_request_tokens() { - #[rustfmt::skip] - let block = [ - 0x02, // Number of Parameters - 0x03, 0x03, 0x03, 0x00, 0xAA, // AUTHORIZATION TOKEN: USE_VALUE, type 0 - 0x03, 0x02, 0x02, 0x07, // and a USE_ALIAS, never registered - ]; - let mut r = Decoder::new(&block, Version::Draft14.into()); - let err = Parameters::skip_request(&mut r).unwrap_err(); - assert!( - matches!(err, DecodeError::Session(crate::SessionError::UnknownAuthTokenAlias)), - "{err:?}" - ); + let version = Version::Draft22; + let mut buf = Vec::new(); + let mut w = Encoder::new(&mut buf, version.into()); + w.varint(2).unwrap(); + w.varint(0x03).unwrap(); + token(0).param_encode(&mut w, version).unwrap(); + w.varint(0).unwrap(); + token(1).param_encode(&mut w, version).unwrap(); - let mut r = Decoder::new(&block, Version::Draft14.into()); - assert_eq!(Parameters::skip_response(&mut r).unwrap(), None); - assert!(r.is_empty()); + let mut r = Decoder::new(&buf, version.into()); + let tokens = (|| -> Result, DecodeError> { + decode_params!(&mut r, version, 0x03 => tokens: Vec); + Ok(tokens) + })() + .unwrap_or_else(|e| panic!("{version}: {e}")); + assert_eq!(tokens, vec![token(0), token(1)], "{version}"); + assert!(r.is_empty(), "{version}"); } } diff --git a/rs/moq-net/src/ietf/peer.rs b/rs/moq-net/src/ietf/peer.rs index 5b87d835e64..415dc86825c 100644 --- a/rs/moq-net/src/ietf/peer.rs +++ b/rs/moq-net/src/ietf/peer.rs @@ -34,7 +34,7 @@ pub(crate) struct Peer { /// publisher's epoch in both directions. pub epoch: bool, - /// Whether this draft-17+ peer offered DEFLATE-compressed tracks. + /// Whether this peer offered DEFLATE-compressed tracks. pub flate: bool, } diff --git a/rs/moq-net/src/ietf/properties.rs b/rs/moq-net/src/ietf/properties.rs index c50c8bd8523..55480987c6b 100644 --- a/rs/moq-net/src/ietf/properties.rs +++ b/rs/moq-net/src/ietf/properties.rs @@ -1,6 +1,6 @@ /// Track Properties: relay-visible metadata attached to tracks. /// -/// Draft-17 adds Track Properties to SUBSCRIBE_OK, PUBLISH, and FETCH_OK. +/// SUBSCRIBE_OK, PUBLISH, and FETCH_OK carry Track Properties. /// They appear after the message parameters as a sequence of Key-Value-Pairs /// (same delta-encoded format) until the end of the message. /// @@ -42,8 +42,7 @@ const DEFAULT_PUBLISHER_PRIORITY: u64 = 0x0e; const DEFAULT_PUBLISHER_GROUP_ORDER: u64 = 0x22; /// DYNAMIC_GROUPS (0x30): a subscriber may ask the original publisher for a new group with -/// NEW_GROUP_REQUEST. Draft-16 calls it a Track Extension and draft-15 a SUBSCRIBE_OK -/// parameter; same number and meaning. +/// NEW_GROUP_REQUEST. const DYNAMIC_GROUPS: u64 = 0x30; /// Immutable Properties (0x0b): Key-Value-Pairs the original publisher sealed, which a @@ -82,16 +81,6 @@ pub struct Properties { } impl Properties { - /// Whether [`Self::encode`] writes a TIMESCALE on this version. - /// - /// Drafts 14-16 do not. 14 and 15 have no properties block, and 16's Track Extensions - /// predate the TIMESCALE registration; an older peer rejects trailing bytes it does not - /// parse. A Timestamp on those drafts would have no units. Draft-16 is still read, so a - /// peer that sends the block is understood. - pub(crate) fn sends_timescale(version: Version) -> bool { - !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - } - /// The properties a relay states for a track it serves, from the `info` it acts on. /// /// We serve the newest group first by default, matching moq-lite. @@ -113,36 +102,27 @@ impl Properties { /// /// Properties are serialized in ascending order by type, delta-encoded. /// - /// Draft 16 gets only DYNAMIC_GROUPS, which it defines and which a subscriber needs - /// before it may send NEW_GROUP_REQUEST in an update, and the unknown properties. - /// /// A property the publisher sealed inside Immutable Properties travels there, so it is /// not restated in the mutable list. pub fn encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if matches!(version, Version::Draft14 | Version::Draft15) { - return Ok(()); - } - let int = |id, value| Property { id, value: Value::Int(value), }; let mut known = Vec::new(); - if Self::sends_timescale(version) { - if let Some(age) = self.max_cache_duration { - let millis = u64::try_from(age.as_millis()).map_err(|_| EncodeError::BoundsExceeded)?; - known.push(int(MAX_CACHE_DURATION, millis)); - } - if let Some(timescale) = self.timescale { - known.push(int(TIMESCALE, u64::from(timescale))); - } - if let Some(priority) = self.priority { - known.push(int(DEFAULT_PUBLISHER_PRIORITY, u64::from(priority))); - } - if let Some(group_order) = self.group_order { - known.push(int(DEFAULT_PUBLISHER_GROUP_ORDER, u64::from(u8::from(group_order)))); - } + if let Some(age) = self.max_cache_duration { + let millis = u64::try_from(age.as_millis()).map_err(|_| EncodeError::BoundsExceeded)?; + known.push(int(MAX_CACHE_DURATION, millis)); + } + if let Some(timescale) = self.timescale { + known.push(int(TIMESCALE, u64::from(timescale))); + } + if let Some(priority) = self.priority { + known.push(int(DEFAULT_PUBLISHER_PRIORITY, u64::from(priority))); + } + if let Some(group_order) = self.group_order { + known.push(int(DEFAULT_PUBLISHER_GROUP_ORDER, u64::from(u8::from(group_order)))); } // Omitted means 0, so only support is worth bytes. @@ -150,7 +130,7 @@ impl Properties { known.push(int(DYNAMIC_GROUPS, 1)); } - if self.compress && super::flate::supported(version) { + if self.compress { known.push(int(super::flate::COMPRESSION, 1)); } @@ -183,18 +163,9 @@ impl Properties { /// /// Unlike an unknown message parameter, an unknown property is kept rather than /// fatal, which is what lets a relay forward properties it does not implement. - /// - /// Drafts before 16 have no such block, so this reads nothing and leaves the buffer alone. pub fn decode(r: &mut Decoder<'_>, version: Version) -> Result { let mut properties = Self::default(); - // Draft-16 calls the block Track Extensions, draft-17+ Track Properties. Same encoding, - // so read either rather than faulting the message for trailing bytes. - match version { - Version::Draft14 | Version::Draft15 => return Ok(properties), - _ => {} - } - let mut prev_type: u64 = 0; let mut i: u64 = 0; let mut seen = Vec::new(); @@ -262,8 +233,8 @@ impl Properties { self.priority = Some(u8::try_from(value).map_err(|_| DecodeError::InvalidValue)?); } DEFAULT_PUBLISHER_GROUP_ORDER => { - // Only Ascending and Descending are defined here. Unlike the draft-14 - // fields, 0x0 has no "publisher decides" meaning to fall back on. + // Only Ascending and Descending are defined: 0x0 has no "publisher + // decides" meaning to fall back on. self.group_order = match value { 1 => Some(GroupOrder::Ascending), 2 => Some(GroupOrder::Descending), @@ -310,10 +281,6 @@ pub(crate) const MAX_FORWARDED: usize = MAX_PROPERTIES as usize - UNDERSTOOD.len /// Check a Track Properties' Immutable Properties `block` from a `version` publisher, as /// [`Properties::decode`] does. pub(crate) fn check_immutable(block: &[u8], version: Version) -> Result<(), DecodeError> { - // Track Properties start at draft-16. - if matches!(version, Version::Draft14 | Version::Draft15) { - return Err(DecodeError::InvalidValue); - } sealed( &mut Decoder::new(block, version.into()), &mut Properties::default(), @@ -449,7 +416,7 @@ mod tests { fn test_skip_empty_properties() { let mut buf = bytes::Bytes::new(); assert_eq!( - crate::coding::decode_buf(&mut buf, Version::Draft17, Properties::decode).unwrap(), + crate::coding::decode_buf(&mut buf, Version::Draft18, Properties::decode).unwrap(), Properties::default() ); } @@ -458,10 +425,10 @@ mod tests { fn test_skip_varint_property() { // Even type (0x02 = DELIVERY_TIMEOUT), varint value let mut buf = Vec::new(); - Encoder::new(&mut buf, Version::Draft17.into()).varint(0x02u64).unwrap(); // delta type - Encoder::new(&mut buf, Version::Draft17.into()).varint(5000u64).unwrap(); // value + Encoder::new(&mut buf, Version::Draft18.into()).varint(0x02u64).unwrap(); // delta type + Encoder::new(&mut buf, Version::Draft18.into()).varint(5000u64).unwrap(); // value let mut bytes = bytes::Bytes::from(buf); - crate::coding::decode_buf(&mut bytes, Version::Draft17, Properties::decode).unwrap(); + crate::coding::decode_buf(&mut bytes, Version::Draft18, Properties::decode).unwrap(); assert!(bytes.is_empty()); } @@ -469,11 +436,11 @@ mod tests { fn test_skip_bytes_property() { // Odd type (0x0B = IMMUTABLE_PROPERTIES), length-prefixed let mut buf = Vec::new(); - Encoder::new(&mut buf, Version::Draft17.into()).varint(0x0Bu64).unwrap(); // delta type - Encoder::new(&mut buf, Version::Draft17.into()).varint(3u64).unwrap(); // length + Encoder::new(&mut buf, Version::Draft18.into()).varint(0x0Bu64).unwrap(); // delta type + Encoder::new(&mut buf, Version::Draft18.into()).varint(3u64).unwrap(); // length buf.extend_from_slice(&[0x03, 0x01, 0x2A]); // sealed type 0x03, one byte let mut bytes = bytes::Bytes::from(buf); - crate::coding::decode_buf(&mut bytes, Version::Draft17, Properties::decode).unwrap(); + crate::coding::decode_buf(&mut bytes, Version::Draft18, Properties::decode).unwrap(); assert!(bytes.is_empty()); } @@ -481,18 +448,18 @@ mod tests { fn test_skip_multiple_properties() { let mut buf = Vec::new(); // First: type 0x02 (even), varint value - Encoder::new(&mut buf, Version::Draft17.into()).varint(0x02u64).unwrap(); - Encoder::new(&mut buf, Version::Draft17.into()).varint(1000u64).unwrap(); + Encoder::new(&mut buf, Version::Draft18.into()).varint(0x02u64).unwrap(); + Encoder::new(&mut buf, Version::Draft18.into()).varint(1000u64).unwrap(); // Second: delta = 0x02 → abs type 0x04 (even), varint value - Encoder::new(&mut buf, Version::Draft17.into()).varint(0x02u64).unwrap(); - Encoder::new(&mut buf, Version::Draft17.into()).varint(2000u64).unwrap(); + Encoder::new(&mut buf, Version::Draft18.into()).varint(0x02u64).unwrap(); + Encoder::new(&mut buf, Version::Draft18.into()).varint(2000u64).unwrap(); // Third: delta = 0x07 → abs type 0x0B (odd), length-prefixed - Encoder::new(&mut buf, Version::Draft17.into()).varint(0x07u64).unwrap(); - Encoder::new(&mut buf, Version::Draft17.into()).varint(2u64).unwrap(); + Encoder::new(&mut buf, Version::Draft18.into()).varint(0x07u64).unwrap(); + Encoder::new(&mut buf, Version::Draft18.into()).varint(2u64).unwrap(); buf.extend_from_slice(&[0x02, 0x2A]); // sealed type 0x02 = 42 let mut bytes = bytes::Bytes::from(buf); - crate::coding::decode_buf(&mut bytes, Version::Draft17, Properties::decode).unwrap(); + crate::coding::decode_buf(&mut bytes, Version::Draft18, Properties::decode).unwrap(); assert!(bytes.is_empty()); } @@ -523,7 +490,7 @@ mod tests { #[test] fn publisher_priority_is_bounded_to_a_byte() { - for version in [Version::Draft16, Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let mut buf = Vec::new(); Encoder::new(&mut buf, version.into()).varint(0x0eu64).unwrap(); Encoder::new(&mut buf, version.into()).varint(256u64).unwrap(); @@ -535,7 +502,7 @@ mod tests { } /// Only Ascending and Descending are defined, so 0x0 is a malformed message rather than - /// the "publisher decides" it means in the draft-14 fields. + /// a "publisher decides". #[test] fn test_rejects_zero_group_order() { let mut buf = Vec::new(); @@ -546,43 +513,6 @@ mod tests { assert!(crate::coding::decode_buf(&mut bytes, Version::Draft18, Properties::decode).is_err()); } - /// Draft-16 carries the same block under the name Track Extensions. We don't write one - /// there, but a peer that does must be understood rather than faulted. - #[test] - fn test_decodes_draft16_track_extensions() { - let mut buf = Vec::new(); - Encoder::new(&mut buf, Version::Draft16.into()).varint(0x22u64).unwrap(); - Encoder::new(&mut buf, Version::Draft16.into()).varint(2u64).unwrap(); - - let mut bytes = bytes::Bytes::from(buf); - let properties = crate::coding::decode_buf(&mut bytes, Version::Draft16, Properties::decode).unwrap(); - assert_eq!(properties.group_order, Some(GroupOrder::Descending)); - assert!(bytes.is_empty()); - } - - /// Drafts 14-16 cannot carry TIMESCALE, so the block (and any Timestamp that would - /// depend on it) stays off the wire. Draft-17 is the first version that writes it. - #[test] - fn drafts_14_through_16_write_no_timescale() { - let properties = Properties { - unknown: Default::default(), - dynamic_groups: false, - compress: false, - max_cache_duration: None, - timescale: Some(Timescale::MILLI), - priority: None, - group_order: None, - }; - - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft17] { - let mut buf = Vec::new(); - properties - .encode(&mut Encoder::new(&mut buf, version.into()), version) - .unwrap(); - assert_eq!(!buf.is_empty(), Properties::sends_timescale(version), "{version}"); - } - } - /// The group order property is delta-encoded against the timescale that precedes it, /// so it has to survive the timescale being absent. #[test] @@ -655,25 +585,6 @@ mod tests { .unwrap(); assert_eq!(buf, [0x30, 0x01]); - // Draft 16 defines DYNAMIC_GROUPS but not TIMESCALE, and drafts 14 and 15 have no block - // (draft 15 carries DYNAMIC_GROUPS as a SUBSCRIBE_OK parameter instead). - let both = Properties { - dynamic_groups: true, - compress: false, - timescale: Some(Timescale::MILLI), - ..Default::default() - }; - for (version, expected) in [ - (Version::Draft14, &[][..]), - (Version::Draft15, &[][..]), - (Version::Draft16, &[0x30, 0x01][..]), - ] { - let mut buf = Vec::new(); - both.encode(&mut Encoder::new(&mut buf, version.into()), version) - .unwrap(); - assert_eq!(buf, expected, "{version}"); - } - let mut buf = Vec::new(); Properties::default() .encode(&mut Encoder::new(&mut buf, Version::Draft22.into()), Version::Draft22) @@ -695,33 +606,32 @@ mod tests { /// in order among the ones we write ourselves. #[test] fn unknown_properties_round_trip() { - for version in [Version::Draft16, Version::Draft22] { - // 0x02 = 5000, 0x0b = { 0x02 = 42 }, 0x3801 = "hi" - let mut block = Vec::new(); - let mut w = Encoder::new(&mut block, version.into()); - w.varint(0x02).unwrap(); - w.varint(5000).unwrap(); - w.varint(0x09).unwrap(); - w.bytes(&[0x02, 42]).unwrap(); - w.varint(0x3801 - 0x0b).unwrap(); - w.bytes(b"hi").unwrap(); - - let (properties, written) = round_trip(version, &block); - assert_eq!(written, block, "{version}"); - assert_eq!(properties.unknown.len(), 3, "{version}"); - let track = forwarded(&properties, version); - assert!(track.sealed().eq([0x02]), "{version}"); - assert!(!track.mandatory(), "{version}"); - - // Ours interleave by type. - let mut ours = properties.clone(); - ours.dynamic_groups = true; - let mut buf = Vec::new(); - ours.encode(&mut Encoder::new(&mut buf, version.into()), version) - .unwrap(); - let decoded = crate::coding::decode_buf(&mut Bytes::from(buf), version, Properties::decode).unwrap(); - assert_eq!(decoded, ours, "{version}"); - } + let version = Version::Draft22; + // 0x02 = 5000, 0x0b = { 0x02 = 42 }, 0x3801 = "hi" + let mut block = Vec::new(); + let mut w = Encoder::new(&mut block, version.into()); + w.varint(0x02).unwrap(); + w.varint(5000).unwrap(); + w.varint(0x09).unwrap(); + w.bytes(&[0x02, 42]).unwrap(); + w.varint(0x3801 - 0x0b).unwrap(); + w.bytes(b"hi").unwrap(); + + let (properties, written) = round_trip(version, &block); + assert_eq!(written, block, "{version}"); + assert_eq!(properties.unknown.len(), 3, "{version}"); + let track = forwarded(&properties, version); + assert!(track.sealed().eq([0x02]), "{version}"); + assert!(!track.mandatory(), "{version}"); + + // Ours interleave by type. + let mut ours = properties.clone(); + ours.dynamic_groups = true; + let mut buf = Vec::new(); + ours.encode(&mut Encoder::new(&mut buf, version.into()), version) + .unwrap(); + let decoded = crate::coding::decode_buf(&mut Bytes::from(buf), version, Properties::decode).unwrap(); + assert_eq!(decoded, ours, "{version}"); } /// A property sealed inside Immutable Properties is not restated in the mutable list. diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index 820b6987816..c9610438e0a 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -101,7 +101,6 @@ The endpoint does not support the PUBLISH method. UNINTERESTED (0x4): The namespace or track is not of interest to the endpoint. - */ use std::borrow::Cow; @@ -110,7 +109,7 @@ use crate::{ Path, coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}, ietf::{ - Filter, GroupOrder, Location, Parameters, Properties, RequestId, + Filter, GroupOrder, Location, Properties, RequestId, namespace::{decode_track, encode_namespace}, }, }; @@ -133,24 +132,14 @@ pub(crate) enum PublishDoneStatus { } impl PublishDoneStatus { - /// Return the registered wire code for every supported draft. - pub(crate) const fn code(self, version: Version) -> u64 { - match version { - // Draft-14 registers no UPDATE_FAILED; its 0x8 is unassigned. - Version::Draft14 if matches!(self, Self::UpdateFailed) => 0x0, - Version::Draft14 - | Version::Draft15 - | Version::Draft16 - | Version::Draft17 - | Version::Draft18 - // Draft-22 removed SUBSCRIPTION_ENDED (0x3), which this implementation never - // emitted, and left the rest of the registry alone. - | Version::Draft22 => match self { - Self::InternalError => 0x0, - Self::Unauthorized => 0x1, - Self::TrackEnded => 0x2, - Self::UpdateFailed => 0x8, - }, + /// Return the registered wire code. Draft-22 removed SUBSCRIPTION_ENDED (0x3), which + /// this implementation never emitted, and left the rest of the registry alone. + pub(crate) const fn code(self) -> u64 { + match self { + Self::InternalError => 0x0, + Self::Unauthorized => 0x1, + Self::TrackEnded => 0x2, + Self::UpdateFailed => 0x8, } } } @@ -158,7 +147,6 @@ impl PublishDoneStatus { /// Used to be called SubscribeDone #[derive(Clone, Debug)] pub struct PublishDone<'a> { - pub request_id: Option, pub status_code: u64, pub stream_count: u64, pub reason_phrase: Cow<'a, str>, @@ -168,17 +156,11 @@ impl PublishDone<'_> { /// How the publisher ended the subscription: cleanly, or with the error its status names. pub(crate) fn end(&self, version: Version) -> Result<(), crate::Error> { match self.status_code { - code if code == PublishDoneStatus::TrackEnded.code(version) => Ok(()), - code if code == PublishDoneStatus::Unauthorized.code(version) => Err(crate::Error::Unauthorized), + code if code == PublishDoneStatus::TrackEnded.code() => Ok(()), + code if code == PublishDoneStatus::Unauthorized.code() => Err(crate::Error::Unauthorized), // SUBSCRIPTION_ENDED: the subscription reached the end its filter asked for. // Draft-22 removed it and left 0x3 unassigned. - 0x3 if matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 - ) => - { - Ok(()) - } + 0x3 if matches!(version, Version::Draft18) => Ok(()), code => Err(crate::Error::Remote(u32::try_from(code).unwrap_or(u32::MAX))), } } @@ -187,32 +169,19 @@ impl PublishDone<'_> { impl Message for PublishDone<'_> { const ID: u64 = 0x0b; - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - self.request_id - .expect("request_id required for draft14-16") - .encode(w, version)?; - } else { - assert!(self.request_id.is_none(), "request_id must be None for draft17+"); - } + fn encode_msg(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { w.varint(self.status_code)?; w.varint(self.stream_count)?; w.string(&self.reason_phrase)?; Ok(()) } - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - Some(RequestId::decode(r, version)?) - } else { - None - }; + fn decode_msg(r: &mut Decoder<'_>, _: Version) -> Result { let status_code = r.varint()?; let stream_count = r.varint()?; let reason_phrase = Cow::Owned(r.string()?); Ok(Self { - request_id, status_code, stream_count, reason_phrase, @@ -229,7 +198,7 @@ pub struct Publish<'a> { pub largest_location: Option, pub forward: bool, - /// Metadata about the track, sent as Track Properties (draft-17+). + /// Metadata about the track, sent as Track Properties. pub properties: Properties, /// The subscription's SUBSCRIBER_PRIORITY (draft-22), `None` when absent: 128. pub subscriber_priority: Option, @@ -244,150 +213,74 @@ impl Message for Publish<'_> { fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { self.request_id.encode(w, version)?; - if version == Version::Draft17 { - w.varint(0)?; // required_request_id_delta = 0 - } encode_namespace(w, &self.track_namespace)?; w.string(&self.track_name)?; w.varint(self.track_alias)?; - match version { - Version::Draft14 => { - self.properties - .group_order - .unwrap_or(GroupOrder::Ascending) - .encode(w, version)?; - if let Some(location) = &self.largest_location { - w.bool(true); - location.encode(w, version)?; - } else { - w.bool(false); - } - - w.bool(self.forward); - // parameters - w.u8(0); - } - _ => { - // GROUP_ORDER is the publisher's preference on a draft-15 PUBLISH, illegal from - // draft-16 to draft-18 (a peer closes the session with PROTOCOL_VIOLATION), and - // from draft-22 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 draft22 = Filter::is_draft22(version); - let group_order = match version { - Version::Draft15 => self.properties.group_order, - _ if draft22 => self.group_order, - _ => None, - }; - - encode_params!(w, version, - 0x09 => self.largest_location, - 0x10 => self.forward, - 0x20 => self.subscriber_priority.filter(|_| draft22), - 0x21 => self.filter.filter(|_| draft22), - 0x22 => group_order, - ); + // GROUP_ORDER is illegal on PUBLISH on draft-18 (a peer closes the session with + // PROTOCOL_VIOLATION), and from draft-22 one of the subscription's parameters, which + // moved here. The publisher's preference is a DEFAULT_PUBLISHER_GROUP_ORDER track + // property instead. + let draft22 = Filter::is_draft22(version); + encode_params!(w, version, + 0x09 => self.largest_location, + 0x10 => self.forward, + 0x20 => self.subscriber_priority.filter(|_| draft22), + 0x21 => self.filter.filter(|_| draft22), + 0x22 => self.group_order.filter(|_| draft22), + ); - // Track Properties are the final field, so nothing may follow. - self.properties.encode(w, version)?; - } - } + // Track Properties are the final field, so nothing may follow. + self.properties.encode(w, version)?; Ok(()) } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { let request_id = RequestId::decode(r, version)?; - if version == Version::Draft17 { - let _required_request_id_delta = r.varint()?; - } let (track_namespace, track_name) = decode_track(r)?; let track_name = Cow::Owned(track_name); let track_alias = r.varint()?; - match version { - Version::Draft14 => { - let group_order = GroupOrder::decode(r, version)?.any_to_descending(); - let content_exists = r.bool()?; - let largest_location = match content_exists { - true => Some(Location::decode(r, version)?), - false => None, - }; - let forward = r.bool()?; - // parameters - super::token::single(Parameters::skip_request(r)?)?; - - Ok(Self { - request_id, - track_namespace, - track_name, - track_alias, - largest_location, - forward, - properties: Properties { - group_order: Some(group_order), - ..Default::default() - }, - subscriber_priority: None, - group_order: None, - filter: None, - }) - } - _ => { - // GROUP_ORDER is legal on PUBLISH in draft-15 and again from draft-22, when - // the subscription parameters moved here. Draft-16 still knows the id, so one - // on this message is ignored. Draft-17 and draft-18 close the session. - // - // The draft-22 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 timeouts are dropped. - decode_params!(r, version, - 0x02 => _object_delivery_timeout: Option where version == Version::Draft15 || Filter::is_draft22(version), - 0x03 => authorization_token: Vec, - 0x06 => _subgroup_delivery_timeout: Option where Filter::is_draft22(version), - 0x08 => _expires: Option, - 0x09 => largest_location: Option, - 0x10 => forward: Option, - 0x20 => subscriber_priority: Option where Filter::is_draft22(version), - 0x21 => filter: Option where Filter::is_draft22(version), - 0x22 => group_order: Option where version == Version::Draft15 || Filter::is_draft22(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)?; - // Draft-15's GROUP_ORDER is the publisher's preference; draft-22'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); - - Ok(Self { - request_id, - track_namespace, - track_name, - track_alias, - largest_location, - forward, - properties, - subscriber_priority, - group_order, - filter, - }) - } - } + // GROUP_ORDER is legal on PUBLISH from draft-22, when the subscription parameters + // moved here; before that it closes the session. + // + // The draft-22 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. The timeouts are + // dropped. + decode_params!(r, version, + 0x02 => _object_delivery_timeout: Option where Filter::is_draft22(version), + 0x03 => authorization_token: Vec, + 0x06 => _subgroup_delivery_timeout: Option where Filter::is_draft22(version), + 0x08 => _expires: Option, + 0x09 => largest_location: Option, + 0x10 => forward: Option, + 0x20 => subscriber_priority: Option where Filter::is_draft22(version), + 0x21 => filter: Option where Filter::is_draft22(version), + 0x22 => group_order: Option where Filter::is_draft22(version), + ); + let properties = Properties::decode(r, version)?; + // Last, so a malformed field closes the session before a token refusal. + super::token::single(authorization_token)?; + + Ok(Self { + request_id, + track_namespace, + track_name, + track_alias, + largest_location, + forward: forward.unwrap_or(true), + properties, + subscriber_priority, + group_order, + filter, + }) } } #[derive(Debug)] pub struct PublishOk { - pub request_id: Option, pub forward: bool, /// SUBSCRIBER_PRIORITY, `None` when absent: the subscription keeps the one it had. pub subscriber_priority: Option, @@ -395,7 +288,7 @@ pub struct PublishOk { pub group_order: Option, /// The Location Filter (through draft-18), `None` when absent. pub filter: Option, - /// NEW_GROUP_REQUEST (drafts 16 to 18), which a dynamic track judges as a join. + /// NEW_GROUP_REQUEST (through draft-18), which a dynamic track judges as a join. pub new_group: Option, } @@ -403,40 +296,17 @@ impl Message for PublishOk { const ID: u64 = 0x1E; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - self.request_id - .expect("request_id required for draft14-16") - .encode(w, version)?; - } else { - assert!(self.request_id.is_none(), "request_id must be None for draft17+"); - } - match version { - Version::Draft14 => { - w.bool(self.forward); - w.u8(self.subscriber_priority.unwrap_or(super::priority::DEFAULT_WIRE)); - 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.unwrap_or(Filter::Unfiltered).encode(w, version)?; - // no parameters - w.u8(0); - } // Draft-22 moved the subscription parameters out of PUBLISH_OK; they belong to // PUBLISH and REQUEST_UPDATE now, so a PUBLISH_OK carries none of them. _ if Filter::is_draft22(version) => encode_params!(w, version,), _ => { // GROUP_ORDER left PUBLISH_OK in draft-22. Sending it there is a parameter // on the wrong message, which closes the session. - let group_order = matches!( - version, - Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 - ) - .then_some(self.group_order) - .flatten(); - let new_group = matches!(version, Version::Draft16 | Version::Draft17 | Version::Draft18) - .then_some(self.new_group) + let group_order = matches!(version, Version::Draft18) + .then_some(self.group_order) .flatten(); + let new_group = matches!(version, Version::Draft18).then_some(self.new_group).flatten(); encode_params!(w, version, 0x10 => self.forward, 0x20 => self.subscriber_priority, @@ -451,83 +321,27 @@ impl Message for PublishOk { } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - Some(RequestId::decode(r, version)?) - } else { - None - }; - - match version { - Version::Draft14 => { - let forward = r.bool()?; - let subscriber_priority = r.u8()?; - let group_order = GroupOrder::decode(r, version)?; - let filter = Filter::decode(r, version)?; - - // no parameters - Parameters::skip_response(r)?; - - Ok(Self { - request_id, - forward, - 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 are legal on the - // drafts that list them and dropped: nothing here acts on a publisher's timeout. Draft-22 moved the - // subscription parameters, GROUP_ORDER included, off PUBLISH_OK. - decode_params!(r, version, - 0x02 => _delivery_timeout: Option where !Filter::is_draft22(version), - 0x06 => _subgroup_delivery_timeout: Option where matches!(version, Version::Draft18), - 0x08 => _expires: Option, - 0x10 => forward: Option where !Filter::is_draft22(version), - 0x20 => subscriber_priority: Option where !Filter::is_draft22(version), - 0x21 => filter: Option where !Filter::is_draft22(version), - 0x22 => group_order: Option where matches!(version, Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18), - 0x32 => new_group: Option where matches!(version, Version::Draft16 | Version::Draft17 | Version::Draft18), - ); - - Ok(Self { - request_id, - forward: forward.unwrap_or(true), - subscriber_priority, - group_order, - filter, - new_group, - }) - } - } - } -} - -#[derive(Debug)] -pub struct PublishError<'a> { - pub request_id: RequestId, - pub error_code: u64, - pub reason_phrase: Cow<'a, str>, -} -impl Message for PublishError<'_> { - const ID: u64 = 0x1F; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - w.varint(self.error_code)?; - w.string(&self.reason_phrase)?; - Ok(()) - } + // 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-22 + // moved the subscription parameters off PUBLISH_OK, GROUP_ORDER + // included. + decode_params!(r, version, + 0x02 => _delivery_timeout: Option where !Filter::is_draft22(version), + 0x06 => _subgroup_delivery_timeout: Option where matches!(version, Version::Draft18), + 0x08 => _expires: Option, + 0x10 => forward: Option where !Filter::is_draft22(version), + 0x20 => subscriber_priority: Option where !Filter::is_draft22(version), + 0x21 => filter: Option where !Filter::is_draft22(version), + 0x22 => group_order: Option where matches!(version, Version::Draft18), + 0x32 => new_group: Option where matches!(version, Version::Draft18), + ); - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - let error_code = r.varint()?; - let reason_phrase = Cow::Owned(r.string()?); Ok(Self { - request_id, - error_code, - reason_phrase, + forward: forward.unwrap_or(true), + subscriber_priority, + group_order, + filter, + new_group, }) } } @@ -590,157 +404,10 @@ mod tests { crate::coding::decode_buf(&mut buf, version, M::decode_msg) } - #[test] - fn test_publish_v14_round_trip() { - let msg = Publish { - request_id: RequestId(1), - track_namespace: Path::from_static("test/ns"), - track_name: "video".into(), - track_alias: 42, - largest_location: Some(Location { group: 10, object: 5 }), - forward: true, - properties: Properties { - group_order: Some(GroupOrder::Descending), - ..Default::default() - }, - subscriber_priority: None, - group_order: None, - filter: None, - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: Publish = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test/ns"); - assert_eq!(decoded.track_name, "video"); - assert_eq!(decoded.track_alias, 42); - assert_eq!(decoded.largest_location, Some(Location { group: 10, object: 5 })); - assert!(decoded.forward); - } - - #[test] - fn test_publish_v15_round_trip() { - let msg = Publish { - request_id: RequestId(1), - track_namespace: Path::from_static("test/ns"), - track_name: "video".into(), - track_alias: 42, - largest_location: Some(Location { group: 10, object: 5 }), - forward: true, - properties: Properties { - group_order: Some(GroupOrder::Descending), - ..Default::default() - }, - subscriber_priority: None, - group_order: None, - filter: None, - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: Publish = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test/ns"); - assert_eq!(decoded.track_name, "video"); - assert_eq!(decoded.track_alias, 42); - assert_eq!(decoded.largest_location, Some(Location { group: 10, object: 5 })); - assert!(decoded.forward); - } - - #[test] - fn test_publish_ok_v14_round_trip() { - let msg = PublishOk { - request_id: Some(RequestId(7)), - forward: true, - subscriber_priority: Some(128), - group_order: Some(GroupOrder::Descending), - filter: Some(Filter::NextObject), - new_group: None, - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: PublishOk = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(7))); - assert!(decoded.forward); - assert_eq!(decoded.subscriber_priority, Some(128)); - } - - #[test] - fn test_publish_ok_v15_round_trip() { - let msg = PublishOk { - request_id: Some(RequestId(7)), - forward: true, - subscriber_priority: Some(128), - group_order: Some(GroupOrder::Descending), - filter: Some(Filter::NextObject), - new_group: None, - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: PublishOk = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(7))); - assert!(decoded.forward); - assert_eq!(decoded.subscriber_priority, Some(128)); - } - - #[test] - fn test_publish_v17_round_trip() { - let msg = Publish { - request_id: RequestId(1), - track_namespace: Path::from_static("test/ns"), - track_name: "video".into(), - track_alias: 42, - largest_location: Some(Location { group: 10, object: 5 }), - forward: true, - properties: Properties { - group_order: Some(GroupOrder::Descending), - ..Default::default() - }, - subscriber_priority: None, - group_order: None, - filter: None, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: Publish = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test/ns"); - assert_eq!(decoded.track_name, "video"); - assert_eq!(decoded.track_alias, 42); - assert_eq!(decoded.largest_location, Some(Location { group: 10, object: 5 })); - assert!(decoded.forward); - } - - #[test] - fn test_publish_ok_v17_round_trip() { - let msg = PublishOk { - request_id: None, - forward: true, - subscriber_priority: Some(128), - group_order: Some(GroupOrder::Descending), - filter: Some(Filter::NextObject), - new_group: None, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: PublishOk = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, None); - assert!(decoded.forward); - assert_eq!(decoded.subscriber_priority, Some(128)); - } - fn publish_with_param(version: Version, key: u64, value: u64) -> Vec { let mut buf = Vec::new(); let w = &mut Encoder::new(&mut buf, version.into()); RequestId(1).encode(w, version).unwrap(); - if version == Version::Draft17 { - w.varint(0).unwrap(); - } encode_namespace(w, &Path::from_static("t")).unwrap(); w.string("v").unwrap(); w.varint(1).unwrap(); @@ -753,34 +420,29 @@ mod tests { fn publish_ok_with_param(version: Version, key: u64, value: u64) -> Vec { let mut buf = Vec::new(); let w = &mut Encoder::new(&mut buf, version.into()); - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - RequestId(1).encode(w, version).unwrap(); - } w.varint(1).unwrap(); w.varint(key).unwrap(); w.varint(value).unwrap(); buf } - /// SUBSCRIBER_PRIORITY is known in draft-16 but not defined for PUBLISH until draft-22. - /// OBJECT_DELIVERY_TIMEOUT is legal on PUBLISH again from draft-22. + /// SUBSCRIBER_PRIORITY is not defined for PUBLISH until draft-22. OBJECT_DELIVERY_TIMEOUT + /// is legal on PUBLISH again from draft-22. #[test] fn publish_parameters_follow_the_draft() { - decode_message::(&publish_with_param(Version::Draft16, 0x20, 1), Version::Draft16) - .expect("draft-16 ignores SUBSCRIBER_PRIORITY"); assert!(decode_message::(&publish_with_param(Version::Draft18, 0x20, 1), Version::Draft18).is_err()); decode_message::(&publish_with_param(Version::Draft22, 0x02, 1), Version::Draft22) .expect("draft-22 accepts OBJECT_DELIVERY_TIMEOUT"); } - /// PUBLISH_OK carries the delivery timeout through draft-18, and NEW_GROUP_REQUEST from - /// draft-16 through draft-18. Draft-22 no longer carries FORWARD. + /// PUBLISH_OK carries the delivery timeout and NEW_GROUP_REQUEST through draft-18. + /// Draft-22 no longer carries FORWARD. #[test] fn publish_ok_parameters_follow_the_draft() { decode_message::(&publish_ok_with_param(Version::Draft18, 0x02, 1), Version::Draft18) .expect("draft-18 accepts DELIVERY_TIMEOUT"); - decode_message::(&publish_ok_with_param(Version::Draft16, 0x32, 1), Version::Draft16) - .expect("draft-16 accepts NEW_GROUP_REQUEST"); + decode_message::(&publish_ok_with_param(Version::Draft18, 0x32, 1), Version::Draft18) + .expect("draft-18 accepts NEW_GROUP_REQUEST"); assert!( decode_message::(&publish_ok_with_param(Version::Draft22, 0x10, 0), Version::Draft22).is_err() ); @@ -788,23 +450,15 @@ mod tests { #[test] fn test_publish_done_registered_statuses_all_versions() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { + for version in [Version::Draft18] { for (status, expected) in [ (PublishDoneStatus::InternalError, 0x0), (PublishDoneStatus::TrackEnded, 0x2), ] { - assert_eq!(status.code(version), expected); + assert_eq!(status.code(), expected); let msg = PublishDone { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(7)), - status_code: status.code(version), + status_code: status.code(), stream_count: 5, reason_phrase: "done".into(), }; @@ -823,13 +477,12 @@ mod tests { #[test] fn publish_done_end_follows_the_status() { let done = |status_code| PublishDone { - request_id: None, status_code, stream_count: 0, reason_phrase: "".into(), }; - for version in [Version::Draft14, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { assert!(done(0x2).end(version).is_ok(), "{version:?}"); assert!( matches!(done(0x1).end(version), Err(crate::Error::Unauthorized)), @@ -880,14 +533,7 @@ mod tests { #[test] fn publish_priority_property_bytes_on_every_draft() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { let mut msg = Publish { request_id: RequestId(1), track_namespace: Path::from_static("ns"), @@ -903,24 +549,20 @@ mod tests { let baseline = encode_message(&msg, version); msg.properties.priority = Some(37); let encoded = encode_message(&msg, version); - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - assert_eq!(encoded, baseline, "{version}"); - } else { - assert_eq!(encoded, [baseline, vec![0x0e, 37]].concat(), "{version}"); - assert_eq!( - decode_message::(&encoded, version) - .unwrap() - .properties - .priority, - Some(37) - ); - } + assert_eq!(encoded, [baseline, vec![0x0e, 37]].concat(), "{version}"); + assert_eq!( + decode_message::(&encoded, version) + .unwrap() + .properties + .priority, + Some(37) + ); } } - /// GROUP_ORDER (0x22) is only a legal PUBLISH *message parameter* through draft-15; a - /// draft-16+ peer closes the session with PROTOCOL_VIOLATION when it sees one. The - /// publisher's preference belongs in the DEFAULT_PUBLISHER_GROUP_ORDER track property, + /// GROUP_ORDER (0x22) is not a legal PUBLISH *message parameter*; a peer closes the + /// session with PROTOCOL_VIOLATION when it sees one. The publisher's preference + /// belongs in the DEFAULT_PUBLISHER_GROUP_ORDER track property, /// which shares the number 0x22 in the separate property registry. #[test] fn test_publish_v18_group_order_is_a_property() { @@ -962,77 +604,9 @@ mod tests { assert_eq!(decoded.properties.group_order, Some(GroupOrder::Descending)); } - /// Draft-15 is the last version that takes it as a message parameter, and has no track - /// properties to put it in. - #[test] - fn test_publish_v15_group_order_is_a_parameter() { - let msg = Publish { - request_id: RequestId(1), - track_namespace: Path::from_static("ns"), - track_name: "video".into(), - track_alias: 42, - largest_location: None, - forward: true, - properties: Properties { - unknown: Default::default(), - dynamic_groups: false, - compress: false, - max_cache_duration: None, - timescale: None, - priority: None, - group_order: Some(GroupOrder::Descending), - }, - subscriber_priority: None, - group_order: None, - filter: None, - }; - - #[rustfmt::skip] - let expected = vec![ - 1, // request id - 1, // namespace tuple count - 2, b'n', b's', // "ns" - 5, b'v', b'i', b'd', b'e', b'o', // "video" - 42, // track alias - 2, // two message parameters - 0x10, 0x01, // FORWARD = true - 0x22, 0x02, // GROUP_ORDER = descending - ]; - assert_eq!(encode_message(&msg, Version::Draft15), expected); - - let decoded: Publish = decode_message(&expected, Version::Draft15).unwrap(); - assert_eq!(decoded.properties.group_order, Some(GroupOrder::Descending)); - } - - /// Draft-18 drops the required_request_id_delta varint (#1615). - /// For Publish (#1D) the field is a single zero varint = 1 byte. - #[test] - fn test_publish_v18_is_one_byte_shorter_than_v17() { - let msg = Publish { - request_id: RequestId(1), - track_namespace: Path::from_static("test/ns"), - track_name: "video".into(), - track_alias: 42, - largest_location: None, - forward: true, - properties: Properties { - group_order: Some(GroupOrder::Descending), - ..Default::default() - }, - subscriber_priority: None, - group_order: None, - filter: None, - }; - - let v17 = encode_message(&msg, Version::Draft17); - let v18 = encode_message(&msg, Version::Draft18); - assert_eq!(v17.len(), v18.len() + 1); - } - #[test] fn test_publish_ok_v18_round_trip() { let msg = PublishOk { - request_id: None, forward: true, subscriber_priority: Some(128), group_order: Some(GroupOrder::Descending), @@ -1043,7 +617,6 @@ mod tests { let encoded = encode_message(&msg, Version::Draft18); let decoded: PublishOk = decode_message(&encoded, Version::Draft18).unwrap(); - assert_eq!(decoded.request_id, None); assert!(decoded.forward); assert_eq!(decoded.subscriber_priority, Some(128)); } @@ -1051,10 +624,9 @@ mod tests { /// Draft-18 lets PUBLISH_OK carry EXPIRES; it is ignored rather than rejected. #[test] fn test_publish_ok_ignores_expires() { - let bytes = [0x04, 0x01, 0x08, 0x05]; + let bytes = [0x01, 0x08, 0x05]; - let decoded: PublishOk = decode_message(&bytes, Version::Draft16).unwrap(); - assert_eq!(decoded.request_id, Some(RequestId(4))); + let decoded: PublishOk = decode_message(&bytes, Version::Draft18).unwrap(); assert!(decoded.forward); } @@ -1076,7 +648,6 @@ mod tests { fn test_publish_ok_carries_new_group_request() { let version = Version::Draft18; let msg = PublishOk { - request_id: None, forward: true, subscriber_priority: None, group_order: None, @@ -1091,7 +662,6 @@ mod tests { fn test_publish_done_preserves_unknown_status() { const UNKNOWN: u64 = 0xface; let msg = PublishDone { - request_id: None, status_code: UNKNOWN, stream_count: 5, reason_phrase: "extension".into(), diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 03e22cb0485..fc0da3de030 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -1,6 +1,4 @@ -//! IETF moq-transport-14 publish namespace messages - -use std::borrow::Cow; +//! IETF moq-transport publish namespace messages use crate::{Epoch, Path, coding::*, ietf::RequestId}; @@ -65,9 +63,6 @@ impl PublishNamespace { /// which surfaces here as [`DecodeError::InvalidValue`]. pub fn decode_body(r: &mut Decoder<'_>, version: Version, negotiated: Negotiated) -> Result { let request_id = RequestId::decode(r, version)?; - if version == Version::Draft17 { - let _required_request_id_delta = r.varint()?; - } let track_namespace = decode_namespace(r)?; decode_params!(r, version, @@ -97,9 +92,6 @@ impl Message for PublishNamespace { fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { self.request_id.encode(w, version)?; - if version == Version::Draft17 { - w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) - } encode_namespace(w, &self.track_namespace)?; // ROUTE_COST is optional and absent means 0, so a free path sends nothing. let hops = self.params.cluster.as_ref().map(|advert| advert.hops.clone()); @@ -146,14 +138,11 @@ fn cluster_params( /// /// An omitted parameter keeps its value (moq-transport Section 9.5), so a cost that /// dropped to 0 is sent as an explicit 0, unlike the advertisement itself where absent -/// means 0. Draft-16+: the cluster extension negotiates on draft-17+ only, so draft-16 -/// carries only the token. +/// means 0. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublishNamespaceUpdate { /// The update's own Request ID; every REQUEST_UPDATE consumes one. pub request_id: RequestId, - /// Draft-16 only: the PUBLISH_NAMESPACE this updates, which later drafts name by stream. - pub namespace_request_id: Option, /// HOP_PATH, when the route changed. pub hops: Option, /// ROUTE_COST, when the cost changed. @@ -168,7 +157,6 @@ impl PublishNamespaceUpdate { pub fn between(request_id: RequestId, held: &cluster::Advert, next: &cluster::Advert) -> Self { Self { request_id, - namespace_request_id: None, hops: (held.hops != next.hops).then(|| next.hops.clone()), cost: (held.cost != next.cost).then_some(next.cost), authorization_token: None, @@ -188,20 +176,7 @@ impl Message for PublishNamespaceUpdate { const ID: u64 = 0x02; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - match version { - Version::Draft14 | Version::Draft15 => return Err(EncodeError::Version), - Version::Draft16 => { - self.request_id.encode(w, version)?; - self.namespace_request_id - .ok_or(EncodeError::Version)? - .encode(w, version)?; - } - Version::Draft17 => { - self.request_id.encode(w, version)?; - w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) - } - _ => self.request_id.encode(w, version)?, - } + self.request_id.encode(w, version)?; encode_params!(w, version, 0x03 => self.authorization_token.clone(), cluster::HOP_PATH => self.hops, @@ -211,16 +186,7 @@ impl Message for PublishNamespaceUpdate { } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let (request_id, namespace_request_id) = match version { - Version::Draft14 | Version::Draft15 => return Err(DecodeError::Version), - Version::Draft16 => (RequestId::decode(r, version)?, Some(RequestId::decode(r, version)?)), - Version::Draft17 => { - let request_id = RequestId::decode(r, version)?; - let _required_request_id_delta = r.varint()?; - (request_id, None) - } - _ => (RequestId::decode(r, version)?, None), - }; + let request_id = RequestId::decode(r, version)?; decode_params!(r, version, 0x03 => authorization_token: Vec, cluster::HOP_PATH => hops: Option, @@ -229,7 +195,6 @@ impl Message for PublishNamespaceUpdate { let authorization_token = super::token::single(authorization_token)?; Ok(Self { request_id, - namespace_request_id, hops, cost, authorization_token, @@ -276,161 +241,6 @@ pub(super) fn decode_advert_params( }) } -/// PublishNamespaceOk message (0x07) -#[derive(Clone, Debug)] -pub struct PublishNamespaceOk { - pub request_id: RequestId, -} - -impl Message for PublishNamespaceOk { - const ID: u64 = 0x07; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - Ok(Self { request_id }) - } -} - -/// PublishNamespaceError message (0x08) -#[derive(Clone, Debug)] -pub struct PublishNamespaceError<'a> { - pub request_id: RequestId, - pub error_code: u64, - pub reason_phrase: Cow<'a, str>, -} - -impl Message for PublishNamespaceError<'_> { - const ID: u64 = 0x08; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - w.varint(self.error_code)?; - w.string(&self.reason_phrase)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - let error_code = r.varint()?; - let reason_phrase = Cow::Owned(r.string()?); - - Ok(Self { - request_id, - error_code, - reason_phrase, - }) - } -} - -/// PublishNamespaceDone message (0x09) -/// v14/v15: uses track_namespace. v16: uses request_id. -#[derive(Clone, Debug)] -pub struct PublishNamespaceDone { - /// v14/v15: the namespace being unannounced - pub track_namespace: Path, - /// v16: the request ID of the original PublishNamespace - pub request_id: RequestId, -} - -impl Message for PublishNamespaceDone { - const ID: u64 = 0x09; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - match version { - Version::Draft14 | Version::Draft15 => { - encode_namespace(w, &self.track_namespace)?; - } - Version::Draft16 => { - self.request_id.encode(w, version)?; - } - _ => return Err(EncodeError::Version), - } - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - match version { - Version::Draft14 | Version::Draft15 => { - let track_namespace = decode_namespace(r)?; - Ok(Self { - track_namespace, - request_id: RequestId(0), - }) - } - Version::Draft16 => { - let request_id = RequestId::decode(r, version)?; - Ok(Self { - track_namespace: Path::default(), - request_id, - }) - } - _ => Err(DecodeError::Version), - } - } -} - -/// PublishNamespaceCancel message (0x0c) -/// v14/v15: uses track_namespace. v16: uses request_id. -#[derive(Clone, Debug)] -pub struct PublishNamespaceCancel<'a> { - /// v14/v15: the namespace being cancelled - pub track_namespace: Path, - /// v16: the request ID of the original PublishNamespace - pub request_id: RequestId, - pub error_code: u64, - pub reason_phrase: Cow<'a, str>, -} - -impl Message for PublishNamespaceCancel<'_> { - const ID: u64 = 0x0c; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - match version { - Version::Draft14 | Version::Draft15 => { - encode_namespace(w, &self.track_namespace)?; - } - Version::Draft16 => { - self.request_id.encode(w, version)?; - } - _ => { - return Err(EncodeError::Version); - } - } - w.varint(self.error_code)?; - w.string(&self.reason_phrase)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let (track_namespace, request_id) = match version { - Version::Draft14 | Version::Draft15 => { - let track_namespace = decode_namespace(r)?; - (track_namespace, RequestId(0)) - } - Version::Draft16 => { - let request_id = RequestId::decode(r, version)?; - (Path::default(), request_id) - } - _ => { - return Err(DecodeError::Version); - } - }; - let error_code = r.varint()?; - let reason_phrase = Cow::Owned(r.string()?); - Ok(Self { - track_namespace, - request_id, - error_code, - reason_phrase, - }) - } -} - #[cfg(test)] mod tests { use super::*; @@ -456,103 +266,12 @@ mod tests { authorization_token: None, }; - let encoded = encode_message(&msg, Version::Draft14); - let decoded: PublishNamespace = decode_message(&encoded, Version::Draft14).unwrap(); + let encoded = encode_message(&msg, Version::Draft18); + let decoded: PublishNamespace = decode_message(&encoded, Version::Draft18).unwrap(); assert_eq!(decoded.track_namespace.as_str(), "test/broadcast"); } - #[test] - fn test_announce_error() { - let msg = PublishNamespaceError { - request_id: RequestId(1), - error_code: 404, - reason_phrase: "Unauthorized".into(), - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: PublishNamespaceError = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.error_code, 404); - assert_eq!(decoded.reason_phrase, "Unauthorized"); - } - - #[test] - fn test_unannounce_v14() { - let msg = PublishNamespaceDone { - track_namespace: Path::from_static("old/stream"), - request_id: RequestId(0), - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: PublishNamespaceDone = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.track_namespace.as_str(), "old/stream"); - } - - #[test] - fn test_unannounce_v16() { - let msg = PublishNamespaceDone { - track_namespace: Path::default(), - request_id: RequestId(42), - }; - - let encoded = encode_message(&msg, Version::Draft16); - let decoded: PublishNamespaceDone = decode_message(&encoded, Version::Draft16).unwrap(); - - assert_eq!(decoded.request_id, RequestId(42)); - } - - #[test] - fn test_announce_cancel_v14() { - let msg = PublishNamespaceCancel { - track_namespace: Path::from_static("canceled"), - request_id: RequestId(0), - error_code: 1, - reason_phrase: "Shutdown".into(), - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: PublishNamespaceCancel = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.track_namespace.as_str(), "canceled"); - assert_eq!(decoded.error_code, 1); - assert_eq!(decoded.reason_phrase, "Shutdown"); - } - - #[test] - fn test_announce_cancel_v16() { - let msg = PublishNamespaceCancel { - track_namespace: Path::default(), - request_id: RequestId(7), - error_code: 1, - reason_phrase: "Shutdown".into(), - }; - - let encoded = encode_message(&msg, Version::Draft16); - let decoded: PublishNamespaceCancel = decode_message(&encoded, Version::Draft16).unwrap(); - - assert_eq!(decoded.request_id, RequestId(7)); - assert_eq!(decoded.error_code, 1); - assert_eq!(decoded.reason_phrase, "Shutdown"); - } - - #[test] - fn test_publish_namespace_v17_round_trip() { - let msg = PublishNamespace { - request_id: RequestId(5), - track_namespace: Path::from_static("v17/broadcast"), - params: Default::default(), - authorization_token: None, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: PublishNamespace = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, RequestId(5)); - assert_eq!(decoded.track_namespace.as_str(), "v17/broadcast"); - } - #[test] fn test_publish_namespace_v18_round_trip() { let msg = PublishNamespace { @@ -569,50 +288,6 @@ mod tests { assert_eq!(decoded.track_namespace.as_str(), "v18/broadcast"); } - #[test] - fn test_publish_namespace_done_v18_rejected() { - let msg = PublishNamespaceDone { - track_namespace: Path::default(), - request_id: RequestId(42), - }; - - let mut buf = Vec::new(); - assert!( - msg.encode_msg(&mut Encoder::new(&mut buf, Version::Draft18.into()), Version::Draft18) - .is_err() - ); - } - - #[test] - fn test_publish_namespace_done_v17_rejected() { - let msg = PublishNamespaceDone { - track_namespace: Path::default(), - request_id: RequestId(42), - }; - - let mut buf = Vec::new(); - assert!( - msg.encode_msg(&mut Encoder::new(&mut buf, Version::Draft17.into()), Version::Draft17) - .is_err() - ); - } - - #[test] - fn test_publish_namespace_cancel_v17_rejected() { - let msg = PublishNamespaceCancel { - track_namespace: Path::default(), - request_id: RequestId(7), - error_code: 1, - reason_phrase: "Shutdown".into(), - }; - - let mut buf = Vec::new(); - assert!( - msg.encode_msg(&mut Encoder::new(&mut buf, Version::Draft17.into()), Version::Draft17) - .is_err() - ); - } - fn hop_path(ids: &[u64]) -> cluster::HopPath { let hops: Vec = ids.iter().map(|&id| crate::Hop::new(id).unwrap()).collect(); cluster::HopPath::new(crate::Hops::try_from(hops).unwrap()) @@ -635,7 +310,7 @@ mod tests { assert_eq!(msg.hops, None, "an unchanged path is omitted"); assert_eq!(msg.cost, Some(0), "a cost of 0 is sent explicitly"); - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let encoded = encode_message(&msg, version); let decoded: PublishNamespaceUpdate = decode_message(&encoded, version).unwrap(); assert_eq!(decoded, msg, "{version}"); @@ -654,36 +329,6 @@ mod tests { assert_eq!(decoded.apply(&held), rerouted); } - /// Drafts 14-15 have no update for an advertisement, and draft-16's names the one it - /// updates. - #[test] - fn update_names_the_advertisement_on_draft_16() { - let mut msg = PublishNamespaceUpdate { - request_id: RequestId(2), - namespace_request_id: None, - hops: None, - cost: None, - authorization_token: Some(super::super::token::RequestToken(crate::setup::Token { - kind: 0, - value: b"jwt".to_vec(), - })), - }; - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let mut buf = Vec::new(); - assert!( - msg.encode_msg(&mut Encoder::new(&mut buf, version.into()), version) - .is_err(), - "{version:?}" - ); - } - assert!(decode_message::(&[0x02, 0x00], Version::Draft15).is_err()); - - msg.namespace_request_id = Some(RequestId(1)); - let decoded: PublishNamespaceUpdate = - decode_message(&encode_message(&msg, Version::Draft16), Version::Draft16).unwrap(); - assert_eq!(decoded, msg); - } - #[test] fn test_announce_rejects_parameters() { #[rustfmt::skip] @@ -693,7 +338,7 @@ mod tests { 0x01, // INVALID: num_params = 1 ]; - let result: Result = decode_message(&invalid_bytes, Version::Draft14); + let result: Result = decode_message(&invalid_bytes, Version::Draft18); assert!(result.is_err()); } } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index fac8175b32c..ebf1e666afc 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -55,7 +55,7 @@ fn track_properties( version: Version, negotiated: bool, ) -> Result { - let compressed = info.compress && negotiated && super::flate::supported(version); + let compressed = info.compress && negotiated; super::flate::egress(info, compressed)?; let mut plain = info.clone(); plain.compress = compressed; @@ -67,14 +67,10 @@ fn track_properties( return Ok(ietf::Properties::restated(info)); }; let mut unknown = track.values.clone(); - let block = !matches!(version, Version::Draft14 | Version::Draft15); - if block && track.immutable().is_some() && !super::object::same_bytes(track.version, version) { - return Err(Error::Unsupported); - } // Between draft-18 and draft-22, forwarding 0x06 would give it a different meaning, as for object // properties. A mutable one is dropped; a sealed one is refused. if super::object::has_delivery_timeout(track.version) != super::object::has_delivery_timeout(version) { - if block && track.sealed().any(|id| id == 0x06) { + if track.sealed().any(|id| id == 0x06) { return Err(Error::Unsupported); } unknown.retain(|property| property.id != 0x06); @@ -121,23 +117,16 @@ enum FetchPrior { } /// Where a fetch object sits, given the group written before its own, if any. -fn fetch_position( - version: Version, - prior_group: Option, - group: u64, - prior: Option, - object: u64, -) -> Result { +fn fetch_position(prior_group: Option, group: u64, prior: Option, object: u64) -> Result { match (prior_group, prior) { - // Draft-18 and later spell a later group as its distance from the prior one, less one. - (Some(prior_group), None) => Ok(FetchPrior::Group(match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 => group, - _ => group + // A later group is spelled as its distance from the prior one, less one. + (Some(prior_group), None) => Ok(FetchPrior::Group( + group .abs_diff(prior_group) .checked_sub(1) .ok_or(Error::MalformedTrack)?, - })), - _ => fetch_prior(version, prior, object), + )), + _ => fetch_prior(prior, object), } } @@ -338,7 +327,7 @@ struct FetchObjectInfo<'a> { source: Option, } -fn fetch_prior(version: Version, prior: Option, object: u64) -> Result { +fn fetch_prior(prior: Option, object: u64) -> Result { let Some(prior) = prior else { return Ok(FetchPrior::None); }; @@ -346,21 +335,14 @@ fn fetch_prior(version: Version, prior: Option, object: u64) -> Result 0) .ok_or(Error::MalformedTrack)?; - Ok(FetchPrior::Same((step != 1).then_some(match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 => object, - _ => step, - }))) + Ok(FetchPrior::Same((step != 1).then_some(step))) } impl FetchedGroup { - /// Keep the END_OF_GROUP object only where `version` can write it. Only draft-14 and 15 - /// FETCH objects carry a status. Later drafts end each group at its last frame, which is - /// all a receiver can account for. - fn for_version(mut self, version: Version) -> Self { - self.end_object = match version { - Version::Draft14 | Version::Draft15 => self.end_object.map(|end| status_fields(end, version)), - _ => None, - }; + /// Drop the END_OF_GROUP object: FETCH objects carry no status, so each group ends at its + /// last frame, which is all a receiver can account for. + fn without_status(mut self) -> Self { + self.end_object = None; self } @@ -601,38 +583,18 @@ fn jitter(delay: Duration) -> Duration { /// Where one announce loop's advertisements go. enum Target { - /// Inline NAMESPACE entries on the SUBSCRIBE_NAMESPACE stream that asked for them - /// (draft-16+). + /// Inline NAMESPACE entries on the SUBSCRIBE_NAMESPACE stream that asked for them. Inline(Stream), - /// Each advertisement on its own PUBLISH_NAMESPACE request. Unsolicited when there - /// is no stream; draft-14/15 answer a SUBSCRIBE_NAMESPACE this way, since they - /// predate NAMESPACE, and hold onto its stream to end with the subscription. - Requests(Option>), + /// Each advertisement on its own unsolicited PUBLISH_NAMESPACE request. + Requests, } impl Target { /// The SUBSCRIBE_NAMESPACE stream this loop answers, if any. fn stream(&mut self) -> Option<&mut Stream> { match self { - Self::Inline(stream) | Self::Requests(Some(stream)) => Some(stream), - Self::Requests(None) => None, - } - } - - /// Ready when the peer ends this loop by closing the stream it asked on. - /// - /// An unsolicited loop has no stream of its own to watch and parks here: the - /// session driver polling it is what drops it when the session ends. - fn poll_closed( - &mut self, - finished: &mut bool, - goaways: &mut super::request_stream::Messages, - version: Version, - cx: &mut std::task::Context<'_>, - ) -> Poll> { - match self.stream() { - Some(stream) => super::request_stream::poll_cancel(stream, finished, goaways, version, cx), - None => Poll::Pending, + Self::Inline(stream) => Some(stream), + Self::Requests => None, } } @@ -646,14 +608,6 @@ impl Target { ) -> Poll { use super::request_stream::{NamespaceUpdate, Next}; - if matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) { - return self - .poll_closed(finished, goaways, version, cx) - .map(NamespaceEvent::Closed); - } let Some(stream) = self.stream() else { return Poll::Pending; }; @@ -747,12 +701,12 @@ enum Served { /// The track and any fill finished, or serving ended with this error. Track(Result<(), Error>), /// An update could not be applied, its parameters unsupported or its renewal refused - /// (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. + /// (draft-18 section 10.9.1). The update was already answered, and PUBLISH_DONE says + /// UPDATE_FAILED. UpdateFailed(Error), - /// FORWARD=0 paused the subscription, which stays established. The update setting it, if - /// any, is not answered yet; none when a SUBSCRIBE started paused. - Paused(Option), + /// FORWARD=0 paused the subscription, which stays established. True when an update set + /// it and is not answered yet, false when a SUBSCRIBE started paused. + Paused(bool), } /// What reading a subscriber's next message on its request stream found. @@ -779,11 +733,8 @@ enum Subscribed { impl Served { /// The PUBLISH_DONE status and reason this end reports. - fn status(&self, version: Version) -> (ietf::PublishDoneStatus, &'static str) { + fn status(&self) -> (ietf::PublishDoneStatus, &'static str) { match self { - // Draft 14 neither answers an update nor registers UPDATE_FAILED, so the - // PUBLISH_DONE is the only place the subscriber learns why its renewal failed. - Self::UpdateFailed(err) if version == Version::Draft14 => Self::Track(Err(err.clone())).status(version), Self::Track(Ok(())) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), Self::Track(Err( Error::Unauthorized @@ -824,9 +775,9 @@ enum NamespaceEvent { /// What came back for a PUBLISH_NAMESPACE or an update in flight. enum Answer { - /// The peer's REQUEST_OK or REQUEST_ERROR, or a draft-16 PUBLISH_NAMESPACE_CANCEL. + /// The peer's REQUEST_OK or REQUEST_ERROR. Message(super::request_stream::Framed), - /// The peer ended the request instead, a cancel from draft 17. + /// The peer ended the request instead, which cancels it. Ended, /// Nothing before its deadline. Late, @@ -990,16 +941,12 @@ impl Publisher { self } - /// What the peer declared in its SETUP, or the default (extension off) on a version - /// that cannot negotiate it. + /// What the peer declared in its SETUP. /// /// Blocks until the peer's SETUP arrives, because the extension changes the NAMESPACE /// encoding: nothing can be advertised until we know whether the peer speaks it. fn poll_peer(&self, waiter: &kio::Waiter) -> Poll { - match cluster::supported(self.version) { - true => self.peer_setup.poll_get(waiter).map(|peer| peer.cluster), - false => Poll::Ready(cluster::Peer::default()), - } + self.peer_setup.poll_get(waiter).map(|peer| peer.cluster) } /// Await [`Self::poll_peer`]. @@ -1114,7 +1061,7 @@ impl Publisher { if !data.is_empty() { return Err(Error::WrongSize); } - return Ok(this.reject_token(stream, request::Kind::Subscribe, body.clone(), err, serve)); + return Ok(this.reject_token(stream, request::Kind::Subscribe, err, serve)); } msg => msg?, }; @@ -1146,7 +1093,7 @@ impl Publisher { if !data.is_empty() { return Err(Error::WrongSize); } - return Ok(this.reject_token(stream, request::Kind::Fetch, body.clone(), err, serve)); + return Ok(this.reject_token(stream, request::Kind::Fetch, err, serve)); } msg => msg?, }; @@ -1166,47 +1113,21 @@ impl Publisher { } .maybe_boxed() } - // Draft-18 SUBSCRIBE_NAMESPACE (0x50) and the legacy 0x11 message decode - // to the same request_id + namespace. We never send PUBLISH, so a legacy - // request for PUBLISH alone is refused, and one for both gets only NAMESPACE. - ietf::SubscribeNamespace::ID | ietf::SubscribeNamespaceLegacy::ID => { - let decoded = if id == ietf::SubscribeNamespace::ID { - ietf::SubscribeNamespace::decode_msg(&mut data, this.version) - .map(|msg| (msg, ietf::SubscribeOptions::Namespace)) - } else { - ietf::SubscribeNamespaceLegacy::decode_msg(&mut data, this.version).map(|legacy| { - let msg = ietf::SubscribeNamespace { - request_id: legacy.request_id, - namespace: legacy.namespace, - hidden: legacy.hidden, - }; - (msg, legacy.subscribe_options) - }) - }; - let (msg, options) = match decoded { + ietf::SubscribeNamespace::ID => { + let msg = match ietf::SubscribeNamespace::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)); + return Ok(this.reject_token(stream, kind, err, serve)); } decoded => decoded?, }; if !data.is_empty() { return Err(Error::WrongSize); } - if options == ietf::SubscribeOptions::Publish { - let request_id = msg.request_id; - return Ok(async move { - let reason = "SUBSCRIBE_NAMESPACE for PUBLISH is not supported"; - if let Err(err) = this.reject_namespace_request(stream, request_id, reason).await { - tracing::debug!(%err, "subscribe_namespace refusal failed"); - } - } - .maybe_boxed()); - } tracing::debug!(message = ?msg, "received subscribe_namespace"); let gate = crate::auth::Gate::new( this.auth.clone(), @@ -1227,7 +1148,7 @@ impl Publisher { } .maybe_boxed() } - ietf::SubscribeTracks::ID if super::subscribe_tracks::has_subscribe_tracks(this.version) => { + ietf::SubscribeTracks::ID => { 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. @@ -1235,7 +1156,7 @@ impl Publisher { return Err(Error::WrongSize); } let kind = request::Kind::SubscribeNamespace; - return Ok(this.reject_token(stream, kind, body.clone(), err, serve)); + return Ok(this.reject_token(stream, kind, err, serve)); } msg => msg?, }; @@ -1257,7 +1178,7 @@ impl Publisher { if !data.is_empty() { return Err(Error::WrongSize); } - return Ok(this.reject_token(stream, request::Kind::TrackStatus, body.clone(), err, serve)); + return Ok(this.reject_token(stream, request::Kind::TrackStatus, err, serve)); } msg => msg?, }; @@ -1294,14 +1215,13 @@ impl Publisher { &self, stream: Stream, kind: request::Kind, - body: ietf::Body, err: DecodeError, serve: Option, ) -> MaybeSendBox<'static, ()> { let version = self.version; async move { let _serve = serve; - if let Err(err) = super::token::reject(stream, kind, body, err, version).await { + if let Err(err) = super::token::reject(stream, kind, err, version).await { tracing::debug!(%err, "token refusal failed"); } } @@ -1345,7 +1265,6 @@ impl Publisher { 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, @@ -1384,7 +1303,7 @@ impl Publisher { }; if token.is_none() && !gate.allows() { let err = Error::Unauthorized; - return self.reject_subscribe(stream, request_id, &err, "not granted").await; + return self.reject_subscribe(stream, &err, "not granted").await; } // Legal requests we can't honor are refused one at a time, never by closing the // session. Serving a Range Filter unfiltered would deliver objects the subscriber @@ -1393,7 +1312,6 @@ impl Publisher { return super::request_stream::reject( stream, ietf::RequestError { - request_id: None, error_code: request::INVALID_FILTER, reason_phrase: "Range Filters exceed MAX_FILTER_RANGES=0".into(), retry_interval: 0, @@ -1438,9 +1356,7 @@ impl Publisher { match admitted { Ok(grant) => (gate.named(), Some(grant)), Err(refusal) => { - return self - .reject_subscribe(stream, request_id, &refusal.error, &refusal.reason) - .await; + return self.reject_subscribe(stream, &refusal.error, &refusal.reason).await; } } } @@ -1506,7 +1422,7 @@ impl Publisher { // Timestamp's units from TRACK_STATUS, so the opt-out strips nothing from the // objects. let timescale = track_serve.timescale; - let mut properties = match track_properties( + let properties = match track_properties( track_serve.track.info(), msg.properties_wanted, self.version, @@ -1515,12 +1431,6 @@ impl Publisher { Ok(properties) => properties, Err(err) => return Ok(Subscribed::Refused(err.to_string(), err)), }; - // Through draft-15 this restates the order the subscription is served in. - // Later drafts carry the publisher's default instead. - if matches!(self.version, Version::Draft14 | Version::Draft15) { - properties.group_order = Some(msg.group_order.to_order().into()); - } - // Draft-22 replaced joining FETCH with subscription fills. Older drafts save // the same boundary used by the subscription so the two streams never overlap, // again on every resume. @@ -1563,10 +1473,6 @@ impl Publisher { stream .writer .encode_message(&ietf::SubscribeOk { - request_id: match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(request_id), - _ => None, - }, track_alias: request_id.0, // The subscription floor and FETCH/FILL cap use this same snapshot. largest: edge.largest, @@ -1600,12 +1506,12 @@ impl Publisher { false => { // Unpolled, it would still hold the track it serves from. drop(fill); - Some(Served::Paused(None)) + Some(Served::Paused(false)) } }; // A pause drops the subscription, and its demand with it; a resume subscribes // again as a new SUBSCRIBE would, so nothing carries across but the request. - while let Some(Served::Paused(update)) = served { + while let Some(Served::Paused(answer)) = served { // What an update changed while forwarding outlives the pause. let applied = track_serve.track.subscription(); msg.subscriber_priority = super::priority::to_wire(applied.priority); @@ -1614,21 +1520,19 @@ impl Publisher { track_serve.close_groups(); self.pause_join(_join.as_ref()); drop(track_serve); - if let Some(update) = update - && self.version != Version::Draft14 - { - self.write_request_ok(&mut stream.writer, update, None).await?; + if answer { + self.write_request_ok(&mut stream.writer, None).await?; } - let resume = match self + match self .wait_resume(&mut stream, &mut request_finished, &mut goaways, &mut msg) .await { - Ok(resume) => resume, + Ok(()) => {} Err(served) => { let served = served.unwrap_or(Served::Track(Ok(()))); return Ok(Subscribed::Served(served, opened.load(Ordering::Relaxed))); } - }; + } let resumed = match broadcast.track(&msg.track_name) { Ok(track) => self .subscribe_track(&mut stream, &track, &msg) @@ -1648,10 +1552,8 @@ impl Publisher { Ok(resumed) => resumed, // The resuming update is owed an answer, so it fails. Err(err) => { - if self.version != Version::Draft14 { - self.write_subscribe_error(&mut stream.writer, resume, &err, "resume failed") - .await?; - } + self.write_subscribe_error(&mut stream.writer, &err, "resume failed") + .await?; return Ok(Subscribed::Served( Served::UpdateFailed(err), opened.load(Ordering::Relaxed), @@ -1660,9 +1562,7 @@ impl Publisher { }; track_serve.opened = opened.clone(); save_join(edge.largest, &track, &track_serve.ranking); - if self.version != Version::Draft14 { - self.write_request_ok(&mut stream.writer, resume, edge.largest).await?; - } + self.write_request_ok(&mut stream.writer, edge.largest).await?; served = self .run_subscription( &mut stream, @@ -1714,7 +1614,7 @@ impl Publisher { let (served, streams) = match subscribed { Ok(Ok(Subscribed::Served(served, streams))) => (served, streams), Ok(Ok(Subscribed::Refused(reason, err))) => { - return self.reject_subscribe(stream, request_id, &err, &reason).await; + return self.reject_subscribe(stream, &err, &reason).await; } // Nobody is left to answer. Ok(Ok(Subscribed::Withdrawn)) => { @@ -1725,14 +1625,11 @@ impl Publisher { Err(refusal) => match acked { Some(opened) => (Served::Track(Err(refusal.error)), opened.load(Ordering::Relaxed)), None => { - return self - .reject_subscribe(stream, request_id, &refusal.error, &refusal.reason) - .await; + return self.reject_subscribe(stream, &refusal.error, &refusal.reason).await; } }, }; - self.write_publish_done(stream, request_id, served, streams, Some(&credit)) - .await + self.write_publish_done(stream, served, streams, Some(&credit)).await } } @@ -1779,8 +1676,8 @@ impl Publisher { .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 + /// Send the END_OF_TRACK a subscription that ran to the track's end still owes: + /// PUBLISH_DONE carries no end location, 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( @@ -1821,12 +1718,11 @@ impl Publisher { async fn write_publish_done( &self, mut stream: Stream, - request_id: RequestId, served: Served, streams: u64, credit: Option<&kio::Producer<()>>, ) -> Result<(), Error> { - let (status, reason) = served.status(self.version); + let (status, reason) = served.status(); let res = match served { Served::Track(res) => res, Served::UpdateFailed(err) => Err(err), @@ -1836,11 +1732,7 @@ impl Publisher { let _ = stream .writer .encode_message(&ietf::PublishDone { - request_id: match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(request_id), - _ => None, - }, - status_code: status.code(self.version), + status_code: status.code(), stream_count: streams, reason_phrase: reason.into(), }) @@ -1880,7 +1772,7 @@ impl Publisher { // against serving (never a bare await), so media keeps flowing while the acceptor is // slow; the caller's guard still ends the request when the old grant does. While a verdict is // pending the loop keeps reading so a cancellation still ends the request, stashing every - // further update, token or not, to handle once the verdict resolves: draft-17+ answers + // further update, token or not, to handle once the verdict resolves: answers // carry no Request ID, so they must go out in the order the updates arrived. The stash is // a FIFO bounded by MAX_REQUEST_UPDATES (counting the one being verified). let mut renewal = request_update::Renewal::::default(); @@ -1907,9 +1799,7 @@ impl Publisher { if session.poll_closed(&mut cx).is_ready() { return Poll::Ready(Turn::Cancelled); } - if !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) - && stream.writer.poll_closed(&mut cx).is_ready() - { + if stream.writer.poll_closed(&mut cx).is_ready() { return Poll::Ready(Turn::Cancelled); } if let Poll::Ready(renewed) = renewal.poll(waiter) { @@ -1932,7 +1822,7 @@ impl Publisher { return Poll::Ready(Turn::Update(update)); } if waiting.is_none() { - match self.poll_subscribe_update(stream, finished, goaways, &mut cx) { + 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)), @@ -1961,10 +1851,6 @@ impl Publisher { }) .await; - // Draft 14 answers no update. Drafts 15 and 16 answer on the control stream, - // naming the update's own Request ID; later drafts name none. - let answer = self.version != Version::Draft14; - match turn { // A malformed update, such as one whose AUTHORIZATION TOKEN names an alias, // closes the session rather than only ending this subscription, as the same @@ -1981,32 +1867,23 @@ impl Publisher { } Turn::Served(res) => return Some(Served::Track(res)), Turn::Cancelled => return None, - Turn::Renewal(request_update::Verdict { - verdict, - grant: res, - request_id: rid, - }) => { + Turn::Renewal(request_update::Verdict { verdict, grant: res }) => { // Only a token-authorized request starts a renewal. let rg = request_grant.as_ref().expect("a renewal without a request grant"); // 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, renewal_largest.take()) - .await - { + if let Err(err) = self.write_request_ok(&mut stream.writer, renewal_largest.take()).await { return Some(Served::Track(Err(err))); } continue; }; - // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused or uncovered + // Per draft-18 section 10.9.1, a refused or uncovered // renewal ends only this subscription: REQUEST_ERROR, then PUBLISH_DONE // UPDATE_FAILED. The session stays up and the old grant does not survive. - if answer - && let Err(err) = self - .write_subscribe_error(&mut stream.writer, rid, &refusal.error, &refusal.reason) - .await + if let Err(err) = self + .write_subscribe_error(&mut stream.writer, &refusal.error, &refusal.reason) + .await { return Some(Served::Track(Err(err))); } @@ -2128,9 +2005,8 @@ impl Publisher { } range.apply(&mut subscription); let _ = subscribed.update(subscription); - // A Timestamp goes out wherever the draft can declare its units and the track has them. - // Drafts 14-16 never write TIMESCALE, and an untimed track declares none, so their - // objects stay unstamped. + // A Timestamp goes out wherever the track has units. An untimed track declares none, + // so its objects stay unstamped. let timescale = subscribed.info().timescale; // Serve the track while reading updates; only abrupt closure cancels on draft-22. A @@ -2156,8 +2032,8 @@ impl Publisher { Some(Ok((serve, edge))) } - /// Hold a paused SUBSCRIBE, answering its updates, until one resumes it: the Request ID of - /// that update, or how the request ended (`None` when cancelled). + /// Hold a paused SUBSCRIBE, answering its updates, until one resumes it, or return how the + /// request ended (`None` when cancelled). /// /// A paused subscription holds nothing upstream, so only its priority and max age change /// while it waits; anything else fails the update, ending the request. @@ -2167,7 +2043,7 @@ impl Publisher { finished: &mut bool, goaways: &mut super::request_stream::Messages, msg: &mut ietf::Subscribe<'_>, - ) -> Result> { + ) -> Result<(), Option> { let mut session = self.session.clone(); loop { let read = kio::wait(|waiter| { @@ -2175,12 +2051,10 @@ impl Publisher { if session.poll_closed(&mut cx).is_ready() { return Poll::Ready(UpdateRead::Cancelled); } - if !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) - && stream.writer.poll_closed(&mut cx).is_ready() - { + if stream.writer.poll_closed(&mut cx).is_ready() { return Poll::Ready(UpdateRead::Cancelled); } - self.poll_subscribe_update(stream, finished, goaways, &mut cx) + self.poll_update(stream, finished, goaways, &mut cx) }) .await; let update = match read { @@ -2209,16 +2083,13 @@ impl Publisher { None => None, }; if let Some(err) = refused { - if self.version != Version::Draft14 { - self.write_subscribe_error( - &mut stream.writer, - update.request_id, - &err, - "REQUEST_UPDATE parameters not supported while paused", - ) - .await - .map_err(|err| Some(Served::Track(Err(err))))?; - } + self.write_subscribe_error( + &mut stream.writer, + &err, + "REQUEST_UPDATE parameters not supported while paused", + ) + .await + .map_err(|err| Some(Served::Track(Err(err))))?; return Err(Some(Served::UpdateFailed(Error::Unsupported))); } if let Some(priority) = update.priority { @@ -2228,44 +2099,15 @@ impl Publisher { msg.max_age = Some(max_age); } if update.forward == Some(true) { - return Ok(update.request_id); - } - if self.version != Version::Draft14 { - self.write_request_ok(&mut stream.writer, update.request_id, None) - .await - .map_err(|err| Some(Served::Track(Err(err))))?; + return Ok(()); } + self.write_request_ok(&mut stream.writer, None) + .await + .map_err(|err| Some(Served::Track(Err(err))))?; } } - /// Read a SUBSCRIBE's next REQUEST_UPDATE: from the control stream's follow-ups through - /// draft 16, where UNSUBSCRIBE cancels, or from its request stream after. - fn poll_subscribe_update( - &self, - stream: &mut Stream, - finished: &mut bool, - goaways: &mut super::request_stream::Messages, - cx: &mut std::task::Context<'_>, - ) -> Poll { - if *finished { - return Poll::Pending; - } - if !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - return self.poll_update(stream, finished, goaways, cx); - } - use super::request_stream::{FollowUp, Update}; - match ready!(stream.reader.poll_decode_maybe::(cx)) { - Ok(Some(FollowUp::Update(body))) => Poll::Ready(match Update::decode_legacy(&body, self.version) { - Ok(update) => UpdateRead::Update(update), - Err(err) => UpdateRead::Malformed(err), - }), - // UNSUBSCRIBE, or the control stream going away. - Ok(Some(FollowUp::End) | None) => Poll::Ready(UpdateRead::Cancelled), - Err(err) => Poll::Ready(UpdateRead::Malformed(err)), - } - } - - /// Read the requester's next REQUEST_UPDATE on a draft-17+ request stream, admitting its + /// Read the requester's next REQUEST_UPDATE on its request stream, admitting its /// Request ID. Pending once the requester finished sending where a FIN does not cancel. fn poll_update( &self, @@ -2319,9 +2161,6 @@ impl Publisher { renewal: &mut request_update::Renewal, update: super::request_stream::Update, ) -> Result, Served> { - // Draft 14 answers no update. Drafts 15 and 16 answer on the control stream, naming the - // update's own Request ID; later drafts name none. - let answer = self.version != Version::Draft14; let failed = |err| Served::Track(Err(err)); // A token renews a token-authorized request. Where tokens decide, one arriving on a // request admitted without one is refused rather than taken unread, so every token this @@ -2332,9 +2171,7 @@ impl Publisher { Some(err) => Some(("REQUEST_UPDATE parameters not supported", err)), // A SUBSCRIBE serves the range it asked for. A subscription we initiated with // PUBLISH decodes those parameters itself. - None if (update.fill.is_some() && !Filter::is_draft22(self.version)) - || (update.filter.is_some() && self.version == Version::Draft14) => - { + None if update.fill.is_some() && !Filter::is_draft22(self.version) => { Some(("REQUEST_UPDATE parameters not supported", Error::Unsupported)) } None if update.authorization_token.is_some() @@ -2363,12 +2200,10 @@ impl Publisher { None => None, }; if let Some((reason, err)) = refused { - if answer { - self.write_subscribe_error(&mut stream.writer, update.request_id, &err, reason) - .await - .map_err(failed)?; - } - // Either way the update failed, which ends the subscription. + self.write_subscribe_error(&mut stream.writer, &err, reason) + .await + .map_err(failed)?; + // The update failed, which ends the subscription. return Err(Served::UpdateFailed(Error::Unsupported)); } let fill = if update.filter.is_some() || update.fill.is_some() { @@ -2392,38 +2227,26 @@ impl Publisher { } // The caller stops forwarding, then answers. if update.forward == Some(false) { - return Err(Served::Paused(Some(update.request_id))); + return Err(Served::Paused(true)); } if let (Some(token), Some(grant)) = (update.authorization_token, request_grant) { - renewal.verify(grant.verify_renewal(&self.auth, token.0), update.request_id); + renewal.verify(grant.verify_renewal(&self.auth, token.0)); return Ok(fill); } - if answer { - self.write_request_ok(&mut stream.writer, update.request_id, largest) - .await - .map_err(failed)?; - } + self.write_request_ok(&mut stream.writer, largest) + .await + .map_err(failed)?; Ok(fill) } /// 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. async fn write_request_ok( &self, writer: &mut Writer, - request_id: RequestId, largest: Option, ) -> Result<(), Error> { - writer - .encode_message(&ietf::RequestOk { - largest, - request_id: matches!(self.version, Version::Draft15 | Version::Draft16).then_some(request_id), - active: None, - }) - .await + writer.encode_message(&ietf::RequestOk { largest, active: None }).await } /// Reject a SUBSCRIBE, ending the request stream. @@ -2435,12 +2258,10 @@ impl Publisher { async fn reject_subscribe( &self, mut stream: Stream, - request_id: RequestId, err: &Error, reason: &str, ) -> Result<(), Error> { - self.write_subscribe_error(&mut stream.writer, request_id, err, reason) - .await?; + self.write_subscribe_error(&mut stream.writer, err, reason).await?; // The peer dropping the stream once it has the rejection is a normal end, not our failure. let _ = stream.writer.close().await; @@ -2451,12 +2272,10 @@ impl Publisher { async fn write_subscribe_error( &self, writer: &mut Writer, - request_id: RequestId, err: &Error, reason: &str, ) -> Result<(), Error> { - super::request_stream::write_error(writer, request::Kind::Subscribe, request_id, err, reason, self.version) - .await + super::request_stream::write_error(writer, request::Kind::Subscribe, err, reason, self.version).await } /// Serve a draft-22 fill on its own fetch stream: the requested range, read from the @@ -2618,7 +2437,7 @@ impl Publisher { /// The first object carries its absolute Group and Object IDs plus the priority; /// every later one inherits them and increments the Object ID, so only the /// properties (the timestamp) and the payload go on the wire. A fetch object has no - /// status field from draft-16 onward; older drafts require Normal for an empty object. + /// status field. async fn write_fetch_group( stream: &mut Writer, Version>, mut group: group::Consumer, @@ -2684,7 +2503,7 @@ impl Publisher { FetchObjectInfo { sequence, object: index, - prior: fetch_position(version, *prior_group, sequence, prior, index)?, + prior: fetch_position(*prior_group, sequence, prior, index)?, timestamp: frame.timestamp, inflated: frame.inflated, timescale, @@ -2694,7 +2513,7 @@ impl Publisher { }, ) .await?; - Self::write_fetch_payload(stream, frame.frame.payload, version).await?; + Self::write_fetch_payload(stream, frame.frame.payload).await?; prior = Some(index); } index += 1; @@ -2732,7 +2551,7 @@ impl Publisher { FetchObjectInfo { sequence, object: index, - prior: fetch_position(version, *prior_group, sequence, prior, index)?, + prior: fetch_position(*prior_group, sequence, prior, index)?, timestamp: frame.timestamp, inflated: frame.inflated, timescale, @@ -2746,9 +2565,6 @@ impl Publisher { index += 1; stream.varint(frame.size).await?; - if frame.size == 0 && matches!(version, Version::Draft14 | Version::Draft15) { - stream.varint(0).await?; - } loop { let chunk = { let mut closed = std::pin::pin!(stream.closed()); @@ -2798,8 +2614,7 @@ impl Publisher { source, } = header; // Relayed properties survive without a timescale. Plain frames generate a - // timestamp only on drafts that can declare its units. - let timescale = timescale.filter(|_| ietf::Properties::sends_timescale(version)); + // timestamp only on a timed track. let properties = if let Some(fields) = fields { let properties = super::object::write(&fields.properties, source, timestamp, version, timescale)?; (!properties.is_empty()).then_some(properties) @@ -2809,12 +2624,7 @@ impl Publisher { // Every frame on a timed track is timed (see `track::Info::timescale`). let timestamp = timestamp.ok_or(Error::TimestampMismatch)?; let mut properties = Vec::new(); - ietf::encode_object_time( - &mut Encoder::new(&mut properties, version.into()), - timestamp, - timescale, - version, - )?; + ietf::encode_object_time(&mut Encoder::new(&mut properties, version.into()), timestamp, timescale)?; Some(properties) } None => None, @@ -2823,18 +2633,6 @@ impl Publisher { let properties = super::object::compression(properties.unwrap_or_default(), version, inflated)?; let properties = (!properties.is_empty()).then_some(properties); - if version == Version::Draft14 { - let properties = properties.unwrap_or_default(); - stream.buffer_varint(sequence)?; - stream.buffer_varint(0)?; - stream.buffer_varint(object)?; - // Publisher priority, a raw byte. - stream.buffer_raw(&[0]); - stream.buffer_varint(properties.len() as u64)?; - stream.buffer_raw(&properties); - std::future::poll_fn(|cx| stream.poll_flush(cx)).await?; - return Ok(()); - } let header = match prior { // The first object must carry its absolute Group and Object IDs. Include the @@ -2884,7 +2682,7 @@ impl Publisher { for (ordinal, frame) in (group.first..).zip(group.frames) { kio::wait(|waiter| budget.poll_yield(waiter)).await; let object = frame.fields.as_ref().map_or(ordinal, |fields| fields.id); - let position = fetch_position(version, *prior_group, group.sequence, prior, object)?; + let position = fetch_position(*prior_group, group.sequence, prior, object)?; prior = Some(object); Self::write_fetch_object( writer, @@ -2901,28 +2699,7 @@ impl Publisher { }, ) .await?; - Self::write_fetch_payload(writer, frame.frame.payload, version).await?; - } - if let Some(end) = group.end_object.as_deref() { - let position = fetch_position(version, *prior_group, group.sequence, prior, end.id)?; - prior = Some(end.id); - Self::write_fetch_object( - writer, - FetchObjectInfo { - sequence: group.sequence, - object: end.id, - prior: position, - timestamp: None, - inflated: None, - timescale, - version, - fields: Some(end), - source, - }, - ) - .await?; - writer.varint(0).await?; - writer.varint(END_OF_GROUP).await?; + Self::write_fetch_payload(writer, frame.frame.payload).await?; } if prior.is_some() { *prior_group = Some(group.sequence); @@ -2934,13 +2711,8 @@ impl Publisher { async fn write_fetch_payload( stream: &mut Writer, Version>, payload: crate::frame::Payload, - version: Version, ) -> Result<(), Error> { stream.varint(payload.len() as u64).await?; - // Draft-14 and 15 still carry a Normal status after an empty object. - if payload.is_empty() && matches!(version, Version::Draft14 | Version::Draft15) { - stream.varint(0).await?; - } if !payload.is_empty() { let mut payload = payload; stream.write_all(&mut payload).await?; @@ -3010,7 +2782,6 @@ impl Publisher { return super::request_stream::reject( stream, ietf::RequestError { - request_id: None, error_code: request::INVALID_FILTER, reason_phrase: "Range Filters exceed MAX_FILTER_RANGES=0".into(), retry_interval: 0, @@ -3023,12 +2794,7 @@ impl Publisher { // don't write, so waiting on upstream regardless would ignore what was asked. if msg.fill_timeout { return self - .reject_fetch( - stream, - msg.request_id, - &Error::Unsupported, - "FILL_TIMEOUT not supported", - ) + .reject_fetch(stream, &Error::Unsupported, "FILL_TIMEOUT not supported") .await; } @@ -3048,7 +2814,7 @@ impl Publisher { // object plus one. Without one, both include the whole End Group. let Some(object) = end.object.map_or(Some(0), |object| object.checked_add(1)) else { return self - .reject_fetch(stream, msg.request_id, &Error::InvalidRange, "End Object overflows") + .reject_fetch(stream, &Error::InvalidRange, "End Object overflows") .await; }; FetchType::Standalone { @@ -3098,9 +2864,7 @@ impl Publisher { return Err(Error::Unauthorized); }; if !gate.allows() { - return self - .reject_fetch(stream, msg.request_id, &Error::Unauthorized, "not granted") - .await; + return self.reject_fetch(stream, &Error::Unauthorized, "not granted").await; } // End Object 0 includes the entire End Group, even at the largest Group ID. @@ -3108,9 +2872,7 @@ impl Publisher { && (start.group > end.group || (start.group == end.group && end.object != 0 && start.object >= end.object)) { - return self - .reject_fetch(stream, msg.request_id, &Error::InvalidRange, "empty range") - .await; + return self.reject_fetch(stream, &Error::InvalidRange, "empty range").await; } // The peer must have seen the announcement to name this namespace, so this @@ -3120,11 +2882,11 @@ impl Publisher { .await { Ok(broadcast) => broadcast, - Err(err) => return self.reject_fetch(stream, msg.request_id, &err, &err.to_string()).await, + Err(err) => return self.reject_fetch(stream, &err, &err.to_string()).await, }; let track = match broadcast.track(&track) { Ok(track) => track, - Err(err) => return self.reject_fetch(stream, msg.request_id, &err, &err.to_string()).await, + Err(err) => return self.reject_fetch(stream, &err, &err.to_string()).await, }; // Its own entry in the queue, or its own send group, so it competes with group @@ -3156,7 +2918,7 @@ impl Publisher { .await? { Ok(joined) => joined, - Err((err, reason)) => return self.reject_fetch(stream, msg.request_id, &err, reason).await, + Err((err, reason)) => return self.reject_fetch(stream, &err, reason).await, }; // The cache outlives the subscription, so the fetch holds its own gate and ends // with the subscription rather than trusting the cache. One the subscription's @@ -3169,12 +2931,7 @@ impl Publisher { FetchType::AbsoluteJoining { group_id, .. } if group_id <= end.group => group_id, _ => { return self - .reject_fetch( - stream, - msg.request_id, - &Error::InvalidRange, - "joining group past the subscription", - ) + .reject_fetch(stream, &Error::InvalidRange, "joining group past the subscription") .await; } }; @@ -3192,9 +2949,7 @@ impl Publisher { } // Rewritten as standalone above. FetchType::Filtered { .. } => { - return self - .reject_fetch(stream, msg.request_id, &Error::Unsupported, "not supported") - .await; + return self.reject_fetch(stream, &Error::Unsupported, "not supported").await; } }; @@ -3247,19 +3002,16 @@ impl Publisher { _filled, } = match prepared { Some(Ok(prepared)) => prepared, - Some(Err((err, reason))) => return self.reject_fetch(stream, msg.request_id, &err, &reason).await, + Some(Err((err, reason))) => return self.reject_fetch(stream, &err, &reason).await, None => return Ok(()), }; // The range ended short of the requested group, at the Largest Object's. let capped = range.end != end; let end = range.end; - let group = group.for_version(self.version); + let group = group.without_status(); // FETCH keeps each object's Timestamp, in the units FETCH_OK declares wherever the // draft can declare them. Opting out of the properties strips nothing from the objects. - let timescale = group - .info - .timescale - .filter(|_| ietf::Properties::sends_timescale(self.version)); + let timescale = group.info.timescale; let frames = group .frames .iter() @@ -3268,12 +3020,12 @@ impl Publisher { if let Err(err) = super::object::write(&fields.properties, group.info.ietf(), frame, self.version, timescale) { - return self.reject_fetch(stream, msg.request_id, &err, &err.to_string()).await; + return self.reject_fetch(stream, &err, &err.to_string()).await; } } let properties = match track_properties(&group.info, msg.properties_wanted, self.version, compression) { Ok(properties) => properties, - Err(err) => return self.reject_fetch(stream, msg.request_id, &err, &err.to_string()).await, + Err(err) => return self.reject_fetch(stream, &err, &err.to_string()).await, }; let draft22 = Filter::is_draft22(self.version); // Only a finished track's final object ends it, never a live track's Largest Object. @@ -3284,7 +3036,7 @@ impl Publisher { // group has to be here in full or the two leave a gap. if group.sequence != end.group || group.end() != end.object { return self - .reject_fetch(stream, msg.request_id, &Error::Evicted, "joining prefix unavailable") + .reject_fetch(stream, &Error::Evicted, "joining prefix unavailable") .await; } end @@ -3334,15 +3086,6 @@ impl Publisher { stream .writer .encode_message(&ietf::FetchOk { - request_id: match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(msg.request_id), - _ => None, - }, - // Only draft-14 encodes it, and only as the publisher restating the order. - group_order: match msg.group_order { - GroupOrder::Descending => GroupOrder::Descending, - _ => GroupOrder::Ascending, - }, end_of_track, end_location, properties, @@ -3394,7 +3137,7 @@ impl Publisher { ) .await { - Ok(group) => group.for_version(self.version), + Ok(group) => group.without_status(), // Gone since it was listed, or never fetchable: absent from the answer, // which says it does not exist. Err(Error::NotFound | Error::NotFetchable | Error::Unsupported | Error::Old) => continue, @@ -3469,9 +3212,7 @@ impl Publisher { let mut deadline = crate::time::Deadline::after(&self.runtime, Duration::from_secs(10)); kio::wait(|waiter| { let mut cx = waiter.context(); - // The request reader is what the subscriber FINs or resets. The writer - // on a draft-14-16 virtual stream reports closed immediately, which is - // not a cancellation. + // The request reader is what the subscriber FINs or resets. if super::request_stream::poll_cancel(stream, finished, messages, self.version, &mut cx).is_ready() { return Poll::Ready(Err(Error::Cancel)); } @@ -3506,23 +3247,12 @@ impl Publisher { })) => return Ok(Ok((end, cache, namespace, ranking, token))), Ok(None) => ( match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 => { - Error::InvalidJoiningRequestId - } + Version::Draft18 => Error::InvalidJoiningRequestId, _ => Error::NotFound, }, "no such subscription", ), - Ok(Some(Joined::Unsupported)) => { - if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - self.session.close( - super::error::to_session_code(&crate::SessionError::ProtocolViolation), - "joining FETCH requires Largest Object filter", - ); - return Err(Error::ProtocolViolation); - } - (Error::Unsupported, "joining filter not supported") - } + Ok(Some(Joined::Unsupported)) => (Error::Unsupported, "joining filter not supported"), Ok(Some(Joined::Empty)) => (Error::InvalidRange, "no objects at subscription start"), }; Ok(Err(refusal)) @@ -3557,10 +3287,8 @@ impl Publisher { if let Some(error) = super::namespace::refusal(&msg.track_namespace, self.version) { return super::request_stream::reject(stream, error).await; } - let request_id = msg.request_id; - if !gate.allows() { - return self.reject_track_status(stream, request_id, &Error::Unauthorized).await; + return self.reject_track_status(stream, &Error::Unauthorized).await; } let broadcast = match gate .guard(async { @@ -3573,12 +3301,12 @@ impl Publisher { .await { Ok(broadcast) => broadcast, - Err(err) => return self.reject_track_status(stream, request_id, &err).await, + Err(err) => return self.reject_track_status(stream, &err).await, }; let track = match broadcast.track(&msg.track_name) { Ok(track) => track, - Err(err) => return self.reject_track_status(stream, request_id, &err).await, + Err(err) => return self.reject_track_status(stream, &err).await, }; // The requester abandoning the request is owed nothing. @@ -3604,7 +3332,7 @@ impl Publisher { let properties = match info.map(|info| track_properties(&info?, msg.properties_wanted, self.version, negotiated)) { Some(Ok(properties)) => properties, - Some(Err(err)) => return self.reject_track_status(stream, request_id, &err).await, + Some(Err(err)) => return self.reject_track_status(stream, &err).await, None => return Ok(()), }; @@ -3612,16 +3340,10 @@ impl Publisher { // when the grant narrows is reset rather than delivered. let respond = async { let ok = ietf::TrackStatusOk { - request_id: match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(request_id), - _ => None, - }, largest: live_edge(&track).largest, properties, }; - stream - .writer - .buffer_message(ietf::TrackStatusOk::id(self.version), &ok)?; + stream.writer.buffer_message(ietf::TrackStatusOk::ID, &ok)?; std::future::poll_fn(|cx| stream.writer.poll_flush(cx)).await?; Ok::<(), Error>(()) }; @@ -3646,29 +3368,9 @@ impl Publisher { Ok(()) } - async fn reject_track_status( - &self, - mut stream: Stream, - request_id: RequestId, - err: &Error, - ) -> Result<(), Error> { + async fn reject_track_status(&self, mut stream: Stream, err: &Error) -> Result<(), Error> { let (kind, reason) = (request::Kind::TrackStatus, err.to_string()); - super::request_stream::write_error(&mut stream.writer, kind, request_id, err, &reason, self.version).await?; - let _ = stream.writer.close().await; - Ok(()) - } - - /// Refuse a SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS that asks for PUBLISH, which we - /// never send. Both exist only from draft-16, so the reply is always REQUEST_ERROR. - async fn reject_namespace_request( - &self, - mut stream: Stream, - request_id: RequestId, - reason: &str, - ) -> Result<(), Error> { - let kind = request::Kind::SubscribeNamespace; - let err = &Error::Unsupported; - super::request_stream::write_error(&mut stream.writer, kind, request_id, err, reason, self.version).await?; + super::request_stream::write_error(&mut stream.writer, kind, err, &reason, self.version).await?; let _ = stream.writer.close().await; Ok(()) } @@ -3678,25 +3380,23 @@ impl Publisher { async fn reject_fetch( &self, mut stream: Stream, - request_id: RequestId, err: &Error, reason: &str, ) -> Result<(), Error> { let kind = request::Kind::Fetch; - super::request_stream::write_error(&mut stream.writer, kind, request_id, err, reason, self.version).await?; + super::request_stream::write_error(&mut stream.writer, kind, err, reason, self.version).await?; let _ = stream.writer.close().await; Ok(()) } /// Bring the peer's view of one namespace in line with the current selection. /// - /// A loop writing inline (draft-16+ answering a SUBSCRIBE_NAMESPACE) re-sends + /// A loop writing inline (answering a SUBSCRIBE_NAMESPACE) re-sends /// NAMESPACE on that stream, which the receiver treats as a replacement, and /// retracts with NAMESPACE_DONE. Otherwise each advertisement rides its own /// PUBLISH_NAMESPACE request: an update is a REQUEST_UPDATE **on the stream that /// already carries it**, since a second stream would leave two claiming one - /// namespace, and a withdrawal cancels the request (PUBLISH_NAMESPACE_DONE before - /// draft-17). + /// namespace, and a withdrawal cancels the request. async fn sync_namespace(&self, ns: &mut Namespaces, suffix: &crate::Path, path: &crate::Path) -> Result<(), Error> { let Some((advert, auth)) = self.offer(ns, suffix, path) else { return Ok(()); @@ -3728,13 +3428,9 @@ impl Publisher { let renamed = wanted && held && held_epoch != epoch; let replaced = renamed && held_epoch.is_some() && epoch.is_some(); // moq-transport renames a request's auth with REQUEST_UPDATE, but cannot name one on - // a request that had none, nor drop one, and before draft-16 cannot update a - // namespace at all: those withdraw the request and make it again. - let restart = renamed - || held_auth != auth - && (held_auth.is_none() - || auth.is_none() - || matches!(self.version, Version::Draft14 | Version::Draft15)); + // a request that had none, nor drop one: those withdraw the request and make it + // again. + let restart = renamed || held_auth != auth && (held_auth.is_none() || auth.is_none()); // An update already waiting or in flight carries whatever is current once it goes // out, and its answer brings the loop back here for anything after that. @@ -3771,7 +3467,7 @@ impl Publisher { // else below either rides a stream the peer already accepted or says nothing at all. let mut refused = watch.refused; let sent = match target { - Target::Requests(_) => { + Target::Requests => { match (advert.wanted(), requests.get_mut(suffix)) { (false, _) => { if held { @@ -3789,7 +3485,7 @@ impl Publisher { .advertise_namespace(requests, watch, path, suffix.clone(), advert.params(), auth) .await?; if let Some(old) = old { - self.end_request(old.stream, &old.path, old.request_id).await; + end_request(old.stream); } } (true, Some(_)) => { @@ -3807,11 +3503,7 @@ impl Publisher { } } // Register an idle reader too: GOAWAY can arrive without an update in flight. - if !matches!( - self.version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) && requests.contains_key(suffix) - { + if requests.contains_key(suffix) { woken.suffixes.lock().unwrap().push(suffix.clone()); } // The peer can reject a fresh PUBLISH_NAMESPACE or an update, which leaves @@ -3870,9 +3562,9 @@ impl Publisher { /// /// A request stream that fails on its own, reset or ended before its answer, ends only /// this advertisement, which comes back on `watch`'s growing backoff. Only the control - /// stream failing, the peer breaking the protocol, or a draft-14/15 header that failed - /// to send ends the loop. Those last two close the session here, since the solicited - /// path runs this inside a request task whose errors end only that stream. + /// stream failing or the peer breaking the protocol ends the loop. The latter closes the + /// session here, since the solicited path runs this inside a request task whose errors + /// end only that stream. /// /// Returns what the refusal, if any, said about coming back. async fn advertise_namespace( @@ -3893,7 +3585,7 @@ impl Publisher { tracing::debug!(broadcast = %crate::path::Rooted(self.origin.root(), path), "no stream for the advertisement"); return Ok(Refused::No); }; - let request_id = self.control.next_request_id(&self.runtime).await?; + let request_id = self.control.next_request_id(); let mut messages = super::request_stream::Messages::new(self.session.clone(), self.control.client()); let sent = request @@ -3907,15 +3599,6 @@ impl Publisher { .await; let answer = match sent { Ok(()) => self.read_response(&mut request, &mut messages).await, - // A sequential-ID peer cannot accept any later request when this header failed - // before it learned the allocated ID. - Err(err) if matches!(self.version, Version::Draft14 | Version::Draft15) => { - self.session.clone().close( - super::error::to_session_code(&crate::SessionError::Internal), - "failed to send PUBLISH_NAMESPACE", - ); - return Err(err); - } Err(err) => Err(err), }; @@ -3924,9 +3607,8 @@ impl Publisher { Ok(Answer::Message(framed)) => framed, Ok(Answer::Late) => { tracing::debug!(broadcast = %absolute, "no answer to the advertisement"); - // The peer may hold the request it never answered. Dropping one riding the - // draft-14-16 control stream tells it nothing, so it is withdrawn instead. - self.end_request(request, path, request_id).await; + // The peer may hold the request it never answered, so it is withdrawn. + end_request(request); return Ok(Refused::No); } Ok(Answer::Ended) => { @@ -3942,18 +3624,8 @@ impl Publisher { // `None` when the peer holds the namespace. let refused = (|| { - match (self.version, type_id) { - (Version::Draft14, ietf::PublishNamespaceOk::ID) => { - let msg: ietf::PublishNamespaceOk = body.message(self.version)?; - tracing::debug!(message = ?msg, "publish namespace ok"); - } - (Version::Draft14, ietf::PublishNamespaceError::ID) => { - let msg: ietf::PublishNamespaceError = body.message(self.version)?; - tracing::warn!(message = ?msg, "publish namespace error"); - // Draft-14's error carries no retry interval, so our own backoff stands. - return Ok(Some(Refused::No)); - } - (_, ietf::RequestOk::ID) => { + match type_id { + ietf::RequestOk::ID => { let msg: ietf::RequestOk = body.message(self.version)?; // ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count). if msg.active.is_some() { @@ -3961,7 +3633,7 @@ impl Publisher { } tracing::debug!(message = ?msg, "publish namespace ok"); } - (_, ietf::RequestError::ID) => { + ietf::RequestError::ID => { let msg: ietf::RequestError = body.message(self.version)?; tracing::warn!(message = ?msg, "publish namespace error"); return Ok(Some(self.refusal(msg.retry_interval))); @@ -3980,7 +3652,6 @@ impl Publisher { NamespaceRequest { messages, path: path.clone(), - request_id, stream: request, auth, queued: false, @@ -4009,7 +3680,7 @@ impl Publisher { false => Advert::None, }; let auth = match (&ns.target, advert.wanted()) { - (Target::Requests(_), true) => watch + (Target::Requests, true) => watch .route .auth .as_ref() @@ -4074,7 +3745,6 @@ impl Publisher { (Some(next), Some(held)) => ietf::PublishNamespaceUpdate::between(request_id, &held, &next), _ => ietf::PublishNamespaceUpdate { request_id, - namespace_request_id: (self.version == Version::Draft16).then_some(request.request_id), hops: None, cost: None, authorization_token: None, @@ -4117,7 +3787,6 @@ impl Publisher { inflight: &mut VecDeque<(crate::time::Instant, crate::Path)>, woken: &Arc, now: crate::time::Instant, - version: Version, cx: &mut std::task::Context<'_>, ) -> Poll<(crate::Path, Result)> { // Registered before the take, so a wake landing between them is not lost. @@ -4127,13 +3796,6 @@ impl Publisher { let Some(request) = requests.get_mut(&suffix) else { continue; }; - if request.pending.is_none() - && matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) { - continue; - } let waker = request.waker.get_or_insert_with(|| woken.waker(suffix.clone())).clone(); if let Poll::Ready(answer) = request .messages @@ -4195,18 +3857,8 @@ impl Publisher { let refused = match answer { Answer::Late => { tracing::debug!(broadcast = %absolute, "no answer to the update"); - match self.version { - // Dropping a request riding the control stream tells the peer nothing, so - // it would hold the namespace beside the one advertised next. - Version::Draft16 => { - self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) - .await? - } - // Abrupt: a peer that never answers is not owed the FIN handshake. - _ => { - ns.requests.remove(&suffix); - } - } + // Abrupt: a peer that never answers is not owed the FIN handshake. + ns.requests.remove(&suffix); Refused::No } Answer::Ended if idle => { @@ -4245,29 +3897,11 @@ impl Publisher { ietf::RequestError::ID => { let msg: ietf::RequestError = body.message(self.version)?; tracing::warn!(message = ?msg, "publish_namespace update error"); - match self.version { - // The peer follows its refusal with PUBLISH_NAMESPACE_CANCEL, and draft - // 16 sends no PUBLISH_NAMESPACE_DONE after a cancel. - Version::Draft16 => { - ns.requests.remove(&suffix); - } - // The peer closed its side; closing ours completes the withdrawal. - _ => { - self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) - .await? - } - } + // The peer closed its side; closing ours completes the withdrawal. + self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) + .await?; self.refusal(msg.retry_interval) } - // Draft-16 carries its cancel as a message, which can beat the answer. - ietf::PublishNamespaceCancel::ID if self.version == Version::Draft16 => { - let msg: ietf::PublishNamespaceCancel = body.message(self.version)?; - tracing::debug!(broadcast = %absolute, message = ?msg, "update cancelled"); - // The peer already let it go, and draft 16 sends no - // PUBLISH_NAMESPACE_DONE after a cancel. - ns.requests.remove(&suffix); - Refused::No - } _ => return Err(Error::UnexpectedMessage), } } @@ -4283,15 +3917,13 @@ impl Publisher { /// How to read a refusal's retry interval, in milliseconds. /// - /// Draft-14/15 errors carry no interval, so a decoded 0 there says nothing and our own - /// backoff stands. Everywhere else 0 is the peer asking not to be offered this again, - /// which is what keeps a permanent refusal (unauthorized, uninterested) from becoming - /// a request every few seconds for the life of the session. + /// 0 is the peer asking not to be offered this again, which is what keeps a permanent + /// refusal (unauthorized, uninterested) from becoming a request every few seconds for + /// the life of the session. fn refusal(&self, retry_interval: u64) -> Refused { - match (self.version, retry_interval) { - (Version::Draft14 | Version::Draft15, _) => Refused::No, - (_, 0) => Refused::Never, - (_, ms) => Refused::Until(self.runtime.now() + Duration::from_millis(ms)), + match retry_interval { + 0 => Refused::Never, + ms => Refused::Until(self.runtime.now() + Duration::from_millis(ms)), } } @@ -4318,11 +3950,7 @@ impl Publisher { kio::wait(|waiter| { if let Poll::Ready(res) = waiter.poll_future(open.as_mut()) { return Poll::Ready(match res { - Ok(stream) => Ok(Some(super::request_stream::with_stats( - stream, - self.origin.stats(), - self.version, - ))), + Ok(stream) => Ok(Some(stream.with_stats(self.origin.stats()))), Err(Error::NoStreamCredit) => Ok(None), Err(err) => Err(err), }); @@ -4363,7 +3991,7 @@ impl Publisher { } /// Withdraw an advertised namespace: NAMESPACE_DONE inline, or end the request that - /// carried it (PUBLISH_NAMESPACE_DONE before draft-17, a cancel after). + /// carried it. async fn withdraw_namespace( &self, target: &mut Target, @@ -4371,10 +3999,9 @@ impl Publisher { suffix: crate::Path, ) -> Result<(), Error> { match target { - Target::Requests(_) => { + Target::Requests => { if let Some(request) = requests.remove(&suffix) { - self.end_request(request.stream, &request.path, request.request_id) - .await; + end_request(request.stream); } } Target::Inline(stream) => { @@ -4387,29 +4014,6 @@ impl Publisher { Ok(()) } - /// Withdraw one PUBLISH_NAMESPACE request, best effort: the peer may already be gone. - async fn end_request(&self, mut stream: Stream, path: &crate::Path, request_id: RequestId) { - // Draft-17+ withdraws by cancelling the request in both directions; a FIN is only - // allowed to end SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS. - if !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - stream.reader.abort(&Error::Cancel); - stream.writer.abort(&Error::Cancel); - return; - } - - let _ = stream - .writer - .encode_message(&ietf::PublishNamespaceDone { - track_namespace: path.clone(), - request_id, - }) - .await; - - // The withdrawal rides this request's own stream, which drops with it, so it needs - // the acknowledgement before the drop-time reset can discard it. - let _ = stream.writer.close().await; - } - /// Close out every open PUBLISH_NAMESPACE request. A no-op for a loop whose entries /// ride the SUBSCRIBE_NAMESPACE stream itself, which retracts them by ending. async fn withdraw_requests(&self, target: &mut Target, requests: &mut HashMap) { @@ -4427,7 +4031,7 @@ impl Publisher { /// peer that would rather ask says so with the MoQ Solicit extension /// ([`solicit`](super::solicit)), /// and then this loop does nothing. - /// On draft-16 and later [`Self::serve_subscribe_namespace_stream`] also carries every + /// [`Self::serve_subscribe_namespace_stream`] also carries every /// match, so a peer that did not ask hears each namespace both ways. A NAMESPACE is /// discovery only, not a second route. pub async fn run_publish_namespaces(self) -> Result<(), Error> { @@ -4445,7 +4049,7 @@ impl Publisher { .advertising(self.origin.clone(), &peer) .discovery(!self.peer_setup.get().await.hidden || self.origin.includes_hidden()); - let mut ns = Namespaces::new(peer, Target::Requests(None)); + let mut ns = Namespaces::new(peer, Target::Requests); ns.epochs = self.peer_setup.get().await.epoch; self.run_namespaces(origin.announced(), crate::Path::empty(), ns, Vec::new()) .await @@ -4455,10 +4059,8 @@ impl Publisher { /// /// All the announce state is local to this task (mirroring `lite::Publisher`'s /// announce handling): whatever this subscription advertised is withdrawn - /// when its stream ends. On draft-16 and later every match is a NAMESPACE on - /// this stream, whatever the peer's SETUP said. Draft-14 and 15 predate that - /// message and still answer with PUBLISH_NAMESPACE only for what - /// [`Self::run_publish_namespaces`] does not already say. + /// when its stream ends. Every match is a NAMESPACE on this stream, whatever the + /// peer's SETUP said. #[cfg(test)] async fn run_subscribe_namespace_stream( self, @@ -4516,21 +4118,8 @@ impl Publisher { // origin that already opted in (the caller's choice for this peer) keeps them. let origin = origin.discovery(!declared.hidden || msg.hidden || self.origin.includes_hidden()); - // Draft-16 and later always fill this stream. A NAMESPACE is discovery, not a - // route, so a peer that also hears the unsolicited PUBLISH_NAMESPACE still gets - // the match here. Draft-14 and 15 answer with PUBLISH_NAMESPACE and only say - // what that loop does not already say: everything when the peer asked to be - // told on request, otherwise the hidden remainder. - let origin = if matches!(self.version, Version::Draft14 | Version::Draft15) { - match declared.solicit.unwrap_or(false) { - true => origin, - false if !declared.hidden || self.origin.includes_hidden() => origin.empty(), - false => origin.beyond(&self.origin.clone().discovery(false)), - } - } else { - origin - }; - + // A NAMESPACE is discovery, not a route, so a peer that also hears the unsolicited + // PUBLISH_NAMESPACE still gets the match here. let mut announced = origin.announced(); // MoQ Active Count: take what is advertised now, so the OK can say how many @@ -4554,43 +4143,12 @@ impl Publisher { }; // Send OK response - match self.version { - Version::Draft14 => { - stream - .writer - .encode_message(&ietf::SubscribeNamespaceOk { - request_id: msg.request_id, - }) - .await?; - } - Version::Draft15 | Version::Draft16 => { - stream - .writer - .encode_message(&ietf::RequestOk { - largest: None, - request_id: Some(msg.request_id), - active, - }) - .await?; - } - _ => { - stream - .writer - .encode_message(&ietf::RequestOk { - largest: None, - request_id: None, - active, - }) - .await?; - } - } + stream + .writer + .encode_message(&ietf::RequestOk { largest: None, active }) + .await?; - // Draft-14/15 predate NAMESPACE, so they answer with their own PUBLISH_NAMESPACE - // requests and keep this stream open for the subscription's lifetime. - let target = match self.version { - Version::Draft14 | Version::Draft15 => Target::Requests(Some(stream)), - _ => Target::Inline(stream), - }; + let target = Target::Inline(stream); let mut ns = Namespaces::new(peer, target); ns.gate = Some(gate); @@ -4719,19 +4277,14 @@ impl Publisher { // Each update in flight is answered on its own stream, so many renew at // once and none holds up the announcements behind it. let now = self.runtime.now(); - if let Poll::Ready((suffix, answer)) = - Self::poll_answer(requests, inflight, woken, now, self.version, &mut cx) - { + if let Poll::Ready((suffix, answer)) = Self::poll_answer(requests, inflight, woken, now, &mut cx) { return Poll::Ready(NamespaceEvent::Answered(suffix, answer)); } // The front is now the earliest update still waiting. answer_by.set(inflight.front().map(|(deadline, _)| *deadline)); let _ = answer_by.poll(waiter); - // Credit goes to queued updates in order, as the drafts before 17 require. - if !updates.is_empty() - && let Poll::Ready(id) = self.control.poll_next_request_id(waiter) - { - return Poll::Ready(NamespaceEvent::Renew(id)); + if !updates.is_empty() { + return Poll::Ready(NamespaceEvent::Renew(self.control.next_request_id())); } if let Poll::Ready(next) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(next.map(|event| match event { @@ -4769,7 +4322,6 @@ impl Publisher { let refusal = update .refused .map(|error| ietf::RequestError { - request_id: None, error_code: request::to_code(&error, request::Kind::SubscribeNamespace, self.version), reason_phrase: "namespace update refused".into(), retry_interval: 0, @@ -4818,7 +4370,6 @@ impl Publisher { .writer .encode_message(&ietf::RequestOk { largest: None, - request_id: None, active: None, }) .await?; @@ -5022,10 +4573,6 @@ impl TrackServe { let end = range.end.map_or(Bound::Unbounded, |end| Bound::Included(end.group)); let track = track.with_range((Bound::Included(floor), end)); let datagrams = session.max_datagram_size() > 0; - // A Timestamp whose units were not declared is worse than none. Drafts 14-16 - // 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 publisher = track.info().priority; @@ -5068,7 +4615,7 @@ impl TrackServe { } fn compression(mut self, negotiated: bool) -> Self { - self.compression = negotiated && super::flate::supported(self.version) && self.track.info().compress; + self.compression = negotiated && self.track.info().compress; self } @@ -5268,8 +4815,7 @@ impl TrackServe { // model ranks higher-first and this wire field lower-first. publisher_priority: super::priority::to_wire(self.track.info().priority), // Carry per-object timestamps as extension headers (the Timestamp - // Object Property) only when TIMESCALE was declared for this version. - // Drafts 14-16 cannot, so the flag stays clear. + // Object Property) only when TIMESCALE was declared. flags: ietf::GroupFlags { has_extensions: self.compression || self.timescale.is_some() @@ -5380,10 +4926,7 @@ impl TrackServe { } // Exhausting an updated filter does not end the track. Keep the // request updatable once its current range runs out. - if self.version != Version::Draft14 - && self.range.end.is_some() - && self.track.poll_finished(waiter).is_pending() - { + if self.range.end.is_some() && self.track.poll_finished(waiter).is_pending() { let _ = self.children.poll(waiter); return Poll::Pending; } @@ -5432,20 +4975,14 @@ impl TrackServe { Some(wire) => { // A datagram this draft cannot carry is dropped like an oversized one: it // is best-effort, and the track's groups do not depend on it. - if wire - .status - .is_some_and(|status| !super::object::status_defined(status, self.version)) - { + if wire.status.is_some_and(|status| !super::object::status_defined(status)) { tracing::debug!(sequence, "dropping a datagram status this draft cannot carry"); continue; } // A status keeps its meaning without the properties this draft forbids // beside it, as a stream's status object does. let stripped = Default::default(); - let forwarded = match wire - .status - .is_some_and(|status| !super::datagram::carries_properties(status, self.version)) - { + let forwarded = match wire.status.is_some_and(|status| status != 0) { true => &stripped, false => &wire.properties, }; @@ -5462,11 +4999,7 @@ impl TrackServe { continue; } }; - let object = if self.version == Version::Draft14 && wire.status.is_some() { - Some(wire.object_id.unwrap_or(0)) - } else { - wire.object_id - }; + let object = wire.object_id; // An omitted priority inherited the source track's default, which this // response may not carry (or carry the same), so resolve it here. let priority = wire @@ -5488,7 +5021,6 @@ impl TrackServe { &mut Encoder::new(&mut properties, self.version.into()), timestamp, timescale, - self.version, )?; } ( @@ -6114,8 +5646,7 @@ impl GroupServe { } Poll::Ready(Ok(None)) => { if !self.ended - && let Some(end) = - self.group.end_object().map(|end| status_fields(end, self.version)) + && let Some(end) = self.group.end_object().map(status_fields) && end.id >= self.slice.skip && self .slice .until @@ -6218,7 +5749,7 @@ impl GroupServe { } } -use super::object::{END_OF_GROUP, END_OF_TRACK}; +use super::object::END_OF_TRACK; /// An object's header, with private relay fields when it came from an IETF peer. struct Object<'a> { @@ -6248,12 +5779,17 @@ fn object_delta(prior: Option, id: u64) -> Result { } } -/// A status object's fields, without properties where its draft forbids them. -fn status_fields( - end: std::sync::Arc, - version: Version, -) -> std::sync::Arc { - match super::object::status_has_properties(version) || end.properties.is_empty() { +/// Withdraw one PUBLISH_NAMESPACE request by cancelling it in both directions, best effort: +/// the peer may already be gone. A FIN is only allowed to end SUBSCRIBE_NAMESPACE and +/// SUBSCRIBE_TRACKS. +fn end_request(mut stream: Stream) { + stream.reader.abort(&Error::Cancel); + stream.writer.abort(&Error::Cancel); +} + +/// A status object's fields, without properties, which it may not carry. +fn status_fields(end: std::sync::Arc) -> std::sync::Arc { + match end.properties.is_empty() { true => end, false => std::sync::Arc::new(crate::model::object::Fields { id: end.id, @@ -6285,7 +5821,6 @@ fn buffer_object( &mut Encoder::new(&mut ext, encoding.version.into()), timestamp, timescale, - encoding.version, )?; } ext @@ -6302,11 +5837,9 @@ fn buffer_object( Ok(()) } -/// One draft-14/15 advertisement: the PUBLISH_NAMESPACE request it rode on and -/// what closes it out with PUBLISH_NAMESPACE_DONE. +/// One advertisement: the PUBLISH_NAMESPACE request stream it rides on. struct NamespaceRequest { path: crate::Path, - request_id: RequestId, stream: Stream, messages: super::request_stream::Messages, /// The auth the peer holds the request under: the one it opened with, or the one @@ -6418,14 +5951,7 @@ mod group_priority_test { #[moq_clock::test] async fn exact_end_of_track_survives_the_final_group_fin() { use crate::lite::test_transport::Sent; - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { for finished in [false, true] { let log = crate::lite::test_transport::Log::default(); let mut track = track::Producer::new( @@ -6603,7 +6129,7 @@ mod group_priority_test { rank_in(&queue, priority, subscribe), group.consume(), Some(Timescale::MILLI), - Version::Draft14, + Version::Draft18, GroupSlice::default(), ) }); @@ -6645,49 +6171,6 @@ mod group_priority_test { assert_eq!(header.publisher_priority, 127); } - /// Drafts 14-16 cannot send TIMESCALE, so a group served with a track timescale still - /// carries no Timestamp. Draft-17 declares the units and stamps the object. - #[moq_clock::test] - async fn drafts_14_through_16_send_no_timestamp_without_timescale() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft17] { - let stamped = ietf::Properties::sends_timescale(version); - let (header, mut buf) = serve_group(version, Some(Timescale::MILLI)).await; - assert_eq!(header.flags.has_extensions, stamped, "{version}"); - assert_eq!( - crate::coding::decode_varint(&mut buf, version).unwrap(), - 0, - "{version}: object id" - ); - if stamped { - let ext = crate::coding::decode_buf(&mut buf, version, |r, _| Ok(r.bytes()?.to_vec())).unwrap(); - let mut ext = bytes::Bytes::from(ext); - assert!( - crate::coding::decode_buf(&mut ext, version, |r, v| { - ietf::decode_object_time(r, Timescale::MILLI, v) - }) - .unwrap() - .is_some(), - "{version}: Timestamp missing" - ); - } - assert_eq!( - crate::coding::decode_varint(&mut buf, version).unwrap(), - b"hello".len() as u64, - "{version}" - ); - assert_eq!(&buf[..b"hello".len()], b"hello"); - } - - // Opting out of the timescale stays unstamped on a draft that could carry one. - let (header, mut buf) = serve_group(Version::Draft17, None).await; - assert!(!header.flags.has_extensions); - assert_eq!(crate::coding::decode_varint(&mut buf, Version::Draft17).unwrap(), 0); - assert_eq!( - crate::coding::decode_varint(&mut buf, Version::Draft17).unwrap(), - b"hello".len() as u64 - ); - } - /// Drive `serve` beside a sibling that only counts its turns, until `done`, checking every /// poll stays under `most` by `progress` and leaves the sibling its turn. /// Each task-set pass returns to the executor so its children's yields reach the task. @@ -6755,7 +6238,7 @@ mod group_priority_test { crate::driver::Transport::new(session.clone()), subscriber, RequestId(0), - Version::Draft14, + Version::Draft18, ServeRange::default(), None, ); @@ -6805,7 +6288,7 @@ mod group_priority_test { crate::driver::Transport::new(session.clone()), subscriber, RequestId(0), - Version::Draft14, + Version::Draft18, ServeRange::default(), None, ); @@ -6861,7 +6344,7 @@ mod group_priority_test { crate::driver::Transport::new(session.clone()), subscriber, RequestId(0), - Version::Draft14, + Version::Draft18, ServeRange::default(), None, ); @@ -6909,7 +6392,7 @@ mod group_priority_test { crate::driver::Transport::new(session.clone()), subscriber, RequestId(0), - Version::Draft14, + Version::Draft18, ServeRange::default(), None, ); @@ -6926,39 +6409,6 @@ mod group_priority_test { .await; } - /// Serve one group at `version` with `timescale` and return the header plus what follows it. - async fn serve_group(version: Version, timescale: Option) -> (ietf::GroupHeader, bytes::Bytes) { - let log = crate::lite::test_transport::Log::default(); - let session = SinkSession::new(log.clone()); - - let track = track::Producer::new( - std::sync::Arc::new(crate::broadcast::Info::default()), - "test", - crate::track::Info::timed(), - ); - let subscriber = track.subscribe(None); - - let mut group = track.append_group().unwrap(); - group.write_frame(crate::Timestamp::ZERO, b"hello".as_slice()).unwrap(); - group.finish().unwrap(); - track.finish().unwrap(); - - let mut serve = TrackServe::new( - crate::driver::Transport::new(session.clone()), - subscriber, - RequestId(0), - version, - ServeRange::default(), - timescale, - ); - kio::wait(|waiter| serve.poll(waiter)).await.unwrap(); - - let written = log.writes.lock().unwrap().clone(); - let mut buf = bytes::Bytes::from(written); - let header = crate::coding::decode_buf(&mut buf, version, ietf::GroupHeader::decode).expect("a group header"); - (header, buf) - } - /// Serve one group of a track with `info` and decode the subgroup header it opens with. async fn serve_group_header(info: track::Info) -> ietf::GroupHeader { let log = crate::lite::test_transport::Log::default(); @@ -6976,7 +6426,7 @@ mod group_priority_test { crate::driver::Transport::new(session.clone()), subscriber, RequestId(0), - Version::Draft14, + Version::Draft18, ServeRange::default(), Some(Timescale::MILLI), ); @@ -6984,7 +6434,7 @@ mod group_priority_test { let written = log.writes.lock().unwrap().clone(); let mut buf = bytes::Bytes::from(written); - crate::coding::decode_buf(&mut buf, Version::Draft14, ietf::GroupHeader::decode).expect("a group header") + crate::coding::decode_buf(&mut buf, Version::Draft18, ietf::GroupHeader::decode).expect("a group header") } /// `hang::catalog::PRIORITY` isn't reachable from `moq-net` (hang depends on it, not the @@ -7404,7 +6854,7 @@ mod subscribe_cursor_test { crate::driver::Transport::new(session.clone()), subscriber, RequestId(1), - Version::Draft14, + Version::Draft18, ServeRange::default(), Some(Timescale::MILLI), ); @@ -7435,7 +6885,6 @@ mod serve_tests { suffix.clone(), NamespaceRequest { path: suffix.clone(), - request_id: RequestId(0), stream, messages: super::super::request_stream::Messages::new( crate::driver::Transport::new(session.clone()), @@ -7452,14 +6901,12 @@ mod serve_tests { let mut inflight = VecDeque::new(); let runtime = crate::time::sim(); let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); - assert!( - Publisher::poll_answer(&mut requests, &mut inflight, &woken, runtime.now(), version, &mut cx).is_pending() - ); + assert!(Publisher::poll_answer(&mut requests, &mut inflight, &woken, runtime.now(), &mut cx).is_pending()); assert!(session.log.closes().is_empty()); session.push(&[0x10, 0, 2, 0, 0]); woken.suffixes.lock().unwrap().push(suffix); assert!(matches!( - Publisher::poll_answer(&mut requests, &mut inflight, &woken, runtime.now(), version, &mut cx), + Publisher::poll_answer(&mut requests, &mut inflight, &woken, runtime.now(), &mut cx), Poll::Ready((_, Err(Error::ProtocolViolation))) )); assert_eq!(session.log.closes()[0].0, 0x03); @@ -7608,7 +7055,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -7626,7 +7073,7 @@ mod serve_tests { #[moq_clock::test] async fn requester_fin_is_version_gated_for_subscriptions() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let h = serve(version); let session = ScriptedSession::per_stream_eof(vec![vec![]]); let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) @@ -7671,14 +7118,7 @@ mod serve_tests { /// A reset request stream still cancels the subscription on every draft. #[moq_clock::test] async fn requester_reset_cancels_subscriptions() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { let h = serve(version); let session = ScriptedSession::per_stream_reset(vec![vec![]]); let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) @@ -7708,7 +7148,7 @@ mod serve_tests { /// when a dispatched task is cancelled or its message is refused. #[moq_clock::test] async fn drain_counts_dispatched_subscribe_and_fetch() { - for version in [Version::Draft14, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let h = serve(version); let mut subscribe_body = Vec::new(); subscribe(Filter::NextObject, None) @@ -8030,7 +7470,7 @@ mod serve_tests { let wire = Wire::read(&h.log, version); let (status, _) = wire.publish_done.expect("PUBLISH_DONE was sent"); - assert_eq!(status, ietf::PublishDoneStatus::InternalError.code(version)); + assert_eq!(status, ietf::PublishDoneStatus::InternalError.code()); assert_eq!(wire.data.len(), 2, "a group stream and a fill stream"); wire.assert_streams_closed_before_publish_done(); drop(group); @@ -8065,7 +7505,7 @@ mod serve_tests { let wire = Wire::read(&h.log, version); let (status, count) = wire.publish_done.expect("PUBLISH_DONE was sent"); - assert_eq!(status, ietf::PublishDoneStatus::TrackEnded.code(version)); + assert_eq!(status, ietf::PublishDoneStatus::TrackEnded.code()); assert_eq!(wire.data.len(), 3, "a fill, a group and an END_OF_TRACK stream"); assert_eq!(count, 3); wire.assert_streams_closed_before_publish_done(); @@ -8145,7 +7585,7 @@ mod serve_tests { assert_eq!(h.log.resets(), vec![crate::StreamError::Cancel.to_code()]); let wire = Wire::read(&h.log, version); let (status, _) = wire.publish_done.expect("PUBLISH_DONE was sent"); - assert_eq!(status, ietf::PublishDoneStatus::TrackEnded.code(version)); + assert_eq!(status, ietf::PublishDoneStatus::TrackEnded.code()); wire.assert_streams_closed_before_publish_done(); } @@ -8180,19 +7620,11 @@ mod serve_tests { .await .expect("the track's error ends the subscription"); assert!(matches!(served, Served::Track(Err(Error::Unsupported))), "{served:?}"); + assert_eq!(served.status().0, ietf::PublishDoneStatus::InternalError); assert_eq!( - served.status(Version::Draft16).0, - ietf::PublishDoneStatus::InternalError - ); - assert_eq!( - Served::UpdateFailed(Error::Unsupported).status(Version::Draft16).0, + Served::UpdateFailed(Error::Unsupported).status().0, ietf::PublishDoneStatus::UpdateFailed ); - // Draft 14 answers no update, so its PUBLISH_DONE carries the refusal itself. - assert_eq!( - Served::UpdateFailed(Error::Unauthorized).status(Version::Draft14).0, - ietf::PublishDoneStatus::Unauthorized - ); // A revocation bridged from upstream, however it was received, is still one. for err in [ Error::Unauthorized, @@ -8200,7 +7632,7 @@ mod serve_tests { Error::Stream(crate::StreamError::Unauthorized), ] { assert_eq!( - Served::Track(Err(err)).status(Version::Draft16).0, + Served::Track(Err(err)).status().0, ietf::PublishDoneStatus::Unauthorized ); } @@ -8255,7 +7687,7 @@ mod serve_tests { let done = wire.sent(ietf::PublishDone::ID).expect("PUBLISH_DONE was sent"); assert!(error.end() < done.start(), "REQUEST_ERROR precedes PUBLISH_DONE"); let (status, _) = wire.publish_done.unwrap(); - assert_eq!(status, ietf::PublishDoneStatus::UpdateFailed.code(version)); + assert_eq!(status, ietf::PublishDoneStatus::UpdateFailed.code()); assert_eq!(wire.data.len(), 2, "a group stream and a fill stream"); wire } @@ -8280,130 +7712,6 @@ mod serve_tests { ); } - /// A draft 14-16 update framed as the adapter routes it onto its subscription. - /// - /// Drafts 15 and 16 carry only SUBSCRIBER_PRIORITY and FORWARD here, written by hand so - /// the bytes are pinned. - fn legacy_update(version: Version, priority: u8, forward: bool) -> Vec { - const UPDATE_ID: u8 = 10; - let body = match version { - Version::Draft14 => { - let mut body = Vec::new(); - ietf::SubscribeUpdate { - max_age: None, - new_group: None, - request_id: RequestId(UPDATE_ID.into()), - subscription_request_id: Some(RequestId(0)), - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority: Some(priority), - forward: Some(forward), - filter: None, - authorization_token: None, - fill: None, - } - .encode_msg(&mut Encoder::new(&mut body, version.into()), version) - .unwrap(); - body - } - // Draft 16 delta-encodes keys: SUBSCRIBER_PRIORITY (0x20) follows FORWARD (0x10). - Version::Draft16 => vec![UPDATE_ID, 0, 2, 0x10, forward as u8, 0x10, priority], - _ => vec![UPDATE_ID, 0, 2, 0x10, forward as u8, 0x20, priority], - }; - let mut frame = vec![ietf::SubscribeUpdate::ID as u8, 0, body.len() as u8]; - frame.extend(body); - frame - } - - /// Drafts 14-16 deliver an update on the subscription's own virtual stream. It - /// reprices the subscription, which keeps serving and owes no PUBLISH_DONE, and only - /// UNSUBSCRIBE ends it. - #[moq_clock::test] - async fn legacy_subscription_update_keeps_the_subscription() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let h = serve(version); - let session = ScriptedSession::new(legacy_update(version, 10, true)); - let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) - .await - .unwrap(); - let mut goaways = super::super::request_stream::Messages::new( - crate::driver::Transport::new(h.session.clone()), - h.publisher.control.client(), - ); - let mut serving = TrackServe::new( - crate::driver::Transport::new(h.session.clone()), - h.track.subscribe(None), - RequestId(0), - version, - ServeRange::default(), - None, - ); - let mut finished = false; - let mut run = std::pin::pin!(h.publisher.run_subscription( - &mut stream, - &mut serving, - &mut finished, - &mut goaways, - async {}, - None, - )); - assert!(futures::poll!(run.as_mut()).is_pending(), "{version}: update ended it"); - assert_eq!(h.track.subscription().unwrap().priority, 117, "{version}"); - - // Drafts 15 and 16 answer with REQUEST_OK naming the update; draft 14 answers nothing. - let answered = occurrences(&session.log, &[ietf::RequestOk::ID as u8, 0, 2, 10, 0]); - assert_eq!(answered, usize::from(version != Version::Draft14), "{version}"); - - let mut unsubscribe = vec![ietf::Unsubscribe::ID as u8]; - ietf::Unsubscribe { - request_id: RequestId(0), - } - .encode(&mut Encoder::new(&mut unsubscribe, version.into()), version) - .unwrap(); - session.push(&unsubscribe); - assert!( - matches!(futures::poll!(run.as_mut()), Poll::Ready(None)), - "{version}: UNSUBSCRIBE cancels" - ); - } - } - - /// A draft 14-16 FORWARD=0 update pauses the subscription, leaving the answer, which names - /// the update on drafts 15 and 16, to the caller once it stopped forwarding. - #[moq_clock::test] - async fn a_legacy_update_pauses_the_subscription() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let h = serve(version); - let session = ScriptedSession::new(legacy_update(version, 10, false)); - let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) - .await - .unwrap(); - let mut goaways = super::super::request_stream::Messages::new( - crate::driver::Transport::new(h.session.clone()), - h.publisher.control.client(), - ); - let mut serving = TrackServe::new( - crate::driver::Transport::new(h.session.clone()), - h.track.subscribe(None), - RequestId(0), - version, - ServeRange::default(), - None, - ); - let mut finished = false; - let served = h - .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, &mut goaways, async {}, None) - .await; - assert!( - matches!(served, Some(Served::Paused(Some(RequestId(10))))), - "{version}: {served:?}" - ); - let written = session.log.writes.lock().unwrap().clone(); - assert!(written.is_empty(), "{version}: {written:?}"); - } - } - /// A REQUEST_UPDATE with Request ID `id` carrying only FORWARD, framed for the stream of /// the [`subscribe`] it updates. fn forward_update(version: Version, id: u64, forward: bool) -> Vec { @@ -8412,10 +7720,6 @@ mod serve_tests { max_age: None, new_group: None, request_id: RequestId(id), - subscription_request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(REQUEST_ID)), - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: Some(forward), filter: None, @@ -8433,7 +7737,7 @@ mod serve_tests { /// established; FORWARD=1 subscribes again as a new SUBSCRIBE would, at the live edge. #[moq_clock::test] async fn a_subscribe_pauses_and_resumes_as_a_new_subscriber() { - for version in [Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { for start_paused in [false, true] { let h = serve(version); let mut request = ScriptedSession::new(Vec::new()); @@ -8495,7 +7799,7 @@ mod serve_tests { /// the resume joined rather than refused as the pause left it. #[moq_clock::test] async fn a_joining_fetch_after_a_resume_is_served() { - let version = Version::Draft16; + let version = Version::Draft18; let h = serve(version); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); group.write_frame(timestamp(), b"head".as_slice()).unwrap(); @@ -8527,7 +7831,7 @@ mod serve_tests { /// PUBLISH_DONE says UPDATE_FAILED. #[moq_clock::test] async fn a_failed_resume_answers_its_update() { - for version in [Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { let h = serve(version); let mut request = ScriptedSession::new(Vec::new()); request.log = h.log.clone(); @@ -8554,7 +7858,7 @@ mod serve_tests { assert!(done.start() > error.end(), "{version}: {:?}", wire.messages); assert_eq!( wire.publish_done.map(|(status, _)| status), - Some(ietf::PublishDoneStatus::UpdateFailed.code(version)), + Some(ietf::PublishDoneStatus::UpdateFailed.code()), "{version}" ); } @@ -8568,9 +7872,6 @@ mod serve_tests { max_age: None, new_group: Some(value), request_id: RequestId(id), - subscription_request_id: matches!(version, Version::Draft15 | Version::Draft16).then_some(RequestId(0)), - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: None, filter: None, @@ -8614,7 +7915,7 @@ mod serve_tests { /// wants the group past one over its ratio, and SUBSCRIBE_OK advertises DYNAMIC_GROUPS. #[moq_clock::test] async fn a_subscribe_joins_under_the_refresh_ratio() { - for version in [Version::Draft15, Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { for refresh in [Some(1), None] { let h = serve_track(version, crate::track::Info::timed().with_refresh(refresh)); let _group = refresh_group(&h); @@ -8692,7 +7993,7 @@ mod serve_tests { /// REQUEST_OK. One naming a group the track holds asks nothing. #[moq_clock::test] async fn a_new_group_update_joins_a_refreshing_track() { - for version in [Version::Draft15, Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { for (value, wanted) in [(0, 5), (5, 5), (4, 0)] { let h = serve_track(version, crate::track::Info::timed().with_refresh(1)); let session = ScriptedSession::new(new_group_update(version, 10, value)); @@ -8725,12 +8026,8 @@ mod serve_tests { )); assert!(futures::poll!(run.as_mut()).is_pending(), "{version}: update ended it"); assert_eq!(h.track.demand().wanted(), Some(wanted), "{version} {value}"); - // Draft 16 names the update it answers; later drafts answer on its own stream. let written = session.log.writes.lock().unwrap().clone(); assert_eq!(written[0], ietf::RequestOk::ID as u8, "{version}: {written:?}"); - if version == Version::Draft16 { - assert_eq!(written[3], 10, "{version}: {written:?}"); - } } } } @@ -8739,7 +8036,7 @@ mod serve_tests { /// subscription like any update it cannot apply. #[moq_clock::test] async fn a_new_group_update_without_refresh_fails() { - for version in [Version::Draft15, Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { let h = serve(version); let session = ScriptedSession::new(new_group_update(version, 10, 9)); let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) @@ -8771,35 +8068,10 @@ mod serve_tests { /// An update whose only parameters are AUTHORIZATION TOKENs holding `tokens`, framed for /// the subscription's stream. - fn update_with_tokens(version: Version, tokens: &[&[u8]]) -> Vec { - let mut body = match version { - Version::Draft14 => { - let mut body = Vec::new(); - ietf::SubscribeUpdate { - max_age: None, - new_group: None, - request_id: RequestId(2), - subscription_request_id: Some(RequestId(0)), - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority: Some(10), - forward: Some(true), - filter: None, - authorization_token: None, - fill: None, - } - .encode_msg(&mut Encoder::new(&mut body, version.into()), version) - .unwrap(); - // Replace the empty parameter block with the token. - assert_eq!(body.pop(), Some(0)); - body - } - // Then the request it updates, through draft 16. Every parameter but the token is - // left as it is. - Version::Draft15 | Version::Draft16 => vec![2, 0], - _ => vec![2], - }; - body.extend(token_params(version, tokens)); + fn update_with_tokens(tokens: &[&[u8]]) -> Vec { + // Every parameter but the token is left as it is. + let mut body = vec![2]; + body.extend(token_params(tokens)); let mut frame = vec![ietf::SubscribeUpdate::ID as u8, 0, body.len() as u8]; frame.extend(body); frame @@ -8809,7 +8081,7 @@ mod serve_tests { /// the draft's code, as it would on the SUBSCRIBE, not only the subscription. #[moq_clock::test] async fn an_update_token_that_cannot_be_used_closes_the_session() { - for version in [Version::Draft14, Version::Draft22] { + for version in [Version::Draft22] { for (token, expected, why) in [ ( &[0x01, 0x07, 0x00][..], @@ -8829,7 +8101,7 @@ mod serve_tests { ), ] { let h = serve(version); - let session = ScriptedSession::per_stream(vec![update_with_tokens(version, &[token])]); + let session = ScriptedSession::per_stream(vec![update_with_tokens(&[token])]); let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) .await .unwrap(); @@ -8860,12 +8132,12 @@ mod serve_tests { } } - /// A parameter block of AUTHORIZATION TOKENs. Draft-16 on codes each key as a delta from - /// the one before, so a repeat is 0. - fn token_params(version: Version, tokens: &[&[u8]]) -> Vec { + /// A parameter block of AUTHORIZATION TOKENs. Each key is coded as a delta from the one + /// before, so a repeat is 0. + fn token_params(tokens: &[&[u8]]) -> Vec { let mut params = vec![tokens.len() as u8]; for (i, token) in tokens.iter().enumerate() { - let delta = i > 0 && !matches!(version, Version::Draft14 | Version::Draft15); + let delta = i > 0; params.extend([if delta { 0 } else { 0x03 }, token.len() as u8]); params.extend(*token); } @@ -8875,17 +8147,17 @@ mod serve_tests { /// USE_VALUE, type 0, value 0xAA. const TOKEN: &[u8] = &[0x03, 0x00, 0xAA]; - /// Section 8.9: a request whose tokens repeat is rejected MALFORMED_AUTH_TOKEN, which - /// draft-15 moved from 0x10 to 0x4. Distinct tokens are legal but refused NOT_SUPPORTED, + /// Section 8.9: a request whose tokens repeat is rejected MALFORMED_AUTH_TOKEN (0x4). + /// Distinct tokens are legal but refused NOT_SUPPORTED, /// since authorizing on one would ignore the rest. Either way the session stays open, and /// a single token is answered as usual. #[moq_clock::test] async fn a_request_with_repeated_or_several_tokens_is_refused_alone() { - for (version, malformed) in [(Version::Draft14, 0x10), (Version::Draft22, 0x4)] { + for (version, malformed) in [(Version::Draft22, 0x4)] { let request = |tokens: &[&[u8]]| { let mut body = track_status("room", true, version); assert_eq!(body.pop(), Some(0), "{version}"); - body.extend(token_params(version, tokens)); + body.extend(token_params(tokens)); body }; @@ -8930,13 +8202,11 @@ mod serve_tests { async fn a_repeated_or_several_update_tokens_are_refused() { let other: &[u8] = &[0x03, 0x00, 0xBB]; for (version, tokens, code) in [ - (Version::Draft16, [TOKEN, TOKEN], 0x4), (Version::Draft22, [TOKEN, TOKEN], 0x4), - (Version::Draft16, [TOKEN, other], 0x3), (Version::Draft22, [TOKEN, other], 0x3), ] { let h = serve(version); - let session = ScriptedSession::per_stream(vec![update_with_tokens(version, &tokens)]); + let session = ScriptedSession::per_stream(vec![update_with_tokens(&tokens)]); let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) .await .unwrap(); @@ -9023,7 +8293,7 @@ mod serve_tests { /// A source track failing to decode ends only the subscriptions it feeds, not the requester's session. #[moq_clock::test] async fn source_decode_failure_keeps_the_session() { - for version in [Version::Draft16, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let h = serve(version); let session = ScriptedSession::new(Vec::new()); let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) @@ -9189,16 +8459,12 @@ mod serve_tests { "second" ); assert!(wire.is_empty()); - // A fresh update ID is required even if the prefix does not change. - session.push(&update); - assert!(futures::poll!(running.as_mut()).is_ready()); - assert_eq!(h.log.closes().iter().map(|(code, _)| *code).collect::>(), [0x04]); } } #[moq_clock::test] async fn namespace_requester_fin_is_version_gated() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let h = serve(version); moq_clock::sim::yield_now().await; let session = ScriptedSession::per_stream_eof(vec![vec![]]); @@ -9272,27 +8538,14 @@ mod serve_tests { } /// A subscribe for a broadcast we do not serve is refused with the negotiated draft's own - /// "does not exist" value. - /// - /// Draft-14 numbers it 0x4 and draft-15 moved it to 0x10, which is draft-14's - /// MALFORMED_AUTH_TOKEN: a peer told the wrong one re-authenticates instead of waiting - /// for the announcement. The reply is encoded here rather than matched by code alone, so + /// "does not exist" value, 0x10, not 0x4 (MALFORMED_AUTH_TOKEN): a peer told the wrong + /// one re-authenticates instead of waiting for the announcement. The reply is encoded here rather than matched by code alone, so /// a value slipping outside the draft's table cannot pass, and the reason phrase is the /// origin's own, which is what carries a refusal the registry has no value for. #[moq_clock::test] async fn a_missing_broadcast_is_refused_with_the_draft_s_code() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { - let error_code = match version { - Version::Draft14 => 0x4, - _ => 0x10, - }; + for version in [Version::Draft18, Version::Draft22] { + let error_code = 0x10; let h = serve(version); let session = h.session.clone(); @@ -9312,34 +8565,15 @@ mod serve_tests { version, ); - match version { - Version::Draft14 => { - writer.varint(ietf::SubscribeError::ID).await.unwrap(); - writer - .encode(&ietf::SubscribeError { - request_id: RequestId(REQUEST_ID), - error_code, - reason_phrase: Error::Unroutable.to_string().into(), - }) - .await - .unwrap(); - } - _ => { - writer.varint(ietf::RequestError::ID).await.unwrap(); - writer - .encode(&ietf::RequestError { - request_id: match version { - Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), - _ => None, - }, - error_code, - reason_phrase: Error::Unroutable.to_string().into(), - retry_interval: 0, - }) - .await - .unwrap(); - } - } + writer.varint(ietf::RequestError::ID).await.unwrap(); + writer + .encode(&ietf::RequestError { + error_code, + reason_phrase: Error::Unroutable.to_string().into(), + retry_interval: 0, + }) + .await + .unwrap(); log.writes.lock().unwrap().clone() }; @@ -9352,7 +8586,7 @@ mod serve_tests { } } - /// The refusal of request [`REQUEST_ID`] as gone, as draft-17 and later frame it. + /// The refusal of request [`REQUEST_ID`] as gone. async fn unroutable(version: Version) -> Vec { let log = crate::lite::test_transport::Log::default(); let mut writer = crate::coding::Writer::new( @@ -9362,7 +8596,6 @@ mod serve_tests { writer.varint(ietf::RequestError::ID).await.unwrap(); writer .encode(&ietf::RequestError { - request_id: None, error_code: 0x10, reason_phrase: Error::Unroutable.to_string().into(), retry_interval: 0, @@ -9421,7 +8654,7 @@ mod serve_tests { /// refuses them as gone instead of serving another instance's content. #[moq_clock::test] async fn a_request_for_another_instance_is_refused() { - for version in [Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { let served = crate::Epoch::mint(); let stale = crate::Epoch::mint(); let pinned = crate::origin::Route::default().with_epoch(served.clone()); @@ -9463,7 +8696,7 @@ mod serve_tests { /// wait: an instance never comes back, so there is nothing to rendezvous with. #[moq_clock::test] async fn a_pinned_request_never_waits_for_its_instance() { - for version in [Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { let stale = crate::Epoch::mint(); let pinned = crate::origin::Route::default().with_epoch(crate::Epoch::mint()); for (name, route) in [("another epoch", pinned), ("no epoch", crate::origin::Route::default())] { @@ -9476,9 +8709,6 @@ mod serve_tests { let encode = |body: &mut Vec| -> Result<(), crate::coding::EncodeError> { let w = &mut Encoder::new(body, version.into()); RequestId(REQUEST_ID).encode(w, version)?; - if version == Version::Draft17 { - w.varint(0)?; // required_request_id_delta - } super::super::namespace::encode_namespace(w, &crate::Path::from_static("room"))?; w.string("video")?; encode_params!(w, version, @@ -9512,7 +8742,7 @@ mod serve_tests { /// cannot have learned an instance it was never told about. #[moq_clock::test] async fn an_epoch_without_the_extension_is_a_violation() { - for version in [Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { let h = serve(version); for (id, body) in pinned_requests(version, &crate::Epoch::mint()) { let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) @@ -9530,14 +8760,7 @@ mod serve_tests { } } - const ALL_VERSIONS: [Version; 6] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; + const ALL_VERSIONS: [Version; 2] = [Version::Draft18, Version::Draft22]; fn track_status(namespace: &str, wanted: bool, version: Version) -> Vec { let msg = ietf::TrackStatus { @@ -9585,17 +8808,11 @@ mod serve_tests { let wire = h.log.writes.lock().unwrap()[mark..].to_vec(); let mut buf = Decoder::new(&wire, version.into()); - assert_eq!(buf.varint().unwrap(), ietf::TrackStatusOk::id(version), "{version}"); + assert_eq!(buf.varint().unwrap(), ietf::TrackStatusOk::ID, "{version}"); let ok = ietf::TrackStatusOk::decode(&mut buf, version).unwrap(); assert!(buf.is_empty(), "{version}: trailing bytes"); let expected = match version { - // GROUP_ORDER is a field on draft-14 and a parameter on draft-15. - Version::Draft14 | Version::Draft15 => ietf::Properties { - group_order: Some(GroupOrder::Descending), - ..Default::default() - }, - Version::Draft16 | Version::Draft17 => ietf::Properties::default(), // Only draft-22 can carry the opt-out. _ if !wanted && Filter::is_draft22(version) => ietf::Properties::default(), _ => track_properties(&track::Info::timed(), true, version, true).unwrap(), @@ -9603,8 +8820,6 @@ mod serve_tests { assert_eq!( ok, ietf::TrackStatusOk { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(REQUEST_ID)), largest: Some(Location { group: 4, object: 1 }), properties: expected, }, @@ -9697,7 +8912,7 @@ mod serve_tests { subscribe: Default::default(), expires: None, }; - for version in [Version::Draft14, Version::Draft22] { + for version in [Version::Draft22] { let mut h = serve(version); let auth = crate::auth::Link::new(false); auth.handle().authorize(¬hing); @@ -9732,7 +8947,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -9812,10 +9027,7 @@ mod serve_tests { let mut buf = Decoder::new(wire, version.into()); let id = buf.varint().unwrap(); match version { - Version::Draft14 if id == ietf::TRACK_STATUS_ERROR_14 => { - Some(ietf::SubscribeError::decode(&mut buf, version).unwrap().error_code) - } - _ if version != Version::Draft14 && id == ietf::RequestError::ID => { + _ if id == ietf::RequestError::ID => { Some(ietf::RequestError::decode(&mut buf, version).unwrap().error_code) } _ => None, @@ -9827,10 +9039,7 @@ mod serve_tests { async fn track_status_for_a_missing_broadcast_is_refused() { for version in ALL_VERSIONS { let code = refusal(version, ietf::TrackStatus::ID, track_status("absent", true, version)).await; - let expected = match version { - Version::Draft14 => 0x4, - _ => 0x10, - }; + let expected = 0x10; assert_eq!(code, expected, "{version}"); } } @@ -9846,7 +9055,6 @@ mod serve_tests { &mut Encoder::new(&mut properties, version.into()), stamp, track::Info::timed().timescale.expect("a timed track"), - version, ) .unwrap(); @@ -9884,17 +9092,8 @@ mod serve_tests { let wire = h.log.writes.lock().unwrap()[mark..].to_vec(); let mut buf = Decoder::new(&wire, version.into()); - match version { - Version::Draft14 => { - // SUBSCRIBE_ERROR and FETCH_ERROR share their layout. - let _id = buf.varint().unwrap(); - ietf::SubscribeError::decode(&mut buf, version).unwrap().error_code - } - _ => { - assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID); - ietf::RequestError::decode(&mut buf, version).unwrap().error_code - } - } + assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID); + ietf::RequestError::decode(&mut buf, version).unwrap().error_code } #[moq_clock::test] @@ -10029,7 +9228,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(h.session.clone()), scoped, - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -10045,16 +9244,8 @@ mod serve_tests { let wire = h.log.writes.lock().unwrap()[mark..].to_vec(); let mut buf = Decoder::new(&wire, version.into()); - let code = match version { - Version::Draft14 => { - assert_eq!(buf.varint().unwrap(), ietf::SubscribeError::ID, "{version}"); - ietf::SubscribeError::decode(&mut buf, version).unwrap().error_code - } - _ => { - assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID, "{version}"); - ietf::RequestError::decode(&mut buf, version).unwrap().error_code - } - }; + assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID, "{version}"); + let code = ietf::RequestError::decode(&mut buf, version).unwrap().error_code; let expected = request::to_code(&Error::from(crate::path::TooLong), request::Kind::Subscribe, version); assert_eq!(code, expected, "{version}"); assert!(h.log.closes().is_empty(), "{version}"); @@ -10117,119 +9308,58 @@ mod serve_tests { } } - /// A draft-16/17 SUBSCRIBE_NAMESPACE gets NAMESPACE whenever it asks for namespaces, - /// and one asking for PUBLISH alone is refused, since we never send PUBLISH. Draft-18 - /// has no Subscribe Options and always asks for namespaces. + /// A SUBSCRIBE_NAMESPACE always asks for namespaces, and gets NAMESPACE for each. #[moq_clock::test] - async fn subscribe_namespace_honors_subscribe_options() { - use ietf::SubscribeOptions::{Both, Namespace, Publish}; - - for (version, options, namespaces) in [ - (Version::Draft16, Some(Publish), false), - (Version::Draft16, Some(Namespace), true), - (Version::Draft16, Some(Both), true), - (Version::Draft17, Some(Publish), false), - (Version::Draft17, Some(Namespace), true), - (Version::Draft17, Some(Both), true), - (Version::Draft18, None, true), - ] { - let h = serve(version); - settle().await; - - let mut body = Vec::new(); - let mut w = Encoder::new(&mut body, version.into()); - let id = match options { - Some(subscribe_options) => { - ietf::SubscribeNamespaceLegacy { - request_id: RequestId(REQUEST_ID), - namespace: crate::Path::from_static(""), - subscribe_options, - hidden: false, - } - .encode_msg(&mut w, version) - .unwrap(); - ietf::SubscribeNamespaceLegacy::ID - } - None => { - ietf::SubscribeNamespace { - request_id: RequestId(REQUEST_ID), - namespace: crate::Path::from_static(""), - hidden: false, - } - .encode_msg(&mut w, version) - .unwrap(); - ietf::SubscribeNamespace::ID - } - }; + async fn subscribe_namespace_gets_namespaces() { + let version = Version::Draft18; + let h = serve(version); + settle().await; - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - let mark = h.log.writes.lock().unwrap().len(); - let task = h - .publisher - .clone() - .handle_stream(id, ietf::Body(bytes::Bytes::from(body)), stream, false) - .unwrap_or_else(|e| panic!("{version}: the request closed the session: {e}")); - let mut task = std::pin::pin!(task); - let mut done = false; - for _ in 0..100 { - done = futures::poll!(task.as_mut()).is_ready(); - if done || occurrences(&h.log, b"room") > 0 { - break; - } - settle().await; - } + let mut body = Vec::new(); + let mut w = Encoder::new(&mut body, version.into()); + ietf::SubscribeNamespace { + request_id: RequestId(REQUEST_ID), + namespace: crate::Path::from_static(""), + hidden: false, + } + .encode_msg(&mut w, version) + .unwrap(); - let wire = h.log.writes.lock().unwrap()[mark..].to_vec(); - let mut buf = Decoder::new(&wire, version.into()); - let label = format!("{version} {options:?}"); - if namespaces { - assert!(!done, "{label}: the subscription ended"); - assert_eq!(buf.varint().unwrap(), ietf::RequestOk::ID, "{label}"); - ietf::RequestOk::decode(&mut buf, version).unwrap(); - assert_eq!(buf.varint().unwrap(), ietf::Namespace::ID, "{label}"); - assert_eq!( - ietf::Namespace::decode(&mut buf, version).unwrap().suffix.as_str(), - "room", - "{label}" - ); - } else { - assert!(done, "{label}: the refusal did not finish"); - assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID, "{label}"); - let err = ietf::RequestError::decode(&mut buf, version).unwrap(); - assert_eq!(err.error_code, 0x3, "{label}: NOT_SUPPORTED"); - assert_eq!( - err.request_id, - (version == Version::Draft16).then_some(RequestId(REQUEST_ID)), - "{label}" - ); + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) + .await + .unwrap(); + let mark = h.log.writes.lock().unwrap().len(); + let task = h + .publisher + .clone() + .handle_stream( + ietf::SubscribeNamespace::ID, + ietf::Body(bytes::Bytes::from(body)), + stream, + false, + ) + .unwrap_or_else(|e| panic!("{version}: the request closed the session: {e}")); + let mut task = std::pin::pin!(task); + let mut done = false; + for _ in 0..100 { + done = futures::poll!(task.as_mut()).is_ready(); + if done || occurrences(&h.log, b"room") > 0 { + break; } - assert!(buf.is_empty(), "{label}: trailing bytes"); + settle().await; } - // Undefined Subscribe Options close the session: dispatch returns an error. - #[rustfmt::skip] - let cases: [(Version, &[u8]); 2] = [ - // Request ID, empty namespace, Subscribe Options 0x03, no parameters. - (Version::Draft16, &[0x2B, 0x00, 0x03, 0x00]), - // Draft-17 adds the Required Request ID delta after the Request ID. - (Version::Draft17, &[0x2B, 0x00, 0x00, 0x03, 0x00]), - ]; - for (version, body) in cases { - let h = serve(version); - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - let body = ietf::Body(bytes::Bytes::copy_from_slice(body)); - assert!( - h.publisher - .clone() - .handle_stream(ietf::SubscribeNamespaceLegacy::ID, body, stream, false) - .is_err(), - "{version}: Subscribe Options 0x03 kept the session open" - ); - } + let wire = h.log.writes.lock().unwrap()[mark..].to_vec(); + let mut buf = Decoder::new(&wire, version.into()); + assert!(!done, "the subscription ended"); + assert_eq!(buf.varint().unwrap(), ietf::RequestOk::ID); + ietf::RequestOk::decode(&mut buf, version).unwrap(); + assert_eq!(buf.varint().unwrap(), ietf::Namespace::ID); + assert_eq!( + ietf::Namespace::decode(&mut buf, version).unwrap().suffix.as_str(), + "room" + ); + assert!(buf.is_empty(), "trailing bytes"); } /// A Next Object or unfiltered join gives its current-group fill the head exactly @@ -10315,9 +9445,6 @@ mod serve_tests { .await; let update = ietf::SubscribeUpdate { request_id: RequestId(2), - subscription_request_id: None, - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: None, filter: Some(Filter::Absolute { start, end }), @@ -10407,9 +9534,6 @@ mod serve_tests { for (answered, (start, fill)) in updates.into_iter().enumerate() { let update = ietf::SubscribeUpdate { request_id: RequestId(2 + 2 * answered as u64), - subscription_request_id: None, - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: None, filter: Some(Filter::Absolute { start, end: None }), @@ -10481,9 +9605,6 @@ mod serve_tests { .await; let update = ietf::SubscribeUpdate { request_id: RequestId(2), - subscription_request_id: None, - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: None, filter: None, @@ -10545,9 +9666,6 @@ mod serve_tests { .await; let update = ietf::SubscribeUpdate { request_id: RequestId(2), - subscription_request_id: None, - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: None, filter: Some(Filter::Absolute { @@ -10859,7 +9977,7 @@ mod serve_tests { /// request id breaking ties, whatever group and GROUP_ORDER it asks for. #[moq_clock::test] async fn a_standalone_fetch_ranks_by_its_priority() { - for version in [Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { for (group, group_order) in [(1, GroupOrder::Ascending), (3, GroupOrder::Descending)] { let mut h = serve(version); publish_groups(&mut h, 3); @@ -10924,30 +10042,14 @@ mod serve_tests { const FETCH_ID: RequestId = RequestId(0x2C); /// Every draft that still carries a joining FETCH. - const JOINING_DRAFTS: [Version; 5] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ]; + const JOINING_DRAFTS: [Version; 1] = [Version::Draft18]; - fn invalid_joining_request_id(version: Version) -> u64 { - if version == Version::Draft14 { 0x7 } else { 0x32 } - } + const INVALID_JOINING_REQUEST_ID: u64 = 0x32; - fn invalid_range(version: Version) -> u64 { - if version == Version::Draft14 { 0x5 } else { 0x11 } - } + const INVALID_RANGE: u64 = 0x11; - /// The registry's "does not exist" value: draft-14 numbers it 0x4 on FETCH_ERROR and - /// draft-15 moved it to 0x10. - fn does_not_exist(version: Version) -> u64 { - match version { - Version::Draft14 => 0x4, - _ => 0x10, - } - } + /// The registry's "does not exist" value. + const DOES_NOT_EXIST: u64 = 0x10; /// Yield so the broadcast registered by `serve` is visible to the lookup. async fn settle() { @@ -11055,22 +10157,8 @@ mod serve_tests { assert_eq!(id, ietf::FetchOk::ID, "{version}: not a FETCH_OK"); let ok = crate::coding::decode_buf(&mut buf, version, ietf::FetchOk::decode).unwrap(); - assert_eq!( - ok.request_id, - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(FETCH_ID), - _ => None, - }, - "{version}: wrong request id" - ); assert!(!ok.end_of_track, "{version}: the track has not ended"); - if version == Version::Draft14 { - assert_eq!(ok.group_order, GroupOrder::Ascending); - } - let properties = match ietf::Properties::sends_timescale(version) { - false => ietf::Properties::default(), - true => track_properties(&track::Info::timed(), true, version, true).unwrap(), - }; + let properties = track_properties(&track::Info::timed(), true, version, true).unwrap(); assert_eq!(ok.properties, properties, "{version}: wrong properties"); assert_eq!( ok.end_location, @@ -11091,47 +10179,22 @@ mod serve_tests { .request_id, FETCH_ID ); - // Drafts 14-16 never declared TIMESCALE, so the prefix must not carry a Timestamp. - let stamped = ietf::Properties::sends_timescale(version); for (index, payload) in payloads.iter().enumerate() { - if version == Version::Draft14 { + let flags = if index == 0 { 0x3c } else { 0x20 }; + assert_eq!( + crate::coding::decode_varint(&mut buf, version).unwrap(), + flags, + "{version}" + ); + if index == 0 { assert_eq!(crate::coding::decode_varint(&mut buf, version).unwrap(), LATEST); assert_eq!(crate::coding::decode_varint(&mut buf, version).unwrap(), 0); - assert_eq!(crate::coding::decode_varint(&mut buf, version).unwrap(), index as u64); assert_eq!(bytes::Buf::try_get_u8(&mut buf).unwrap(), 0); - } else { - let flags = match (index == 0, stamped) { - (true, true) => 0x3c, - (true, false) => 0x1c, - (false, true) => 0x20, - (false, false) => 0, - }; - assert_eq!( - crate::coding::decode_varint(&mut buf, version).unwrap(), - flags, - "{version}" - ); - if index == 0 { - assert_eq!(crate::coding::decode_varint(&mut buf, version).unwrap(), LATEST); - assert_eq!(crate::coding::decode_varint(&mut buf, version).unwrap(), 0); - assert_eq!(bytes::Buf::try_get_u8(&mut buf).unwrap(), 0); - } - } - let properties = if version == Version::Draft14 || stamped { - crate::coding::decode_buf(&mut buf, version, |r, _| Ok(r.bytes()?.to_vec())).unwrap() - } else { - Vec::new() - }; - if stamped { - assert!(!properties.is_empty(), "{version}: missing Timestamp"); - } else { - assert!(properties.is_empty(), "{version}: Timestamp without TIMESCALE"); } + let properties = crate::coding::decode_buf(&mut buf, version, |r, _| Ok(r.bytes()?.to_vec())).unwrap(); + assert!(!properties.is_empty(), "{version}: missing Timestamp"); let size = crate::coding::decode_varint(&mut buf, version).unwrap() as usize; assert_eq!(size, payload.len()); - if size == 0 && matches!(version, Version::Draft14 | Version::Draft15) { - assert_eq!(crate::coding::decode_varint(&mut buf, version).unwrap(), 0); - } assert_eq!(buf.split_to(size).as_ref(), *payload); } assert!(buf.is_empty(), "FETCH delivered objects beyond the saved prefix"); @@ -11150,7 +10213,7 @@ mod serve_tests { /// Dispatch order must not depend on which request task gets polled first. #[moq_clock::test] async fn a_joining_fetch_waits_for_its_dispatched_subscription() { - let version = Version::Draft17; + let version = Version::Draft18; let mut h = serve(version); publish_groups(&mut h, 5); settle().await; @@ -11232,23 +10295,13 @@ mod serve_tests { drop(serving); let mut response = bytes::Bytes::from(fetching.await.unwrap()); let id = crate::coding::decode_varint(&mut response, version).unwrap(); - if version == Version::Draft14 { - assert_eq!(id, ietf::FetchError::ID); - assert_eq!( - crate::coding::decode_buf(&mut response, version, ietf::FetchError::decode) - .unwrap() - .error_code, - invalid_joining_request_id(version) - ); - } else { - assert_eq!(id, ietf::RequestError::ID); - assert_eq!( - crate::coding::decode_buf(&mut response, version, ietf::RequestError::decode) - .unwrap() - .error_code, - invalid_joining_request_id(version) - ); - } + assert_eq!(id, ietf::RequestError::ID); + assert_eq!( + crate::coding::decode_buf(&mut response, version, ietf::RequestError::decode) + .unwrap() + .error_code, + INVALID_JOINING_REQUEST_ID + ); assert!(response.is_empty()); assert!(h.publisher.joins.read().is_empty()); } @@ -11256,7 +10309,7 @@ mod serve_tests { #[moq_clock::test] async fn a_joining_fetch_times_out_an_unresolved_subscription() { - let version = Version::Draft17; + let version = Version::Draft18; let h = serve(version); let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) .await @@ -11284,7 +10337,7 @@ mod serve_tests { /// refusal carries that error: refused before FETCH_OK, and never reset. #[moq_clock::test] async fn a_joining_fetch_refuses_a_missing_prefix_before_fetch_ok() { - let version = Version::Draft17; + let version = Version::Draft18; let h = serve(version); let mut group = h.track.create_group(group::Info { sequence: 5 }).unwrap(); group.start_at(1).unwrap(); @@ -11310,7 +10363,7 @@ mod serve_tests { crate::coding::decode_buf(&mut response, version, ietf::RequestError::decode) .unwrap() .error_code, - does_not_exist(version) + DOES_NOT_EXIST ); assert!(response.is_empty()); assert!(h.log.resets().is_empty()); @@ -11320,7 +10373,7 @@ mod serve_tests { /// earlier group it holds has as many objects as the prefix promised. #[moq_clock::test] async fn a_joining_fetch_refuses_a_prefix_group_it_lost() { - let version = Version::Draft17; + let version = Version::Draft18; let h = serve(version); let mut earlier = h.track.create_group(group::Info { sequence: 3 }).unwrap(); earlier.write_frame(timestamp(), b"frame".as_slice()).unwrap(); @@ -11381,23 +10434,13 @@ mod serve_tests { let mark = h.log.writes.lock().unwrap().len(); let mut buf = bytes::Bytes::from(joining_fetch(&h, mark).await.unwrap()); let id = crate::coding::decode_varint(&mut buf, version).unwrap(); - if version == Version::Draft14 { - assert_eq!(id, ietf::FetchError::ID); - assert_eq!( - crate::coding::decode_buf(&mut buf, version, ietf::FetchError::decode) - .unwrap() - .error_code, - invalid_joining_request_id(version) - ); - } else { - assert_eq!(id, ietf::RequestError::ID); - assert_eq!( - crate::coding::decode_buf(&mut buf, version, ietf::RequestError::decode) - .unwrap() - .error_code, - invalid_joining_request_id(version) - ); - } + assert_eq!(id, ietf::RequestError::ID); + assert_eq!( + crate::coding::decode_buf(&mut buf, version, ietf::RequestError::decode) + .unwrap() + .error_code, + INVALID_JOINING_REQUEST_ID + ); assert!(buf.is_empty()); assert!(h.log.resets().is_empty()); } @@ -11421,22 +10464,17 @@ mod serve_tests { registered(&h, serving.as_mut()).await; let mark = h.log.writes.lock().unwrap().len(); let result = joining_fetch(&h, mark).await; - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - assert!(matches!(result, Err(Error::ProtocolViolation))); - assert_eq!(h.log.closes()[0].0, 0x3); - } else { - let mut buf = bytes::Bytes::from(result.unwrap()); - assert_eq!( - crate::coding::decode_varint(&mut buf, version).unwrap(), - ietf::RequestError::ID - ); - assert_eq!( - crate::coding::decode_buf(&mut buf, version, ietf::RequestError::decode) - .unwrap() - .error_code, - 0x3 - ); - } + let mut buf = bytes::Bytes::from(result.unwrap()); + assert_eq!( + crate::coding::decode_varint(&mut buf, version).unwrap(), + ietf::RequestError::ID + ); + assert_eq!( + crate::coding::decode_buf(&mut buf, version, ietf::RequestError::decode) + .unwrap() + .error_code, + 0x3 + ); } } } @@ -11460,37 +10498,20 @@ mod serve_tests { let mark = h.log.writes.lock().unwrap().len(); let mut buf = bytes::Bytes::from(joining_fetch(&h, mark).await.unwrap()); let id = crate::coding::decode_varint(&mut buf, version).unwrap(); - if version == Version::Draft14 { - assert_eq!(id, ietf::FetchError::ID); - assert_eq!( - crate::coding::decode_buf(&mut buf, version, ietf::FetchError::decode) - .unwrap() - .error_code, - invalid_range(version) - ); - } else { - assert_eq!(id, ietf::RequestError::ID); - assert_eq!( - crate::coding::decode_buf(&mut buf, version, ietf::RequestError::decode) - .unwrap() - .error_code, - invalid_range(version) - ); - } + assert_eq!(id, ietf::RequestError::ID); + assert_eq!( + crate::coding::decode_buf(&mut buf, version, ietf::RequestError::decode) + .unwrap() + .error_code, + INVALID_RANGE + ); assert!(buf.is_empty()); assert!(h.log.resets().is_empty()); } } /// Every draft, each carrying a standalone FETCH or draft-22's filtered one. - const FETCH_DRAFTS: [Version; 6] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; + const FETCH_DRAFTS: [Version; 2] = [Version::Draft18, Version::Draft22]; /// A bounded FETCH_OK's End Location: one past `last` before draft 22, /// and the object itself from then on. Whole-group answers use Object 0 before draft 22. @@ -11517,7 +10538,7 @@ mod serve_tests { /// Run a standalone FETCH of `room/video`, returning what the peer reads back. /// - /// `end` is spelled as drafts 14 to 18 do: the last object plus one, or 0 for the whole + /// `end` is spelled as draft 18 does: the last object plus one, or 0 for the whole /// End Group. From draft 22 the same range goes out as an inclusive LOCATION_FILTER. async fn standalone_fetch(h: &Serve, start: Location, end: Location, group_order: GroupOrder) -> bytes::Bytes { let fetch_type = fetch_range(h.publisher.version, "video", start, end); @@ -11644,14 +10665,7 @@ mod serve_tests { let mut objects = Vec::new(); let mut prior: Option<(u64, u64)> = None; while !buf.is_empty() { - let (group, object) = if version == Version::Draft14 { - let group = buf.varint().unwrap(); - assert_eq!(buf.varint().unwrap(), 0, "subgroup"); - let object = buf.varint().unwrap(); - let _priority = buf.u8().unwrap(); - let _properties = buf.bytes().unwrap(); - (group, object) - } else { + let (group, object) = { let ietf::FetchObject::Object { group, object, .. } = ietf::FetchObject::decode(&mut buf, version).unwrap() else { @@ -11663,7 +10677,6 @@ mod serve_tests { (Some((group, prior)), None) => (group, prior + object.unwrap_or(1)), (Some((prior, _)), Some(group)) => { let group = match (version, order) { - (Version::Draft15 | Version::Draft16 | Version::Draft17, _) => group, (_, GroupOrder::Descending) => prior - group - 1, _ => prior + group + 1, }; @@ -11748,28 +10761,21 @@ mod serve_tests { let (ok, objects) = fetch_answer(run_fetch(&h, range, GroupOrder::Ascending, wanted).await, version); assert_eq!(objects.len(), 1, "{version}"); - let expected = match ietf::Properties::sends_timescale(version) { - // Drafts 14-16 write no properties, as their SUBSCRIBE_OK does not. - false => ietf::Properties::default(), - true => track_properties(&info, wanted, version, true).unwrap(), - }; + let expected = track_properties(&info, wanted, version, true).unwrap(); assert_eq!(ok.properties, expected, "{version} wanted={wanted}"); - if ietf::Properties::sends_timescale(version) { - let mut properties = Vec::new(); - ietf::encode_object_time( - &mut Encoder::new(&mut properties, version.into()), - stamp, - info.timescale.expect("a timed track"), - version, - ) - .unwrap(); - assert_eq!( - occurrences(&h.log, &properties), - 1, - "{version} wanted={wanted}: object unstamped" - ); - } + let mut properties = Vec::new(); + ietf::encode_object_time( + &mut Encoder::new(&mut properties, version.into()), + stamp, + info.timescale.expect("a timed track"), + ) + .unwrap(); + assert_eq!( + occurrences(&h.log, &properties), + 1, + "{version} wanted={wanted}: object unstamped" + ); } } } @@ -11794,7 +10800,7 @@ mod serve_tests { /// unchanged, beside the ones it does, on every draft with a Track Properties block. #[moq_clock::test] async fn unknown_track_properties_are_forwarded() { - for version in [Version::Draft16, Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { let upstream = unknown_properties(version); let info = track::Info { properties: Some(Arc::new(crate::model::object::TrackProperties { @@ -11826,33 +10832,29 @@ mod serve_tests { let (ok, _) = fetch_answer(run_fetch(&h, range, GroupOrder::Ascending, true).await, version); assert_eq!(ok.properties.unknown, upstream.unknown, "{version}: FETCH_OK"); - // Drafts 16 and 17 answer TRACK_STATUS with REQUEST_OK, which has no block. - if version == Version::Draft22 { - let h = serve_track(version, info); - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - h.publisher - .clone() - .handle_stream( - ietf::TrackStatus::ID, - ietf::Body(track_status("room", true, version).into()), - stream, - false, - ) - .unwrap() - .await; - let wire = h.log.writes.lock().unwrap().clone(); - let mut buf = Decoder::new(&wire, version.into()); - assert_eq!(buf.varint().unwrap(), ietf::TrackStatusOk::id(version)); - let ok = ietf::TrackStatusOk::decode(&mut buf, version).unwrap(); - assert_eq!(ok.properties.unknown, upstream.unknown, "{version}: TRACK_STATUS_OK"); - } + let h = serve_track(version, info); + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) + .await + .unwrap(); + h.publisher + .clone() + .handle_stream( + ietf::TrackStatus::ID, + ietf::Body(track_status("room", true, version).into()), + stream, + false, + ) + .unwrap() + .await; + let wire = h.log.writes.lock().unwrap().clone(); + let mut buf = Decoder::new(&wire, version.into()); + assert_eq!(buf.varint().unwrap(), ietf::TrackStatusOk::ID); + let ok = ietf::TrackStatusOk::decode(&mut buf, version).unwrap(); + assert_eq!(ok.properties.unknown, upstream.unknown, "{version}: TRACK_STATUS_OK"); } } - /// Immutable Properties keep the bytes their publisher sealed, so they are forwarded - /// only to a draft that encodes them the same way, and refused elsewhere. + /// Immutable Properties keep the bytes their publisher sealed. #[moq_clock::test] async fn immutable_track_properties_keep_their_encoding() { // Sealed: OBJECT_DELIVERY_TIMEOUT = 42 and DYNAMIC_GROUPS = 1. @@ -11881,28 +10883,6 @@ mod serve_tests { assert_eq!(ok.properties.unknown, upstream.unknown); // Its key is delta-encoded against TIMESCALE now, but the sealed bytes are verbatim. assert_eq!(occurrences(&h.log, &block[1..]), 1); - - // Draft 17 forbids the 7-byte varints draft 18 allows, and draft 16 has QUIC varints. - for version in [Version::Draft16, Version::Draft17] { - let h = serve_track(version, info.clone()); - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - h.publisher - .clone() - .run_subscribe_stream(stream, subscribe(Filter::NextObject, None)) - .await - .unwrap(); - let wire = h.log.writes.lock().unwrap().clone(); - let mut buf = Decoder::new(&wire, version.into()); - assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID, "{version}"); - let error = ietf::RequestError::decode(&mut buf, version).unwrap(); - assert_eq!( - error.error_code, - request::to_code(&Error::Unsupported, request::Kind::Subscribe, version), - "{version}" - ); - } } /// The info a relay behind a moq-lite hop with the `ietf` extension learns of a track @@ -12123,7 +11103,7 @@ mod serve_tests { match largest { Largest::Finished | Largest::Live | Largest::IdleFinished => assert_eq!( fetch_refusal(buf, version), - invalid_range(version), + INVALID_RANGE, "{version} {largest:?} start={start}" ), _ => { @@ -12158,7 +11138,7 @@ mod serve_tests { GroupOrder::Ascending, ) .await; - assert_eq!(fetch_refusal(buf, version), invalid_range(version), "{version}"); + assert_eq!(fetch_refusal(buf, version), INVALID_RANGE, "{version}"); } /// On draft 22, an End Object past a finished group's last object is still the End @@ -12351,11 +11331,9 @@ mod serve_tests { let buf = run_fetch(&h, fetch_type, GroupOrder::Ascending, true).await; match (&groups, idle && !reported) { (_, true) => assert_eq!(fetch_refusal(buf, version), 0x3, "{version}: {filter:?}"), - (None, false) => assert_eq!( - fetch_refusal(buf, version), - invalid_range(version), - "{version}: {filter:?}" - ), + (None, false) => { + assert_eq!(fetch_refusal(buf, version), INVALID_RANGE, "{version}: {filter:?}") + } (Some(groups), false) => { let (ok, objects) = fetch_answer(buf, version); assert_eq!( @@ -12496,15 +11474,11 @@ mod serve_tests { } /// A whole End Group at the wire's limit is valid: a range from the first group stops at - /// the track's end, and a missing single group is not found. Drafts 14-16 cannot encode - /// u64::MAX. + /// the track's end, and a missing single group is not found. #[moq_clock::test] async fn a_standalone_fetch_through_the_maximum_group_is_capped() { for version in FETCH_DRAFTS { - let maximum = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => (1 << 62) - 1, - _ => u64::MAX, - }; + let maximum = u64::MAX; let mut h = serve(version); publish_pairs(&mut h, 1, None); h.track.finish().unwrap(); @@ -12524,7 +11498,7 @@ mod serve_tests { ) .await; if start == maximum { - assert_eq!(fetch_refusal(buf, version), does_not_exist(version), "{version}"); + assert_eq!(fetch_refusal(buf, version), DOES_NOT_EXIST, "{version}"); continue; } let (ok, objects) = fetch_answer(buf, version); @@ -12535,20 +11509,11 @@ mod serve_tests { } } - /// Read a REQUEST_ERROR (or draft-14 FETCH_ERROR) off a refused FETCH, as its code. + /// Read a REQUEST_ERROR off a refused FETCH, as its code. fn fetch_refusal(wire: bytes::Bytes, version: Version) -> u64 { let mut buf = Decoder::new(&wire, version.into()); - let id = buf.varint().unwrap(); - let code = match version { - Version::Draft14 => { - assert_eq!(id, ietf::FetchError::ID); - ietf::FetchError::decode(&mut buf, version).unwrap().error_code - } - _ => { - assert_eq!(id, ietf::RequestError::ID); - ietf::RequestError::decode(&mut buf, version).unwrap().error_code - } - }; + assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID); + let code = ietf::RequestError::decode(&mut buf, version).unwrap().error_code; assert!(buf.is_empty(), "{version}: a refusal opens no fetch stream"); code } @@ -12577,7 +11542,7 @@ mod serve_tests { .await; assert_eq!( fetch_refusal(buf, version), - does_not_exist(version), + DOES_NOT_EXIST, "{version}: group {missing}" ); } @@ -12673,7 +11638,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -12759,7 +11724,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -12863,7 +11828,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -12977,7 +11942,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -13126,7 +12091,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -13203,7 +12168,6 @@ mod serve_tests { let wire = Wire::read(&session.log, version); let sent: Vec = wire.messages.iter().map(|(id, _)| *id).collect(); let expected = match (parked, version) { - (Parked::Resolving, Version::Draft14) => vec![ietf::SubscribeError::ID], (Parked::Resolving, _) => vec![ietf::RequestError::ID], _ => vec![ietf::SubscribeOk::ID, ietf::PublishDone::ID], }; @@ -13222,21 +12186,12 @@ mod serve_tests { Live, } - /// Withdraw the SUBSCRIBE on `request` as `version` cancels one: UNSUBSCRIBE through - /// draft 16, a FIN on drafts 17 and 18, a reset after. + /// Withdraw the SUBSCRIBE on `request` as `version` cancels one: a FIN on draft 18, a + /// reset after. fn withdraw(request: &ScriptedSession, version: Version) { use crate::lite::test_transport::Close; match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => { - let mut unsubscribe = vec![ietf::Unsubscribe::ID as u8]; - ietf::Unsubscribe { - request_id: RequestId(REQUEST_ID), - } - .encode(&mut Encoder::new(&mut unsubscribe, version.into()), version) - .unwrap(); - request.push(&unsubscribe); - } - Version::Draft17 | Version::Draft18 => request.close(Close::Fin), + Version::Draft18 => request.close(Close::Fin), _ => request.close(Close::Reset), } } @@ -13261,7 +12216,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -13411,7 +12366,7 @@ mod serve_tests { let buf = standalone_fetch(&h, location, location, GroupOrder::Ascending).await; assert_eq!( fetch_refusal(buf, version), - does_not_exist(version), + DOES_NOT_EXIST, "{version}: newest={newest}" ); } @@ -13490,7 +12445,7 @@ mod serve_tests { /// An absolute joining FETCH starting past the subscription's group names an empty range. #[moq_clock::test] async fn an_absolute_joining_fetch_past_the_subscription_is_refused() { - let version = Version::Draft16; + let version = Version::Draft18; let mut h = serve(version); publish_pairs(&mut h, 3, None); settle().await; @@ -13530,7 +12485,7 @@ mod serve_tests { .await .unwrap(); let buf = bytes::Bytes::from(h.log.writes.lock().unwrap()[mark..].to_vec()); - assert_eq!(fetch_refusal(buf, version), invalid_range(version)); + assert_eq!(fetch_refusal(buf, version), INVALID_RANGE); } /// A fill against an empty track has an empty range: no fetch stream is owed. @@ -13580,22 +12535,12 @@ mod serve_tests { .largest } - /// Through draft-15 SUBSCRIBE_OK restates the order the subscription is served in; later - /// drafts announce the publisher's default, newest first. + /// SUBSCRIBE_OK announces the publisher's default order, newest first, whatever the + /// subscription asked for. #[moq_clock::test] - async fn subscribe_ok_announces_the_served_order() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft22, - ] { - for (requested, announced) in [ - (GroupOrder::Ascending, GroupOrder::Ascending), - (GroupOrder::Descending, GroupOrder::Descending), - (GroupOrder::Any, GroupOrder::Descending), - ] { + async fn subscribe_ok_announces_the_default_order() { + for version in [Version::Draft18, Version::Draft22] { + for requested in [GroupOrder::Ascending, GroupOrder::Descending, GroupOrder::Any] { let mut h = serve(version); let mut msg = subscribe(Filter::NextObject, None); msg.group_order = requested; @@ -13608,26 +12553,18 @@ mod serve_tests { ietf::SubscribeOk::ID ); let ok = crate::coding::decode_buf(&mut buf, version, ietf::SubscribeOk::decode).unwrap(); - let expected = match version { - Version::Draft14 | Version::Draft15 => Some(announced), - // Draft-16 has no room for it. - Version::Draft16 => None, - _ => Some(GroupOrder::Descending), - }; - assert_eq!(ok.properties.group_order, expected, "{version:?} {requested:?}"); + assert_eq!( + ok.properties.group_order, + Some(GroupOrder::Descending), + "{version:?} {requested:?}" + ); } } } #[moq_clock::test] async fn largest_object_is_the_live_edge_before_draft22() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { + for version in [Version::Draft18] { assert_eq!( subscribe_ok_largest(version).await, Some(Location { group: 5, object: 3 }), @@ -13785,7 +12722,7 @@ mod serve_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -13847,17 +12784,10 @@ mod serve_tests { crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), version, ); - let subscription_request_id = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), - _ => None, - }; let msg = ietf::SubscribeUpdate { max_age: None, new_group: None, request_id: RequestId(0x40), - subscription_request_id, - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: Some(128), forward: Some(forward), filter: None, @@ -13869,9 +12799,8 @@ mod serve_tests { log.writes.lock().unwrap().clone() } - /// The wire bytes of one REQUEST_OK answering `request_id`: keyed to it on draft-15/16, - /// unkeyed on draft-17+. - async fn request_ok_bytes(version: Version, request_id: RequestId) -> Vec { + /// The wire bytes of one REQUEST_OK. + async fn request_ok_bytes(version: Version) -> Vec { let log = crate::lite::test_transport::Log::default(); let mut writer = crate::coding::Writer::new( crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), @@ -13881,7 +12810,6 @@ mod serve_tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: matches!(version, Version::Draft15 | Version::Draft16).then_some(request_id), active: None, }) .await @@ -13889,20 +12817,17 @@ mod serve_tests { log.writes.lock().unwrap().clone() } - /// The REQUEST_ERROR a refused renewal writes before the subscription ends: keyed to the - /// update's Request ID on draft-15/16, unkeyed on draft-17+, matching + /// The REQUEST_ERROR a refused renewal writes before the subscription ends, matching /// `write_subscribe_error(.., &Error::Unauthorized, reason)`. - async fn request_error_bytes(version: Version, request_id: RequestId, reason: &str) -> Vec { + async fn request_error_bytes(version: Version, reason: &str) -> Vec { let log = crate::lite::test_transport::Log::default(); let mut writer = crate::coding::Writer::new( crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), version, ); - let request_id = matches!(version, Version::Draft15 | Version::Draft16).then_some(request_id); writer.varint(ietf::RequestError::ID).await.unwrap(); writer .encode(&ietf::RequestError { - request_id, error_code: request::to_code(&Error::Unauthorized, request::Kind::Subscribe, version), reason_phrase: reason.into(), retry_interval: 0, @@ -13998,36 +12923,11 @@ mod serve_tests { } } - /// The pre-draft-17 renewal: a SUBSCRIBE_UPDATE carrying a fresh token renews a - /// token-authorized subscription past the old grant's expiry at draft-14/15/16, where the - /// update rides the control-stream adapter. Draft-14 writes no answer (it defines none, and - /// its control stream would read a 0x05 as ending the subscription); draft-15 and draft-16 - /// (section 9.11) write exactly one REQUEST_OK, keyed to the update's own Request ID so the - /// peer matches it to the REQUEST_UPDATE it sent. The adapter's follow-up routing of the - /// update to the subscription's stream is proven by - /// `super::super::adapter::tests::test_classify_subscribe_update_followup`. - #[moq_clock::test] - async fn a_subscribe_update_renewal_is_answered_from_draft_15() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - renews(version, true).await; - } - } - - /// A refused renewal ends only its subscription at once (PUBLISH_DONE UPDATE_FAILED, or the - /// refusal itself on draft 14), never the session. The update is answered first: nothing on - /// draft-14, a keyed REQUEST_ERROR to the update's Request ID on draft-15/16. - #[moq_clock::test] - async fn a_refused_subscribe_update_renewal_ends_the_subscription_from_draft_14() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - renews(version, false).await; - } - } - /// A pause is answered before a renewal riding it could be verified, so an update carrying /// both fails instead of acknowledging a token nobody checked. #[moq_clock::test] async fn a_pause_carrying_a_token_fails_the_update() { - for version in [Version::Draft15, Version::Draft16] { + for version in [Version::Draft18] { let (auth, mut requests, _cred) = auth_covering_other(); let h = serve_with_auth(version, auth, subscribe_update_with_token(version, false).await); let rt = h.publisher.runtime.clone(); @@ -14061,17 +12961,70 @@ mod serve_tests { "{version:?}: nothing acknowledged it" ); let (status, _) = wire.publish_done.expect("PUBLISH_DONE was sent"); - assert_eq!( - status, - ietf::PublishDoneStatus::UpdateFailed.code(version), - "{version:?}" - ); + assert_eq!(status, ietf::PublishDoneStatus::UpdateFailed.code(), "{version:?}"); } } - async fn renews(version: Version, accept: bool) { + /// One REQUEST_UPDATE carrying a fresh token, keyed to its own Request ID so several can be + /// told apart on the wire. + async fn subscribe_update_token_rid(version: Version, rid: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new( + crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), + version, + ); + let msg = ietf::SubscribeUpdate { + max_age: None, + new_group: None, + request_id: RequestId(rid), + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: Some(request_token()), + fill: None, + }; + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// One token-less REQUEST_UPDATE (a priority or forward change) keyed to `rid`, framed as the + /// peer sends it. It carries no AUTHORIZATION TOKEN, so it starts no verify and is answered at + /// once rather than held. + async fn subscribe_update_bare_rid(version: Version, rid: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new( + crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), + version, + ); + let msg = ietf::SubscribeUpdate { + max_age: None, + new_group: None, + request_id: RequestId(rid), + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: None, + fill: None, + }; + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Two token renewals that arrive while a first renewal's verdict is still pending are each + /// verified and answered, not collapsed. The loop keeps per-update state while a verify is + /// pending (draft-18 section 10.9.1 answers each update); the single slot it replaced + /// dropped the middle renewal (0x42) entirely, so its token was never verified. + #[moq_clock::test] + async fn two_renewals_buffered_behind_a_pending_verdict_each_get_a_verdict() { + const VERSION: Version = Version::Draft18; + let (auth, mut requests, _cred) = auth_covering_other(); - let h = serve_with_auth(version, auth, subscribe_update_with_token(version, true).await); + let mut script = subscribe_update_token_rid(VERSION, 0x40).await; + script.extend(subscribe_update_token_rid(VERSION, 0x42).await); + script.extend(subscribe_update_token_rid(VERSION, 0x44).await); + let h = serve_with_auth(VERSION, auth, script); let rt = h.publisher.runtime.clone(); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -14079,237 +13032,22 @@ mod serve_tests { group.finish().unwrap(); settle().await; - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), VERSION) .await .unwrap(); - // The renewal is held until the setup writes have settled, so anything written after - // it is released is the renewal's answer. + // Hold the first renewal (0x40) so the next two (0x42, 0x44) are read and buffered while + // its verdict is pending: the exact window the single slot used to collapse. let release = std::sync::Arc::new(Release::default()); - let answered = std::sync::Arc::new(AtomicU64::new(0)); + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let acceptor = { let answered = answered.clone(); let release = release.clone(); async move { - let mut held = Vec::new(); let first = requests.next().await.unwrap(); - held.push(first.accept(grant_all_expiring(&rt, Some(60)))); - answered.fetch_add(1, Ordering::Relaxed); - let renewal = requests.next().await.unwrap(); - release.notified().await; - if accept { - held.push(renewal.accept(grant_all_expiring(&rt, None))); - } else { - renewal.reject(crate::SessionError::Unauthorized, "no"); - } - answered.fetch_add(1, Ordering::Relaxed); - std::future::pending::<()>().await - } - }; - let mut acceptor = std::pin::pin!(acceptor); - let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); - - for _ in 0..200 { - let _ = futures::poll!(acceptor.as_mut()); - assert!( - futures::poll!(serving.as_mut()).is_pending(), - "{version:?}: ended during setup" - ); - settle().await; - } - assert_eq!( - answered.load(Ordering::Relaxed), - 1, - "{version:?}: the first token was answered" - ); - let before = h.log.writes.lock().unwrap().len(); - - release.notify_one(); - - if accept { - for _ in 0..200 { - let _ = futures::poll!(acceptor.as_mut()); - assert!( - futures::poll!(serving.as_mut()).is_pending(), - "{version:?}: an accepted renewal keeps serving" - ); - settle().await; - } - assert_eq!( - answered.load(Ordering::Relaxed), - 2, - "{version:?}: the renewal was answered" - ); - // Draft-14 writes nothing; draft-15/16 write exactly one keyed REQUEST_OK to the - // update's own Request ID (0x40), the id the renewing peer waits on. - let written = h.log.writes.lock().unwrap()[before..].to_vec(); - match version { - Version::Draft14 => assert!(written.is_empty(), "{version:?}: a renewal writes no answer"), - _ => assert_eq!( - written, - request_ok_bytes(version, RequestId(0x40)).await, - "{version:?}: one keyed REQUEST_OK to the update's request id" - ), - } - // The re-armed deadline keeps the subscription past the old 60s expiry. - moq_clock::sim::advance(Duration::from_secs(120)).await; - let mut ended = false; - for _ in 0..50 { - let _ = futures::poll!(acceptor.as_mut()); - if futures::poll!(serving.as_mut()).is_ready() { - ended = true; - break; - } - settle().await; - } - assert!(!ended, "{version:?}: an accepted renewal keeps the subscription"); - } else { - // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused renewal ends only - // this subscription, promptly and without the old 60s grant having to lapse, with - // PUBLISH_DONE UPDATE_FAILED. The update is answered first: nothing on draft-14, - // exactly one keyed REQUEST_ERROR to the update's Request ID (0x40) on draft-15/16. - // The session stays up. - let mut result = None; - for _ in 0..200 { - let _ = futures::poll!(acceptor.as_mut()); - if let Poll::Ready(res) = futures::poll!(serving.as_mut()) { - result = Some(res); - break; - } - settle().await; - } - assert_eq!( - answered.load(Ordering::Relaxed), - 2, - "{version:?}: the renewal was answered" - ); - assert!( - matches!(result, Some(Err(Error::Session(crate::SessionError::Unauthorized)))), - "{version:?}: a refused renewal ends the subscription at once, got {result:?}" - ); - // Draft 14 has no UPDATE_FAILED and answers no update, so its PUBLISH_DONE - // carries the refusal itself. - let (status, _) = Wire::read(&h.log, version).publish_done.expect("PUBLISH_DONE was sent"); - let expected = match version { - Version::Draft14 => ietf::PublishDoneStatus::Unauthorized, - _ => ietf::PublishDoneStatus::UpdateFailed, - }; - assert_eq!(status, expected.code(version), "{version:?}"); - let written = h.log.writes.lock().unwrap()[before..].to_vec(); - match version { - // draft-14 has no renewal reply: the refusal is signalled only by the subscription - // ending (SUBSCRIBE_DONE UNAUTHORIZED, written by the caller), with no REQUEST_ERROR. - Version::Draft14 => {} - // draft-15/16 answer with one keyed REQUEST_ERROR before the subscription ends. - _ => { - let error = request_error_bytes(version, RequestId(0x40), "no").await; - assert!( - written.starts_with(&error), - "{version:?}: a keyed REQUEST_ERROR is written before the subscription ends" - ); - } - } - assert!(h.log.closes().is_empty(), "{version:?}: the session must stay up"); - } - } - - /// One REQUEST_UPDATE carrying a fresh token, keyed to its own Request ID so several can be - /// told apart on the wire. - async fn subscribe_update_token_rid(version: Version, rid: u64) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - version, - ); - let subscription_request_id = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), - _ => None, - }; - let msg = ietf::SubscribeUpdate { - max_age: None, - new_group: None, - request_id: RequestId(rid), - subscription_request_id, - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority: Some(128), - forward: Some(true), - filter: None, - authorization_token: Some(request_token()), - fill: None, - }; - writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); - writer.encode(&msg).await.unwrap(); - log.writes.lock().unwrap().clone() - } - - /// One token-less REQUEST_UPDATE (a priority or forward change) keyed to `rid`, framed as the - /// peer sends it. It carries no AUTHORIZATION TOKEN, so it starts no verify and is answered at - /// once rather than held. - async fn subscribe_update_bare_rid(version: Version, rid: u64) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - version, - ); - let subscription_request_id = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), - _ => None, - }; - let msg = ietf::SubscribeUpdate { - max_age: None, - new_group: None, - request_id: RequestId(rid), - subscription_request_id, - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority: Some(128), - forward: Some(true), - filter: None, - authorization_token: None, - fill: None, - }; - writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); - writer.encode(&msg).await.unwrap(); - log.writes.lock().unwrap().clone() - } - - /// Two token renewals that arrive while a first renewal's verdict is still pending are each - /// verified and answered, not collapsed. The loop keeps per-update state while a verify is - /// pending (draft-18 section 10.9.1 answers each update); the single slot it replaced - /// dropped the middle renewal (0x42) entirely, so its token was never verified. - #[moq_clock::test] - async fn two_renewals_buffered_behind_a_pending_verdict_each_get_a_verdict() { - const VERSION: Version = Version::Draft18; - - let (auth, mut requests, _cred) = auth_covering_other(); - let mut script = subscribe_update_token_rid(VERSION, 0x40).await; - script.extend(subscribe_update_token_rid(VERSION, 0x42).await); - script.extend(subscribe_update_token_rid(VERSION, 0x44).await); - let h = serve_with_auth(VERSION, auth, script); - let rt = h.publisher.runtime.clone(); - - let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); - group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); - group.finish().unwrap(); - settle().await; - - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), VERSION) - .await - .unwrap(); - - // Hold the first renewal (0x40) so the next two (0x42, 0x44) are read and buffered while - // its verdict is pending: the exact window the single slot used to collapse. - let release = std::sync::Arc::new(Release::default()); - let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); - let acceptor = { - let answered = answered.clone(); - let release = release.clone(); - async move { - let first = requests.next().await.unwrap(); - let mut held = vec![first.accept(grant_all_expiring(&rt, None))]; - answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let a = requests.next().await.unwrap(); + let mut held = vec![first.accept(grant_all_expiring(&rt, None))]; + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let a = requests.next().await.unwrap(); release.notified().await; held.push(a.accept(grant_all_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -14550,7 +13288,6 @@ mod serve_tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: None, }) .await @@ -14573,12 +13310,12 @@ mod serve_tests { } /// An update that arrives while a renewal's verdict is pending is answered after the - /// renewal, never before it: from draft 17 an answer names no Request ID, so its order is + /// renewal, never before it: an answer names no Request ID, so its order is /// all that ties it to its update. The renewal is refused, so its REQUEST_ERROR is the first /// answer either way, and the token-less update behind it is never answered ahead of it. #[moq_clock::test] async fn an_update_behind_a_pending_renewal_is_answered_in_order() { - for version in [Version::Draft16, Version::Draft18] { + for version in [Version::Draft18] { let (auth, mut requests, _cred) = auth_covering_other(); let mut script = subscribe_update_token_rid(version, 0x40).await; script.extend(subscribe_update_bare_rid(version, 0x42).await); @@ -14614,7 +13351,7 @@ mod serve_tests { assert!(futures::poll!(serving.as_mut()).is_pending(), "{version}: ended early"); settle().await; } - let ok = request_ok_bytes(version, RequestId(0x42)).await; + let ok = request_ok_bytes(version).await; let held = h.log.writes.lock().unwrap().clone(); assert!( !held.windows(ok.len()).any(|window| window == ok.as_slice()), @@ -14631,7 +13368,7 @@ mod serve_tests { settle().await; } let written = h.log.writes.lock().unwrap()[before..].to_vec(); - let error = request_error_bytes(version, RequestId(0x40), "no").await; + let error = request_error_bytes(version, "no").await; assert!( written.starts_with(&error), "{version}: the renewal's REQUEST_ERROR is the first answer" @@ -14639,62 +13376,6 @@ mod serve_tests { } } - /// Before draft-17 a token-less REQUEST_UPDATE is answered keyed to its own request id - /// (draft-15/16), or not at all (draft-14, which defines no response). This pins the keyed and - /// silent branches of the acknowledgement so a wrong key cannot misroute the peer's answer. - #[moq_clock::test] - async fn token_less_updates_are_keyed_before_draft_17() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let (auth, mut requests, _cred) = auth_covering_other(); - let rids = [0x42u64, 0x44, 0x46]; - let mut script = Vec::new(); - for rid in rids { - script.extend(subscribe_update_bare_rid(version, rid).await); - } - let h = serve_with_auth(version, auth, script); - let rt = h.publisher.runtime.clone(); - - let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); - group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); - group.finish().unwrap(); - settle().await; - - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - let acceptor = async move { - let first = requests.next().await.unwrap(); - let _held = first.accept(grant_all_expiring(&rt, None)); - std::future::pending::<()>().await - }; - let mut acceptor = std::pin::pin!(acceptor); - let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); - - for _ in 0..500 { - let _ = futures::poll!(acceptor.as_mut()); - assert!( - futures::poll!(serving.as_mut()).is_pending(), - "{version}: token-less updates must not end the request" - ); - settle().await; - } - - // Draft-14 answers nothing; draft-15/16 answer each update keyed to its own request id. - let written = h.log.writes.lock().unwrap().clone(); - if !matches!(version, Version::Draft14) { - for rid in rids { - let one = request_ok_bytes(version, RequestId(rid)).await; - let n = written.windows(one.len()).filter(|w| *w == one.as_slice()).count(); - assert_eq!(n, 1, "{version}: update {rid:#x} gets exactly one keyed REQUEST_OK"); - } - } - assert!( - h.log.closes().is_empty(), - "{version}: token-less updates must not close the session" - ); - } - } - /// On drafts without the option the guard is a memory backstop, not a protocol limit: a flood /// past it ends the one request through PUBLISH_DONE, never the session. #[moq_clock::test] @@ -14829,45 +13510,8 @@ mod serve_tests { assert!(ended, "{label}: dropping the renewal ends the request"); } - /// One UNSUBSCRIBE keyed to the subscription, the draft-14/15/16 cancellation message. - async fn unsubscribe_bytes(version: Version) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - version, - ); - writer.varint(ietf::Unsubscribe::ID).await.unwrap(); - writer - .encode(&ietf::Unsubscribe { - request_id: RequestId(REQUEST_ID), - }) - .await - .unwrap(); - log.writes.lock().unwrap().clone() - } - - /// Before draft-17 a cancellation is an UNSUBSCRIBE message, not a FIN. It must end the - /// subscription even while a renewal verdict is pending: with a non-expiring grant and an - /// acceptor sitting on the renewal, the UNSUBSCRIBE is the only thing that can. - #[moq_clock::test] - async fn a_pending_renewal_ends_on_an_unsubscribe_before_draft_17() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let (auth, requests, _cred) = auth_covering_other(); - // The stream carries the renewal, then the UNSUBSCRIBE, and never FINs, so only the - // message can end the request. - let session = ScriptedSession::new(subscribe_update_with_token(version, true).await); - let h = serve_on(version, auth, session.clone()); - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - let unsubscribe = unsubscribe_bytes(version).await; - let cancel = || session.push(&unsubscribe); - ends_while_a_renewal_is_pending(&h, requests, stream, cancel, &format!("{version:?}")).await; - } - } - /// A REQUEST_UPDATE the acceptor refuses ends only that subscription, at once, through the - /// PUBLISH_DONE UPDATE_FAILED path (drafts 16 section 9.11.1 / 18 section 10.9.1). The old + /// PUBLISH_DONE UPDATE_FAILED path (draft-18 section 10.9.1). The old /// grant does not keep it alive to its 60s expiry, and the session stays up. #[moq_clock::test] async fn a_refused_request_update_ends_the_subscription() { @@ -14924,15 +13568,11 @@ mod serve_tests { "a refused renewal ends the subscription at once, got {result:?}" ); let (status, _) = Wire::read(&h.log, VERSION).publish_done.expect("PUBLISH_DONE was sent"); - assert_eq!( - status, - ietf::PublishDoneStatus::UpdateFailed.code(VERSION), - "UPDATE_FAILED" - ); - // Draft-17+ answers the refused update with exactly one unkeyed REQUEST_ERROR before the - // subscription ends. + assert_eq!(status, ietf::PublishDoneStatus::UpdateFailed.code(), "UPDATE_FAILED"); + // The refused update is answered with exactly one REQUEST_ERROR before the subscription + // ends. let written = h.log.writes.lock().unwrap().clone(); - let error = request_error_bytes(VERSION, RequestId(0), "no").await; + let error = request_error_bytes(VERSION, "no").await; assert_eq!( written.windows(error.len()).filter(|w| *w == error.as_slice()).count(), 1, @@ -15263,11 +13903,11 @@ mod serve_tests { /// one is refused NOT_SUPPORTED rather than taken unread, failing only that subscription. #[moq_clock::test] async fn a_token_on_a_subscription_admitted_without_one_is_refused() { - for version in [Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { let auth = crate::auth::Link::new(false); auth.handle().authorize(&crate::auth::Grant::all()); let h = serve_with_auth(version, auth, Vec::new()); - let session = ScriptedSession::per_stream(vec![update_with_tokens(version, &[TOKEN])]); + let session = ScriptedSession::per_stream(vec![update_with_tokens(&[TOKEN])]); let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) .await .unwrap(); @@ -15369,7 +14009,6 @@ mod serve_tests { writer.varint(ietf::RequestError::ID).await.unwrap(); writer .encode(&ietf::RequestError { - request_id: None, error_code: request::to_code(&Error::Unsupported, request::Kind::Subscribe, version), reason_phrase: Error::Unsupported.to_string().into(), retry_interval: 0, @@ -15658,10 +14297,10 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), Some(assigned), peer::PeerSetup::default(), - Version::Draft16, + Version::Draft18, ); let mut echoed_hops = crate::Hops::new(); @@ -15716,7 +14355,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(crate::lite::test_transport::SinkSession::new(Default::default())), origin.consume(), - Control::new(None, false), + Control::new(false), Some(assigned), peer::PeerSetup::default(), Version::Draft18, @@ -15787,10 +14426,10 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(crate::lite::test_transport::SinkSession::new(Default::default())), origin.consume(), - Control::new(None, false), + Control::new(false), Some(assigned), peer::PeerSetup::default(), - Version::Draft16, + Version::Draft18, ); // The chain as ingress stores it: the peer named itself 0, and `via` is the @@ -15835,10 +14474,10 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, setup, - Version::Draft17, + Version::Draft18, ); let mut clean = crate::Hops::new(); @@ -15854,7 +14493,7 @@ mod tests { .unwrap(); settle().await; - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), Version::Draft17) + let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), Version::Draft18) .await .unwrap(); let msg = ietf::SubscribeNamespace { @@ -15871,13 +14510,12 @@ mod tests { let expected = crate::lite::test_transport::Log::default(); let mut writer = crate::coding::Writer::new( crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(expected.clone())), - Version::Draft17, + Version::Draft18, ); writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: Some(1), }) .await @@ -15906,10 +14544,10 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), Some(assigned), requires_solicitation(), - Version::Draft16, + Version::Draft18, ); // The prefix starts with only a route from the assigned peer: hop 0 on the @@ -15926,7 +14564,7 @@ mod tests { .unwrap(); settle().await; - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), Version::Draft16) + let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), Version::Draft18) .await .unwrap(); let msg = ietf::SubscribeNamespace { @@ -15982,8 +14620,7 @@ mod tests { writer.varint(ietf::RequestError::ID).await.unwrap(); writer .encode(&ietf::RequestError { - request_id: matches!(version, Version::Draft15 | Version::Draft16).then_some(RequestId(1)), - // UNINTERESTED, draft-17 section 14.5.2. + // UNINTERESTED. error_code: 0x20, reason_phrase: "no".into(), retry_interval, @@ -15999,7 +14636,7 @@ mod tests { /// uninterested) into a request every few seconds for the life of the session. #[moq_clock::test] async fn a_refusal_that_forbids_retrying_is_not_retried() { - const VERSION: Version = Version::Draft17; + const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); @@ -16016,7 +14653,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), VERSION, @@ -16052,7 +14689,7 @@ mod tests { #[moq_clock::test] async fn another_epoch_is_advertised_before_the_old_is_withdrawn() { use crate::lite::test_transport::Sent; - const VERSION: Version = Version::Draft17; + const VERSION: Version = Version::Draft18; for epochs in [true, false] { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); @@ -16074,7 +14711,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared, VERSION, @@ -16135,133 +14772,18 @@ mod tests { version, ); - match version { - Version::Draft14 => { - writer.varint(ietf::PublishNamespaceOk::ID).await.unwrap(); - writer - .encode(&ietf::PublishNamespaceOk { - request_id: RequestId(1), - }) - .await - .unwrap(); - } - Version::Draft15 | Version::Draft16 => { - writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - largest: None, - request_id: Some(RequestId(1)), - active: None, - }) - .await - .unwrap(); - } - // Draft-17+ dropped the request id: the response rides the request's stream. - _ => { - writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - largest: None, - request_id: None, - active: None, - }) - .await - .unwrap(); - } - } - - let writes = log.writes.lock().unwrap(); - writes.clone() - } - - /// Draft-14/15 predate the NAMESPACE message, so a SUBSCRIBE_NAMESPACE is - /// answered with one PUBLISH_NAMESPACE request per matching namespace over the - /// control stream, and PUBLISH_NAMESPACE_DONE withdraws it. The state is local - /// to the subscription's task, mirroring lite's announce handling. - #[moq_clock::test] - async fn v14_subscribe_namespace_is_answered_with_publish_namespace() { - const VERSION: Version = Version::Draft14; - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let consumer = origin.consume(); - - // Announced before the peer subscribes: it must only hit the wire after. - let early = origin.announce("early-cam", crate::origin::Route::default()).unwrap(); - settle().await; - - // Stream 1 is the peer's SUBSCRIBE_NAMESPACE (the peer stays quiet after); - // streams 2 and 3 answer our two PUBLISH_NAMESPACE requests. - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new(), ok.clone(), ok]); - let log = session.log.clone(); - - let publisher = Publisher::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - consumer, - Control::new(None, false), - None, - requires_solicitation(), - VERSION, - ); - - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), VERSION) + // The response names no request id: it rides the request's stream. + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + largest: None, + active: None, + }) .await .unwrap(); - let msg = ietf::SubscribeNamespace { - request_id: RequestId(1), - namespace: crate::Path::from_static(""), - hidden: false, - }; - let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); - - // The subscription solicits the already-announced namespace. - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"early-cam") >= 1 { - break; - } - settle().await; - } - assert_eq!( - occurrences(&log, b"early-cam"), - 1, - "PUBLISH_NAMESPACE after subscribing" - ); - // A later announce reaches the same subscription. - let _late = origin.announce("late-cam", crate::origin::Route::default()).unwrap(); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"late-cam") >= 1 { - break; - } - settle().await; - } - assert_eq!( - occurrences(&log, b"late-cam"), - 1, - "PUBLISH_NAMESPACE for a live announce" - ); - - // An unannounce closes out its own request with PUBLISH_NAMESPACE_DONE. - drop(early); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"early-cam") >= 2 { - break; - } - settle().await; - } - assert_eq!( - occurrences(&log, b"early-cam"), - 2, - "PUBLISH_NAMESPACE_DONE on unannounce" - ); - - // One stream for the subscription itself, one per PUBLISH_NAMESPACE: the - // withdrawal rode the announce's own request, not a new stream. - assert_eq!(log.bi_opens(), 3, "no extra stream for the withdrawal"); + let writes = log.writes.lock().unwrap(); + writes.clone() } /// A peer that declared nothing is told without being asked. Relays that never send @@ -16269,7 +14791,7 @@ mod tests { /// that way: a publisher is expected to announce itself. #[moq_clock::test] async fn a_peer_that_declared_nothing_is_told_unsolicited() { - const VERSION: Version = Version::Draft17; + const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _local = origin.announce("local-cam", crate::origin::Route::default()).unwrap(); @@ -16287,7 +14809,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, VERSION, @@ -16310,11 +14832,11 @@ mod tests { assert_eq!(log.bi_opens(), 1, "one request stream"); } - /// From draft-17 a PUBLISH_NAMESPACE is withdrawn by cancelling its request: reset + /// A PUBLISH_NAMESPACE is withdrawn by cancelling its request: reset /// plus STOP_SENDING. A FIN there is a protocol violation some peers close on. #[moq_clock::test] - async fn publish_namespace_withdraws_by_reset_from_draft_17() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + async fn publish_namespace_withdraws_by_reset() { + for version in [Version::Draft18, Version::Draft22] { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let local = origin.announce("local-cam", crate::origin::Route::default()).unwrap(); settle().await; @@ -16328,7 +14850,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -16377,7 +14899,7 @@ mod tests { /// to a PUBLISH_NAMESPACE is the peer breaking the extension, negotiated or not. #[moq_clock::test] async fn a_counted_publish_namespace_ok_is_a_violation() { - const VERSION: Version = Version::Draft17; + const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _local = origin.announce("local-cam", crate::origin::Route::default()).unwrap(); @@ -16392,7 +14914,6 @@ mod tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: Some(0), }) .await @@ -16410,7 +14931,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, VERSION, @@ -16452,7 +14973,7 @@ mod tests { impl Default for Discovery<'_> { fn default() -> Self { Self { - version: Version::Draft17, + version: Version::Draft18, declared: true, solicit: Some(true), prefix: "", @@ -16501,7 +15022,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), consume, - Control::new(None, false), + Control::new(false), None, setup, version, @@ -16535,7 +15056,7 @@ mod tests { } /// A hidden namespace reaches only a subscription that opted in or named its dot - /// segment. On draft-16 and later the subscription also repeats every visible + /// segment. The subscription also repeats every visible /// namespace the unsolicited loop already sent. A NAMESPACE is discovery, not /// a second route. #[moq_clock::test] @@ -16564,13 +15085,7 @@ mod tests { #[moq_clock::test] async fn hidden_discovery_obeys_peer_setup_and_requested_prefix() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { for declared in [false, true] { for solicit in [Some(false), Some(true)] { for scoped in [false, true] { @@ -16586,18 +15101,10 @@ mod tests { .await; // Visible on the subscription: not filtered, opted in, or the prefix // names the dot segment. The unsolicited loop sends a dot namespace - // only when the peer did not declare MoQ Hidden. Draft-14/15 keep - // the two paths disjoint; draft-16 and later fill the stream too. + // only when the peer did not declare MoQ Hidden. let on_stream = !declared || hidden || prefix == ".stats"; let unsolicited = solicit != Some(true) && !declared; - let legacy = matches!(version, Version::Draft14 | Version::Draft15); - let inline = if legacy { - (solicit == Some(true) && on_stream) - || (solicit != Some(true) && declared && (hidden || prefix == ".stats")) - } else { - on_stream - }; - let expected = usize::from(inline) + usize::from(unsolicited); + let expected = usize::from(on_stream) + usize::from(unsolicited); assert_eq!( occurrences(&log, b"node"), expected, @@ -16610,11 +15117,10 @@ mod tests { } } - /// On draft-16 and later a peer that did not ask to be solicited hears each - /// namespace twice: an unsolicited PUBLISH_NAMESPACE and a NAMESPACE on the - /// SUBSCRIBE_NAMESPACE stream. Those are two discoveries of one namespace, not - /// two routes. A peer that asked to be solicited hears it only on the stream. - /// Draft-14 and 15 still answer a non-solicit peer only with PUBLISH_NAMESPACE. + /// A peer that did not ask to be solicited hears each namespace twice: an + /// unsolicited PUBLISH_NAMESPACE and a NAMESPACE on the SUBSCRIBE_NAMESPACE stream. + /// Those are two discoveries of one namespace, not two routes. A peer that asked to + /// be solicited hears it only on the stream. #[moq_clock::test] async fn a_non_solicit_peer_hears_a_namespace_both_ways() { let (times, streams) = advertise_both_ways(Some(false)).await; @@ -16624,28 +15130,14 @@ mod tests { let (once, streams) = advertise_both_ways(Some(true)).await; assert_eq!(once, 1, "a peer that asked to be told on request is told once"); assert_eq!(streams, 1, "inline on the SUBSCRIBE_NAMESPACE stream it asked on"); - - for version in [Version::Draft14, Version::Draft15] { - let log = advertise_with_hidden(Discovery { - version, - solicit: Some(false), - ..Discovery::default() - }) - .await; - assert_eq!( - occurrences(&log, b"cam"), - 1, - "{version:?} still answers only with PUBLISH_NAMESPACE" - ); - } } /// A peer that did not send SOLICIT still gets NAMESPACE on its SUBSCRIBE_NAMESPACE - /// stream on draft-16 and later: one for a match that already exists, one announced + /// stream: one for a match that already exists, one announced /// after, then NAMESPACE_DONE when that announcement ends. #[moq_clock::test] async fn a_non_solicit_subscribe_namespace_carries_namespace() { - for version in [Version::Draft16, Version::Draft18] { + for version in [Version::Draft18] { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let early = origin.announce("early-cam", crate::origin::Route::default()).unwrap(); settle().await; @@ -16660,7 +15152,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -16730,13 +15222,11 @@ mod tests { } /// Send capacity can park an admitted stream's header. That must not wedge the loop: - /// withdrawals queued behind it still need to progress. - /// - /// Draft-14 so the withdrawal names its namespace on the wire, which is what makes the - /// loop's progress visible while every open is blocked. + /// withdrawals queued behind it still need to progress. A withdrawal ends its request's + /// own stream, which needs no new open, so it is visible while every open is blocked. #[moq_clock::test] async fn a_parked_open_still_lets_a_namespace_be_withdrawn() { - const VERSION: Version = Version::Draft14; + const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); @@ -16752,7 +15242,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), VERSION, @@ -16781,18 +15271,19 @@ mod tests { // it despite the open above. drop(first); + let ended = || { + log.trail() + .into_iter() + .any(|(_, sent)| !matches!(sent, crate::lite::test_transport::Sent::Write(_))) + }; for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"first-cam") >= 2 { + if ended() { break; } tick().await; } - assert_eq!( - occurrences(&log, b"first-cam"), - 2, - "PUBLISH_NAMESPACE_DONE never sent: the open wedged the loop" - ); + assert!(ended(), "the withdrawal never went out: the open wedged the loop"); // Send capacity returns, and nothing else about the origin changes. set_gate(&gate, true); @@ -16830,10 +15321,10 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), Some(assigned), declared(Some(false)), - Version::Draft17, + Version::Draft18, ); // The state a refused or failed offer leaves behind: the peer holds nothing, and @@ -16842,7 +15333,7 @@ mod tests { let mut watch = Watched::new(crate::origin::Route::default().with_hops(hops)); watch.deferred = true; - let mut ns = Namespaces::new(cluster::Peer::default(), Target::Requests(None)); + let mut ns = Namespaces::new(cluster::Peer::default(), Target::Requests); ns.watched.insert(suffix.clone(), watch); publisher.sync_namespace(&mut ns, &suffix, &suffix).await.unwrap(); @@ -16858,7 +15349,7 @@ mod tests { /// peer asked for. #[moq_clock::test] async fn a_route_change_still_waits_out_a_refusal() { - const VERSION: Version = Version::Draft17; + const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); // One epoch, so the second route re-prices the instance rather than restart it. @@ -16878,7 +15369,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), VERSION, @@ -16937,44 +15428,6 @@ mod tests { slot } - /// PUBLISH_NAMESPACE_DONE withdrawing the first request, "cam". - async fn publish_namespace_done(version: Version) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - version, - ); - writer.varint(ietf::PublishNamespaceDone::ID).await.unwrap(); - writer - .encode(&ietf::PublishNamespaceDone { - track_namespace: crate::Path::from_static("cam"), - request_id: RequestId(1), - }) - .await - .unwrap(); - log.writes.lock().unwrap().clone() - } - - /// PUBLISH_NAMESPACE_CANCEL letting go of the first request, "cam". - async fn publish_namespace_cancel(version: Version) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - version, - ); - writer.varint(ietf::PublishNamespaceCancel::ID).await.unwrap(); - writer - .encode(&ietf::PublishNamespaceCancel { - track_namespace: crate::Path::from_static("cam"), - request_id: RequestId(1), - error_code: 0, - reason_phrase: "gone".into(), - }) - .await - .unwrap(); - log.writes.lock().unwrap().clone() - } - /// The bytes of one REQUEST_UPDATE, framed as the publisher writes it. async fn request_update(version: Version, msg: &ietf::PublishNamespaceUpdate) -> Vec { let log = crate::lite::test_transport::Log::default(); @@ -17021,7 +15474,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, clustered(Some(false)), VERSION, @@ -17048,7 +15501,6 @@ mod tests { VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), - namespace_request_id: None, hops: None, cost: Some(0), authorization_token: None, @@ -17094,7 +15546,6 @@ mod tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: Some(0), }) .await @@ -17109,7 +15560,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, clustered(Some(false)), VERSION, @@ -17180,7 +15631,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, clustered(Some(false)), VERSION, @@ -17210,7 +15661,6 @@ mod tests { VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), - namespace_request_id: None, hops: Some(cluster::HopPath::new( crate::Hops::try_from(vec![crate::Hop::new(8).unwrap(), crate::Hop::new(1).unwrap()]).unwrap(), )), @@ -17254,7 +15704,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), version, @@ -17290,10 +15740,9 @@ mod tests { /// A route's auth rides its PUBLISH_NAMESPACE as the request's token, and swapping it /// renews the request in place: REQUEST_UPDATE on the same stream, carrying only the - /// new token. Draft-16 names the request in the update. + /// new token. #[moq_clock::test] async fn swapping_a_route_auth_is_a_request_update() { - swaps_a_route_auth(Version::Draft16).await; swaps_a_route_auth(Version::Draft18).await; } @@ -17312,10 +15761,7 @@ mod tests { // From draft 18 the update goes unanswered: an answer scripted ahead of it would // arrive while the request is idle. let ok = publish_namespace_ok(version).await; - let script = match version { - Version::Draft16 => [ok.clone(), ok].concat(), - _ => ok, - }; + let script = ok; let (publisher, log) = auth_publisher(&origin, &auth, vec![script], version); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); poll_until(&mut run, &log, b"cam", 1).await; @@ -17335,7 +15781,6 @@ mod tests { version, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), - namespace_request_id: (version == Version::Draft16).then_some(RequestId(1)), hops: None, cost: None, authorization_token: Some(auth_token(b"second-secret")), @@ -17362,97 +15807,6 @@ mod tests { assert_eq!(log.bi_opens(), 1, "{version}: the update rode the request's own stream"); } - /// A draft-16 peer's PUBLISH_NAMESPACE_CANCEL can beat its answer to our update. It - /// withdraws only that namespace, which the jittered retry sweep offers again, and the - /// loop carries on. - #[moq_clock::test] - async fn a_draft_16_cancel_ahead_of_the_answer_ends_only_its_request() { - const VERSION: Version = Version::Draft16; - - let origin = unheld_origin(); - let auth = crate::auth::Link::ietf(false); - let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); - let second = auth - .present(bytes::Bytes::from_static(b"second-secret"), false) - .unwrap(); - let cam = origin - .announce("cam", crate::origin::Route::default().with_auth(first)) - .unwrap(); - settle().await; - - let ok = publish_namespace_ok(VERSION).await; - let cancel = publish_namespace_cancel(VERSION).await; - let (publisher, log) = auth_publisher(&origin, &auth, vec![[ok.clone(), cancel].concat(), ok], VERSION); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - poll_until(&mut run, &log, b"first-secret", 1).await; - - // The re-offer waits out a randomly jittered sweep, which 1ms settles can overrun. - cam.update(crate::origin::Route::default().with_auth(second)).unwrap(); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 2 { - break; - } - tick().await; - } - - assert_eq!(occurrences(&log, b"cam"), 2, "the namespace was not offered again"); - assert_eq!(log.bi_opens(), 2, "a fresh request after the cancel"); - assert_eq!( - occurrences(&log, &publish_namespace_done(VERSION).await), - 0, - "PUBLISH_NAMESPACE_DONE after the peer's cancel" - ); - } - - /// A draft-16 peer refuses an update with REQUEST_ERROR and then PUBLISH_NAMESPACE_CANCEL. - /// Draft 16 sends no PUBLISH_NAMESPACE_DONE after a cancel, so the namespace is only - /// offered again once the refusal's wait is out. - #[moq_clock::test] - async fn a_refused_draft_16_update_sends_no_done() { - const VERSION: Version = Version::Draft16; - - let origin = unheld_origin(); - let auth = crate::auth::Link::ietf(false); - let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); - let second = auth - .present(bytes::Bytes::from_static(b"second-secret"), false) - .unwrap(); - let cam = origin - .announce("cam", crate::origin::Route::default().with_auth(first)) - .unwrap(); - settle().await; - - let ok = publish_namespace_ok(VERSION).await; - let refusal = publish_namespace_error(VERSION, 50).await; - let cancel = publish_namespace_cancel(VERSION).await; - let (publisher, log) = auth_publisher( - &origin, - &auth, - vec![[ok.clone(), refusal, cancel].concat(), ok], - VERSION, - ); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - poll_until(&mut run, &log, b"first-secret", 1).await; - - cam.update(crate::origin::Route::default().with_auth(second)).unwrap(); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 2 { - break; - } - tick().await; - } - - assert_eq!(occurrences(&log, b"cam"), 2, "the namespace was not offered again"); - assert_eq!(log.bi_opens(), 2, "a fresh request after the refusal"); - assert_eq!( - occurrences(&log, &publish_namespace_done(VERSION).await), - 0, - "PUBLISH_NAMESPACE_DONE after the peer's refusal" - ); - } - /// An auth another handle issued is not this session's to present: the route is /// advertised under the session's scope, and the credential never reaches this peer. #[moq_clock::test] @@ -17508,40 +15862,6 @@ mod tests { assert_eq!(log.bi_opens(), 2, "a fresh stream for the fresh request"); } - /// Before draft-16 a namespace cannot be updated, so a renewal withdraws the request - /// and makes it again with the new token. - #[moq_clock::test] - async fn a_draft_14_renewal_restarts_the_request() { - const VERSION: Version = Version::Draft14; - - let origin = unheld_origin(); - let auth = crate::auth::Link::ietf(false); - let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); - let second = auth - .present(bytes::Bytes::from_static(b"second-secret"), false) - .unwrap(); - let cam = origin - .announce("cam", crate::origin::Route::default().with_auth(first)) - .unwrap(); - settle().await; - - let ok = publish_namespace_ok(VERSION).await; - let (publisher, log) = auth_publisher(&origin, &auth, vec![ok.clone(), ok], VERSION); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - poll_until(&mut run, &log, b"first-secret", 1).await; - - cam.update(crate::origin::Route::default().with_auth(second)).unwrap(); - poll_until(&mut run, &log, b"second-secret", 1).await; - - assert_eq!(occurrences(&log, b"second-secret"), 1, "the renewal never went out"); - assert_eq!( - occurrences(&log, b"first-secret"), - 1, - "the first token was not repeated" - ); - assert_eq!(log.bi_opens(), 2, "a fresh request for the renewal"); - } - /// Renewals wait on their answers side by side: one the peer has not answered holds up /// neither another namespace's renewal nor a new announcement. #[moq_clock::test] @@ -17606,7 +15926,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), VERSION, @@ -17653,7 +15973,7 @@ mod tests { /// and the failed one is offered again after a backoff rather than in a loop. #[moq_clock::test] async fn a_failed_advertisement_ends_only_its_namespace() { - for version in [Version::Draft14, Version::Draft18] { + for version in [Version::Draft18] { for fin in [true, false] { fails_one_advertisement(version, fin).await; } @@ -17675,7 +15995,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), version, @@ -17730,24 +16050,20 @@ mod tests { again.writer.write_all(&mut ok).await.unwrap(); cams.remove(second); - match version { - Version::Draft14 => assert_eq!(held.reader.varint().await.unwrap(), ietf::PublishNamespaceDone::ID), - _ => assert!( - held.reader.varint().await.is_err(), - "{version} fin={fin}: not withdrawn" - ), - } + assert!( + held.reader.varint().await.is_err(), + "{version} fin={fin}: not withdrawn" + ); assert!(!run.is_finished(), "{version} fin={fin}: the loop ended"); } - /// A draft-14/15 PUBLISH_NAMESPACE whose header fails to send closes the session: its - /// request ID is spent, and a peer that requires the next ID would refuse every later - /// request. Later drafts back off that namespace instead. + /// A PUBLISH_NAMESPACE whose header fails to send backs off that namespace rather than + /// closing the session. #[moq_clock::test] - async fn a_failed_legacy_header_closes_the_session() { - use crate::transport::{memory, poll::Session as _}; + async fn a_failed_header_backs_off_the_namespace() { + use crate::transport::memory; - for version in [Version::Draft14, Version::Draft15, Version::Draft18] { + for version in [Version::Draft18] { let origin = unheld_origin(); // A token too big for the header's u16 length, so the header fails after its stream // opened and its request ID was spent. A message goes out whole, so this is the one @@ -17759,12 +16075,12 @@ mod tests { .unwrap(); settle().await; - let (session, mut peer) = memory::pair(memory::Config::default()); + let (session, peer) = memory::pair(memory::Config::default()); let publisher = Publisher::new( crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), version, @@ -17780,33 +16096,15 @@ mod tests { .expect("no advertisement") .unwrap(); - match version { - Version::Draft14 | Version::Draft15 => { - let closed = moq_clock::timeout(Duration::from_secs(10), peer.closed()) - .await - .unwrap_or_else(|_| panic!("{version}: the session stayed open")); - let code = super::super::error::to_session_code(&crate::SessionError::Internal); - assert!( - matches!(closed, memory::Error::Session { code: c, .. } if c == code), - "{version}: {closed:?}" - ); - let res = moq_clock::timeout(Duration::from_secs(10), run) - .await - .unwrap_or_else(|_| panic!("{version}: the loop kept running")); - assert!(res.unwrap().is_err(), "{version}: the loop ended cleanly"); - } - _ => { - // Offered again after the backoff, on a session that stays open. - moq_clock::timeout( - Duration::from_secs(10), - Stream::accept(&mut crate::driver::Transport::new(peer.clone()), version), - ) - .await - .unwrap_or_else(|_| panic!("{version}: not offered again")) - .unwrap(); - assert!(!run.is_finished(), "{version}: the loop ended"); - } - } + // Offered again after the backoff, on a session that stays open. + moq_clock::timeout( + Duration::from_secs(10), + Stream::accept(&mut crate::driver::Transport::new(peer.clone()), version), + ) + .await + .unwrap_or_else(|_| panic!("{version}: not offered again")) + .unwrap(); + assert!(!run.is_finished(), "{version}: the loop ended"); } } @@ -17832,7 +16130,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), VERSION, @@ -17862,11 +16160,9 @@ mod tests { } /// An update the peer never answers drops its request once the timeout passes, and - /// the retry advertises the namespace afresh under the current auth. Draft-16's request - /// rides the control stream, where only PUBLISH_NAMESPACE_DONE withdraws it. + /// the retry advertises the namespace afresh under the current auth. #[moq_clock::test] async fn an_unanswered_renewal_advertises_again() { - advertises_again_unanswered(Version::Draft16).await; advertises_again_unanswered(Version::Draft18).await; } @@ -17900,10 +16196,6 @@ mod tests { 2, "{version}: the unanswered renewal was never retried" ); - if version == Version::Draft16 { - let done = publish_namespace_done(version).await; - assert_eq!(occurrences(&log, &done), 1, "the peer was never told to let it go"); - } } /// moq-transport has no restart: another source winning a namespace without an epoch @@ -17931,7 +16223,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, clustered(Some(false)), VERSION, @@ -17993,7 +16285,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, clustered(Some(false)), VERSION, @@ -18028,7 +16320,6 @@ mod tests { VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), - namespace_request_id: None, hops: None, cost: Some(0), authorization_token: None, @@ -18038,52 +16329,12 @@ mod tests { assert_eq!(occurrences(&log, &update), 1, "only the one update was attempted"); } - #[moq_clock::test] - async fn close_withdraws_legacy_namespaces_without_closing_the_origin() { - const VERSION: Version = Version::Draft14; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("closing-cam", crate::origin::Route::default()).unwrap(); - settle().await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); - let log = session.log.clone(); - let publisher = Publisher::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - origin.consume(), - Control::new(None, false), - None, - declared(None), - VERSION, - ); - let mut run = std::pin::pin!(publisher.clone().run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"closing-cam") > 0 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, b"closing-cam"), 1); - assert!(!publisher.withdrawal.drained()); - publisher.withdrawal.begin(); - run.await.unwrap(); - assert!(publisher.withdrawal.drained()); - assert_eq!( - occurrences(&log, b"closing-cam"), - 2, - "PUBLISH_NAMESPACE_DONE names the withdrawn namespace" - ); - } - /// The peer granting a stream is only half the exchange. One it accepts and then never /// answers on wedges the loop exactly as a parked open does, so the response is bounded /// too: everything queued behind it is otherwise stranded for the session. - /// - /// Draft-14 so each advertisement names its namespace on the wire. #[moq_clock::test] async fn a_silent_answer_still_lets_the_next_namespace_be_advertised() { - const VERSION: Version = Version::Draft14; + const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); @@ -18099,7 +16350,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), VERSION, @@ -18126,12 +16377,11 @@ mod tests { ); } - /// An advertisement the peer never answers is given up at its deadline. On drafts 14-16 - /// it rides the control stream, where dropping it tells the peer nothing, so it is - /// withdrawn with PUBLISH_NAMESPACE_DONE; from draft 17 its own stream's reset ends it. + /// An advertisement the peer never answers is given up at its deadline: its own stream's + /// reset ends it. #[moq_clock::test] async fn an_unanswered_advertisement_is_withdrawn() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft17] { + for version in [Version::Draft18] { let origin = unheld_origin(); let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; @@ -18143,7 +16393,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), version, @@ -18151,14 +16401,7 @@ mod tests { let mut run = std::pin::pin!(publisher.run_publish_namespaces()); poll_until(&mut run, &log, b"cam", 1).await; - let done = match version { - Version::Draft17 => None, - _ => Some(publish_namespace_done(version).await), - }; - let withdrawn = || match &done { - Some(done) => occurrences(&log, done) > 0, - None => !log.resets().is_empty(), - }; + let withdrawn = || !log.resets().is_empty(); moq_clock::sleep(ADVERTISE_TIMEOUT - Duration::from_millis(10)).await; assert!(futures::poll!(run.as_mut()).is_pending()); assert!(!withdrawn(), "{version}: withdrawn before its deadline"); @@ -18178,20 +16421,14 @@ mod tests { #[moq_clock::test] async fn an_advertisement_without_credit_is_retried_and_spends_no_id() { use std::sync::atomic::Ordering; - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { + for version in [Version::Draft18] { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); settle().await; let session = crate::lite::test_transport::ScriptedSession::new(publish_namespace_ok(version).await).without_credit(); let log = session.log.clone(); - let control = Control::new(None, false); + let control = Control::new(false); let publisher = Publisher::new( crate::time::sim(), crate::driver::Transport::new(session.clone()), @@ -18222,8 +16459,8 @@ mod tests { ); assert!(log.closes().is_empty()); assert_eq!( - control.poll_next_request_id(&kio::Waiter::noop()), - Poll::Ready(RequestId(3)), + control.next_request_id(), + RequestId(3), "{version}: only the admitted attempt spends an ID" ); } @@ -18234,7 +16471,7 @@ mod tests { /// stays undiscoverable for the life of the session. #[moq_clock::test] async fn an_advertisement_waiting_for_send_capacity_is_retried_on_its_own() { - const VERSION: Version = Version::Draft14; + const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); @@ -18250,7 +16487,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, declared(Some(false)), VERSION, @@ -18313,7 +16550,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer_setup, version, @@ -18399,7 +16636,7 @@ mod tests { /// on a request we already refused. Finishing first makes the drop-time reset a no-op. #[moq_clock::test] async fn missing_broadcast_is_refused_without_resetting_the_stream() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let (writes, resets) = subscribe_missing(version).await; assert!(!writes.is_empty(), "{version}: nothing was sent"); @@ -18439,7 +16676,7 @@ mod tests { ] }; - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { for (label, fetch_type) in refused() { let (writes, resets) = fetch_refused(version, fetch_type).await; @@ -19362,7 +17599,7 @@ mod compression_tests { use super::*; #[test] fn sealed_compression_refuses_incompatible_egress() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let mut sealed = Vec::new(); let mut encoder = Encoder::new(&mut sealed, version.into()); encoder.varint(super::super::flate::COMPRESSION).unwrap(); @@ -19403,14 +17640,9 @@ mod compression_tests { track_properties(&info, wanted, version, false), Err(Error::Unsupported) )); - assert!(matches!( - track_properties(&info, wanted, Version::Draft16, true), - Err(Error::Unsupported) - )); } let plain = track::Info::default().with_compress(true); assert!(!track_properties(&plain, true, version, false).unwrap().compress); - assert!(!track_properties(&plain, true, Version::Draft15, true).unwrap().compress); } } @@ -19420,13 +17652,13 @@ mod compression_tests { async fn ietf_compression_fallback_interop() { use crate::coding::Decode; use crate::lite::test_transport::{Log, SinkSession}; - for (version, draft) in [(Version::Draft17, 17), (Version::Draft22, 22)] { + for (version, draft) in [(Version::Draft18, 18), (Version::Draft22, 22)] { let input = serde_json::json!({"draft": draft}); let bytes: Vec = serde_json::from_slice(&crate::test_interop::bun("ietf-compression.ts", input)).unwrap(); let (parameters, consumed) = ietf::Parameters::decode_slice(&bytes, version).unwrap(); assert_eq!(consumed, bytes.len()); - let negotiated = super::super::flate::from_setup(¶meters, version).unwrap(); + let negotiated = super::super::flate::from_setup(¶meters).unwrap(); assert!(!negotiated, "JS offered an unsupported codec"); let track = track::Producer::new( Arc::new(crate::broadcast::Info::default()), @@ -19442,7 +17674,6 @@ mod compression_tests { group.finish().unwrap(); track.finish().unwrap(); let response = ietf::SubscribeOk { - request_id: None, track_alias: 0, largest: None, properties: track_properties(track.info(), true, version, negotiated).unwrap(), diff --git a/rs/moq-net/src/ietf/publisher/tracks.rs b/rs/moq-net/src/ietf/publisher/tracks.rs index 4edcea281df..076107c3af1 100644 --- a/rs/moq-net/src/ietf/publisher/tracks.rs +++ b/rs/moq-net/src/ietf/publisher/tracks.rs @@ -289,7 +289,6 @@ impl Publisher { 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, @@ -341,7 +340,6 @@ impl Publisher { stream .writer .encode_message(&ietf::RequestOk { - request_id: None, active: None, largest: None, }) @@ -539,7 +537,6 @@ impl Publisher { stream.writer.buffer_message( ietf::RequestError::ID, &ietf::RequestError { - request_id: None, error_code: code, reason_phrase: reason.into(), retry_interval: 0, @@ -870,22 +867,16 @@ impl Publisher { let dynamic = properties.dynamic_groups; let absolute = self.origin.root().rejoin(&namespace); - // Every wait on the peer, for a Request ID or the PUBLISH's bytes, lasts only while - // the track is listed and the request stands. A Request ID abandoned unsent leaves a - // gap, which the drafts with SUBSCRIBE_TRACKS allow: none has MAX_REQUEST_ID. + // Every wait on the peer for the PUBLISH's bytes lasts only while the track is listed + // and the request stands. let mut session = self.session.clone(); - let mut stream = super::super::request_stream::with_stats(stream, self.stats(), self.version); - 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; + let mut stream = stream.with_stats(self.stats()); // Dropped unwritten, the stream is reset before its header, which the peer ignores. - let Some(request_id) = allocated else { + let noop = kio::Waiter::noop(); + if listed.poll_closed(&noop).is_ready() || live.poll_closed(&noop).is_ready() { return Ok(()); - }; + } + let request_id = self.control.next_request_id(); // A joining FETCH needs the subscription forwarding. let join = (!Filter::is_draft22(self.version)).then(|| self.register_join(request_id)); @@ -989,7 +980,7 @@ impl Publisher { if let Some(done) = refused { drop(join); - return self.write_publish_done(stream, request_id, done, 0, None).await; + return self.write_publish_done(stream, done, 0, None).await; } // Group streams opened over every time the subscription forwarded, which @@ -1020,7 +1011,7 @@ impl Publisher { { Ok(Served::Paused(_)) => { self.pause_join(join.as_ref()); - self.write_request_ok(&mut stream.writer, request_id, None).await?; + self.write_request_ok(&mut stream.writer, None).await?; // Unlisted while it forwarded: it ends now that it is idle. if listed.is_closed() { break unlisted(&listed); @@ -1099,8 +1090,7 @@ impl Publisher { None => None, }; if let Some((reason, err)) = refusal { - self.write_subscribe_error(&mut stream.writer, update.request_id, &err, reason) - .await?; + self.write_subscribe_error(&mut stream.writer, &err, reason).await?; break Served::UpdateFailed(Error::Unsupported); } if let Some(priority) = update.priority { @@ -1120,12 +1110,12 @@ impl Publisher { answer: true, }) } - _ => self.write_request_ok(&mut stream.writer, request_id, None).await?, + _ => self.write_request_ok(&mut stream.writer, None).await?, } }; drop(join); - self.write_publish_done(stream, request_id, done, opened, None).await + self.write_publish_done(stream, done, opened, None).await } /// Mark a paused subscription for joining FETCHes: draft-18 section 5.1 refuses one @@ -1142,14 +1132,13 @@ impl Publisher { 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") + self.write_subscribe_error(&mut stream.writer, &err, "resume failed") .await .map_err(|_| ())?; Ok(Served::UpdateFailed(err)) @@ -1212,11 +1201,9 @@ impl Publisher { .await?; let mut subscriber = match subscribed { Some(Ok(subscriber)) => subscriber, - Some(Err(err)) => return self.resume_failed(stream, request_id, how.answer, err).await, + Some(Err(err)) => return self.resume_failed(stream, how.answer, err).await, None => { - return self - .resume_failed(stream, request_id, how.answer, Error::NotFound) - .await; + return self.resume_failed(stream, how.answer, Error::NotFound).await; } }; let live = kio::wait(|waiter| { @@ -1230,19 +1217,14 @@ impl Publisher { }) .await?; if !live { - return self - .resume_failed(stream, request_id, how.answer, Error::NotFound) - .await; + return self.resume_failed(stream, 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 timescale = subscriber.info().timescale; let mut serve = TrackServe::new( self.session.clone(), subscriber, @@ -1281,12 +1263,7 @@ impl Publisher { .filter(|_| Filter::is_draft22(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() - { + if how.answer && self.write_request_ok(&mut stream.writer, edge.largest).await.is_err() { return Err(()); } @@ -1391,7 +1368,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, setup, version, @@ -1489,7 +1466,6 @@ mod tests { stream .writer .encode(&ietf::PublishOk { - request_id: None, forward: false, subscriber_priority: Some(128), group_order: Some(GroupOrder::Ascending), @@ -1503,9 +1479,6 @@ mod tests { 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-22 moved the filter off PUBLISH_OK. @@ -1585,7 +1558,7 @@ mod tests { let done: ietf::PublishDone = body.message(version).unwrap(); assert_eq!( done.status_code, - ietf::PublishDoneStatus::TrackEnded.code(version), + ietf::PublishDoneStatus::TrackEnded.code(), "{version}" ); } @@ -1621,7 +1594,7 @@ mod tests { let (id, body) = next(published.get_mut(name).unwrap()).await; assert_eq!(id, ietf::PublishDone::ID, "{case}: {name}"); let done: ietf::PublishDone = body.message(version).unwrap(); - assert_eq!(done.status_code, status.code(version), "{case}: {name}"); + assert_eq!(done.status_code, status.code(), "{case}: {name}"); } drop(video); } @@ -2027,7 +2000,6 @@ mod tests { stream .writer .encode(&ietf::PublishOk { - request_id: None, forward: true, subscriber_priority: None, group_order: None, @@ -2060,7 +2032,6 @@ mod tests { stream .writer .encode(&ietf::PublishOk { - request_id: None, forward: true, subscriber_priority: Some(128), group_order: Some(GroupOrder::Ascending), @@ -2526,7 +2497,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(h.session.clone()), h._origin.consume(), - Control::new(None, false), + Control::new(false), None, setup, version, diff --git a/rs/moq-net/src/ietf/request.rs b/rs/moq-net/src/ietf/request.rs index 5dff02ffdcb..f6e3a248951 100644 --- a/rs/moq-net/src/ietf/request.rs +++ b/rs/moq-net/src/ietf/request.rs @@ -42,50 +42,9 @@ impl Decode for RequestId { } } -#[derive(Clone, Debug)] -pub struct MaxRequestId { - pub request_id: RequestId, -} - -impl Message for MaxRequestId { - const ID: u64 = 0x15; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - Ok(Self { request_id }) - } -} - -#[derive(Clone, Debug)] -pub struct RequestsBlocked { - pub request_id: RequestId, -} - -impl Message for RequestsBlocked { - const ID: u64 = 0x1a; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - Ok(Self { request_id }) - } -} - -/// REQUEST_OK (0x07 in v15) - Generic success response for any request. -/// Replaces PublishNamespaceOk, SubscribeNamespaceOk in v15. -/// Also used as response to SubscribeUpdate and TrackStatus in v15. +/// REQUEST_OK (0x07): the generic success response for any request, on its own stream. #[derive(Clone, Debug)] pub struct RequestOk { - pub request_id: Option, /// 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, @@ -98,13 +57,6 @@ impl Message for RequestOk { const ID: u64 = 0x07; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - self.request_id - .expect("request_id required for draft14-16") - .encode(w, version)?; - } else { - assert!(self.request_id.is_none(), "request_id must be None for draft17+"); - } encode_params!(w, version, 0x09 => self.largest, ACTIVE_COUNT_PARAM => self.active, @@ -113,11 +65,6 @@ impl Message for RequestOk { } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - Some(RequestId::decode(r, version)?) - } else { - None - }; // 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. @@ -126,59 +73,34 @@ impl Message for RequestOk { 0x09 => largest: Option, ACTIVE_COUNT_PARAM => active: Option, ); - Ok(Self { - request_id, - active, - largest, - }) + Ok(Self { active, largest }) } } -/// REQUEST_ERROR (0x05 in v15) - Generic error response for any request. -/// Replaces SubscribeError, PublishError, PublishNamespaceError, -/// SubscribeNamespaceError, FetchError in v15. +/// REQUEST_ERROR (0x05): the generic error response for any request, on its own stream. #[derive(Clone, Debug)] pub struct RequestError<'a> { - pub request_id: Option, pub error_code: u64, pub reason_phrase: Cow<'a, str>, - /// v16+: retry interval in milliseconds + /// Retry interval in milliseconds. pub retry_interval: u64, } impl Message for RequestError<'_> { const ID: u64 = 0x05; - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - self.request_id - .expect("request_id required for draft14-16") - .encode(w, version)?; - } else { - assert!(self.request_id.is_none(), "request_id must be None for draft17+"); - } + fn encode_msg(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { w.varint(self.error_code)?; - if !matches!(version, Version::Draft14 | Version::Draft15) { - w.varint(self.retry_interval)?; - } + w.varint(self.retry_interval)?; w.string(&self.reason_phrase)?; Ok(()) } - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - Some(RequestId::decode(r, version)?) - } else { - None - }; + fn decode_msg(r: &mut Decoder<'_>, _: Version) -> Result { let error_code = r.varint()?; - let retry_interval = match version { - Version::Draft14 | Version::Draft15 => 0, - _ => r.varint()?, - }; + let retry_interval = r.varint()?; let reason_phrase = Cow::Owned(r.string()?); Ok(Self { - request_id, error_code, reason_phrase, retry_interval, @@ -202,58 +124,25 @@ mod tests { crate::coding::decode_buf(&mut buf, version, M::decode_msg) } - #[test] - fn test_request_ok_round_trip() { - let msg = RequestOk { - largest: None, - request_id: Some(RequestId(42)), - active: None, - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: RequestOk = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(42))); - } - - /// A REQUEST_UPDATE_OK may refresh EXPIRES; it is ignored rather than rejected. - #[test] - fn test_request_ok_ignores_expires() { - let bytes = [0x07, 0x01, 0x08, 0x05]; - - let decoded: RequestOk = decode_message(&bytes, Version::Draft16).unwrap(); - assert_eq!(decoded.request_id, Some(RequestId(7))); - } - - /// A REQUEST_UPDATE_OK or TRACK_STATUS_OK carries LARGEST_OBJECT; it is ignored rather - /// than rejected. Draft-14 has no REQUEST_OK, and the Location is length-prefixed until - /// draft-17. + /// A REQUEST_UPDATE_OK may refresh EXPIRES, and a REQUEST_UPDATE_OK or TRACK_STATUS_OK + /// carries LARGEST_OBJECT; neither is rejected. #[test] fn test_request_ok_ignores_largest_object() { - for version in [ - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { - let bytes: &[u8] = match version { - Version::Draft15 | Version::Draft16 => &[0x07, 0x01, 0x09, 0x02, 0x05, 0x03], - _ => &[0x01, 0x09, 0x05, 0x03], - }; - let mut buf = Decoder::new(bytes, version.into()); - RequestOk::decode_msg(&mut buf, version).unwrap_or_else(|e| panic!("{version}: {e}")); - assert!(buf.is_empty(), "{version}: trailing bytes"); + for version in [Version::Draft18, Version::Draft22] { + for bytes in [&[0x01, 0x08, 0x05][..], &[0x01, 0x09, 0x05, 0x03]] { + let mut buf = Decoder::new(bytes, version.into()); + RequestOk::decode_msg(&mut buf, version).unwrap_or_else(|e| panic!("{version}: {e}")); + assert!(buf.is_empty(), "{version}: trailing bytes"); + } } } #[test] fn test_request_ok_active_round_trips() { - for (version, request_id) in [(Version::Draft16, Some(RequestId(3))), (Version::Draft22, None)] { + for version in [Version::Draft18, Version::Draft22] { for count in [None, Some(0), Some(7)] { let msg = RequestOk { largest: None, - request_id, active: count, }; let decoded: RequestOk = decode_message(&encode_message(&msg, version), version).unwrap(); @@ -265,103 +154,6 @@ mod tests { #[test] fn test_request_error_round_trip() { let msg = RequestError { - request_id: Some(RequestId(99)), - error_code: 500, - reason_phrase: "Internal error".into(), - retry_interval: 0, - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: RequestError = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(99))); - assert_eq!(decoded.error_code, 500); - assert_eq!(decoded.reason_phrase, "Internal error"); - assert_eq!(decoded.retry_interval, 0); - } - - #[test] - fn test_request_error_v16_retry_interval() { - let msg = RequestError { - request_id: Some(RequestId(99)), - error_code: 500, - reason_phrase: "Internal error".into(), - retry_interval: 5000, - }; - - let encoded = encode_message(&msg, Version::Draft16); - let decoded: RequestError = decode_message(&encoded, Version::Draft16).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(99))); - assert_eq!(decoded.error_code, 500); - assert_eq!(decoded.reason_phrase, "Internal error"); - assert_eq!(decoded.retry_interval, 5000); - } - - #[test] - fn test_request_ok_v17_round_trip() { - let msg = RequestOk { - largest: None, - request_id: None, - active: None, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: RequestOk = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, None); - } - - #[test] - fn test_request_error_v17_round_trip() { - let msg = RequestError { - request_id: None, - error_code: 500, - reason_phrase: "Internal error".into(), - retry_interval: 3000, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: RequestError = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, None); - assert_eq!(decoded.error_code, 500); - assert_eq!(decoded.reason_phrase, "Internal error"); - assert_eq!(decoded.retry_interval, 3000); - } - - #[test] - fn test_request_ok_v18_round_trip() { - let msg = RequestOk { - largest: None, - request_id: None, - active: None, - }; - - let encoded = encode_message(&msg, Version::Draft18); - let decoded: RequestOk = decode_message(&encoded, Version::Draft18).unwrap(); - - assert_eq!(decoded.request_id, None); - } - - /// Regression: pre-fix, the `version != Draft17` check caused Draft18 to be - /// treated as Draft14-16 and panic in the encoder. - #[test] - fn test_request_ok_v18_wire_matches_v17() { - let msg = RequestOk { - largest: None, - request_id: None, - active: None, - }; - let v17 = encode_message(&msg, Version::Draft17); - let v18 = encode_message(&msg, Version::Draft18); - assert_eq!(v17, v18); - } - - #[test] - fn test_request_error_v18_round_trip() { - let msg = RequestError { - request_id: None, error_code: 500, reason_phrase: "Internal error".into(), retry_interval: 3000, @@ -370,7 +162,6 @@ mod tests { let encoded = encode_message(&msg, Version::Draft18); let decoded: RequestError = decode_message(&encoded, Version::Draft18).unwrap(); - assert_eq!(decoded.request_id, None); assert_eq!(decoded.error_code, 500); assert_eq!(decoded.reason_phrase, "Internal error"); assert_eq!(decoded.retry_interval, 3000); diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index bd942c282f6..c43f421d393 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -8,25 +8,8 @@ use crate::{ coding::{Decode, DecodeError, Decoder, Stream}, }; -/// Legacy receives are framed once by the physical multiplexer, not again by -/// the virtual request decoder. Encodes still count at their typed codec. -pub(super) fn with_stats( - stream: Stream, - stats: &crate::stats::Session, - version: Version, -) -> Stream { - let mut stream = stream.with_stats(stats); - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - stream.reader = stream.reader.with_stats(&Default::default()); - } - stream -} - pub(super) fn fin_cancels(version: Version) -> bool { - matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 - ) + matches!(version, Version::Draft18) } /// Watch abrupt cancellation after the requester has finished sending messages. @@ -38,21 +21,13 @@ pub(super) fn poll_cancel( cx: &mut Context<'_>, ) -> Poll> { if !*finished { - let closed = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => poll_legacy_end(stream, version, cx), - _ => messages.poll_end(&mut stream.reader, cx), - }; - match closed { + match messages.poll_end(&mut stream.reader, cx) { Poll::Ready(Ok(())) if !fin_cancels(version) => *finished = true, Poll::Ready(result) => return Poll::Ready(result), Poll::Pending => {} } } - // Virtual writers on drafts 14-16 are already closed while their control request lives. - if !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - return stream.writer.poll_closed(cx); - } - Poll::Pending + stream.writer.poll_closed(cx) } /// Watch for the requester ending a request it has not been answered on yet, without consuming @@ -64,16 +39,6 @@ pub(super) fn poll_withdrawn( version: Version, cx: &mut Context<'_>, ) -> Poll<()> { - // Drafts 14-16 deliver the terminal message (UNSUBSCRIBE, PUBLISH_NAMESPACE_DONE) ahead of - // the FIN, so a waiting message that is not an update already withdraws the request. Their - // virtual writers are closed while the control request lives, so only the reader counts. - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - return match stream.reader.poll_varint_peek(cx) { - Poll::Ready(Ok(id)) if id == super::SubscribeUpdate::ID => Poll::Pending, - Poll::Ready(_) => Poll::Ready(()), - Poll::Pending => Poll::Pending, - }; - } if stream.writer.poll_closed(cx).is_ready() { return Poll::Ready(()); } @@ -84,83 +49,6 @@ pub(super) fn poll_withdrawn( } } -/// Poll a draft 14-16 virtual request stream until the requester ends the request. -/// -/// An update modifies the request rather than ending it, and the requests watched here -/// apply none, so each is refused with [`refuse_update`] and the request stays open. Any -/// other message is `Err(Cancel)`, and the stream finishing is `Ok(())`. -pub(super) fn poll_legacy_end( - stream: &mut Stream, - version: Version, - cx: &mut Context<'_>, -) -> Poll> { - loop { - // A refusal goes out before the next message is read. - ready!(stream.writer.poll_flush(cx))?; - match ready!(stream.reader.poll_decode_maybe::(cx))? { - Some(FollowUp::Update(body)) => refuse_update(&mut stream.writer, &body, version)?, - Some(FollowUp::End) => return Poll::Ready(Err(Error::Cancel)), - None => return Poll::Ready(Ok(())), - } - } -} - -/// Buffer the answer to a draft 14-16 update aimed at a request that takes none. -/// -/// Draft 14 answers no update. Drafts 15 and 16 answer each with exactly one REQUEST_OK or -/// REQUEST_ERROR naming the update's own Request ID, and a refusal leaves any request but a -/// subscription standing, so NOT_SUPPORTED is the whole answer. -pub(super) fn refuse_update( - writer: &mut crate::coding::Writer, - body: &super::Body, - version: Version, -) -> Result<(), Error> { - if version == Version::Draft14 { - return Ok(()); - } - let request_id = super::RequestId::decode(&mut body.decoder(version), version).map_err(DecodeError::complete)?; - writer.buffer_message( - super::RequestError::ID, - &super::RequestError { - request_id: Some(request_id), - error_code: super::error::request::to_code( - &Error::Unsupported, - super::error::request::Kind::Subscribe, - version, - ), - reason_phrase: "update not supported".into(), - retry_interval: 0, - }, - ) -} - -/// A message the adapter routed onto a draft 14-16 request after the request itself. -/// -/// Those drafts carry every request on the control stream, and the adapter delivers -/// each message to the request it names. -#[derive(Debug)] -pub(super) enum FollowUp { - /// SUBSCRIBE_UPDATE, or REQUEST_UPDATE on draft 16, still undecoded. - Update(super::Body), - /// UNSUBSCRIBE, FETCH_CANCEL, or any other message, which ends the request. - End, -} - -impl Decode for FollowUp { - fn decode(r: &mut Decoder<'_>, version: Version) -> Result { - let id = r.varint()?; - let update = id == super::SubscribeUpdate::ID; - let start = r.position(); - let body = super::Body::decode(r, version)?; - r.control = Some(super::message::control(id, version)); - r.uncounted = start; - Ok(match update { - true => Self::Update(body), - false => Self::End, - }) - } -} - /// One whole `[type][length][body]` message off a request stream, decoded at once so a read /// dropped part-way consumes nothing. #[derive(Debug)] @@ -171,14 +59,14 @@ impl Decode for Framed { let id = r.varint()?; let start = r.position(); let body = super::Body::decode(r, version)?; - r.control = Some(super::message::control(id, version)); + r.control = Some(super::message::control(id)); r.uncounted = start; Ok(Self(id, body)) } } -/// A message following the request on a request stream: `T`, or from draft 18 a GOAWAY -/// migrating this one request (draft-22 section 9.2). +/// A message following the request on a request stream: `T`, or a GOAWAY migrating this +/// one request (draft-22 section 9.2). #[derive(Debug)] pub(super) enum Next { Message(T), @@ -190,23 +78,18 @@ pub(super) enum Next { impl> Decode for Next { fn decode(r: &mut Decoder<'_>, version: Version) -> Result { - if !matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) { - let mut peek = r.clone(); - if peek.varint()? == super::GoAway::ID { - *r = peek; - r.uncounted = r.position(); - let msg = super::GoAway::decode(r, version)?; - // Draft-18's Request ID belongs on the control stream only. - if msg.request_id.is_some() { - return Err(DecodeError::InvalidValue); - } - return Ok(Self::GoAway { - redirect: !msg.new_session_uri.is_empty(), - }); + let mut peek = r.clone(); + if peek.varint()? == super::GoAway::ID { + *r = peek; + r.uncounted = r.position(); + let msg = super::GoAway::decode(r, version)?; + // Draft-18's Request ID belongs on the control stream only. + if msg.request_id.is_some() { + return Err(DecodeError::InvalidValue); } + return Ok(Self::GoAway { + redirect: !msg.new_session_uri.is_empty(), + }); } T::decode(r, version).map(Self::Message) } @@ -314,7 +197,7 @@ impl Messages { } } -/// Refuse a modern request with its complete REQUEST_ERROR, then finish the response. +/// Refuse a request with its complete REQUEST_ERROR, then finish the response. pub(super) async fn reject( mut stream: Stream, error: super::RequestError<'_>, @@ -325,33 +208,17 @@ pub(super) async fn reject( Ok(()) } -/// Write the error refusing a `kind` request with `err`, as the negotiated draft spells it. -/// -/// Each draft-14 error message has the REQUEST_ERROR body that draft-15 folded them into, so -/// only the message type changes; draft-17 then drops the Request ID. +/// Write the REQUEST_ERROR refusing a `kind` request with `err`. pub(super) async fn write_error( writer: &mut crate::coding::Writer, kind: super::error::request::Kind, - request_id: super::RequestId, err: &Error, reason: &str, version: Version, ) -> Result<(), Error> { - use super::error::request::Kind; - - let id = match (version, kind) { - (Version::Draft14, Kind::Subscribe) => super::SubscribeError::ID, - (Version::Draft14, Kind::Fetch) => super::FetchError::ID, - (Version::Draft14, Kind::Publish) => super::PublishError::ID, - (Version::Draft14, Kind::PublishNamespace) => super::PublishNamespaceError::ID, - (Version::Draft14, Kind::SubscribeNamespace) => super::SubscribeNamespaceError::ID, - (Version::Draft14, Kind::TrackStatus) => super::TRACK_STATUS_ERROR_14, - _ => super::RequestError::ID, - }; writer.buffer_message( - id, + super::RequestError::ID, &super::RequestError { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16).then_some(request_id), error_code: super::error::request::to_code(err, kind, version), reason_phrase: reason.into(), retry_interval: 0, @@ -363,7 +230,7 @@ pub(super) async fn write_error( /// A framed subscription update, preserving omitted preferences. #[derive(Debug)] pub(super) struct Update { - /// The first Request ID: the update's own on drafts 14-16, which their answer names. + /// The update's own Request ID. pub(super) request_id: super::RequestId, pub(super) priority: Option, pub(super) max_age: Option, @@ -374,7 +241,7 @@ pub(super) struct Update { pub(super) refused: Option, /// The AUTHORIZATION TOKEN renewing the request's credential. pub(super) authorization_token: Option, - /// NEW_GROUP_REQUEST (draft-15+), which only a track advertising DYNAMIC_GROUPS takes. + /// NEW_GROUP_REQUEST, which only a track advertising DYNAMIC_GROUPS takes. pub(super) new_group: Option, /// FORWARD, `None` when unchanged. pub(super) forward: Option, @@ -385,63 +252,14 @@ pub(super) struct Update { } impl Update { - /// Decode a draft 14-16 update the adapter routed onto its subscription. - pub(super) fn decode_legacy(body: &super::Body, version: Version) -> Result { - let mut data = body.decoder(version); - // The body is complete, so running short inside it is malformed. - Self::decode_body(&mut data, version) - .map_err(DecodeError::complete) - .map_err(Into::into) - } - /// Decode the message body, after its type and length. fn decode_body(data: &mut Decoder<'_>, version: Version) -> Result { - use super::{Fill, Filter, Location, Opaque, RequestId}; + use super::{Fill, Filter, Opaque, RequestId}; let request_id = RequestId::decode(data, version)?; - match version { - Version::Draft14 => { - // The subscription it updates, which the adapter already routed by. - RequestId::decode(data, version)?; - // The range is left as it is: every field is mandatory, so a narrowed range - // cannot be told from a repeated one, and draft 14 accepts extra objects as - // the worst outcome of an update. - Location::decode(data, version)?; - let _end_group = data.varint()?; - let priority = data.u8()?; - let forward = data.bool()?; - decode_params!(data, version, - 0x03 => tokens: Vec, - super::max_age::PARAMETER => max_age: Option, - ); - if !data.is_empty() { - return Err(DecodeError::InvalidValue); - } - let (refused, authorization_token) = Self::refused(tokens, false); - return Ok(Self { - request_id, - priority: Some(priority), - max_age: super::max_age::validate(max_age)?, - refused, - authorization_token, - new_group: None, - forward: Some(forward), - filter: None, - fill: None, - }); - } - // The request it updates, which the adapter already routed by. - Version::Draft15 | Version::Draft16 => { - RequestId::decode(data, version)?; - } - Version::Draft17 => { - data.varint()?; - } - _ => {} - } decode_params!(data, version, 0x02 => object_timeout: Option, 0x03 => token: Vec, - 0x06 => subgroup_timeout: Option where !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17), + 0x06 => subgroup_timeout: Option, 0x10 => forward: Option, 0x20 => priority: Option, 0x21 => filter: Option, @@ -451,7 +269,7 @@ impl Update { 0x27 => priority_filter: Vec where super::subscribe::has_range_filters(version), 0x28 => property_filter: Vec where super::subscribe::has_range_filters(version), 0x29 => track_filter: Vec where super::subscribe::has_range_filters(version), - 0x32 => new_group: Option where super::subscribe::has_new_group_request(version), + 0x32 => new_group: Option, super::max_age::PARAMETER => max_age: Option, ); if !data.is_empty() { @@ -503,15 +321,15 @@ impl Decode for Update { let mut data = r.sub(size)?; // The complete frame is present; a short field inside it is malformed. let update = Self::decode_body(&mut data, version).map_err(DecodeError::complete)?; - r.control = Some(super::message::control(super::SubscribeUpdate::ID, version)); + r.control = Some(super::message::control(super::SubscribeUpdate::ID)); r.uncounted = start; Ok(update) } } -/// The namespace-only parameters of a modern REQUEST_UPDATE. `TRACKS` marks one on a -/// SUBSCRIBE_TRACKS, which from draft-22 may also carry TRACK_PROPERTY_FILTER and from -/// draft-22 FORWARD. +/// The namespace-only parameters of a REQUEST_UPDATE. `TRACKS` marks one on a +/// SUBSCRIBE_TRACKS, which from draft-22 may also carry TRACK_PROPERTY_FILTER and +/// FORWARD. #[derive(Debug)] pub(super) struct NamespaceUpdate { pub(super) request_id: super::RequestId, @@ -557,7 +375,7 @@ impl Decode for NamespaceUpdate { }) }; let update = decode().map_err(DecodeError::complete)?; - r.control = Some(super::message::control(super::SubscribeUpdate::ID, version)); + r.control = Some(super::message::control(super::SubscribeUpdate::ID)); r.uncounted = start; Ok(update) } @@ -597,13 +415,10 @@ mod tests { let stats = registry.tier(Tier::default()).session(crate::Path::from_static("")); let version = Version::Draft22; let session = ScriptedSession::new(vec![0x10, 0, 2, 0, 0, 0x07, 0, 0]); - let mut stream = with_stats( - Stream::open(&mut crate::driver::Transport::new(session.clone()), version) - .await - .unwrap(), - &stats, - version, - ); + let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) + .await + .unwrap() + .with_stats(&stats); let mut messages = Messages::new(crate::driver::Transport::new(session.clone()), true); assert_eq!(messages.message(&mut stream.reader).await.unwrap().0, 0x07); let rx = |class| { @@ -652,65 +467,6 @@ mod tests { } } - /// On drafts 14-16 an update aimed at a request that applies none, such as a FETCH or - /// SUBSCRIBE_NAMESPACE, keeps the request open. Drafts 15 and 16 refuse it with a - /// REQUEST_ERROR naming the update, and a later cancellation still ends the request. - #[moq_clock::test] - async fn legacy_update_is_answered_without_ending_the_request() { - use crate::lite::test_transport::ScriptedSession; - - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - // Own Request ID 10, target 0, no parameters. Draft 14 carries its fixed start, - // end group, priority and forward first. - let body: &[u8] = match version { - Version::Draft14 => &[10, 0, 0, 0, 0, 128, 1, 0], - _ => &[10, 0, 0], - }; - let mut update = vec![super::super::SubscribeUpdate::ID as u8, 0, body.len() as u8]; - update.extend_from_slice(body); - let session = ScriptedSession::new(update); - let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) - .await - .unwrap(); - let mut finished = false; - let mut messages = Messages::new(crate::driver::Transport::new(session.clone()), false); - - let polled = futures::poll!(std::future::poll_fn(|cx| poll_cancel( - &mut stream, - &mut finished, - &mut messages, - version, - cx - ))); - assert!(polled.is_pending(), "{version}: the update ended the request"); - - let written = session.log.writes.lock().unwrap().clone(); - match version { - Version::Draft14 => assert!(written.is_empty(), "{version}: {written:?}"), - // [type, length (2), Request ID, NOT_SUPPORTED, ...] - _ => assert_eq!( - (written[0], written[3], written[4]), - (super::super::RequestError::ID as u8, 10, 0x3), - "{version}: {written:?}" - ), - } - - // UNSUBSCRIBE, or any message but an update, ends the request. - session.push(&[super::super::Unsubscribe::ID as u8, 0, 1, 0]); - let polled = futures::poll!(std::future::poll_fn(|cx| poll_cancel( - &mut stream, - &mut finished, - &mut messages, - version, - cx - ))); - assert!( - matches!(polled, Poll::Ready(Err(Error::Cancel))), - "{version}: {polled:?}" - ); - } - } - #[test] fn delivery_timeouts_on_subscription_updates_are_accepted() { for version in [Version::Draft18, Version::Draft22] { @@ -762,109 +518,4 @@ mod tests { assert_eq!(update.priority, Some(5)); assert!(update.refused.is_none()); } - - /// Draft-14's per-request error messages share REQUEST_ERROR's body, so the shared writer - /// must put the same bytes on the wire as each legacy message does. - #[moq_clock::test] - async fn draft14_errors_match_their_legacy_messages() { - use super::super::error::request::{Kind, to_code}; - use super::super::{ - FetchError, PublishError, PublishNamespaceError, RequestId, SubscribeError, SubscribeNamespaceError, - }; - use crate::lite::test_transport::{Log, SinkSend}; - - let version = Version::Draft14; - let (request_id, err, reason) = (RequestId(6), Error::NotFound, "gone"); - - for kind in [ - Kind::Subscribe, - Kind::Fetch, - Kind::Publish, - Kind::PublishNamespace, - Kind::SubscribeNamespace, - Kind::TrackStatus, - ] { - let shared = Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(SinkSend::new(shared.clone())), - version, - ); - write_error(&mut writer, kind, request_id, &err, reason, version) - .await - .unwrap(); - - let legacy = Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(SinkSend::new(legacy.clone())), - version, - ); - let error_code = to_code(&err, kind, version); - let reason_phrase = reason.into(); - match kind { - Kind::Subscribe => { - writer - .encode_message(&SubscribeError { - request_id, - error_code, - reason_phrase, - }) - .await - } - Kind::Fetch => { - writer - .encode_message(&FetchError { - request_id, - error_code, - reason_phrase, - }) - .await - } - Kind::Publish => { - writer - .encode_message(&PublishError { - request_id, - error_code, - reason_phrase, - }) - .await - } - Kind::PublishNamespace => { - writer - .encode_message(&PublishNamespaceError { - request_id, - error_code, - reason_phrase, - }) - .await - } - Kind::SubscribeNamespace => { - writer - .encode_message(&SubscribeNamespaceError { - request_id, - error_code, - reason_phrase, - }) - .await - } - // TRACK_STATUS_ERROR has the SUBSCRIBE_ERROR body under its own type. - Kind::TrackStatus => { - writer.buffer_varint(super::super::TRACK_STATUS_ERROR_14).unwrap(); - writer - .encode(&SubscribeError { - request_id, - error_code, - reason_phrase, - }) - .await - } - } - .unwrap(); - - assert_eq!( - *shared.writes.lock().unwrap(), - *legacy.writes.lock().unwrap(), - "{kind:?}" - ); - } - } } diff --git a/rs/moq-net/src/ietf/request_update.rs b/rs/moq-net/src/ietf/request_update.rs index 29b3859bf1c..2094c762013 100644 --- a/rs/moq-net/src/ietf/request_update.rs +++ b/rs/moq-net/src/ietf/request_update.rs @@ -18,7 +18,7 @@ use std::{collections::VecDeque, task::Poll}; -use super::{RequestId, Version}; +use super::Version; use crate::{Error, SessionError, auth}; /// MAX_REQUEST_UPDATES Setup Option (Option Type 0x08). Even, so the value is a bare varint. @@ -40,20 +40,17 @@ pub const UNNEGOTIATED_GUARD: usize = 64; /// Whether this version defines MAX_REQUEST_UPDATES. Draft-22 section 9.1.7 added it; the /// drafts below carry no such Setup Option. pub fn supported(version: Version) -> bool { - !matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 - ) + !matches!(version, Version::Draft18) } /// A request's in-band renewal: the verdict on the token being verified, and the updates that /// arrived behind it. /// -/// From draft 17 an answer names no Request ID, so updates are answered in arrival order: one +/// An answer names no Request ID, so updates are answered in arrival order: one /// behind a pending verdict waits here until the verdict ahead of it resolves. The queue is /// bounded by MAX_REQUEST_UPDATES, counting the one being verified. pub(super) struct Renewal { - pending: Option<(auth::RequestVerdict, RequestId)>, + pending: Option, queued: VecDeque, } @@ -80,10 +77,10 @@ impl Renewal { self.pending.is_some() } - /// Wait on `verdict`, the renewal the update with `request_id` carried. - pub fn verify(&mut self, verdict: auth::RequestVerdict, request_id: RequestId) { + /// Wait on `verdict`, the renewal an update carried. + pub fn verify(&mut self, verdict: auth::RequestVerdict) { debug_assert!(self.pending.is_none(), "one renewal is verified at a time"); - self.pending = Some((verdict, request_id)); + self.pending = Some(verdict); } /// Queue `update` behind the pending verdict, counting it against the bound. @@ -111,16 +108,12 @@ impl Renewal { /// `Ready` with the pending verdict once it resolves. pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll { - let Some((verdict, _)) = &self.pending else { + let Some(verdict) = &self.pending else { return Poll::Pending; }; let grant = std::task::ready!(verdict.poll_grant(waiter)); - let (verdict, request_id) = self.pending.take().expect("a pending renewal"); - Poll::Ready(Verdict { - verdict, - grant, - request_id, - }) + let verdict = self.pending.take().expect("a pending renewal"); + Poll::Ready(Verdict { verdict, grant }) } } @@ -130,8 +123,6 @@ pub(super) struct Verdict { pub verdict: auth::RequestVerdict, /// The acceptor's grant, or its refusal. pub grant: Result, - /// The update that carried the renewal, which the answer names before draft 17. - pub request_id: RequestId, } /// Close `session` for a peer past the MAX_REQUEST_UPDATES we advertised, returning the error @@ -166,20 +157,13 @@ mod tests { Some(MAX_REQUEST_UPDATES) ); - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - let mut params = super::super::Parameters::default(); - into_setup(&mut params, version); - assert_eq!( - params.get_varint(super::super::ParameterVarInt::MaxRequestUpdates), - None, - "{version} has no MAX_REQUEST_UPDATES option to advertise" - ); - } + let version = Version::Draft18; + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!( + params.get_varint(super::super::ParameterVarInt::MaxRequestUpdates), + None, + "{version} has no MAX_REQUEST_UPDATES option to advertise" + ); } } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 0720af9f3fb..98820332f27 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -10,8 +10,8 @@ use crate::{ }; use super::{ - Control, Message, Publisher, Subscriber, Version, active_count, adapter::ControlStreamAdapter, auth, cluster, - epoch, hidden, max_age, peer, request_update, solicit, subscriber::is_protocol_violation, + Control, Message, Publisher, Subscriber, Version, active_count, auth, cluster, epoch, hidden, max_age, peer, + request_update, solicit, subscriber::is_protocol_violation, }; /// Keep the session's SUBSCRIBE_NAMESPACE requests open as `discovery` decides, each on @@ -41,14 +41,7 @@ pub struct Config { pub session: Transport, - /// The bidi SETUP stream (draft-14 through draft-16 only). Draft-17+ passes `None` - /// and exchanges SETUP on uni streams instead. - pub setup: Option>, - - pub request_id_max: Option, - - /// What the peer may make this session hold. On drafts 14 to 16 it also sizes the - /// MAX_REQUEST_ID window advertised in our SETUP (see [`ietf::initial_max_request_id`]). + /// What the peer may make this session hold. pub limits: crate::session::Limits, /// Whether we dialed, which sets the request-id parity. @@ -70,13 +63,13 @@ pub struct Config { /// falling back to 1; that is what a server accepting a connection passes, since it /// has no per-peer configuration of its own. /// - /// Only `moqt-17`+ negotiates the MoQ Cluster extension. Earlier drafts carry no - /// cost at all, so nothing is charged and their routes rank on hop count alone. + /// A peer that does not negotiate the MoQ Cluster extension carries no cost at all, so + /// nothing is charged and its routes rank on hop count alone. pub cost: Option, pub version: Version, - /// The request path we advertise in our SETUP (draft-17+ clients on URL-less + /// The request path we advertise in our SETUP (clients on URL-less /// transports). A server passes `None`. pub path: Option, @@ -87,8 +80,8 @@ pub struct Config { /// server passes `None`. pub token: Option, - /// The peer's SETUP stream, when it was already read before [`start`] (a draft-17+ - /// server that gated on the client's path via [`accept_setup`]). It becomes the + /// The peer's SETUP stream, when it was already read before [`start`] (a server + /// that gated on the client's path via [`accept_setup`]). It becomes the /// GOAWAY channel; `None` lets the uni loop read the SETUP itself. pub peer_setup_stream: Option>, @@ -106,12 +99,11 @@ pub struct Config { /// classified by the session before it accepts any more. pub early_unis: Vec>, - /// The extensions our SETUP offers. Drafts 14-16 already sent it, so there this only - /// records what it said. MoQ Auth is offered only while [`Self::auth`] supports it too. + /// The extensions our SETUP offers. MoQ Auth is offered only while [`Self::auth`] + /// supports it too. pub extensions: crate::setup::Extensions, - /// The Implementation our SETUP advertises (draft-17+), or `None` to omit it. Drafts - /// 14-16 already sent theirs on the bidi SETUP. + /// The Implementation our SETUP advertises, or `None` to omit it. pub implementation: Option, /// Where the implementation the peer's SETUP advertises is published, once the uni loop @@ -145,8 +137,6 @@ pub fn start(config: Config) -> Result<(Driver, crate::goaway::Handle, Option Result<(Driver, crate::goaway::Handle, Option auth.present(bytes::Bytes::new(), true).ok(), false => None, }; @@ -206,10 +196,6 @@ pub fn start(config: Config) -> Result<(Driver, crate::goaway::Handle, Option peer_setup.set(declared), - // A legacy caller that passed nothing (our own tests, and the lite paths): - // settle the slot rather than leave the announce loops waiting on a value - // that is never coming. - None if !cluster::supported(version) => peer_setup.set(peer::Peer::default()), // A client is live before the server's SETUP, which follows its check of the // credential. None if client => peer_setup = peer_setup.accepting(auth.clone()), @@ -247,374 +233,202 @@ pub fn start(config: Config) -> Result<(Driver, crate::goaway::Handle, Option { - let Some(setup) = setup else { - let err = Error::ProtocolViolation; - session.close( - super::error::to_session_code(&SessionError::from(&err)), - "setup stream required", - ); - return Err(err); - }; - let control = Control::new(request_id_max, client).with_window(limits.window(), client, version); - let adapter = ControlStreamAdapter::new(session.clone(), control.clone(), version).with_stats(&stats); - // Boxed like a draft-17+ transport, so the publisher and subscriber below - // are the same instantiation as theirs. - let virtual_session = crate::driver::Transport::new(adapter.clone()); - - // No AUTH on these drafts, but a limit still reaches both halves. - let mut publisher = Publisher::new( - runtime.clone(), - virtual_session.clone(), - publish, - control.clone(), - peer_hop, - peer_setup.clone(), - version, - ) - .with_auth(auth.clone()); - let (tasks, mut task_set) = TaskSet::new(); - publisher.withdrawal = withdrawing.clone(); - publisher.owed = serving.clone(); - publisher.subscriptions = subscriptions; - - let mut subscriber = Subscriber::new( - runtime.clone(), - virtual_session.clone(), - subscribe, - control, - peer_hop, - peer_setup.clone(), - self_origin, - cost, - version, - tasks.clone(), - goaway.going_away.clone(), - ) - .with_auth(auth.clone()) - .with_solicit(extensions.solicit); - subscriber.announces = announces; - - // GOAWAY send task: draft-14-16 carry GOAWAY on the shared control - // stream. Parked on the drain trigger; races the transport close so - // a parked trigger never blocks the task set draining. - { - let mut session = session.clone(); - let adapter = adapter.clone(); - let goaway = goaway.clone(); - tasks.push(async move { - let payload = kio::wait(|waiter| { - let mut cx = waiter.context(); - if session.poll_closed(&mut cx).is_ready() { - return std::task::Poll::Ready(None); - } - goaway.poll_triggered(waiter) - }) - .await; - let Some(payload) = payload else { - return; - }; - let timeout_ms = payload.timeout.map(|d| d.as_millis() as u64).unwrap_or(0); - adapter.send_goaway(&payload.uri, timeout_ms, version); - }); - } - drop(tasks); - - let dispatch_session = virtual_session.clone(); - - // Every half only ends the session on error (err_only parks on clean - // completion); the task set draining is the one clean exit. - let mut adapter_run = std::pin::pin!(err_only(adapter.run(setup.reader, setup.writer, goaway.clone()))); - let mut unis = Unis::new( - virtual_session.clone(), - subscriber.clone(), - UniSetup { - peer: None, - read: false, - early: Vec::new(), - client, - version, - goaway, - }, - ); - let mut dispatch = std::pin::pin!(err_only(run_dispatch( - dispatch_session, - publisher.clone(), - subscriber.clone(), - peer_setup.clone(), - None, - version - ))); - let mut datagrams = - std::pin::pin!(err_only(run_datagrams(virtual_session.clone(), subscriber.clone()))); - // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; - // see `Publisher::run_publish_namespaces`. - let mut pub_ns_run = std::pin::pin!(err_only(publisher.clone().run_publish_namespaces())); - let sub_ns_run = run_namespaces(subscriber.clone(), discovery, { - let adapter = adapter.clone(); - move || { - let mut adapter = adapter.clone(); - async move { - match version { - Version::Draft16 => { - let (send, recv) = adapter.open_native_bi().await?; - let mut reader = crate::coding::Reader::new( - crate::transport::boxed::RecvStream::new(recv), - version, - ); - reader.set_priority(crate::priority::RECV_CONTROL); - Ok(Stream { - writer: crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(send), - version, - ), - reader, - }) - } - _ => Stream::open_request(&mut crate::driver::Transport::new(adapter), version).await, - } - } - } - }); - let mut sub_ns_run = std::pin::pin!(err_only(sub_ns_run)); + let res = { + let control = Control::new(client); + // Send SETUP and retain its writer for the session's lifetime. Only a failure + // ends the session; the other branches report how it closed. + let setup = err_only(run_setup( + session.clone(), + version, + path, + authority, + token, + implementation, + self_origin, + cost, + crate::setup::Extensions { + auth: extensions.auth && auth.supported(), + ..extensions + }, + goaway.clone(), + control.clone(), + stats.clone(), + )); - let res = kio::wait(|waiter| { - use std::task::Poll; - if let Poll::Ready(err) = waiter.poll_future(adapter_run.as_mut()) { - return Poll::Ready(Err::<(), Error>(err)); - } - if let Poll::Ready(res) = unis.poll(waiter) { - return Poll::Ready(res); - } - if let Poll::Ready(err) = waiter.poll_future(dispatch.as_mut()) { - return Poll::Ready(Err(err)); - } - if let Poll::Ready(err) = waiter.poll_future(datagrams.as_mut()) { - return Poll::Ready(Err(err)); - } - if task_set.poll(waiter).is_ready() { - return Poll::Ready(Ok(())); - } - if let Poll::Ready(err) = waiter.poll_future(sub_ns_run.as_mut()) { - return Poll::Ready(Err(err)); - } - if let Poll::Ready(err) = waiter.poll_future(pub_ns_run.as_mut()) { - return Poll::Ready(Err(err)); + let mut publisher = Publisher::new( + runtime.clone(), + session.clone(), + publish, + control.clone(), + peer_hop, + peer_setup.clone(), + version, + ) + .with_auth(auth.clone()); + let (tasks, mut task_set) = TaskSet::new(); + publisher.withdrawal = withdrawing.clone(); + publisher.owed = serving.clone(); + publisher.subscriptions = subscriptions; + + // Only the dialing side fails loud on a publication outside its grant: a + // server's publish origin is everything the peer may read, not what it + // intends to push. + let enforce = { + let auth = auth.clone(); + let publisher = publisher.clone(); + let session = session.clone(); + async move { + match client { + true => enforce_grant(auth, publisher, session).await, + false => std::future::pending().await, } - Poll::Pending - }) - .await; - close(&mut session, &res); - if !closing.is_local() { - kio::wait(|waiter| unis.poll_drain(waiter)).await; - } - if closing.is_local() { - subscriber.close(); - } else if let Err(err) = &res { - // Every track this session was receiving ends with its error. - subscriber.abort(err); } - res - } - _ => { - let control = Control::new(None, client).with_history(limits.window()); - // Send SETUP and retain its writer for the session's lifetime. Only a failure - // ends the session; the other branches report how it closed. - let setup = err_only(run_setup( - session.clone(), - version, - path, - authority, - token, - implementation, - self_origin, - cost, - crate::setup::Extensions { - auth: extensions.auth && auth.supported(), - ..extensions - }, - goaway.clone(), - control.clone(), - stats.clone(), - )); - - let mut publisher = Publisher::new( - runtime.clone(), - session.clone(), - publish, - control.clone(), - peer_hop, - peer_setup.clone(), - version, - ) - .with_auth(auth.clone()); - let (tasks, mut task_set) = TaskSet::new(); - publisher.withdrawal = withdrawing.clone(); - publisher.owed = serving.clone(); - publisher.subscriptions = subscriptions; - - // Only the dialing side fails loud on a publication outside its grant: a - // server's publish origin is everything the peer may read, not what it - // intends to push. - let enforce = { - let auth = auth.clone(); - let publisher = publisher.clone(); - let session = session.clone(); - async move { - match client { - true => enforce_grant(auth, publisher, session).await, - false => std::future::pending().await, + }; + + let mut subscriber = Subscriber::new( + runtime.clone(), + session.clone(), + subscribe, + control.clone(), + peer_hop, + peer_setup.clone(), + self_origin, + cost, + version, + tasks, + goaway.going_away.clone(), + ) + .with_auth(auth.clone()) + .with_solicit(extensions.solicit); + + // Our tokens, one Auth request each, once the peer's SETUP negotiates it. + let present = auth::run_present( + runtime.clone(), + session.clone(), + control.clone(), + auth.clone(), + peer_setup.clone(), + version, + stats.clone(), + ); + // Only a side that offered MoQ Auth answers the peer's Auth requests: one that + // arrives otherwise falls through to the protocol violation in `run_dispatch`. + let serve = auth.supported().then(|| auth::Serve { + runtime: runtime.clone(), + handle: auth.clone(), + peer_grant, + }); + subscriber.announces = announces; + + // When the peer's SETUP was pre-read (a gated server accept), monitor + // GOAWAY on that stream here; otherwise `run_unis` does it when the SETUP + // arrives on the wire. + let goaway_recv = { + let goaway = goaway.clone(); + let stats = stats.clone(); + async move { + match peer_setup_stream { + Some(reader) => { + run_goaway(reader.with_stats(&stats).with_version(version), version, client, goaway).await } + None => std::future::pending().await, } - }; - - let mut subscriber = Subscriber::new( - runtime.clone(), - session.clone(), - subscribe, - control.clone(), - peer_hop, - peer_setup.clone(), - self_origin, - cost, - version, - tasks, - goaway.going_away.clone(), - ) - .with_auth(auth.clone()) - .with_solicit(extensions.solicit); - - // Our tokens, one Auth request each, once the peer's SETUP negotiates it. - let present = auth::run_present( - runtime.clone(), - session.clone(), - control.clone(), - auth.clone(), - peer_setup.clone(), + } + }; + + // Every half only ends the session on error (err_only parks on clean + // completion); `setup` never resolves (it holds the stream open) and the + // task set draining is the one clean exit. + let mut unis = Unis::new( + session.clone(), + subscriber.clone(), + UniSetup { + peer: Some(peer_setup.clone()), + read: setup_read, + early: early_unis, + client, version, - stats.clone(), - ); - // Only a side that offered MoQ Auth answers the peer's Auth requests: one that - // arrives otherwise falls through to the protocol violation in `run_dispatch`. - let serve = auth.supported().then(|| auth::Serve { - runtime: runtime.clone(), - handle: auth.clone(), - peer_grant, - }); - subscriber.announces = announces; - - // When the peer's SETUP was pre-read (a gated server accept), monitor - // GOAWAY on that stream here; otherwise `run_unis` does it when the SETUP - // arrives on the wire. - let goaway_recv = { - let goaway = goaway.clone(); - let stats = stats.clone(); - async move { - match peer_setup_stream { - Some(reader) => { - run_goaway(reader.with_stats(&stats).with_version(version), version, client, goaway) - .await - } - None => std::future::pending().await, - } - } - }; - - // Every half only ends the session on error (err_only parks on clean - // completion); `setup` never resolves (it holds the stream open) and the - // task set draining is the one clean exit. - let mut unis = Unis::new( - session.clone(), - subscriber.clone(), - UniSetup { - peer: Some(peer_setup.clone()), - read: setup_read, - early: early_unis, - client, - version, - goaway, - }, - ); - let mut dispatch = std::pin::pin!(err_only(run_dispatch( - session.clone(), - publisher.clone(), - subscriber.clone(), - peer_setup.clone(), - serve, - version - ))); - let mut datagrams = std::pin::pin!(err_only(run_datagrams(session.clone(), subscriber.clone()))); - let mut goaway_recv = std::pin::pin!(err_only(goaway_recv)); - let mut present = std::pin::pin!(err_only(present)); - let mut enforce = std::pin::pin!(err_only(enforce)); - let mut setup = std::pin::pin!(setup); - // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; - // see `Publisher::run_publish_namespaces`. - let mut pub_ns_run = std::pin::pin!(err_only(publisher.clone().run_publish_namespaces())); - let sub_ns_run = run_namespaces(subscriber.clone(), discovery, { - let session = session.clone(); - move || { - let mut session = session.clone(); - async move { Stream::open_request(&mut session, version).await } - } - }); - let mut sub_ns_run = std::pin::pin!(err_only(sub_ns_run)); + goaway, + }, + ); + let mut dispatch = std::pin::pin!(err_only(run_dispatch( + session.clone(), + publisher.clone(), + subscriber.clone(), + peer_setup.clone(), + serve, + version + ))); + let mut datagrams = std::pin::pin!(err_only(run_datagrams(session.clone(), subscriber.clone()))); + let mut goaway_recv = std::pin::pin!(err_only(goaway_recv)); + let mut present = std::pin::pin!(err_only(present)); + let mut enforce = std::pin::pin!(err_only(enforce)); + let mut setup = std::pin::pin!(setup); + // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; + // see `Publisher::run_publish_namespaces`. + let mut pub_ns_run = std::pin::pin!(err_only(publisher.clone().run_publish_namespaces())); + let sub_ns_run = run_namespaces(subscriber.clone(), discovery, { + let session = session.clone(); + move || { + let mut session = session.clone(); + async move { Stream::open_request(&mut session, version).await } + } + }); + let mut sub_ns_run = std::pin::pin!(err_only(sub_ns_run)); - let res = kio::wait(|waiter| { - use std::task::Poll; - if let Poll::Ready(res) = unis.poll(waiter) { - return Poll::Ready(res); - } - if let Poll::Ready(err) = waiter.poll_future(dispatch.as_mut()) { - return Poll::Ready(Err(err)); - } - if let Poll::Ready(err) = waiter.poll_future(datagrams.as_mut()) { - return Poll::Ready(Err(err)); - } - if let Poll::Ready(err) = waiter.poll_future(goaway_recv.as_mut()) { - return Poll::Ready(Err(err)); - } - // Malformed replies are session-fatal, unlike token refusals. - if let Poll::Ready(err) = waiter.poll_future(present.as_mut()) { - return Poll::Ready(Err(err)); - } - if let Poll::Ready(err) = waiter.poll_future(enforce.as_mut()) { - return Poll::Ready(Err(err)); - } - if let Poll::Ready(err) = waiter.poll_future(setup.as_mut()) { - return Poll::Ready(Err(err)); - } - if task_set.poll(waiter).is_ready() { - return Poll::Ready(Ok(())); - } - if let Poll::Ready(err) = waiter.poll_future(sub_ns_run.as_mut()) { - return Poll::Ready(Err(err)); - } - if let Poll::Ready(err) = waiter.poll_future(pub_ns_run.as_mut()) { - return Poll::Ready(Err(err)); - } - Poll::Pending - }) - .await; - close(&mut session, &res); - // Before this arm's Auth serve tasks drop, so each settles with the - // session's error rather than a bare cancel. - auth.close(match &res { - Ok(()) => Error::Cancel, - Err(err) => err.clone(), - }); - if !closing.is_local() { - kio::wait(|waiter| unis.poll_drain(waiter)).await; + let res = kio::wait(|waiter| { + use std::task::Poll; + if let Poll::Ready(res) = unis.poll(waiter) { + return Poll::Ready(res); } - if closing.is_local() { - subscriber.close(); - } else if let Err(err) = &res { - // Every track this session was receiving ends with its error. - subscriber.abort(err); + if let Poll::Ready(err) = waiter.poll_future(dispatch.as_mut()) { + return Poll::Ready(Err(err)); + } + if let Poll::Ready(err) = waiter.poll_future(datagrams.as_mut()) { + return Poll::Ready(Err(err)); + } + if let Poll::Ready(err) = waiter.poll_future(goaway_recv.as_mut()) { + return Poll::Ready(Err(err)); } - res + // Malformed replies are session-fatal, unlike token refusals. + if let Poll::Ready(err) = waiter.poll_future(present.as_mut()) { + return Poll::Ready(Err(err)); + } + if let Poll::Ready(err) = waiter.poll_future(enforce.as_mut()) { + return Poll::Ready(Err(err)); + } + if let Poll::Ready(err) = waiter.poll_future(setup.as_mut()) { + return Poll::Ready(Err(err)); + } + if task_set.poll(waiter).is_ready() { + return Poll::Ready(Ok(())); + } + if let Poll::Ready(err) = waiter.poll_future(sub_ns_run.as_mut()) { + return Poll::Ready(Err(err)); + } + if let Poll::Ready(err) = waiter.poll_future(pub_ns_run.as_mut()) { + return Poll::Ready(Err(err)); + } + Poll::Pending + }) + .await; + close(&mut session, &res); + // Before this arm's Auth serve tasks drop, so each settles with the + // session's error rather than a bare cancel. + auth.close(match &res { + Ok(()) => Error::Cancel, + Err(err) => err.clone(), + }); + if !closing.is_local() { + kio::wait(|waiter| unis.poll_drain(waiter)).await; + } + if closing.is_local() { + subscriber.close(); + } else if let Err(err) = &res { + // Every track this session was receiving ends with its error. + subscriber.abort(err); } + res }; auth.close(match &res { @@ -700,7 +514,7 @@ fn self_origin(publish: Option<&origin::Consumer>, subscribe: Option<&origin::Pr .unwrap_or_else(Hop::random) } -/// Server (draft-17+): read the peer's SETUP off its uni stream before starting the +/// Server: read the peer's SETUP off its uni stream before starting the /// session, returning that stream plus what it declared. /// /// Blocks on the peer's Setup Stream. Any other uni stream racing ahead of it (padding, @@ -724,7 +538,7 @@ pub async fn accept_setup(session: &mut Transport, version: Version) -> Result

return Err(err), }; - if kind != setup::SETUP_V17 { + if kind != setup::SETUP { early.push(reader); continue; } @@ -783,19 +597,19 @@ pub(crate) fn decode_peer_setup( /// a future option reaches both the pre-read accept path and the uni loop. fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result { Ok(peer::Peer { - cluster: cluster::peer_from_setup(params, version)?, + cluster: cluster::peer_from_setup(params)?, solicit: solicit::from_setup(params, version)?, hidden: hidden::from_setup(params, version), max_age: max_age::from_setup(params, version), - auth: auth::from_setup(params, version) == Some(true), - active_count: active_count::from_setup(params, version), - epoch: epoch::from_setup(params, version), - flate: super::flate::from_setup(params, version)?, + auth: auth::from_setup(params) == Some(true), + active_count: active_count::from_setup(params), + epoch: epoch::from_setup(params), + flate: super::flate::from_setup(params)?, }) } -/// Send our SETUP on a uni stream and keep it alive: on draft-17+ this stream is -/// also our GOAWAY channel, so a fired drain trigger encodes the GOAWAY here. +/// Send our SETUP on a uni stream and keep it alive: this stream is also our GOAWAY +/// channel, so a fired drain trigger encodes the GOAWAY here. /// /// `path` is the request path we advertise (clients on URL-less transports); a /// server passes `None`, as it does for `token`, the client's connection credential. @@ -842,21 +656,21 @@ async fn run_setup( if let Some(token) = &token { super::token::into_setup(&mut parameters, token, version)?; } - cluster::peer_into_setup(&mut parameters, self_origin, cost, version); + cluster::peer_into_setup(&mut parameters, self_origin, cost); if extensions.solicit { solicit::into_setup(&mut parameters, version); } hidden::into_setup(&mut parameters, version); max_age::into_setup(&mut parameters, version); if extensions.auth { - auth::into_setup(&mut parameters, version); + auth::into_setup(&mut parameters); } - active_count::into_setup(&mut parameters, version); + active_count::into_setup(&mut parameters); if extensions.epoch { - epoch::into_setup(&mut parameters, version); + epoch::into_setup(&mut parameters); } request_update::into_setup(&mut parameters, version); - super::flate::into_setup(&mut parameters, version); + super::flate::into_setup(&mut parameters); let parameters = parameters.encode_bytes(version)?; writer @@ -925,7 +739,7 @@ const PADDING: u64 = 0x132B3E28; /// What a unidirectional stream's type names on the negotiated draft. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UniType { - /// The peer's SETUP, which then carries its GOAWAY (draft-17+). + /// The peer's SETUP, which then carries its GOAWAY. Setup, /// A SUBGROUP_HEADER, carrying one subgroup of a subscription. Subgroup, @@ -938,26 +752,19 @@ enum UniType { impl UniType { /// `None` is a type the draft does not define, which MUST close the session /// (draft-22 section 6.4.1, and its equivalent in every draft we negotiate). - fn classify(kind: u64, version: Version) -> Option { - // Draft-14-17 use SUBGROUP_HEADER types 0x10-0x1D and 0x30-0x3D; draft-18 adds - // 0x40 (FIRST_OBJECT), also covering 0x50-0x5D and 0x70-0x7D. A reserved + fn classify(kind: u64) -> Option { + // SUBGROUP_HEADER types are 0x10-0x1D and 0x30-0x3D, plus 0x40 (FIRST_OBJECT) + // covering 0x50-0x5D and 0x70-0x7D. A reserved // SUBGROUP_ID_MODE (0b11) or a bit the draft lacks is invalid, which MUST close the // session too (draft-22 section 11.3.1). - if ietf::GroupFlags::decode(kind, version).is_ok() { + if ietf::GroupFlags::decode(kind).is_ok() { return Some(Self::Subgroup); } match kind { FetchHeader::TYPE => Some(Self::Fetch), - setup::SETUP_V17 => match version { - // SETUP rides the bidi control stream. - Version::Draft14 | Version::Draft15 | Version::Draft16 => None, - _ => Some(Self::Setup), - }, - PADDING => match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 => None, - _ => Some(Self::Padding), - }, + setup::SETUP => Some(Self::Setup), + PADDING => Some(Self::Padding), _ => None, } } @@ -975,7 +782,7 @@ struct UniSetup { goaway: crate::goaway::Protocol, } -/// Accept incoming uni streams, including SETUP and GOAWAY on draft-17+. +/// Accept incoming uni streams, including SETUP and GOAWAY. /// /// Each stream reads its own type in its own child. QUIC opens every lower-numbered /// stream once a higher one arrives, so the next stream accepted can be one whose bytes @@ -1112,7 +919,7 @@ impl Uni { Err(err) => return Err(err), }; - let Some(ty) = UniType::classify(kind, version) else { + let Some(ty) = UniType::classify(kind) else { tracing::warn!(kind, "unknown uni stream type"); return Err(Error::UnexpectedStream); }; @@ -1323,7 +1130,7 @@ struct Bidi { impl Bidi { /// Read and serve a request, failing only when it breaks the session. async fn serve(self, mut stream: Stream) -> Result<(), Error> { - stream = super::request_stream::with_stats(stream, self.publisher.stats(), self.version); + stream = stream.with_stats(self.publisher.stats()); let Self { publisher, mut subscriber, @@ -1353,27 +1160,21 @@ impl Bidi { } Err(err) => return Err(err), }; - let native = !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - || (version == Version::Draft16 && id == ietf::SubscribeNamespaceLegacy::ID); - if native { - stream.reader = stream.reader.with_stats(publisher.stats()); - publisher - .stats() - .control(super::message::control(id, version), data.0.len() + 2, true); - } - if !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - && matches!( - id, - ietf::Subscribe::ID - | ietf::Fetch::ID - | ietf::SubscribeNamespace::ID - | ietf::SubscribeNamespaceLegacy::ID - | ietf::SubscribeTracks::ID - | ietf::TrackStatus::ID - | ietf::Publish::ID - | ietf::PublishNamespace::ID - | auth::Auth::ID - ) { + stream.reader = stream.reader.with_stats(publisher.stats()); + publisher + .stats() + .control(super::message::control(id), data.0.len() + 2, true); + if matches!( + id, + ietf::Subscribe::ID + | ietf::Fetch::ID + | ietf::SubscribeNamespace::ID + | ietf::SubscribeTracks::ID + | ietf::TrackStatus::ID + | ietf::Publish::ID + | ietf::PublishNamespace::ID + | auth::Auth::ID + ) { let request_id = RequestId::decode(&mut data.decoder(version), version)?; match publisher.control.accept(request_id) { Err(Error::GoingAway) => return refuse_going_away(stream, id, version).await, @@ -1383,22 +1184,10 @@ impl Bidi { } } match id { - // Draft-16 moved SUBSCRIBE_NAMESPACE to its own stream, past the control stream - // that admits every other request, but it still takes a request ID from the - // MAX_REQUEST_ID window. Held until the request ends, like the rest. - ietf::SubscribeNamespaceLegacy::ID if version == Version::Draft16 => { - let request_id = RequestId::decode(&mut crate::coding::Decoder::new(&data.0, version.into()), version)?; - let permit = publisher.control.accept(request_id)?; - let task = publisher.handle_stream(id, data, stream, epoch)?; - let _permit = permit; - task.await; - } - // Publisher handles: Subscribe, Fetch, SubscribeNamespace (0x50 modern / - // 0x11 legacy), SubscribeTracks, TrackStatus + // Publisher handles: Subscribe, Fetch, SubscribeNamespace, SubscribeTracks, TrackStatus ietf::Subscribe::ID | ietf::Fetch::ID | ietf::SubscribeNamespace::ID - | ietf::SubscribeNamespaceLegacy::ID | ietf::SubscribeTracks::ID | ietf::TrackStatus::ID => { publisher.handle_stream(id, data, stream, epoch)?.await; @@ -1433,7 +1222,6 @@ async fn refuse_going_away(stream: Stream, id: u64, version: super::request_stream::reject( stream, ietf::RequestError { - request_id: None, error_code: super::error::request::to_code( &Error::GoingAway, super::error::request::Kind::Subscribe, @@ -1566,48 +1354,34 @@ mod tests { /// is not UTF-8 refuses the SETUP. #[test] fn peer_setup_reads_the_implementation() { - for version in [Version::Draft17, Version::Draft22] { - let encode = |value: Option<&[u8]>| { - let mut params = ietf::Parameters::default(); - if let Some(value) = value { - params.set_bytes(ietf::ParameterBytes::Implementation, value.to_vec()); - } - params.encode_bytes(version).unwrap() - }; - let (_, implementation) = decode_peer_setup(encode(Some(b"moq-net-rs/1")), version, true).unwrap(); - assert_eq!(implementation.as_deref(), Some("moq-net-rs/1")); - let (_, implementation) = decode_peer_setup(encode(None), version, true).unwrap(); - assert_eq!(implementation, None); - assert!(decode_peer_setup(encode(Some(&[0xff])), version, true).is_err()); - } + let version = Version::Draft22; + let encode = |value: Option<&[u8]>| { + let mut params = ietf::Parameters::default(); + if let Some(value) = value { + params.set_bytes(ietf::ParameterBytes::Implementation, value.to_vec()); + } + params.encode_bytes(version).unwrap() + }; + let (_, implementation) = decode_peer_setup(encode(Some(b"moq-net-rs/1")), version, true).unwrap(); + assert_eq!(implementation.as_deref(), Some("moq-net-rs/1")); + let (_, implementation) = decode_peer_setup(encode(None), version, true).unwrap(); + assert_eq!(implementation, None); + assert!(decode_peer_setup(encode(Some(&[0xff])), version, true).is_err()); } #[moq_clock::test] async fn announce_interest_without_credit_keeps_the_session_alive() { use std::sync::atomic::Ordering; - for version in [Version::Draft16, Version::Draft17, Version::Draft18] { + for version in [Version::Draft18] { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let interest = origin.consume().announced(); - let scripted = crate::lite::test_transport::ScriptedSession::new(Vec::new()); - let log = scripted.log.clone(); - let mut session = crate::driver::Transport::new(scripted.clone()); - let setup = if version == Version::Draft16 { - Some( - Stream::open(&mut session, version) - .await - .expect("setup waits for credit"), - ) - } else { - None - }; - // SETUP already owns its stream; subsequent requests have no credit. - let _ = scripted.without_credit(); + let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()).without_credit(); + let log = session.log.clone(); + let session = crate::driver::Transport::new(session); let (driver, _goaway, _) = start(Config { runtime: crate::time::sim(), stats: Default::default(), session, - setup, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -1653,16 +1427,11 @@ mod tests { } #[moq_clock::test] - async fn auth_ids_share_the_modern_request_history() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + async fn auth_ids_need_the_peers_parity() { + for version in [Version::Draft18, Version::Draft22] { for client in [false, true] { let first = u64::from(client); - for requests in [ - vec![(true, first), (true, first)], - vec![(true, first), (false, first)], - vec![(false, first), (true, first)], - vec![(true, first ^ 1)], - ] { + for requests in [vec![(true, first ^ 1)], vec![(false, first), (true, (first + 2) ^ 1)]] { let incoming = requests .into_iter() .map(|(is_auth, id)| { @@ -1703,8 +1472,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client, publish: None, @@ -1743,7 +1510,7 @@ mod tests { #[moq_clock::test] async fn malformed_auth_replies_close_the_session() { - for version in [Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { for (id, body) in [ (99, vec![]), (auth::AuthError::ID, vec![0xc0, 0, 0, 1, 0, 0, 0, 0, 0]), @@ -1762,8 +1529,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -1800,7 +1565,7 @@ mod tests { /// session, backing off while each attempt is lost before a reply: at 0s, 1s, and 3s. #[moq_clock::test] async fn a_lost_auth_request_is_presented_again() { - let version = Version::Draft17; + let version = Version::Draft18; let mut grant = Vec::new(); let mut encoder = crate::coding::Encoder::new(&mut grant, version.into()); encoder.varint(auth::AuthOk::ID).unwrap(); @@ -1808,7 +1573,7 @@ mod tests { grant.extend([0, 0, 0]); let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![grant.clone(), grant]); session.close(crate::lite::test_transport::Close::ResetStream( - crate::ietf::error::to_stream_code(&crate::StreamError::Internal, version), + crate::ietf::error::to_stream_code(&crate::StreamError::Internal), )); let log = session.log.clone(); let handle = crate::auth::Handle::new(); @@ -1816,8 +1581,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -1855,7 +1618,7 @@ mod tests { /// nothing: the token fails as unsupported rather than being presented again. #[moq_clock::test] async fn an_auth_request_reset_before_its_reply_is_unsupported() { - let version = Version::Draft17; + let version = Version::Draft18; let mut grant = Vec::new(); let mut encoder = crate::coding::Encoder::new(&mut grant, version.into()); encoder.varint(auth::AuthOk::ID).unwrap(); @@ -1864,7 +1627,7 @@ mod tests { // The setup token is granted; ours is reset before any reply. let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![grant, Vec::new()]); session.close(crate::lite::test_transport::Close::ResetStream( - crate::ietf::error::to_stream_code(&crate::StreamError::Internal, version), + crate::ietf::error::to_stream_code(&crate::StreamError::Internal), )); let log = session.log.clone(); let handle = crate::auth::Handle::new(); @@ -1872,8 +1635,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -1907,7 +1668,7 @@ mod tests { #[moq_clock::test] async fn malformed_presenter_auth_closes_the_session() { - for version in [Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { for body in [vec![0, 2], vec![0, 0, 0]] { let mut script = Vec::new(); let mut encoder = crate::coding::Encoder::new(&mut script, version.into()); @@ -1921,8 +1682,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: false, publish: None, @@ -1987,7 +1746,6 @@ mod tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: None, }) .await @@ -2028,8 +1786,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -2096,8 +1852,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -2133,33 +1887,19 @@ mod tests { assert_eq!(occurrences(&log, b"rootns"), 0, "asked the peer for our local root"); } - /// The bytes of one legacy SUBSCRIBE_NAMESPACE, as the session writes them. - async fn subscribe_namespace_legacy(version: Version, namespace: &str, request_id: RequestId) -> Vec { + /// The bytes of one SUBSCRIBE_NAMESPACE, as the session writes them. + async fn subscribe_namespace(version: Version, namespace: &str, request_id: RequestId) -> Vec { let log = crate::lite::test_transport::Log::default(); let mut writer = Writer::new( crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), version, ); - let msg = ietf::SubscribeNamespaceLegacy { + let msg = ietf::SubscribeNamespace { request_id, namespace: crate::Path::new(namespace).unwrap(), - subscribe_options: ietf::SubscribeOptions::Namespace, hidden: false, }; - writer.varint(ietf::SubscribeNamespaceLegacy::ID).await.unwrap(); - writer.encode(&msg).await.unwrap(); - log.writes.lock().unwrap().clone() - } - - /// The bytes of one UNSUBSCRIBE_NAMESPACE, as the session writes them. - async fn unsubscribe_namespace(version: Version, prefix: &str, request_id: RequestId) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); - let msg = ietf::UnsubscribeNamespace { - prefix: crate::Path::new(prefix).unwrap(), - request_id, - }; - writer.varint(ietf::UnsubscribeNamespace::ID).await.unwrap(); + writer.varint(ietf::SubscribeNamespace::ID).await.unwrap(); writer.encode(&msg).await.unwrap(); log.writes.lock().unwrap().clone() } @@ -2199,22 +1939,12 @@ mod tests { moq_clock::sim::JoinHandle>, ) { let log = session.log.clone(); - let mut session = crate::driver::Transport::new(session); - let setup = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some( - Stream::open(&mut session, version) - .await - .expect("open the control stream"), - ), - _ => None, - }; + let session = crate::driver::Transport::new(session); let (driver, _goaway, _) = start(Config { runtime: crate::time::sim(), stats: Default::default(), session, - setup, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -2251,28 +1981,13 @@ mod tests { false } - /// How many times a client watching everything under `prefix` writes SUBSCRIBE_NAMESPACE - /// for it. `solicit` is what the peer's SETUP declared (MoQ Solicit). - async fn asked_namespace(version: Version, prefix: &str, solicit: Option) -> usize { - let subscribe = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _watch = watch(&subscribe, prefix); - let (log, driver) = client(version, subscribe, solicit).await; - - let needle = subscribe_namespace_legacy(version, prefix, RequestId(0)).await; - sent(&log, &needle).await; - - assert!(!driver.is_finished(), "{version:?} ended the session"); - assert!(log.closes().is_empty(), "{version:?} closed: {:?}", log.closes()); - occurrences(&log, &needle) - } - /// A client asks nothing until something is watched, then only about what is. #[moq_clock::test] async fn a_client_asks_only_about_what_is_watched() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft17] { + for version in [Version::Draft18] { let subscribe = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let (log, _driver) = client(version, subscribe.clone(), Some(true)).await; - let first = subscribe_namespace_legacy(version, "cam", RequestId(0)).await; + let first = subscribe_namespace(version, "cam", RequestId(0)).await; assert!( !sent(&log, &first).await, "{version:?} asked before anything was watched" @@ -2280,7 +1995,7 @@ mod tests { let _cam = watch(&subscribe, "cam"); assert!(sent(&log, &first).await, "{version:?} did not ask about the watch"); - let second = subscribe_namespace_legacy(version, "", RequestId(2)).await; + let second = subscribe_namespace(version, "", RequestId(2)).await; assert!( !sent(&log, &second).await, "{version:?} asked about more than the watch" @@ -2288,61 +2003,12 @@ mod tests { } } - /// Once nothing has watched a prefix for the linger, the request ends. Draft-14 and 15 - /// carry it on the control stream, where only UNSUBSCRIBE_NAMESPACE tells the peer; it - /// names the prefix on draft-14 and the request on draft-15. - #[moq_clock::test] - async fn an_unwatched_prefix_is_unsubscribed_after_the_linger() { - for version in [Version::Draft14, Version::Draft15] { - let subscribe = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let cam = watch(&subscribe, "cam"); - let (log, _driver) = client(version, subscribe.clone(), None).await; - let asked = subscribe_namespace_legacy(version, "cam", RequestId(0)).await; - assert!(sent(&log, &asked).await, "{version:?}"); - let unsubscribe = unsubscribe_namespace(version, "cam", RequestId(0)).await; - - drop(cam); - moq_clock::sleep(crate::discovery::LINGER / 2).await; - assert!( - !sent(&log, &unsubscribe).await, - "{version:?} ended the request before the linger" - ); - moq_clock::sleep(crate::discovery::LINGER).await; - assert!(sent(&log, &unsubscribe).await, "{version:?} never unsubscribed"); - } - } - - /// A broader watch replaces the narrower request at a foreign peer rather than - /// overlapping it, which that peer refuses: on draft-14 and 15 the UNSUBSCRIBE_NAMESPACE - /// goes out on the ordered control stream ahead of the broader SUBSCRIBE_NAMESPACE. - #[moq_clock::test] - async fn a_broader_watch_replaces_the_narrower_at_a_foreign_peer() { - for version in [Version::Draft14, Version::Draft15] { - let subscribe = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _front = watch(&subscribe, "cam/front"); - let (log, _driver) = client(version, subscribe.clone(), None).await; - let asked = subscribe_namespace_legacy(version, "cam/front", RequestId(0)).await; - assert!(sent(&log, &asked).await, "{version:?}"); - - let _cam = watch(&subscribe, "cam"); - let broader = subscribe_namespace_legacy(version, "cam", RequestId(2)).await; - assert!(sent(&log, &broader).await, "{version:?} never asked the broader prefix"); - let unsubscribe = unsubscribe_namespace(version, "cam/front", RequestId(0)).await; - let writes = log.writes.lock().unwrap().clone(); - let at = |needle: &[u8]| writes.windows(needle.len()).position(|window| window == needle); - assert!( - at(&unsubscribe).expect("unsubscribed the narrower request") < at(&broader).unwrap(), - "{version:?} overlapped the narrower request" - ); - } - } - - /// From draft-16 each request has a stream of its own, so at a foreign peer the broader - /// one waits for the peer to close the narrower one's, which ends with a FIN on - /// draft-18 and a reset on draft-22: a peer that never closes it never sees the two overlap. + /// Each request has a stream of its own, so at a foreign peer the broader one waits for + /// the peer to close the narrower one's, which ends with a FIN on draft-18 and a + /// reset on draft-22: a peer that never closes it never sees the two overlap. #[moq_clock::test] async fn a_broader_watch_waits_for_the_narrower_to_leave_the_wire() { - for version in [Version::Draft16, Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let subscribe = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _front = watch(&subscribe, "cam/front"); let (log, _driver) = client(version, subscribe.clone(), None).await; @@ -2367,13 +2033,12 @@ mod tests { /// broader request that replaced it goes out alone once the credit arrives. #[moq_clock::test] async fn a_request_ended_before_it_opened_does_not_block_the_broader_one() { - for version in [Version::Draft16, Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { let subscribe = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let gate = kio::Producer::new(true); let credit = kio::Producer::new(true); let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()).with_open_bi_gate(credit.consume()); - // Credit for draft-16's control stream only, opened before the driver runs. let (log, _driver) = client_on(version, session, subscribe.clone(), None).await; *credit.write().ok().unwrap() = false; let opened = log.bi_opens(); @@ -2398,60 +2063,24 @@ mod tests { /// routes over before it has synced. #[moq_clock::test] async fn a_broader_watch_keeps_the_narrower_open_at_our_peer() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft17] { + for version in [Version::Draft18] { let subscribe = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _front = watch(&subscribe, "cam/front"); let (log, _driver) = client(version, subscribe.clone(), Some(true)).await; - let asked = subscribe_namespace_legacy(version, "cam/front", RequestId(0)).await; + let asked = subscribe_namespace(version, "cam/front", RequestId(0)).await; assert!(sent(&log, &asked).await, "{version:?}"); let _cam = watch(&subscribe, "cam"); - let broader = subscribe_namespace_legacy(version, "cam", RequestId(2)).await; + let broader = subscribe_namespace(version, "cam", RequestId(2)).await; assert!(sent(&log, &broader).await, "{version:?} never asked the broader prefix"); - let ended = match version { - Version::Draft14 | Version::Draft15 => { - occurrences(&log, &unsubscribe_namespace(version, "cam/front", RequestId(0)).await) > 0 - } - _ => log - .trail() - .iter() - .any(|(_, sent)| !matches!(sent, crate::lite::test_transport::Sent::Write(_))), - }; + let ended = log + .trail() + .iter() + .any(|(_, sent)| !matches!(sent, crate::lite::test_transport::Sent::Write(_))); assert!(!ended, "{version:?} ended the narrower request: {:?}", log.trail()); } } - /// Draft-14 and draft-15 reject a zero-field track namespace, so a foreign peer (one - /// that declared no MoQ Solicit) is not asked for it, and a watched prefix still goes - /// out, which is how a client discovers anything through a peer that only tells when - /// asked. A peer that declared Solicit is ours: it only tells when asked, so it still - /// gets the empty prefix. Draft-16 made the empty prefix legal, so it stays. - #[moq_clock::test] - async fn an_empty_namespace_is_not_asked_of_a_foreign_peer_before_draft_16() { - for version in [Version::Draft14, Version::Draft15] { - assert_eq!( - asked_namespace(version, "", None).await, - 0, - "{version:?} asked a foreign peer for every namespace" - ); - assert_eq!( - asked_namespace(version, "cam", None).await, - 1, - "{version:?} skipped a watched prefix" - ); - assert_eq!( - asked_namespace(version, "", Some(true)).await, - 1, - "{version:?} did not ask a soliciting peer for every namespace" - ); - } - assert_eq!( - asked_namespace(Version::Draft16, "", None).await, - 1, - "draft-16 dropped the empty prefix" - ); - } - /// How many scheduling turns an advertisement gets before the count is taken. Time is /// paused in these tests, so each turn costs nothing and only runs the driver until it /// parks again; a busy machine cannot turn a slow announce into a passing silence. @@ -2475,8 +2104,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: Some(origin.consume()), @@ -2582,8 +2209,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: Some(origin.consume()), @@ -2671,8 +2296,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client, publish: None, @@ -2702,7 +2325,7 @@ mod tests { let (setup, _) = setup::Setup::decode_slice(&writes, crate::Version::Ietf(VERSION)).expect("our SETUP"); let (params, _) = ietf::Parameters::decode_slice(&setup.parameters, VERSION).expect("its options"); ( - auth::from_setup(¶ms, VERSION) == Some(true), + auth::from_setup(¶ms) == Some(true), solicit::from_setup(¶ms, VERSION).unwrap() == Some(true), ) } @@ -2771,8 +2394,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: false, publish: None, @@ -2864,8 +2485,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -2895,8 +2514,8 @@ mod tests { assert_eq!(log.closes(), vec![], "nothing may close the transport"); } - /// A draft-17+ client advertises its AUTHORITY and connection credential in the SETUP - /// it writes on its uni stream. + /// A client advertises its AUTHORITY and connection credential in the SETUP it writes on + /// its uni stream. #[moq_clock::test] async fn setup_carries_the_authority_and_token() { const AUTHORITY: &[u8] = b"relay.example.com:4443"; @@ -2913,8 +2532,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -3035,7 +2652,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, true), + Control::new(true), None, peer_setup.clone(), crate::Hop::new(1).unwrap(), @@ -3193,8 +2810,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish, @@ -3294,14 +2909,8 @@ mod tests { async fn an_unknown_uni_type_closes_the_session() { for (version, kind) in [ (Version::Draft18, 0), - // Padding and uni SETUP arrived in later drafts, so earlier ones do not know them. - (Version::Draft17, PADDING), - (Version::Draft16, setup::SETUP_V17), // SUBGROUP_HEADER types with the reserved SUBGROUP_ID_MODE (0b11). (Version::Draft18, 0x56), - (Version::Draft14, 0x16), - // FIRST_OBJECT (0x40) arrived in draft-18. - (Version::Draft17, 0x50), ] { let (log, result) = dispatch_uni(version, uni_stream(version, kind), None).await; @@ -3316,7 +2925,7 @@ mod tests { /// The bidi dispatcher closes the session for an unknown full-width message type. #[moq_clock::test] async fn an_unknown_bidi_type_closes_the_session() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { for kind in [0u64, 1 << 53] { let mut payload = Vec::new(); let mut w = crate::coding::Encoder::new(&mut payload, version.into()); @@ -3329,8 +2938,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: false, publish: None, @@ -3401,8 +3008,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, client: false, publish: None, subscribe: None, @@ -3435,153 +3040,13 @@ mod tests { } } - /// A peer's advertisement of `room/host`, then two namespace-keyed withdrawals of it. - /// The second has no advertisement left to name. - async fn publish_namespace_then_two_withdrawals(version: Version) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - version, - ); - - writer.varint(ietf::PublishNamespace::ID).await.unwrap(); - writer - .encode(&ietf::PublishNamespace { - request_id: RequestId(1), - track_namespace: crate::Path::from_static("room/host"), - params: Default::default(), - authorization_token: None, - }) - .await - .unwrap(); - - for _ in 0..2 { - writer.varint(ietf::PublishNamespaceDone::ID).await.unwrap(); - writer - .encode(&ietf::PublishNamespaceDone { - track_namespace: crate::Path::from_static("room/host"), - request_id: RequestId(0), - }) - .await - .unwrap(); - } - - let writes = log.writes.lock().unwrap(); - writes.clone() - } - - /// The acknowledgement draft-14 answers a PUBLISH_NAMESPACE with, as bytes to look - /// for in what we wrote. - async fn publish_namespace_ok(request_id: RequestId) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - Version::Draft14, - ); - - writer.varint(ietf::PublishNamespaceOk::ID).await.unwrap(); - writer.encode(&ietf::PublishNamespaceOk { request_id }).await.unwrap(); - - let writes = log.writes.lock().unwrap(); - writes.clone() - } - - /// draft-14 names its withdrawals rather than numbering them, so a repeat has no - /// advertisement left to resolve to. Dropping it is what keeps the session up: failing - /// the lookup takes the whole connection down over a message with nothing left to do. - /// - /// Driven through `start` because only the full loop shows the consequence, the read - /// task propagating the classifier's error. - #[moq_clock::test] - async fn a_repeated_publish_namespace_done_does_not_end_the_session() { - const VERSION: Version = Version::Draft14; - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let consumer = origin.consume(); - - let session = - crate::lite::test_transport::ScriptedSession::new(publish_namespace_then_two_withdrawals(VERSION).await); - let log = session.log.clone(); - let session = crate::driver::Transport::new(session.clone()); - let setup = Stream::open(&mut crate::driver::Transport::new(session.clone()), VERSION) - .await - .expect("open the control stream"); - - let (driver, _goaway, _) = start(Config { - runtime: crate::time::sim(), - stats: Default::default(), - session, - setup: Some(setup), - request_id_max: None, - limits: Default::default(), - client: true, - publish: None, - subscribe: Some(origin), - peer_hop: None, - cost: None, - version: VERSION, - path: None, - authority: None, - token: None, - peer_setup_stream: None, - peer_declared: None, - auth: crate::auth::Link::new(false), - early_unis: Vec::new(), - implementation: None, - peer_implementation: Default::default(), - extensions: Default::default(), - }) - .expect("start the session"); - let driver = moq_clock::sim::spawn(driver); - - let accepted = publish_namespace_ok(RequestId(1)).await; - for _ in 0..ANNOUNCE_TURNS { - if occurrences(&log, &accepted) > 0 && consumer.get_broadcast("room/host").is_none() { - break; - } - moq_clock::sleep(std::time::Duration::from_millis(1)).await; - } - - assert_eq!(occurrences(&log, &accepted), 1, "the advertisement was not accepted"); - assert!( - consumer.get_broadcast("room/host").is_none(), - "the withdrawal did not reach the request that advertised it" - ); - assert!(!driver.is_finished(), "the repeated withdrawal ended the session"); - assert!(log.closes().is_empty(), "closed the session: {:?}", log.closes()); - } - /// A SUBSCRIBE for `room/video` whose parameters are one AUTHORIZATION TOKEN holding /// `token`, framed as a control message. fn subscribe_with_token(version: Version, token: &[u8]) -> Vec { - let msg = ietf::Subscribe { - max_age: None, - new_group: None, - request_id: RequestId(0), - track_namespace: crate::Path::from_static("room"), - track_name: "video".into(), - subscriber_priority: 128, - group_order: ietf::GroupOrder::Descending, - filter: ietf::Filter::NextObject, - fill: None, - properties_wanted: true, - forward: true, - rendezvous: false, - range_filters: false, - authorization_token: None, - epoch: None, - }; let mut body = Vec::new(); let mut w = crate::coding::Encoder::new(&mut body, version.into()); - match version { - Version::Draft14 => { - msg.encode_msg(&mut w, version).unwrap(); - // Replace the empty parameter block with the token. - assert_eq!(body.pop(), Some(0)); - } - // The request ID, namespace and name; every parameter but the token defaults. - _ => w.slice(&[0, 1, 4, b'r', b'o', b'o', b'm', 5, b'v', b'i', b'd', b'e', b'o']), - } + // The request ID, namespace and name; every parameter but the token defaults. + w.slice(&[0, 1, 4, b'r', b'o', b'o', b'm', 5, b'v', b'i', b'd', b'e', b'o']); let mut w = crate::coding::Encoder::new(&mut body, version.into()); w.varint(1).unwrap(); w.varint(u64::from(ietf::ParameterBytes::AuthorizationToken)).unwrap(); @@ -3596,11 +3061,10 @@ mod tests { } /// A request's AUTHORIZATION TOKEN decodes by the SETUP option's rules, so one this - /// endpoint cannot use closes the session with the draft's code. Draft-14 carries the - /// request on the control stream, a strict draft on its own stream. + /// endpoint cannot use closes the session with the draft's code. #[moq_clock::test] async fn a_request_token_that_cannot_be_used_closes_the_session() { - for version in [Version::Draft14, Version::Draft22] { + for version in [Version::Draft22] { for (token, expected, why) in [ ( &[0x01, 0x07, 0x00][..], @@ -3621,29 +3085,15 @@ mod tests { ), ] { let payload = subscribe_with_token(version, token); - let session = match version { - Version::Draft14 => crate::lite::test_transport::ScriptedSession::new(payload), - _ => { - crate::lite::test_transport::ScriptedSession::new(Vec::new()).with_incoming_bidis(vec![payload]) - } - }; + let session = + crate::lite::test_transport::ScriptedSession::new(Vec::new()).with_incoming_bidis(vec![payload]); let log = session.log.clone(); let session = crate::driver::Transport::new(session.clone()); - let setup = match version { - Version::Draft14 => Some( - Stream::open(&mut crate::driver::Transport::new(session.clone()), version) - .await - .unwrap(), - ), - _ => None, - }; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let (driver, _goaway, _) = start(Config { runtime: crate::time::sim(), stats: Default::default(), session, - setup, - request_id_max: None, limits: Default::default(), client: false, publish: Some(origin.consume()), @@ -3814,7 +3264,7 @@ mod tests { #[moq_clock::test] async fn a_control_stream_fin_or_reset_is_a_protocol_violation() { use crate::lite::test_transport::{Close, DeadStreamSession, ScriptedSession}; - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let session = ScriptedSession::new(Vec::new()).with_incoming_unis(vec![setup_stream( version, ietf::Parameters::default(), @@ -3887,8 +3337,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client: true, publish: None, @@ -3945,15 +3393,15 @@ mod tests { } } - /// Draft-18 GOAWAY names the smallest peer ID not processed, here one a reordered stream - /// skipped, preserving the peer's parity. + /// Draft-18 GOAWAY names the first peer ID past every one used, preserving the peer's + /// parity, and still admits one a reordered stream skipped below it. #[moq_clock::test] async fn our_draft18_goaway_names_the_admitted_request_cutoff() { const VERSION: Version = Version::Draft18; for client in [false, true] { let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); let log = session.log.clone(); - let control = Control::new(None, client); + let control = Control::new(client); let first = u64::from(client); control.accept(RequestId(first)).unwrap(); control.accept(RequestId(first + 4)).unwrap(); @@ -3974,9 +3422,10 @@ mod tests { )); handle.producer().send(crate::goaway::Goaway::new()).unwrap(); assert!(futures::poll!(setup.as_mut()).is_pending()); - let sent = goaway(VERSION, Some(first + 2)); + let sent = goaway(VERSION, Some(first + 6)); assert_eq!(occurrences(&log, &sent), 1, "client={client}"); - assert!(matches!(control.accept(RequestId(first + 2)), Err(Error::GoingAway))); + control.accept(RequestId(first + 2)).unwrap(); + assert!(matches!(control.accept(RequestId(first + 6)), Err(Error::GoingAway))); } } @@ -3995,8 +3444,6 @@ mod tests { runtime: crate::time::sim(), stats: Default::default(), session: crate::driver::Transport::new(session.clone()), - setup: None, - request_id_max: None, limits: Default::default(), client, publish: Some(origin.consume()), diff --git a/rs/moq-net/src/ietf/solicit.rs b/rs/moq-net/src/ietf/solicit.rs index c8bfbf5c1dd..e303d08f1f3 100644 --- a/rs/moq-net/src/ietf/solicit.rs +++ b/rs/moq-net/src/ietf/solicit.rs @@ -57,11 +57,10 @@ mod tests { /// Options: an old peer can opt out too. #[test] fn every_version_round_trips() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft22] { - let mut params = super::super::Parameters::default(); - into_setup(&mut params, version); - assert_eq!(from_setup(¶ms, version).unwrap(), Some(true)); - } + let version = Version::Draft22; + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(true)); } /// A peer that declared nothing wants to be told unasked, which is what keeps this diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 182939b3b08..b924dcef53b 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -1,11 +1,11 @@ -//! IETF moq-transport subscribe messages (v14 + v15) +//! IETF moq-transport subscribe messages use std::borrow::Cow; use crate::{ Path, coding::*, - ietf::{Fill, Filter, GroupOrder, Location, Opaque, Parameters, Properties, RequestId}, + ietf::{Fill, Filter, GroupOrder, Location, Opaque, Properties, RequestId}, }; use super::token::RequestToken; @@ -42,27 +42,19 @@ pub struct Subscribe<'a> { /// The `AUTHORIZATION TOKEN` (0x03) this request presents. At most one is /// accepted: decoding refuses a repeated or second token. pub authorization_token: Option, - /// NEW_GROUP_REQUEST (0x32, draft-15+): the subscriber's largest known Group ID plus - /// one, or 0 when it knows none. Never encoded where the draft has no such parameter. + /// NEW_GROUP_REQUEST (0x32): the subscriber's largest known Group ID plus one, or 0 + /// when it knows none. pub new_group: Option, /// Subscriber max age in milliseconds (MoQ Timestamp), if negotiated. pub max_age: Option, - /// The publisher instance the subscriber expects (MoQ Epoch, draft-17+). Only on a + /// The publisher instance the subscriber expects (MoQ Epoch). Only on a /// session that negotiated it; the session refuses one that arrives otherwise. pub epoch: Option, } -/// NEW_GROUP_REQUEST (0x32) and DYNAMIC_GROUPS (0x30) arrived in draft-15. -pub(super) fn has_new_group_request(version: Version) -> bool { - version != Version::Draft14 -} - /// The Range Filters (0x25-0x29) arrived in draft-22. pub(super) fn has_range_filters(version: Version) -> bool { - !matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 - ) + !matches!(version, Version::Draft18) } impl Message for Subscribe<'_> { @@ -70,158 +62,99 @@ impl Message for Subscribe<'_> { fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { let request_id = RequestId::decode(r, version)?; - if version == Version::Draft17 { - let _required_request_id_delta = r.varint()?; - } let (track_namespace, track_name) = decode_track(r)?; let track_name = Cow::Owned(track_name); - match version { - Version::Draft14 => { - let subscriber_priority = r.u8()?; - let group_order = GroupOrder::decode(r, version)?; + decode_params!(r, version, + 0x02 => _object_delivery_timeout: Option, + 0x03 => authorization_token: Vec, + 0x04 => rendezvous_timeout: Option, + 0x06 => _subgroup_delivery_timeout: Option, + 0x10 => forward: Option, + 0x20 => subscriber_priority: Option, + 0x21 => filter: Option, + 0x22 => group_order: Option, + 0x23 => fill: Option where Filter::is_draft22(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), + 0x32 => new_group: Option, + 0x35 => include_properties: Option where Filter::is_draft22(version), + epoch::EPOCH_PARAM => epoch: Option, + super::max_age::PARAMETER => max_age: Option, + ); + let authorization_token = super::token::single(authorization_token)?; - let forward = r.bool()?; - let filter = Filter::decode(r, version)?; + let range_filters = [ + subgroup_filter, + object_id_filter, + priority_filter, + object_property_filter, + ] + .iter() + .any(|filter| !filter.is_empty()); - decode_params!(r, version, - 0x03 => tokens: Vec, - super::max_age::PARAMETER => max_age: Option, - ); - let authorization_token = super::token::single(tokens)?; - - Ok(Self { - request_id, - track_namespace, - track_name, - subscriber_priority, - group_order, - filter, - fill: None, - properties_wanted: true, - forward, - rendezvous: false, - range_filters: false, - authorization_token, - new_group: None, - max_age: super::max_age::validate(max_age)?, - epoch: None, - }) - } - _ => { - decode_params!(r, version, - 0x02 => _object_delivery_timeout: Option, - 0x03 => authorization_token: Vec, - // 0x04 is MAX_CACHE_DURATION in draft-15 (a publisher parameter) and not a - // message parameter at all in draft-16. RENDEZVOUS_TIMEOUT arrives in draft-17. - 0x04 => rendezvous_timeout: Option where !matches!(version, Version::Draft15 | Version::Draft16), - 0x06 => _subgroup_delivery_timeout: Option where !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17), - 0x10 => forward: Option, - 0x20 => subscriber_priority: Option, - 0x21 => filter: Option, - 0x22 => group_order: Option, - 0x23 => fill: Option where Filter::is_draft22(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), - 0x32 => new_group: Option where has_new_group_request(version), - 0x35 => include_properties: Option where Filter::is_draft22(version), - epoch::EPOCH_PARAM => epoch: Option where epoch::supported(version), - super::max_age::PARAMETER => max_age: Option, - ); - let authorization_token = super::token::single(authorization_token)?; - - let range_filters = [ - subgroup_filter, - object_id_filter, - priority_filter, - object_property_filter, - ] - .iter() - .any(|filter| !filter.is_empty()); - - // Defaults to 1, so an absent parameter means the subscriber wants them. - let properties_wanted = include_properties.unwrap_or(true); - - // A zero cap still distinguishes a requested wait from an immediate miss. - let rendezvous = rendezvous_timeout.is_some_and(|millis| millis > 0); - - let subscriber_priority = subscriber_priority.unwrap_or(super::priority::DEFAULT_WIRE); - let group_order = group_order.unwrap_or(GroupOrder::Descending); - // An absent LOCATION_FILTER means the subscription is unfiltered. - let filter = filter.unwrap_or(Filter::Unfiltered); - - Ok(Self { - request_id, - track_namespace, - track_name, - subscriber_priority, - group_order, - filter, - fill, - properties_wanted, - // Absent means 1. - forward: forward.unwrap_or(true), - rendezvous, - range_filters, - authorization_token, - new_group, - max_age: super::max_age::validate(max_age)?, - epoch, - }) - } - } + // Defaults to 1, so an absent parameter means the subscriber wants them. + let properties_wanted = include_properties.unwrap_or(true); + + // A zero cap still distinguishes a requested wait from an immediate miss. + let rendezvous = rendezvous_timeout.is_some_and(|millis| millis > 0); + + let subscriber_priority = subscriber_priority.unwrap_or(super::priority::DEFAULT_WIRE); + let group_order = group_order.unwrap_or(GroupOrder::Descending); + // An absent LOCATION_FILTER means the subscription is unfiltered. + let filter = filter.unwrap_or(Filter::Unfiltered); + + Ok(Self { + request_id, + track_namespace, + track_name, + subscriber_priority, + group_order, + filter, + fill, + properties_wanted, + // Absent means 1. + forward: forward.unwrap_or(true), + rendezvous, + range_filters, + authorization_token, + new_group, + max_age: super::max_age::validate(max_age)?, + epoch, + }) } fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { self.request_id.encode(w, version)?; - if version == Version::Draft17 { - w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) - } encode_namespace(w, &self.track_namespace)?; w.string(&self.track_name)?; - match version { - Version::Draft14 => { - w.u8(self.subscriber_priority); - self.group_order.encode(w, version)?; - w.bool(self.forward); - - self.filter.encode(w, version)?; - encode_params!(w, version, - 0x03 => self.authorization_token.clone(), - super::max_age::PARAMETER => self.max_age, - ); - } - _ => { - // FILL_PARAMETERS arrived in draft-22. Sending it to an older peer would be an - // unknown parameter, which is a protocol violation, so it is dropped instead. - // A subscriber there simply joins mid-group, which is what it did all along. - let fill = self.fill.filter(|_| Filter::is_draft22(version)); - - // INCLUDE_PROPERTIES defaults to 1, so only the opt-out is worth bytes. It - // arrived in draft-22, and an older peer would read it as an unknown - // parameter, which is a protocol violation. - let include_properties = (!self.properties_wanted && Filter::is_draft22(version)).then_some(false); - let new_group = self.new_group.filter(|_| has_new_group_request(version)); - // Only a session that negotiated MoQ Epoch names one, and it exists from draft-17. - let epoch = self.epoch.clone().filter(|_| epoch::supported(version)); - - encode_params!(w, version, - 0x03 => self.authorization_token.clone(), - 0x10 => self.forward, - 0x20 => self.subscriber_priority, - 0x21 => self.filter, - 0x22 => self.group_order, - 0x23 => fill, - 0x32 => new_group, - 0x35 => include_properties, - epoch::EPOCH_PARAM => epoch, - super::max_age::PARAMETER => self.max_age, - ); - } - } + // FILL_PARAMETERS arrived in draft-22. Sending it to an older peer would be an + // unknown parameter, which is a protocol violation, so it is dropped instead. + // A subscriber there simply joins mid-group, which is what it did all along. + let fill = self.fill.filter(|_| Filter::is_draft22(version)); + + // INCLUDE_PROPERTIES defaults to 1, so only the opt-out is worth bytes. It + // arrived in draft-22, and an older peer would read it as an unknown + // parameter, which is a protocol violation. + let include_properties = (!self.properties_wanted && Filter::is_draft22(version)).then_some(false); + // Only a session that negotiated MoQ Epoch names one. + let epoch = self.epoch.clone(); + + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + 0x10 => self.forward, + 0x20 => self.subscriber_priority, + 0x21 => self.filter, + 0x22 => self.group_order, + 0x23 => fill, + 0x32 => self.new_group, + 0x35 => include_properties, + epoch::EPOCH_PARAM => epoch, + super::max_age::PARAMETER => self.max_age, + ); Ok(()) } @@ -230,14 +163,13 @@ impl Message for Subscribe<'_> { /// SubscribeOk message (0x04) #[derive(Clone, Debug)] pub struct SubscribeOk { - pub request_id: Option, pub track_alias: u64, /// The largest Location in the track (LARGEST_OBJECT, 0x09), which the spec requires /// once the track has content. It is what a subscriber sizes a fill against. pub largest: Option, - /// Metadata about the track, sent as Track Properties (draft-17+). + /// Metadata about the track, sent as Track Properties. pub properties: Properties, } @@ -245,113 +177,34 @@ impl Message for SubscribeOk { const ID: u64 = 0x04; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - self.request_id - .expect("request_id required for draft14-16") - .encode(w, version)?; - } else { - assert!(self.request_id.is_none(), "request_id must be None for draft17+"); - } w.varint(self.track_alias)?; - match version { - Version::Draft14 => { - w.varint(0)?; // expires = 0 - self.properties - .group_order - .unwrap_or(GroupOrder::Ascending) - .encode(w, version)?; - w.bool(self.largest.is_some()); - if let Some(largest) = self.largest { - largest.encode(w, version)?; - } - w.u8(0); // no parameters - } - _ => { - // GROUP_ORDER is a legal SUBSCRIBE_OK 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. - let group_order = match version { - Version::Draft15 => self.properties.group_order, - _ => None, - }; - // Draft-15 has no properties block, so DYNAMIC_GROUPS is a parameter there. - let dynamic_groups = (version == Version::Draft15 && self.properties.dynamic_groups).then_some(1u64); - - encode_params!(w, version, - 0x09 => self.largest, - 0x22 => group_order, - 0x30 => dynamic_groups, - ); + // GROUP_ORDER is not a legal SUBSCRIBE_OK parameter; a peer closes the session with + // PROTOCOL_VIOLATION when it sees one. The publisher's preference is a + // DEFAULT_PUBLISHER_GROUP_ORDER track property instead. + encode_params!(w, version, + 0x09 => self.largest, + ); - // Track Properties are the final field, so nothing may follow. - self.properties.encode(w, version)?; - } - } + // Track Properties are the final field, so nothing may follow. + self.properties.encode(w, version)?; Ok(()) } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - Some(RequestId::decode(r, version)?) - } else { - None - }; let track_alias = r.varint()?; - let mut properties = Properties::default(); - let mut largest = None; - - match version { - Version::Draft14 => { - // EXPIRES is when the publisher expects to end the subscription. That end - // arrives as PUBLISH_DONE regardless, so there is nothing to act on. - let _expires = r.varint()?; - - properties.group_order = Some(GroupOrder::decode(r, version)?.any_to_descending()); - if r.bool()? { - largest = Some(Location::decode(r, version)?); - } - - properties.max_cache_duration = Parameters::skip_response(r)?.map(std::time::Duration::from_millis); - } - _ => { - // GROUP_ORDER and MAX_CACHE_DURATION are legal here only in draft-15. Draft-16 - // still knows GROUP_ORDER, so one on this message is ignored. From draft-17 - // it closes the session. LARGEST_OBJECT is required once the track has - // content, so rejecting it would tear down a session over a parameter - // compliant publishers must send. EXPIRES is ignored, as on draft-14. - decode_params!(r, version, - 0x04 => max_cache_duration: Option where version == Version::Draft15, - 0x08 => _expires: Option, - 0x09 => largest: Option, - 0x22 => group_order: Option where version == Version::Draft15, - 0x30 => dynamic_groups: Option where version == Version::Draft15, - ); - properties = Properties::decode(r, version)?; - properties.group_order = properties.group_order.or(group_order); - if version == Version::Draft15 { - properties.max_cache_duration = max_cache_duration.map(std::time::Duration::from_millis); - properties.dynamic_groups = dynamic_groups - .map(super::properties::dynamic_groups) - .transpose()? - .unwrap_or(false); - } - - return Ok(Self { - request_id, - track_alias, - largest, - properties, - }); - } - } + // GROUP_ORDER closes the session. LARGEST_OBJECT is required once the track has + // content, so rejecting it would tear down a session over a parameter compliant + // publishers must send. EXPIRES is ignored. + decode_params!(r, version, + 0x08 => _expires: Option, + 0x09 => largest: Option, + ); + let properties = Properties::decode(r, version)?; Ok(Self { - request_id, track_alias, largest, properties, @@ -359,77 +212,20 @@ impl Message for SubscribeOk { } } -/// SubscribeError message (0x05) -#[derive(Clone, Debug)] -pub struct SubscribeError<'a> { - pub request_id: RequestId, - pub error_code: u64, - pub reason_phrase: Cow<'a, str>, -} - -impl Message for SubscribeError<'_> { - const ID: u64 = 0x05; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - w.varint(self.error_code)?; - w.string(&self.reason_phrase)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - let error_code = r.varint()?; - let reason_phrase = Cow::Owned(r.string()?); - - Ok(Self { - request_id, - error_code, - reason_phrase, - }) - } -} - -/// Unsubscribe message (0x0a) -#[derive(Clone, Debug)] -pub struct Unsubscribe { - pub request_id: RequestId, -} - -impl Message for Unsubscribe { - const ID: u64 = 0x0a; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - Ok(Self { request_id }) - } -} - /// SubscribeUpdate message (0x02) #[derive(Clone, Debug)] pub struct SubscribeUpdate { pub request_id: RequestId, - pub subscription_request_id: Option, - /// Draft-14's fixed start, which a peer must not see decrease; unused on later drafts. - pub start_location: Location, - /// Draft-14's fixed end group (0 for open-ended); unused on later drafts. - pub end_group: u64, - /// `None` leaves the priority as it is. Draft-14 cannot omit it, so `None` sends the - /// default there. + /// `None` leaves the priority as it is. pub subscriber_priority: Option, /// `None` leaves forwarding as it is, as for the priority. pub forward: Option, - /// The new LOCATION_FILTER (draft-15+); `None` keeps the subscription's own range. + /// The new LOCATION_FILTER; `None` keeps the subscription's own range. pub filter: Option, /// The `AUTHORIZATION TOKEN` (0x03) renewing the request's credential, if any. At /// most one is accepted: decoding refuses a repeated or second token. pub authorization_token: Option, - /// NEW_GROUP_REQUEST (0x32, draft-16+), as on [`Subscribe::new_group`]. + /// NEW_GROUP_REQUEST (0x32), as on [`Subscribe::new_group`]. pub new_group: Option, /// Subscriber max age in milliseconds (MoQ Timestamp), if negotiated. pub max_age: Option, @@ -441,156 +237,53 @@ impl Message for SubscribeUpdate { const ID: u64 = 0x02; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - match version { - Version::Draft14 => { - self.request_id.encode(w, version)?; - self.subscription_request_id - .expect("subscription_request_id required for draft14") - .encode(w, version)?; - self.start_location.encode(w, version)?; - w.varint(self.end_group)?; - w.u8(self.subscriber_priority.unwrap_or(super::priority::DEFAULT_WIRE)); - w.bool(self.forward.unwrap_or(true)); - encode_params!(w, version, - 0x03 => self.authorization_token.clone(), - super::max_age::PARAMETER => self.max_age, - ); - } - Version::Draft15 | Version::Draft16 => { - self.request_id.encode(w, version)?; - self.subscription_request_id - .expect("subscription_request_id required for draft15-16") - .encode(w, version)?; - encode_params!(w, version, - 0x03 => self.authorization_token.clone(), - 0x10 => self.forward, - 0x20 => self.subscriber_priority, - 0x21 => self.filter, - 0x32 => self.new_group.filter(|_| has_new_group_request(version)), - super::max_age::PARAMETER => self.max_age, - ); - } - _ => { - assert!( - self.subscription_request_id.is_none(), - "subscription_request_id must be None for draft17+" - ); - // REQUEST_UPDATE - self.request_id.encode(w, version)?; - if matches!(version, Version::Draft17) { - w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) - } - encode_params!(w, version, - 0x03 => self.authorization_token.clone(), - 0x10 => self.forward, - 0x20 => self.subscriber_priority, - 0x21 => self.filter, - 0x23 => self.fill.filter(|_| Filter::is_draft22(version)), - 0x32 => self.new_group, - super::max_age::PARAMETER => self.max_age, - ); - } - } + // REQUEST_UPDATE + self.request_id.encode(w, version)?; + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + 0x10 => self.forward, + 0x20 => self.subscriber_priority, + 0x21 => self.filter, + 0x23 => self.fill.filter(|_| Filter::is_draft22(version)), + 0x32 => self.new_group, + super::max_age::PARAMETER => self.max_age, + ); Ok(()) } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - match version { - Version::Draft14 => { - let request_id = RequestId::decode(r, version)?; - let subscription_request_id = Some(RequestId::decode(r, version)?); - let start_location = Location::decode(r, version)?; - let end_group = r.varint()?; - let subscriber_priority = Some(r.u8()?); - let forward = Some(r.bool()?); - decode_params!(r, version, - 0x03 => tokens: Vec, - super::max_age::PARAMETER => max_age: Option, - ); - let authorization_token = super::token::single(tokens)?; - - Ok(Self { - request_id, - subscription_request_id, - start_location, - end_group, - subscriber_priority, - forward, - filter: None, - authorization_token, - new_group: None, - max_age: super::max_age::validate(max_age)?, - fill: None, - }) - } - Version::Draft15 | Version::Draft16 => { - let request_id = RequestId::decode(r, version)?; - let subscription_request_id = Some(RequestId::decode(r, version)?); - decode_params!(r, version, - 0x02 => _object_delivery_timeout: Option, - 0x03 => authorization_token: Vec, - 0x10 => forward: Option, - 0x20 => subscriber_priority: Option, - 0x21 => filter: Option, - 0x32 => new_group: Option where has_new_group_request(version), - super::max_age::PARAMETER => max_age: Option, - ); - - Ok(Self { - request_id, - subscription_request_id, - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority, - forward, - filter, - authorization_token: super::token::single(authorization_token)?, - new_group, - max_age: super::max_age::validate(max_age)?, - fill: None, - }) - } - _ => { - // REQUEST_UPDATE - let request_id = RequestId::decode(r, version)?; - if matches!(version, Version::Draft17) { - let _required_request_id_delta = r.varint()?; - } - // Nothing reads an update's Range Filters yet; they are - // consumed so a legal update does not fail the session. - decode_params!(r, version, - 0x02 => _object_delivery_timeout: Option, - 0x03 => authorization_token: Vec, - 0x06 => _subgroup_delivery_timeout: Option where !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17), - 0x10 => forward: Option, - 0x20 => subscriber_priority: Option, - 0x21 => filter: Option, - 0x23 => fill: Option where Filter::is_draft22(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, - super::max_age::PARAMETER => max_age: Option, - ); + // REQUEST_UPDATE + let request_id = RequestId::decode(r, version)?; + // Nothing reads an update's Range Filters yet; they are + // consumed so a legal update does not fail the session. + decode_params!(r, version, + 0x02 => _object_delivery_timeout: Option, + 0x03 => authorization_token: Vec, + 0x06 => _subgroup_delivery_timeout: Option, + 0x10 => forward: Option, + 0x20 => subscriber_priority: Option, + 0x21 => filter: Option, + 0x23 => fill: Option where Filter::is_draft22(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, + super::max_age::PARAMETER => max_age: Option, + ); - Ok(Self { - request_id, - subscription_request_id: None, - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority, - forward, - filter, - authorization_token: super::token::single(authorization_token)?, - new_group, - max_age: super::max_age::validate(max_age)?, - fill, - }) - } - } + Ok(Self { + request_id, + subscriber_priority, + forward, + filter, + authorization_token: super::token::single(authorization_token)?, + new_group, + max_age: super::max_age::validate(max_age)?, + fill, + }) } } @@ -612,14 +305,7 @@ mod tests { #[test] fn subscriber_max_age_round_trips() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { for max_age in [None, Some(0), Some(250), Some(super::super::max_age::MAX_MILLIS)] { let msg = Subscribe { max_age, @@ -643,10 +329,6 @@ mod tests { let update = SubscribeUpdate { max_age, request_id: RequestId(2), - subscription_request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(0)), - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: None, filter: None, @@ -680,37 +362,8 @@ mod tests { epoch: None, }; - let encoded = encode_message(&msg, Version::Draft14); - let decoded: Subscribe = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test"); - assert_eq!(decoded.track_name, "video"); - assert_eq!(decoded.subscriber_priority, 128); - } - - #[test] - fn test_subscribe_round_trip_v15() { - let msg = Subscribe { - new_group: None, - max_age: None, - request_id: RequestId(1), - track_namespace: Path::from_static("test"), - 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, - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: Subscribe = decode_message(&encoded, Version::Draft15).unwrap(); + let encoded = encode_message(&msg, Version::Draft18); + let decoded: Subscribe = decode_message(&encoded, Version::Draft18).unwrap(); assert_eq!(decoded.request_id, RequestId(1)); assert_eq!(decoded.track_namespace.as_str(), "test"); @@ -719,21 +372,13 @@ mod tests { } /// Build a SUBSCRIBE body carrying a single RENDEZVOUS_TIMEOUT parameter. - const VERSIONS: [Version; 6] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; + const VERSIONS: [Version; 2] = [Version::Draft18, Version::Draft22]; - /// NEW_GROUP_REQUEST survives SUBSCRIBE and REQUEST_UPDATE from draft 16 on, and is - /// never written before. + /// NEW_GROUP_REQUEST survives SUBSCRIBE and REQUEST_UPDATE. #[test] - fn new_group_request_round_trips_where_the_draft_has_it() { + fn new_group_request_round_trips() { for version in VERSIONS { - let expected = has_new_group_request(version).then_some(7); + let expected = Some(7); let subscribe = Subscribe { new_group: Some(7), max_age: None, @@ -758,10 +403,6 @@ mod tests { new_group: Some(7), max_age: None, request_id: RequestId(3), - subscription_request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(1)), - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: None, filter: None, @@ -778,9 +419,6 @@ mod tests { let mut buf = Vec::new(); let w = &mut Encoder::new(&mut buf, version.into()); RequestId(1).encode(w, version)?; - if version == Version::Draft17 { - w.varint(0)?; // required_request_id_delta - } encode_namespace(w, &Path::from_static("test"))?; w.string("video")?; encode_params!(w, version, @@ -792,13 +430,13 @@ mod tests { build(millis, version).unwrap() } - /// A subscriber may send RENDEZVOUS_TIMEOUT (draft-17+). We do not honor the wait, but an + /// A subscriber may send RENDEZVOUS_TIMEOUT. We do not honor the wait, but an /// unknown message parameter is a session-killing protocol violation, so it still has to /// parse: failing to decode it takes the whole session down instead of answering the /// SUBSCRIBE. #[test] fn rendezvous_timeout_retains_whether_a_wait_was_requested() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { for millis in [0, 5000] { let encoded = subscribe_with_rendezvous(millis, version); let msg: Subscribe = decode_message(&encoded, version) @@ -809,17 +447,6 @@ mod tests { } } - /// 0x04 is MAX_CACHE_DURATION in draft-15, defined for other messages, so a SUBSCRIBE - /// that carries it is ignored. Draft-16 dropped the id, which makes it unknown. - #[test] - fn cache_duration_on_subscribe_follows_the_draft() { - let draft15 = subscribe_with_rendezvous(0, Version::Draft15); - decode_message::(&draft15, Version::Draft15).expect("draft-15 ignores MAX_CACHE_DURATION"); - - let draft16 = subscribe_with_rendezvous(0, Version::Draft16); - decode_message::(&draft16, Version::Draft16).expect_err("draft-16 rejects unknown parameter 0x04"); - } - /// The first message parameter key on an encoded SUBSCRIBE, or `None` when it carries no /// parameters. /// @@ -831,12 +458,9 @@ mod tests { fn first_param_key(encoded: &[u8], version: Version) -> Option { let r = &mut Decoder::new(encoded, version.into()); RequestId::decode(r, version).unwrap(); - if version == Version::Draft17 { - r.varint().unwrap(); - } decode_track(r).unwrap(); - // draft-14/15 write absolute keys, draft-16+ deltas, but the first is absolute either way. + // Keys are delta coded, but the first is absolute. let count = r.varint().unwrap(); (count > 0).then(|| r.varint().unwrap()) } @@ -862,7 +486,7 @@ mod tests { epoch: None, }; - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { // The reader has to be able to see a 0x04, or its absence below proves nothing. assert_eq!( first_param_key(&subscribe_with_rendezvous(5000, version), version), @@ -898,8 +522,8 @@ mod tests { epoch: None, }; - let encoded = encode_message(&msg, Version::Draft14); - let decoded: Subscribe = decode_message(&encoded, Version::Draft14).unwrap(); + let encoded = encode_message(&msg, Version::Draft18); + let decoded: Subscribe = decode_message(&encoded, Version::Draft18).unwrap(); assert_eq!(decoded.track_namespace.as_str(), "conference/room123"); } @@ -907,41 +531,25 @@ mod tests { #[test] fn test_subscribe_ok() { let msg = SubscribeOk { - request_id: Some(RequestId(42)), track_alias: 42, largest: None, properties: Properties::default(), }; - let encoded = encode_message(&msg, Version::Draft14); - let decoded: SubscribeOk = decode_message(&encoded, Version::Draft14).unwrap(); + let encoded = encode_message(&msg, Version::Draft18); + let decoded: SubscribeOk = decode_message(&encoded, Version::Draft18).unwrap(); - assert_eq!(decoded.request_id, Some(RequestId(42))); + assert_eq!(decoded.track_alias, 42); } + /// The parameter value is two bare varints. The empty Track Properties block follows the + /// parameters. #[test] fn test_subscribe_ok_accepts_largest_object() { - // request_id=4, track_alias=4, one LARGEST_OBJECT parameter at {5, 0}. - let payload = [0x04, 0x04, 0x01, 0x09, 0x02, 0x05, 0x00]; - for version in [Version::Draft15, Version::Draft16] { - let decoded: SubscribeOk = decode_message(&payload, version).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(4))); - assert_eq!(decoded.track_alias, 4); - assert_eq!(decoded.largest, Some(Location { group: 5, object: 0 }), "{version}"); - } - } - - /// From draft-17 the parameter value is two bare varints, so the same Location costs - /// one byte less than the length-prefixed form above. The empty Track Properties block - /// follows the parameters. - #[test] - fn test_subscribe_ok_accepts_largest_object_draft17_on() { let payload = [0x04, 0x01, 0x09, 0x05, 0x00]; - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { let decoded: SubscribeOk = decode_message(&payload, version).unwrap(); - assert_eq!(decoded.request_id, None, "{version}"); assert_eq!(decoded.track_alias, 4, "{version}"); assert_eq!(decoded.largest, Some(Location { group: 5, object: 0 }), "{version}"); } @@ -966,21 +574,16 @@ mod tests { } /// LARGEST_OBJECT is required once the track has content, so every draft that defines - /// it carries it: a content flag on draft-14, a length-prefixed parameter through - /// draft-16, and a bare Location parameter after. + /// it carries it as a bare Location parameter. #[test] fn test_subscribe_ok_largest_object_wire_vectors() { let largest = Some(Location { group: 9, object: 3 }); for (version, expected) in [ - (Version::Draft14, &[7, 42, 0, 1, 1, 9, 3, 0][..]), - (Version::Draft16, &[7, 42, 1, 0x09, 0x02, 0x09, 0x03][..]), - (Version::Draft17, &[42, 1, 0x09, 0x09, 0x03][..]), (Version::Draft18, &[42, 1, 0x09, 0x09, 0x03][..]), (Version::Draft22, &[42, 1, 0x09, 0x09, 0x03][..]), ] { let msg = SubscribeOk { - request_id: matches!(version, Version::Draft14 | Version::Draft16).then_some(RequestId(7)), track_alias: 42, largest, properties: Properties::default(), @@ -993,50 +596,6 @@ mod tests { } } - #[test] - fn test_subscribe_ok_v15() { - let msg = SubscribeOk { - request_id: Some(RequestId(42)), - track_alias: 42, - largest: None, - properties: Properties::default(), - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: SubscribeOk = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, Some(RequestId(42))); - assert_eq!(decoded.track_alias, 42); - } - - #[test] - fn test_subscribe_error() { - let msg = SubscribeError { - request_id: RequestId(123), - error_code: 500, - reason_phrase: "Not found".into(), - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: SubscribeError = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, RequestId(123)); - assert_eq!(decoded.error_code, 500); - assert_eq!(decoded.reason_phrase, "Not found"); - } - - #[test] - fn test_unsubscribe() { - let msg = Unsubscribe { - request_id: RequestId(999), - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: Unsubscribe = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, RequestId(999)); - } - /// Every parameter the draft lets a message carry has to parse, even the ones we act on /// nowhere: an unlisted key fails the whole message, which tears the session down /// instead of letting the request reach its answer. @@ -1109,43 +668,31 @@ mod tests { 0x00, // num_params ]; - let result: Result = decode_message(&invalid_bytes, Version::Draft14); + let result: Result = decode_message(&invalid_bytes, Version::Draft18); assert!(result.is_err()); } - /// Draft-16 registers Filter Types 0x1-0x4 and requires PROTOCOL_VIOLATION for any - /// other. Moxygen's `SubscribeRequest::make` defaults to its unregistered LargestGroup - /// (0xFA), so its interop client sends this exact SUBSCRIBE; refuse it rather than - /// guess a meaning. + /// Moxygen's interop client sends filter type 250 by default; only 0x1-0x4 are registered. #[test] - fn test_subscribe_v16_rejects_unregistered_filter_type() { + fn test_subscribe_rejects_unregistered_filter_type() { let subscribe = |filter_type: &[u8]| { let mut bytes = vec![0x00]; // request_id bytes.extend_from_slice(b"\x01\x0bnonexistent\x0dno-such-track"); // namespace, name bytes.extend_from_slice(&[0x01, 0x21, filter_type.len() as u8]); // one SUBSCRIPTION_FILTER bytes.extend_from_slice(filter_type); - decode_message::(&bytes, Version::Draft16) + decode_message::(&bytes, Version::Draft18) }; - assert!(matches!(subscribe(&[0x40, 0xfa]), Err(DecodeError::InvalidValue))); + assert!(matches!(subscribe(&[0x80, 0xfa]), Err(DecodeError::InvalidValue))); assert_eq!(subscribe(&[0x02]).unwrap().filter, Filter::NextObject); } - /// From draft-15 SUBSCRIBER_PRIORITY is a parameter whose omission means 128, not this + /// SUBSCRIBER_PRIORITY is a parameter whose omission means 128, not this /// stack's unset 127, so a relay forwards it unchanged. #[test] fn test_subscribe_omitted_priority_is_128() { - for version in [ - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { let mut bytes = vec![0x00]; // request_id - if version == Version::Draft17 { - bytes.push(0x00); // required_request_id_delta - } bytes.extend_from_slice(b"\x01\x0bnonexistent\x0dno-such-track"); // namespace, name bytes.push(0x00); // no parameters let msg = decode_message::(&bytes, version).unwrap(); @@ -1153,170 +700,6 @@ mod tests { } } - #[test] - fn test_subscribe_update_v15_round_trip() { - let msg = SubscribeUpdate { - new_group: None, - max_age: None, - request_id: RequestId(10), - subscription_request_id: Some(RequestId(5)), - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority: Some(200), - forward: Some(true), - filter: Some(Filter::NextObject), - authorization_token: None, - fill: None, - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: SubscribeUpdate = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, RequestId(10)); - assert_eq!(decoded.subscription_request_id, Some(RequestId(5))); - assert_eq!(decoded.subscriber_priority, Some(200)); - assert_eq!(decoded.forward, Some(true)); - } - - #[test] - fn test_subscribe_update_v14_round_trip() { - let msg = SubscribeUpdate { - new_group: None, - max_age: None, - request_id: RequestId(10), - subscription_request_id: Some(RequestId(5)), - start_location: Location { group: 1, object: 2 }, - end_group: 100, - subscriber_priority: Some(200), - forward: Some(true), - filter: Some(Filter::NextObject), - authorization_token: None, - fill: None, - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: SubscribeUpdate = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, RequestId(10)); - assert_eq!(decoded.subscription_request_id, Some(RequestId(5))); - assert_eq!(decoded.start_location, Location { group: 1, object: 2 }); - assert_eq!(decoded.end_group, 100); - assert_eq!(decoded.subscriber_priority, Some(200)); - assert_eq!(decoded.forward, Some(true)); - } - - #[test] - fn test_subscribe_ok_ignores_expires_v14() { - #[rustfmt::skip] - let bytes = [ - 0x01, // request_id - 0x00, // track_alias - 0x05, // expires = 5 - 0x02, // group_order - 0x00, // content_exists - 0x00, // num_params - ]; - - let decoded: SubscribeOk = decode_message(&bytes, Version::Draft14).unwrap(); - assert_eq!(decoded.request_id, Some(RequestId(1))); - } - - /// The SUBSCRIBE_OK aiomoqt 0.11.0 sends: EXPIRES = 0 and nothing else (#4172). - #[test] - fn test_subscribe_ok_ignores_expires_aiomoqt() { - let bytes = [0x03, 0x00, 0x01, 0x08, 0x00]; - - let decoded: SubscribeOk = decode_message(&bytes, Version::Draft16).unwrap(); - assert_eq!(decoded.request_id, Some(RequestId(3))); - assert_eq!(decoded.track_alias, 0); - assert!(decoded.largest.is_none()); - } - - /// The SUBSCRIBE_OK a libquicr relay sends: EXPIRES = 0 followed by five track - /// properties (#4172). - #[test] - fn test_subscribe_ok_ignores_expires_libquicr() { - let bytes = [ - 0x02, 0xde, 0x53, 0x15, 0xbf, 0xd6, 0x39, 0x31, 0x88, 0x01, 0x08, 0x00, 0x02, 0x00, 0x02, 0x00, 0x0a, 0x01, - 0x14, 0x01, 0x0e, 0x01, - ]; - - let decoded: SubscribeOk = decode_message(&bytes, Version::Draft16).unwrap(); - assert_eq!(decoded.request_id, Some(RequestId(2))); - assert_eq!(decoded.properties.priority, Some(1)); - assert_eq!(decoded.properties.group_order, Some(GroupOrder::Ascending)); - } - - #[test] - fn test_subscribe_v17_round_trip() { - let msg = Subscribe { - new_group: None, - max_age: None, - request_id: RequestId(1), - track_namespace: Path::from_static("test"), - 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, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: Subscribe = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test"); - assert_eq!(decoded.track_name, "video"); - assert_eq!(decoded.subscriber_priority, 128); - } - - #[test] - fn test_subscribe_ok_v17_round_trip() { - let msg = SubscribeOk { - request_id: None, - track_alias: 42, - largest: None, - properties: Properties::default(), - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: SubscribeOk = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, None); - assert_eq!(decoded.track_alias, 42); - } - - #[test] - fn test_subscribe_update_v17_round_trip() { - let msg = SubscribeUpdate { - new_group: None, - max_age: None, - request_id: RequestId(10), - subscription_request_id: None, - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority: Some(200), - forward: Some(true), - filter: Some(Filter::NextObject), - authorization_token: None, - fill: None, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: SubscribeUpdate = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, RequestId(10)); - assert_eq!(decoded.subscription_request_id, None); - assert_eq!(decoded.subscriber_priority, Some(200)); - assert_eq!(decoded.forward, Some(true)); - } - #[test] fn test_subscribe_v18_round_trip() { let msg = Subscribe { @@ -1349,7 +732,6 @@ mod tests { #[test] fn test_subscribe_ok_v18_round_trip() { let msg = SubscribeOk { - request_id: None, track_alias: 42, largest: None, properties: Properties::default(), @@ -1358,25 +740,15 @@ mod tests { let encoded = encode_message(&msg, Version::Draft18); let decoded: SubscribeOk = decode_message(&encoded, Version::Draft18).unwrap(); - assert_eq!(decoded.request_id, None); assert_eq!(decoded.track_alias, 42); } - /// The registered priority property is two literal bytes after the message body on - /// draft-17+, and absent on older drafts. The decoder returns the same wire value. + /// The registered priority property is two literal bytes after the message body. The + /// decoder returns the same wire value. #[test] fn subscribe_ok_priority_property_bytes_on_every_draft() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { let mut msg = SubscribeOk { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(7)), track_alias: 42, largest: None, properties: Properties::default(), @@ -1384,29 +756,24 @@ mod tests { let baseline = encode_message(&msg, version); msg.properties.priority = Some(37); let encoded = encode_message(&msg, version); - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - assert_eq!(encoded, baseline, "{version}"); - } else { - assert_eq!(encoded, [baseline, vec![0x0e, 37]].concat(), "{version}"); - assert_eq!( - decode_message::(&encoded, version) - .unwrap() - .properties - .priority, - Some(37) - ); - } + assert_eq!(encoded, [baseline, vec![0x0e, 37]].concat(), "{version}"); + assert_eq!( + decode_message::(&encoded, version) + .unwrap() + .properties + .priority, + Some(37) + ); } } - /// GROUP_ORDER (0x22) is only a legal SUBSCRIBE_OK *message parameter* through draft-15; - /// a draft-16+ peer closes the session with PROTOCOL_VIOLATION when it sees one. The - /// publisher's preference belongs in the DEFAULT_PUBLISHER_GROUP_ORDER track property, + /// GROUP_ORDER (0x22) is not a legal SUBSCRIBE_OK *message parameter*; a peer closes the + /// session with PROTOCOL_VIOLATION when it sees one. The publisher's preference + /// belongs in the DEFAULT_PUBLISHER_GROUP_ORDER track property, /// which shares the number 0x22 in the separate property registry. #[test] fn test_subscribe_ok_v18_group_order_is_a_property() { let msg = SubscribeOk { - request_id: None, track_alias: 42, largest: None, properties: Properties { @@ -1433,100 +800,8 @@ mod tests { assert_eq!(decoded.properties.group_order, Some(GroupOrder::Descending)); } - /// Draft-15 is the one version that takes it as a message parameter, and has no track - /// properties to put it in. - #[test] - fn test_subscribe_ok_v15_group_order_is_a_parameter() { - let msg = SubscribeOk { - request_id: Some(RequestId(7)), - track_alias: 42, - largest: None, - properties: Properties { - unknown: Default::default(), - dynamic_groups: false, - compress: false, - max_cache_duration: None, - timescale: None, - priority: None, - group_order: Some(GroupOrder::Descending), - }, - }; - - #[rustfmt::skip] - let expected = vec![ - 7, // request id - 42, // track alias - 1, // one message parameter - 0x22, // GROUP_ORDER - 0x02, // descending - ]; - assert_eq!(encode_message(&msg, Version::Draft15), expected); - - let decoded: SubscribeOk = decode_message(&expected, Version::Draft15).unwrap(); - assert_eq!(decoded.properties.group_order, Some(GroupOrder::Descending)); - } - - /// Draft-15 has no properties block, so DYNAMIC_GROUPS is a SUBSCRIBE_OK parameter. - #[test] - fn test_subscribe_ok_v15_dynamic_groups_is_a_parameter() { - let msg = SubscribeOk { - request_id: Some(RequestId(7)), - track_alias: 42, - largest: None, - properties: Properties { - dynamic_groups: true, - compress: false, - ..Default::default() - }, - }; - - #[rustfmt::skip] - let expected = vec![ - 7, // request id - 42, // track alias - 1, // one message parameter - 0x30, // DYNAMIC_GROUPS - 0x01, - ]; - assert_eq!(encode_message(&msg, Version::Draft15), expected); - assert!( - decode_message::(&expected, Version::Draft15) - .unwrap() - .properties - .dynamic_groups - ); - - // A boolean: anything above 1 is malformed. - let malformed = [7, 42, 1, 0x30, 0x02]; - assert!(decode_message::(&malformed, Version::Draft15).is_err()); - } - - /// Draft-16 has the block too, under the name Track Extensions. We don't write one there, - /// but a peer that does used to fail the whole message as `Long`. - #[test] - fn test_subscribe_ok_v16_reads_track_extensions() { - #[rustfmt::skip] - let body = vec![ - 7, // request id - 42, // track alias - 0, // zero message parameters - 0x22, // DEFAULT_PUBLISHER_GROUP_ORDER - 0x02, // descending - ]; - - // Go through the size-prefixed path: that's what rejects unread trailing bytes. - let mut buf = Vec::new(); - Encoder::new(&mut buf, Version::Draft16.into()).u16(body.len() as u16); - buf.extend_from_slice(&body); - - let mut bytes = bytes::Bytes::from(buf); - let decoded = crate::coding::decode_buf(&mut bytes, Version::Draft16, SubscribeOk::decode).unwrap(); - assert_eq!(decoded.track_alias, 42); - assert_eq!(decoded.properties.group_order, Some(GroupOrder::Descending)); - } - - /// GROUP_ORDER left SUBSCRIBE_OK after draft-15. Draft-18 closes the session when a - /// known parameter shows up on a message that does not define it. + /// Draft-18 closes the session when a known parameter shows up on a message that does + /// not define it, such as GROUP_ORDER on SUBSCRIBE_OK. #[test] fn test_subscribe_ok_v18_rejects_group_order_parameter() { #[rustfmt::skip] @@ -1540,17 +815,13 @@ mod tests { assert!(decode_message::(&bytes, Version::Draft18).is_err()); } - /// Draft-18 removes the `required_request_id_delta` field (#1615), so the - /// REQUEST_UPDATE wire format is 1 varint shorter than draft-17. + /// REQUEST_UPDATE has no `required_request_id_delta` field (moq-wg/moq-transport#1615). #[test] fn test_subscribe_update_v18_round_trip() { let msg = SubscribeUpdate { new_group: None, max_age: None, request_id: RequestId(10), - subscription_request_id: None, - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: Some(200), forward: Some(true), filter: Some(Filter::NextObject), @@ -1562,36 +833,10 @@ mod tests { let decoded: SubscribeUpdate = decode_message(&encoded, Version::Draft18).unwrap(); assert_eq!(decoded.request_id, RequestId(10)); - assert_eq!(decoded.subscription_request_id, None); assert_eq!(decoded.subscriber_priority, Some(200)); assert_eq!(decoded.forward, Some(true)); } - /// Cross-check: draft-17 emits an extra 0-byte (required_request_id_delta) that - /// draft-18 does not. So a draft-18 encoding should be exactly 1 byte shorter - /// than draft-17 for SUBSCRIBE_UPDATE. - #[test] - fn test_subscribe_update_v17_v18_size_differs() { - let v17_msg = SubscribeUpdate { - new_group: None, - max_age: None, - request_id: RequestId(10), - subscription_request_id: None, - start_location: Location { group: 0, object: 0 }, - end_group: 0, - subscriber_priority: Some(200), - forward: Some(true), - filter: Some(Filter::NextObject), - authorization_token: None, - fill: None, - }; - let v18_msg = SubscribeUpdate { ..v17_msg.clone() }; - - let v17 = encode_message(&v17_msg, Version::Draft17); - let v18 = encode_message(&v18_msg, Version::Draft18); - assert_eq!(v17.len(), v18.len() + 1); - } - /// The head of a SUBSCRIBE (draft-22 Figure 11) up to its Number of Parameters: /// Request ID 1, Track Namespace ("live"), Track Name ("video"). Every value is /// below 128, so each varint is one byte in the drafts before and after 17 alike. @@ -1701,14 +946,7 @@ mod tests { /// FORWARD=0 is legal on every draft, a paused request rather than a session error. #[test] fn subscribe_decodes_forward_zero() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { let msg = Subscribe { new_group: None, max_age: None, @@ -1731,8 +969,8 @@ mod tests { } } - /// AUTHORIZATION TOKEN is a Key-Value-Pair with an odd type through draft-16, so its - /// value is length prefixed there too. + /// AUTHORIZATION TOKEN is a Key-Value-Pair with an odd type, so its value is length + /// prefixed. #[test] fn subscribe_accepts_a_token_on_every_draft() { #[rustfmt::skip] @@ -1740,40 +978,19 @@ mod tests { 0x01, // Number of Parameters 0x03, 0x03, 0x03, 0x00, 0xAA, // AUTHORIZATION TOKEN ]); - for version in [ - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { - let mut body = body.clone(); - if version == Version::Draft17 { - // Required Request ID Delta, draft-17 only. - body.insert(1, 0x00); - } - let wire = body; + for version in [Version::Draft18, Version::Draft22] { + let wire = body.clone(); let mut buf = Decoder::new(&wire, version.into()); Subscribe::decode_msg(&mut buf, version).unwrap_or_else(|e| panic!("{version}: {e}")); assert!(buf.is_empty(), "{version}: trailing bytes"); } } - /// Draft-15 ignores a parameter it does not know. From draft-16 on, a parameter from - /// a later draft is unknown, which closes the session. - #[test] - fn subscribe_ignores_unrecognized_parameters_in_draft15() { - let body = subscribe_body(&[0x01, 0x32, 0x00]); - decode_message::(&body, Version::Draft15).expect("draft-15 ignores NEW_GROUP_REQUEST"); - } - /// A parameter from a later draft is still an unknown parameter, which the draft makes /// a protocol violation. #[test] fn subscribe_refuses_parameters_before_their_draft() { for (version, params) in [ - // SUBGROUP_DELIVERY_TIMEOUT arrived in draft-18, and 0x06 is not a draft-16 id. - (Version::Draft16, &[0x01, 0x06, 0x00][..]), // The Range Filters arrived in draft-22. (Version::Draft18, &[0x01, 0x25, 0x00][..]), // INCLUDE_PROPERTIES arrived in draft-22. @@ -1805,22 +1022,17 @@ mod tests { assert_eq!(decoded.request_id, RequestId(2)); } - /// SUBGROUP_DELIVERY_TIMEOUT (0x06) is not a draft-16 parameter. Draft-18 defines it - /// for SUBSCRIBE, and the value is dropped. + /// Draft-18 defines SUBGROUP_DELIVERY_TIMEOUT (0x06) for SUBSCRIBE, and the value is + /// dropped. #[test] fn subgroup_delivery_timeout_follows_its_draft() { let head: &[u8] = &[ 0x01, 0x01, 0x04, b'l', b'i', b'v', b'e', 0x05, b'v', b'i', b'd', b'e', b'o', ]; let body = [head, &[0x01, 0x06, 0x00][..]].concat(); - assert!(decode_message::(&body, Version::Draft16).is_err()); decode_message::(&body, Version::Draft18).expect("draft-18 accepts SUBGROUP_DELIVERY_TIMEOUT"); - // GROUP_ORDER (0x22) is known in draft-16 but not defined for REQUEST_UPDATE. - // Request ID, Subscription Request ID, one parameter, absolute key, varint value. - let update16: &[u8] = &[0x02, 0x0A, 0x01, 0x22, 0x02]; - decode_message::(update16, Version::Draft16).expect("draft-16 ignores GROUP_ORDER"); - // Draft-18 has no Subscription Request ID. The value is a uint8. + // GROUP_ORDER (0x22) is not defined for REQUEST_UPDATE. let update18: &[u8] = &[0x02, 0x01, 0x22, 0x02]; assert!(decode_message::(update18, Version::Draft18).is_err()); } @@ -1833,21 +1045,9 @@ mod cache_duration_tests { #[test] fn max_cache_duration_is_read_in_each_drafts_field() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { for age in [0u64, 30_000] { - let mut payload = match version { - Version::Draft14 => vec![0, 0, 0, 1, 0, 1, 4], - Version::Draft15 => vec![0, 0, 1, 4], - Version::Draft16 => vec![0, 0, 0, 4], - _ => vec![0, 0, 4], - }; + let mut payload = vec![0, 0, 4]; Encoder::new(&mut payload, version.into()).varint(age).unwrap(); let got = crate::coding::decode_buf(&mut payload.as_slice(), version, SubscribeOk::decode_msg).unwrap(); assert_eq!( @@ -1860,19 +1060,10 @@ mod cache_duration_tests { } #[test] - fn legacy_never_sends_cache_duration_and_modern_preserves_optional_value() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { - let legacy = matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16); + fn cache_duration_preserves_optional_value() { + for version in [Version::Draft18, Version::Draft22] { for age in [None, Some(Duration::ZERO), Some(Duration::from_secs(30))] { let ok = SubscribeOk { - request_id: legacy.then_some(RequestId(0)), track_alias: 0, largest: None, properties: Properties { @@ -1884,11 +1075,7 @@ mod cache_duration_tests { ok.encode_msg(&mut Encoder::new(&mut payload, version.into()), version) .unwrap(); let got = crate::coding::decode_buf(&mut payload.as_slice(), version, SubscribeOk::decode_msg).unwrap(); - assert_eq!( - got.properties.max_cache_duration, - if legacy { None } else { age }, - "{version}" - ); + assert_eq!(got.properties.max_cache_duration, age, "{version}"); } } } diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 2ee683a69c1..b78ae87eab8 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -1,7 +1,5 @@ //! IETF moq-transport subscribe namespace messages -use std::borrow::Cow; - use crate::{Path, coding::*, ietf::RequestId}; use super::Message; @@ -11,20 +9,7 @@ use super::publish_namespace::{AdvertParams, Negotiated}; use super::Version; -/// 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 { - matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) -} - -/// SUBSCRIBE_NAMESPACE message (draft-18+, type 0x50). -/// -/// Draft-18 renumbered the message from 0x11 to 0x50 and dropped the Subscribe -/// Options field when it split SUBSCRIBE_TRACKS (0x51) off into its own message -/// type (#1542). Draft-14 through draft-17 use [`SubscribeNamespaceLegacy`]. +/// SUBSCRIBE_NAMESPACE message (type 0x50). #[derive(Clone, Debug)] pub struct SubscribeNamespace { pub request_id: RequestId, @@ -52,9 +37,6 @@ impl Message for SubscribeNamespace { const ID: u64 = 0x50; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if is_legacy_version(version) { - return Err(EncodeError::Version); - } self.request_id.encode(w, version)?; encode_namespace(w, &self.namespace)?; encode_params!(w, version, HIDDEN_PARAM => hidden_param(self.hidden)); @@ -62,9 +44,6 @@ impl Message for SubscribeNamespace { } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - if is_legacy_version(version) { - return Err(DecodeError::Version); - } let request_id = RequestId::decode(r, version)?; let namespace = decode_namespace(r)?; // The token decodes but grants nothing: the session's grant is what authorizes the request. @@ -84,200 +63,7 @@ impl Message for SubscribeNamespace { } } -/// What a draft-16/17 SUBSCRIBE_NAMESPACE asks for (d16 §9.25, d17 §9.20). -/// -/// Draft-14/15 have no field and only ever ask for namespaces. Draft-18 dropped it -/// when SUBSCRIBE_TRACKS took over the PUBLISH half. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SubscribeOptions { - /// PUBLISH for each matching track (0x00). - Publish, - /// NAMESPACE for each matching namespace (0x01). - Namespace, - /// Both PUBLISH and NAMESPACE (0x02). - Both, -} - -impl SubscribeOptions { - fn encode(self, w: &mut Encoder<'_>) -> Result<(), EncodeError> { - w.varint(match self { - Self::Publish => 0x00u64, - Self::Namespace => 0x01, - Self::Both => 0x02, - }) - } - - fn decode(r: &mut Decoder<'_>) -> Result { - match r.varint()? { - 0x00 => Ok(Self::Publish), - 0x01 => Ok(Self::Namespace), - 0x02 => Ok(Self::Both), - _ => Err(DecodeError::InvalidValue), - } - } -} - -/// SUBSCRIBE_NAMESPACE message for draft-14 through draft-17 (type 0x11). -/// -/// In v16 this moves from the control stream to its own bidirectional stream. -/// Draft-16/17 carry [`SubscribeOptions`]; draft-17 additionally prefixes a -/// Required Request ID delta (removed in draft-18 per #1615). Draft-18+ uses -/// [`SubscribeNamespace`]. -#[derive(Clone, Debug)] -pub struct SubscribeNamespaceLegacy { - pub request_id: RequestId, - pub namespace: Path, - /// Encoded on v16/v17 only; earlier drafts always mean [`SubscribeOptions::Namespace`]. - pub subscribe_options: SubscribeOptions, - /// MoQ Hidden: see [`SubscribeNamespace::hidden`]. - pub hidden: bool, -} - -impl Message for SubscribeNamespaceLegacy { - const ID: u64 = 0x11; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if !is_legacy_version(version) { - return Err(EncodeError::Version); - } - self.request_id.encode(w, version)?; - if version == Version::Draft17 { - w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) - } - encode_namespace(w, &self.namespace)?; - if matches!(version, Version::Draft16 | Version::Draft17) { - self.subscribe_options.encode(w)?; - } - encode_params!(w, version, HIDDEN_PARAM => hidden_param(self.hidden)); - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - if !is_legacy_version(version) { - return Err(DecodeError::Version); - } - let request_id = RequestId::decode(r, version)?; - if version == Version::Draft17 { - let _required_request_id_delta = r.varint()?; - } - let namespace = decode_namespace(r)?; - let subscribe_options = match version { - Version::Draft16 | Version::Draft17 => SubscribeOptions::decode(r)?, - _ => SubscribeOptions::Namespace, - }; - - // The token decodes but grants nothing: the session's grant is what authorizes the request. - // FORWARD is legal here in draft-15 through draft-17. The value is checked (only - // 0 or 1) and then dropped: a namespace subscription has no objects to forward. - // Draft-14 has no such parameter, so an unlisted id is ignored. Draft-18 moved - // FORWARD onto SUBSCRIBE_TRACKS and closes if it shows up on the new message. - decode_params!(r, version, - 0x03 => authorization_token: Vec, - 0x10 => _forward: Option where matches!(version, Version::Draft15 | Version::Draft16 | Version::Draft17), - HIDDEN_PARAM => hidden: Option, - ); - let hidden = hidden_from_param(hidden)?; - // Last, so a malformed field closes the session before a token refusal. - super::token::single(authorization_token)?; - - Ok(Self { - request_id, - namespace, - subscribe_options, - hidden, - }) - } -} - -/// SubscribeNamespaceOk message (0x12): v14 only -#[derive(Clone, Debug)] -pub struct SubscribeNamespaceOk { - pub request_id: RequestId, -} - -impl Message for SubscribeNamespaceOk { - const ID: u64 = 0x12; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - Ok(Self { request_id }) - } -} - -/// SubscribeNamespaceError message (0x13): v14 only -#[derive(Clone, Debug)] -pub struct SubscribeNamespaceError<'a> { - pub request_id: RequestId, - pub error_code: u64, - pub reason_phrase: Cow<'a, str>, -} - -impl Message for SubscribeNamespaceError<'_> { - const ID: u64 = 0x13; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - w.varint(self.error_code)?; - w.string(&self.reason_phrase)?; - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - let error_code = r.varint()?; - let reason_phrase = Cow::Owned(r.string()?); - - Ok(Self { - request_id, - error_code, - reason_phrase, - }) - } -} - -/// UnsubscribeNamespace message (0x14): v14/v15 only (v16 uses stream close). -/// v14 names the prefix it subscribed, v15 the request. -#[derive(Clone, Debug)] -pub struct UnsubscribeNamespace { - /// v14: the Track Namespace Prefix of the SUBSCRIBE_NAMESPACE it cancels. - pub prefix: Path, - /// v15: the Request ID of the SUBSCRIBE_NAMESPACE it cancels. - pub request_id: RequestId, -} - -impl Message for UnsubscribeNamespace { - const ID: u64 = 0x14; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - match version { - Version::Draft14 => encode_namespace(w, &self.prefix)?, - Version::Draft15 => self.request_id.encode(w, version)?, - _ => return Err(EncodeError::Version), - } - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - match version { - Version::Draft14 => Ok(Self { - prefix: decode_namespace(r)?, - request_id: RequestId(0), - }), - Version::Draft15 => Ok(Self { - prefix: Path::default(), - request_id: RequestId::decode(r, version)?, - }), - _ => Err(DecodeError::Version), - } - } -} - -/// NAMESPACE message (0x08): v16+, sent on the SUBSCRIBE_NAMESPACE bidi stream. +/// NAMESPACE message (0x08), sent on the SUBSCRIBE_NAMESPACE bidi stream. /// Indicates a namespace suffix matching the subscribed prefix is active. /// /// The base message carries only the suffix. A session that negotiated the MoQ Cluster @@ -326,7 +112,7 @@ impl Message for Namespace { } } -/// NAMESPACE_DONE message (0x0E): v16 only, sent on SUBSCRIBE_NAMESPACE bidi stream +/// NAMESPACE_DONE message (0x0E), sent on the SUBSCRIBE_NAMESPACE bidi stream. /// Indicates a namespace suffix matching the subscribed prefix is no longer active. #[derive(Clone, Debug)] pub struct NamespaceDone { @@ -383,29 +169,6 @@ mod tests { Ok((decoded, buf.is_empty())) } - /// Draft-14 names the prefix it cancels and draft-15 the request; later drafts close the - /// stream instead. - #[test] - fn unsubscribe_namespace_names_the_prefix_then_the_request() { - let msg = UnsubscribeNamespace { - prefix: Path::from_static("cam/front"), - request_id: RequestId(6), - }; - let mut v14 = bytes::Bytes::from(body(&msg, Version::Draft14)); - let named = crate::coding::decode_buf(&mut v14, Version::Draft14, UnsubscribeNamespace::decode_msg).unwrap(); - assert_eq!(named.prefix.as_str(), "cam/front"); - - let mut v15 = bytes::Bytes::from(body(&msg, Version::Draft15)); - let numbered = crate::coding::decode_buf(&mut v15, Version::Draft15, UnsubscribeNamespace::decode_msg).unwrap(); - assert_eq!(numbered.request_id, RequestId(6)); - - let mut buf = Vec::new(); - assert!( - msg.encode_msg(&mut Encoder::new(&mut buf, Version::Draft16.into()), Version::Draft16) - .is_err() - ); - } - fn clustered(hops: &[u64], cost: u64) -> Option { Some(AdvertParams { cluster: Some(cluster::Advert { @@ -586,80 +349,40 @@ mod tests { ); } - /// FORWARD (0x10) is legal on SUBSCRIBE_NAMESPACE through draft-17. A value other - /// than 0 or 1 still closes the session. Draft-18 dropped it from this message. + /// Draft-18 moved FORWARD (0x10) onto SUBSCRIBE_TRACKS, so it closes the session here. #[test] - fn forward_follows_the_draft() { - fn with_forward(version: Version, value: u64) -> Vec { - let mut buf = Vec::new(); - let w = &mut Encoder::new(&mut buf, version.into()); - RequestId(1).encode(w, version).unwrap(); - if version == Version::Draft17 { - w.varint(0).unwrap(); - } - encode_namespace(w, &Path::default()).unwrap(); - if matches!(version, Version::Draft16 | Version::Draft17) { - w.varint(1).unwrap(); - } - // Count, absolute key, then the raw varint. encode_params! needs a Result. - w.varint(1).unwrap(); - w.varint(0x10).unwrap(); - w.varint(value).unwrap(); - buf - } - - for version in [Version::Draft15, Version::Draft16, Version::Draft17] { - let mut buf = bytes::Bytes::from(with_forward(version, 0)); - crate::coding::decode_buf(&mut buf, version, SubscribeNamespaceLegacy::decode_msg) - .unwrap_or_else(|err| panic!("{version} FORWARD=0: {err}")); - assert!(buf.is_empty(), "{version}"); - - let mut buf = bytes::Bytes::from(with_forward(version, 2)); - assert!( - crate::coding::decode_buf(&mut buf, version, SubscribeNamespaceLegacy::decode_msg).is_err(), - "{version} FORWARD=2" - ); - } - - let mut buf = bytes::Bytes::from(with_forward(Version::Draft18, 0)); - assert!(crate::coding::decode_buf(&mut buf, Version::Draft18, SubscribeNamespace::decode_msg).is_err()); + fn forward_is_refused() { + let version = Version::Draft18; + let mut buf = Vec::new(); + let w = &mut Encoder::new(&mut buf, version.into()); + RequestId(1).encode(w, version).unwrap(); + encode_namespace(w, &Path::default()).unwrap(); + // Count, key, then the raw varint. + w.varint(1).unwrap(); + w.varint(0x10).unwrap(); + w.varint(0).unwrap(); + let mut buf = bytes::Bytes::from(buf); + assert!(crate::coding::decode_buf(&mut buf, version, SubscribeNamespace::decode_msg).is_err()); } - /// SUBSCRIBE_NAMESPACE's message types, pinned to the drafts: 0x11 through draft-17, - /// renumbered to 0x50 in draft-18. #[test] - fn message_type_code_points() { - for (name, id, spec) in [ - ("SUBSCRIBE_NAMESPACE (draft-17)", SubscribeNamespaceLegacy::ID, 0x11), - ("SUBSCRIBE_NAMESPACE", SubscribeNamespace::ID, 0x50), - ] { - assert_eq!(id, spec, "{name}"); - } + fn message_type_code_point() { + assert_eq!(SubscribeNamespace::ID, 0x50); } #[test] - fn draft18_omits_subscribe_options() { - // Draft-18 modern body: Request ID (0x00), empty namespace field-count - // (0x00), Number of Parameters (0x00). No Subscribe Options field. - let modern = SubscribeNamespace { - request_id: RequestId(0), - namespace: Path::default(), - hidden: false, - }; - assert_eq!(body(&modern, Version::Draft18), vec![0x00, 0x00, 0x00]); - - // The legacy draft-17 body keeps the options field, so it is one byte longer. - let legacy = SubscribeNamespaceLegacy { + fn body_has_no_subscribe_options() { + // Request ID (0x00), empty namespace field-count (0x00), Number of Parameters (0x00). + let msg = SubscribeNamespace { request_id: RequestId(0), namespace: Path::default(), - subscribe_options: SubscribeOptions::Namespace, hidden: false, }; - assert!(body(&legacy, Version::Draft17).len() > body(&modern, Version::Draft18).len()); + assert_eq!(body(&msg, Version::Draft18), vec![0x00, 0x00, 0x00]); } #[test] - fn modern_round_trips() { + fn round_trips() { let msg = SubscribeNamespace { request_id: RequestId(4), namespace: Path::from_static("example/meeting"), @@ -688,74 +411,6 @@ mod tests { .hidden, hidden ); - - for version in [Version::Draft14, Version::Draft16, Version::Draft17] { - let msg = SubscribeNamespaceLegacy { - request_id: RequestId(4), - namespace: Path::from_static("example"), - subscribe_options: SubscribeOptions::Namespace, - hidden, - }; - let mut buf = bytes::Bytes::from(body(&msg, version)); - let decoded = - crate::coding::decode_buf(&mut buf, version, SubscribeNamespaceLegacy::decode_msg).unwrap(); - assert_eq!(decoded.hidden, hidden, "{version:?}"); - } - } - } - - #[test] - fn legacy_round_trips() { - for version in [Version::Draft16, Version::Draft17] { - for options in [ - SubscribeOptions::Publish, - SubscribeOptions::Namespace, - SubscribeOptions::Both, - ] { - let msg = SubscribeNamespaceLegacy { - request_id: RequestId(4), - namespace: Path::from_static("example/meeting"), - subscribe_options: options, - hidden: false, - }; - let mut buf = bytes::Bytes::from(body(&msg, version)); - let decoded = - crate::coding::decode_buf(&mut buf, version, SubscribeNamespaceLegacy::decode_msg).unwrap(); - assert!(buf.is_empty(), "trailing bytes for {version:?}"); - assert_eq!(decoded.request_id, RequestId(4)); - assert_eq!(decoded.namespace.as_str(), "example/meeting"); - assert_eq!(decoded.subscribe_options, options); - } } } - - /// The drafts define only 0x00 through 0x02, so we refuse any other value as a protocol violation. - #[test] - fn legacy_rejects_unknown_subscribe_options() { - // Request ID, empty namespace, Subscribe Options 0x03, no parameters. - let mut buf = bytes::Bytes::from(vec![0x00, 0x00, 0x03, 0x00]); - assert!(matches!( - crate::coding::decode_buf(&mut buf, Version::Draft16, SubscribeNamespaceLegacy::decode_msg), - Err(DecodeError::InvalidValue) - )); - } - - #[test] - fn rejects_wrong_version() { - // The modern 0x50 message only exists in draft-18+. - for version in [Version::Draft14, Version::Draft16, Version::Draft17] { - let mut buf = bytes::Bytes::from(vec![0x00, 0x00, 0x00]); - assert!(matches!( - crate::coding::decode_buf(&mut buf, version, SubscribeNamespace::decode_msg), - Err(DecodeError::Version) - )); - } - - // The legacy 0x11 message only exists in draft-14..17. - let mut buf = bytes::Bytes::from(vec![0x00, 0x00, 0x00]); - assert!(matches!( - crate::coding::decode_buf(&mut buf, Version::Draft18, SubscribeNamespaceLegacy::decode_msg), - Err(DecodeError::Version) - )); - } } diff --git a/rs/moq-net/src/ietf/subscribe_tracks.rs b/rs/moq-net/src/ietf/subscribe_tracks.rs index 26a05e949f9..361d3032d75 100644 --- a/rs/moq-net/src/ietf/subscribe_tracks.rs +++ b/rs/moq-net/src/ietf/subscribe_tracks.rs @@ -48,9 +48,6 @@ 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. @@ -69,9 +66,6 @@ impl Message for SubscribeTracks { } 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-22 any parameter a SUBSCRIBE takes is legal here (section 3.6.2); @@ -126,18 +120,10 @@ impl Message for SubscribeTracks { } } -/// 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 +/// Draft-18 names it PUBLISH_BLOCKED and send it only when the publisher has run /// out of streams for PUBLISH; draft-22 renamed it and allows it for any reason. #[derive(Clone, Debug)] pub struct PublishSkipped<'a> { @@ -149,31 +135,19 @@ pub struct PublishSkipped<'a> { 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); - } + fn encode_msg(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { 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); - } + fn decode_msg(r: &mut Decoder<'_>, _: Version) -> Result { 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::*; @@ -282,20 +256,9 @@ mod tests { assert_eq!(decoded.subscriber_priority, Some(7)); } - #[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] { + for version in [Version::Draft18, Version::Draft22] { let msg = PublishSkipped { suffix: Path::from_static("cam"), track_name: "video".into(), diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 78f0496c0a6..fb202d52f97 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -697,8 +697,7 @@ fn accepted_info(properties: &ietf::Properties, version: Version) -> track::Info .with_compress(properties.compress) .with_timescale(properties.timescale) .with_max_age(properties.max_cache_duration) - // Drafts 14 and 15 carry only per-subgroup priorities, which the model does not keep, - // so they get the later drafts' default too. + // A track without the property gets the default. .with_priority(super::priority::from_wire( properties.priority.unwrap_or(super::priority::DEFAULT_WIRE), )) @@ -715,7 +714,7 @@ fn accepted_info(properties: &ietf::Properties, version: Version) -> track::Info /// Why a track a `version` publisher answered with `properties` is neither processed nor /// forwarded, if it is not. fn refused(properties: &ietf::Properties, version: Version, negotiated: bool) -> Option { - if properties.compress && !(negotiated && super::flate::supported(version)) { + if properties.compress && !negotiated { return Some(Error::UnsupportedExtension); } let info = accepted_info(properties, version); @@ -870,10 +869,10 @@ enum Withdraw { Done, /// Close our side and wait for the peer to read everything. Close, - /// Refuse the request with this ID, then close. - Reject(RequestId, crate::auth::Refusal), - /// Refuse the update with this ID, then cancel the advertisement. - Cancel(RequestId, crate::auth::Refusal), + /// Refuse the request, then close. + Reject(crate::auth::Refusal), + /// Refuse an update, then cancel the advertisement. + Cancel(crate::auth::Refusal), } /// What one advertisement said, once its parameters are resolved against the session. @@ -914,8 +913,8 @@ struct Held { epoch: Option, } -/// A joining request may be abandoned before it takes an ID. Once allocated, its -/// header must still reach the peer so drafts 14/15 never see a missing ID. +/// A joining request may be abandoned before it takes an ID. Once committed, its header +/// must still reach the peer, which otherwise remembers the ID it never saw. struct JoiningTask { work: Option>>, state: kio::Shared, @@ -1209,13 +1208,9 @@ impl Subscriber { retire_track_alias(&aliases, alias, REQUEST_ID); } - /// What the peer declared in its SETUP, or the default (extension off) on a version - /// that cannot negotiate it. See [`super::Publisher::peer`]. + /// What the peer declared in its SETUP. See [`super::Publisher::peer`]. pub(super) async fn peer(&self) -> cluster::Peer { - match cluster::supported(self.version) { - true => self.peer_setup.get().await.cluster, - false => cluster::Peer::default(), - } + self.peer_setup.get().await.cluster } /// The announcing session's declared or assigned identity, for split-horizon. @@ -1356,8 +1351,7 @@ impl Subscriber { /// Send SUBSCRIBE_NAMESPACE for one prefix on a bidi stream, and read what it reports /// until the peer ends it or `live` closes. - /// The caller is responsible for opening the appropriate stream type - /// (virtual for v14/v15, real bidi for v16+), one per prefix. + /// The caller opens the stream, one per prefix. /// /// A failure here is per-prefix, so the caller decides what it means for the /// session: [`is_protocol_violation`] separates the peer's fault (fatal) from a @@ -1376,13 +1370,8 @@ impl Subscriber { snapshot: crate::model::Snapshot, live: kio::Consumer<()>, ) -> Result { - let stream = if self.version == Version::Draft16 { - stream.with_stats(self.origin.stats()) - } else { - super::request_stream::with_stats(stream, self.origin.stats(), self.version) - }; let mut request = NamespaceRequest { - stream, + stream: stream.with_stats(self.origin.stats()), messages: super::request_stream::Messages::new(self.session.clone(), self.control.client()), prefix, request_id: None, @@ -1421,55 +1410,16 @@ impl Subscriber { let hidden = declared.hidden; let prefix = request.prefix.clone(); - // Draft-16 is the first to allow a zero-field namespace, so the empty prefix is a - // protocol violation on draft-14/15. A peer that never declared MoQ Solicit is not - // ours: it may enforce that, and it tells us unasked anyway. One that declared it - // accepts the empty prefix and only tells when asked, so it still gets one. - if prefix.is_empty() - && declared.solicit.is_none() - && matches!(self.version, Version::Draft14 | Version::Draft15) - { - tracing::debug!(version = ?self.version, "not asking a foreign peer for the empty namespace"); - return Ok(namespaces::Ended::Peer); - } - - // No ID is spent on a request that ends while it waits for credit, so the peer - // never sees a gap in our sequential request IDs. - let mut timeout = crate::time::Deadline::after(&self.runtime, Control::REQUEST_ID_TIMEOUT); - let request_id = kio::wait(|waiter| { - if live.poll_closed(waiter).is_ready() { - return Poll::Ready(Ok(None)); - } - self.control.poll_credit(waiter, &mut timeout).map(|id| id.map(Some)) - }) - .await?; - let Some(request_id) = request_id else { - return Ok(namespaces::Ended::Local); - }; + let request_id = self.control.next_request_id(); request.request_id = Some(request_id); let stream = &mut request.stream; - // Draft-18+ uses SUBSCRIBE_NAMESPACE (0x50); earlier drafts use the legacy - // 0x11 message with a Subscribe Options field. - match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 => { - let msg = ietf::SubscribeNamespaceLegacy { - request_id, - namespace: prefix.clone(), - subscribe_options: ietf::SubscribeOptions::Namespace, - hidden, - }; - stream.writer.encode_message(&msg).await?; - } - _ => { - let msg = ietf::SubscribeNamespace { - request_id, - namespace: prefix.clone(), - hidden, - }; - stream.writer.encode_message(&msg).await?; - } - } + let msg = ietf::SubscribeNamespace { + request_id, + namespace: prefix.clone(), + hidden, + }; + stream.writer.encode_message(&msg).await?; tracing::debug!(%prefix, "subscribe_namespace sent"); @@ -1482,17 +1432,7 @@ impl Subscriber { let mut data = body.decoder(self.version); let count = match type_id { - ietf::SubscribeNamespaceOk::ID if self.version == Version::Draft14 => { - ietf::SubscribeNamespaceOk::decode_msg(&mut data, self.version)?; - None - } ietf::RequestOk::ID => ietf::RequestOk::decode_msg(&mut data, self.version)?.active, - ietf::SubscribeNamespaceError::ID if self.version == Version::Draft14 => { - let msg = ietf::SubscribeNamespaceError::decode_msg(&mut data, self.version)?; - let err = request::from_code(msg.error_code, request::Kind::SubscribeNamespace, self.version); - tracing::warn!(%err, reason = %msg.reason_phrase, "subscribe_namespace error"); - return Err(err); - } ietf::RequestError::ID => { let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; let err = request::from_code(msg.error_code, request::Kind::SubscribeNamespace, self.version); @@ -1549,10 +1489,8 @@ impl Subscriber { /// overlapping one can go out: a peer may refuse a prefix that overlaps one it still /// holds. /// - /// Draft-14 and 15 ride the control stream, whose virtual streams have no close of - /// their own, so UNSUBSCRIBE_NAMESPACE is all the peer ever sees, and anything after - /// it on that ordered stream reaches the peer later. Later drafts close the stream - /// (a FIN on draft-18, a reset on draft-22) and read until the peer closes its half. + /// The stream closes (a FIN on draft-18, a reset on draft-22) and is read until the peer + /// closes its half. async fn unsubscribe_namespace(&self, request: NamespaceRequest) { let NamespaceRequest { stream: Stream { mut writer, mut reader }, @@ -1562,23 +1500,10 @@ impl Subscriber { .. } = request; // Nothing went out, so there is nothing to end. - let Some(request_id) = request_id else { - return; - }; - tracing::debug!(%prefix, "unsubscribe_namespace"); - - if matches!(self.version, Version::Draft14 | Version::Draft15) { - let msg = ietf::UnsubscribeNamespace { prefix, request_id }; - let written = async { - writer.varint(ietf::UnsubscribeNamespace::ID).await?; - writer.encode(&msg).await - }; - if let Err(err) = written.await { - tracing::debug!(%err, "failed to write unsubscribe_namespace"); - } - let _ = writer.close().await; + if request_id.is_none() { return; } + tracing::debug!(%prefix, "unsubscribe_namespace"); match super::request_stream::fin_cancels(self.version) { true => { @@ -1740,7 +1665,7 @@ impl Subscriber { if !data.is_empty() { return Err(Error::WrongSize); } - return Ok(this.reject_token(stream, request::Kind::Publish, body.clone(), err)); + return Ok(this.reject_token(stream, request::Kind::Publish, err)); } msg => msg?, }; @@ -1768,7 +1693,7 @@ impl Subscriber { if !data.is_empty() { return Err(Error::WrongSize); } - return Ok(this.reject_token(stream, request::Kind::PublishNamespace, body.clone(), err)); + return Ok(this.reject_token(stream, request::Kind::PublishNamespace, err)); } msg => msg?, }; @@ -1818,12 +1743,11 @@ impl Subscriber { &self, stream: Stream, kind: request::Kind, - body: ietf::Body, err: DecodeError, ) -> MaybeSendBox<'static, ()> { let version = self.version; async move { - if let Err(err) = super::token::reject(stream, kind, body, err, version).await { + if let Err(err) = super::token::reject(stream, kind, err, version).await { tracing::debug!(%err, "token refusal failed"); } } @@ -1847,18 +1771,11 @@ impl Subscriber { /// cannot have advertised before reading our SETUP, since our SETUP is what says whether /// advertising unasked is allowed. So this is a bug in the peer, and a silent one on /// both sides if we tolerate it. - /// - /// Draft-14/15 are exempt: they have no inline NAMESPACE, so a PUBLISH_NAMESPACE - /// request is also how a peer answers our SUBSCRIBE_NAMESPACE there, and the message - /// alone does not say which it is. fn unsolicited_is_a_violation(&self, declared: Option) -> bool { if !self.solicit { return false; } - match self.version { - Version::Draft14 | Version::Draft15 => false, - _ => declared.is_some(), - } + declared.is_some() } /// Handle an incoming PUBLISH_NAMESPACE on its bidi stream. @@ -1901,8 +1818,7 @@ impl Subscriber { if let Some(error) = super::namespace::refusal(&msg.track_namespace, self.version) { return super::request_stream::reject(stream, error).await; } - let request_id = msg.request_id; - let path = msg.track_namespace.clone(); + let path = msg.track_namespace.to_owned(); if self.unsolicited_is_a_violation(declared) { tracing::warn!(%path, "unsolicited publish_namespace from a peer that implements MoQ Solicit"); @@ -1914,13 +1830,8 @@ impl Subscriber { let epoch = msg.params.epoch.clone(); let Some(advert) = self.route(msg.params.cluster.as_ref(), &peer) else { tracing::debug!(%path, "dropping reflected publish_namespace"); - self.write_error( - &mut stream, - request_id, - &Error::Unroutable, - "route loops through this relay", - ) - .await?; + self.write_error(&mut stream, &Error::Unroutable, "route loops through this relay") + .await?; let _ = stream.writer.close().await; return Ok(()); }; @@ -1972,8 +1883,7 @@ impl Subscriber { match admitted { Ok(grant) => (Admit::Token(slot), Some(grant)), Err(refusal) => { - self.write_error(&mut stream, request_id, &refusal.error, &refusal.reason) - .await?; + self.write_error(&mut stream, &refusal.error, &refusal.reason).await?; let _ = stream.writer.close().await; return Ok(()); } @@ -1990,16 +1900,15 @@ impl Subscriber { let lived = crate::auth::RequestGrant::guard(token_grant.as_ref(), async { match self.start_announce(path.clone(), advert, admit) { Ok(id) => attached = Some(id), - Err(err) => return Ok(Withdraw::Reject(request_id, err.into())), + Err(err) => return Ok(Withdraw::Reject(err.into())), } // Buffered whole before its first await, so however this is dropped from here, the // advertisement can still end on the wire. acked = true; - self.write_ok(&mut stream, request_id).await?; + self.write_ok(&mut stream).await?; // An endpoint updates an advertisement with REQUEST_UPDATE on the stream that - // already carries it, so keep reading until the stream ends: a close on - // draft-17+, or v14-16's PublishNamespaceDone (see `terminal_publish_namespace`). + // already carries it, so keep reading until the stream closes. self.run_publish_namespace_updates( &mut stream, &path, @@ -2025,13 +1934,11 @@ impl Subscriber { Err(refusal) => { tracing::info!(%path, err = %refusal.error, reason = %refusal.reason, "request token grant ended"); if !acked { - self.write_error(&mut stream, request_id, &refusal.error, &refusal.reason) - .await?; + self.write_error(&mut stream, &refusal.error, &refusal.reason).await?; let _ = stream.writer.close().await; return Ok(()); } - self.cancel_announce(&mut stream, request_id, &path, &refusal.error, &refusal.reason) - .await?; + self.cancel_announce(&mut stream).await?; return Err(refusal.error); } }; @@ -2040,38 +1947,18 @@ impl Subscriber { Withdraw::Close => { let _ = stream.writer.close().await; } - Withdraw::Reject(id, refusal) => { - self.write_error(&mut stream, id, &refusal.error, &refusal.reason) - .await?; + Withdraw::Reject(refusal) => { + self.write_error(&mut stream, &refusal.error, &refusal.reason).await?; let _ = stream.writer.close().await; } - Withdraw::Cancel(id, refusal) => { - self.write_error(&mut stream, id, &refusal.error, &refusal.reason) - .await?; - self.cancel_announce(&mut stream, request_id, &path, &refusal.error, &refusal.reason) - .await?; + Withdraw::Cancel(refusal) => { + self.write_error(&mut stream, &refusal.error, &refusal.reason).await?; + self.cancel_announce(&mut stream).await?; } } Ok(()) } - /// Whether `type_id` retracts a PUBLISH_NAMESPACE rather than updating it. - /// - /// v14-16 carry the stream over the control stream, and the adapter delivers the - /// terminal message *before* it FINs (`Route::CloseStream`), so the withdrawal - /// arrives here as a message and only then as a close. Draft-17+ has a real stream, - /// where the close alone retracts and a terminal message on it is a violation. - /// - /// Only PUBLISH_NAMESPACE_DONE: the publisher sends that one. PUBLISH_NAMESPACE_CANCEL - /// travels the other way, so receiving it on an advertisement *we* were offered is a - /// violation, not a withdrawal. - fn terminal_publish_namespace(&self, type_id: u64) -> bool { - match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => type_id == ietf::PublishNamespaceDone::ID, - _ => false, - } - } - /// Read advertisement updates off a live PUBLISH_NAMESPACE stream until it closes, and /// return how it ends, which the caller writes once the route is released. /// @@ -2109,7 +1996,7 @@ impl Subscriber { Message(u64, ietf::Body), // The peer finished its send direction. Fin, - // Draft-17+: our send direction closed (the peer stopped reading or reset it). + // Our send direction closed (the peer stopped reading or reset it). WriterClosed(Result<(), Error>), // The read failed. Failed(Error), @@ -2124,12 +2011,9 @@ impl Subscriber { let turn = if let Some((id, body)) = renewal.next() { Turn::Message(id, body) } else { - let version = self.version; kio::wait(|waiter| { let mut cx = waiter.context(); - if !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - && let Poll::Ready(result) = stream.writer.poll_closed(&mut cx) - { + if let Poll::Ready(result) = stream.writer.poll_closed(&mut cx) { return Poll::Ready(Turn::WriterClosed(result)); } if let Poll::Ready(renewed) = renewal.poll(waiter) { @@ -2154,25 +2038,21 @@ impl Subscriber { let (type_id, body) = match turn { // A non-terminal message read while a verdict is pending waits for it. - Turn::Message(id, body) if renewal.pending() && !self.terminal_publish_namespace(id) => { + Turn::Message(id, body) if renewal.pending() => { if id == ietf::PublishNamespaceUpdate::ID { - // Its request ID is spent on arrival, so one dropped with the queue stays spent. - if self.version != Version::Draft16 { - let update_id = ietf::RequestId::decode(&mut body.decoder(self.version), self.version)?; - if let Err(err) = self.spend(update_id) { - if matches!(err, Error::GoingAway) { - return Ok(Withdraw::Cancel( - update_id, - crate::auth::Refusal { - error: err, - reason: "going away".to_string(), - }, - )); - } - return Err(err); + // Its request ID is spent on arrival, so one dropped with the queue still moves + // GOAWAY's cutoff past it. + let update_id = ietf::RequestId::decode(&mut body.decoder(self.version), self.version)?; + if let Err(err) = self.spend(update_id) { + if matches!(err, Error::GoingAway) { + return Ok(Withdraw::Cancel(crate::auth::Refusal { + error: err, + reason: "going away".to_string(), + })); } - spent.insert(update_id); + return Err(err); } + spent.insert(update_id); // Decoded on arrival too, so a session-fatal update still closes the session // when a withdrawal drops the queue. A token refusal only refuses this // announce, which waits for the update's turn. @@ -2199,8 +2079,6 @@ impl Subscriber { Err(request_update::Overflow::Request) => return Ok(Withdraw::Close), } } - // A terminal read while a verdict is pending is handled now, ahead of any queued - // update: nothing more is owed once the peer retracts. Turn::Message(id, body) => (id, body), Turn::Fin => { if super::request_stream::fin_cancels(self.version) { @@ -2221,34 +2099,22 @@ impl Subscriber { return Ok(Withdraw::Done); } Turn::Failed(err) => return Err(err), - Turn::Renewal(request_update::Verdict { - verdict, - grant: res, - request_id: rid, - }) => { + Turn::Renewal(request_update::Verdict { verdict, grant: res }) => { // Only a token-authorized announce starts a renewal. let rg = token_grant.as_ref().expect("a renewal without a request grant"); // On accept the old grant is dropped and the deadline re-armed (REQUEST_OK). let Err(refusal) = rg.renew(verdict, res) else { - self.write_ok(stream, rid).await?; + self.write_ok(stream).await?; continue; }; - // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused or uncovered + // Per draft-18 section 10.9.1, a refused or uncovered // renewal ends only this announce. Answer REQUEST_ERROR, then close the stream; // the session stays up and the old grant does not survive. - return Ok(Withdraw::Cancel(rid, refusal)); + return Ok(Withdraw::Cancel(refusal)); } }; - // Drafts 14-15 have no update for an advertisement, so a SUBSCRIBE_UPDATE the adapter - // routed here is refused and changes nothing. Draft-16's REQUEST_UPDATE renews its token. - if type_id == ietf::SubscribeUpdate::ID && matches!(self.version, Version::Draft14 | Version::Draft15) { - super::request_stream::refuse_update(&mut stream.writer, &body, self.version)?; - std::future::poll_fn(|cx| stream.writer.poll_flush(cx)).await?; - continue; - } - let terminal = self.terminal_publish_namespace(type_id); - if type_id != ietf::PublishNamespaceUpdate::ID && !terminal { + if type_id != ietf::PublishNamespaceUpdate::ID { // A repeated PUBLISH_NAMESPACE lands here too: a second request on the // stream is the base draft's duplicate request ID. tracing::warn!(type_id, "unexpected message on publish_namespace stream"); @@ -2257,31 +2123,17 @@ impl Subscriber { let mut data = body.decoder(self.version); - if terminal { - ietf::PublishNamespaceDone::decode_msg(&mut data, self.version)?; - if !data.is_empty() { - return Err(Error::WrongSize); - } - tracing::debug!(%path, "publish_namespace_done"); - return Ok(Withdraw::Done); - } - - // An update spends its own request ID, even one refused below. On draft-16 the - // adapter already spent it when it routed the update here, and a queued one was + // An update spends its own request ID, even one refused below. A queued one was // spent when it arrived. let update_id = ietf::RequestId::decode(&mut body.decoder(self.version), self.version)?; - if self.version != Version::Draft16 - && !spent.remove(&update_id) + if !spent.remove(&update_id) && let Err(err) = self.spend(update_id) { if matches!(err, Error::GoingAway) { - return Ok(Withdraw::Cancel( - update_id, - crate::auth::Refusal { - error: err, - reason: "going away".to_string(), - }, - )); + return Ok(Withdraw::Cancel(crate::auth::Refusal { + error: err, + reason: "going away".to_string(), + })); } return Err(err); } @@ -2297,7 +2149,7 @@ impl Subscriber { reason: super::token::reason(&err).to_string(), error: Error::Decode(err), }; - return Ok(Withdraw::Cancel(update_id, refusal)); + return Ok(Withdraw::Cancel(refusal)); } msg => msg?, }; @@ -2363,7 +2215,7 @@ impl Subscriber { // The close is the withdrawal. if let Err(err) = applied { tracing::warn!(%path, %err, "publish_namespace update refused"); - return Ok(Withdraw::Reject(msg.request_id, err.into())); + return Ok(Withdraw::Reject(err.into())); } // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant @@ -2374,7 +2226,7 @@ impl Subscriber { if let Some(token) = msg.authorization_token { match &token_grant { Some(grant) => { - renewal.verify(grant.verify_renewal(&self.auth, token.0), msg.request_id); + renewal.verify(grant.verify_renewal(&self.auth, token.0)); continue; } // Where tokens decide, a token on an announce admitted without one is refused @@ -2384,14 +2236,14 @@ impl Subscriber { error: Error::Unsupported, reason: "AUTHORIZATION TOKEN on an announce admitted without one".to_string(), }; - return Ok(Withdraw::Cancel(msg.request_id, refusal)); + return Ok(Withdraw::Cancel(refusal)); } None => {} } } // No token rides this update: acknowledge it now. - self.write_ok(stream, msg.request_id).await?; + self.write_ok(stream).await?; } } @@ -2404,27 +2256,8 @@ impl Subscriber { Ok(()) } - /// End an advertisement we accepted, leaving the session up: a closed stream from draft-17, - /// and PUBLISH_NAMESPACE_CANCEL before it, where the request outlives its stream. - async fn cancel_announce( - &self, - stream: &mut Stream, - request_id: RequestId, - path: &Path, - err: &Error, - reason: &str, - ) -> Result<(), Error> { - if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - stream - .writer - .encode_message(&ietf::PublishNamespaceCancel { - track_namespace: path.clone(), - request_id, - error_code: request::to_code(err, request::Kind::PublishNamespace, self.version), - reason_phrase: reason.into(), - }) - .await?; - } + /// End an advertisement we accepted, leaving the session up: a closed stream. + async fn cancel_announce(&self, stream: &mut Stream) -> Result<(), Error> { // An OK cut short by the grant ending is still buffered, and goes out ahead of the FIN. std::future::poll_fn(|cx| stream.writer.poll_flush(cx)).await?; if stream.writer.finish().is_ok() { @@ -2461,13 +2294,8 @@ impl Subscriber { // it, and a rejected request has no lifetime of ours to hang the cleanup on, so the // entry would have to be swept asynchronously. Any data streams the publisher opened // before reading this are dropped by the unknown-alias path instead. - self.write_publish_error( - &mut stream, - msg.request_id, - &Error::Unsupported, - "PUBLISH is not supported", - ) - .await?; + self.write_publish_error(&mut stream, &Error::Unsupported, "PUBLISH is not supported") + .await?; // The rejection is the whole exchange, but it still has to arrive: a finish alone // leaves the drop-time reset free to discard it before the peer acknowledges it. let _ = stream.writer.close().await; @@ -2491,35 +2319,14 @@ impl Subscriber { Ok(()) } - async fn write_ok(&self, stream: &mut Stream, request_id: RequestId) -> Result<(), Error> { - match self.version { - Version::Draft14 => { - stream - .writer - .encode_message(&ietf::PublishNamespaceOk { request_id }) - .await?; - } - Version::Draft15 | Version::Draft16 => { - stream - .writer - .encode_message(&ietf::RequestOk { - largest: None, - request_id: Some(request_id), - active: None, - }) - .await?; - } - _ => { - stream - .writer - .encode_message(&ietf::RequestOk { - largest: None, - request_id: None, - active: None, - }) - .await?; - } - } + async fn write_ok(&self, stream: &mut Stream) -> Result<(), Error> { + stream + .writer + .encode_message(&ietf::RequestOk { + largest: None, + active: None, + }) + .await?; Ok(()) } @@ -2527,14 +2334,12 @@ impl Subscriber { async fn write_error( &self, stream: &mut Stream, - request_id: RequestId, err: &Error, reason: &str, ) -> Result<(), Error> { super::request_stream::write_error( &mut stream.writer, request::Kind::PublishNamespace, - request_id, err, reason, self.version, @@ -2546,19 +2351,10 @@ impl Subscriber { async fn write_publish_error( &self, stream: &mut Stream, - request_id: RequestId, err: &Error, reason: &str, ) -> Result<(), Error> { - super::request_stream::write_error( - &mut stream.writer, - request::Kind::Publish, - request_id, - err, - reason, - self.version, - ) - .await + super::request_stream::write_error(&mut stream.writer, request::Kind::Publish, err, reason, self.version).await } /// Attach the route for one newly advertised namespace, returning the id that names @@ -3013,12 +2809,7 @@ impl Subscriber { if !matches!(lister.poll_used(waiter), Poll::Ready(Ok(()))) { return; } - if !super::subscribe_tracks::has_subscribe_tracks(self.version) { - // An empty answer would claim the peer lists nothing. - lister.abort(Error::Unsupported); - continue; - } - *listening = Some(self.listen(path.clone(), lister.clone())); + *listening = Some(self.listen(path.to_owned(), lister.clone())); } Some(_) if lister.poll_unused(waiter).is_ready() => { *listening = None; @@ -3086,14 +2877,12 @@ impl Subscriber { // Demand with nobody subscribing (fetches, or a TRACK_STATUS downstream) learns the // track from TRACK_STATUS rather than a SUBSCRIBE: a finished track refuses one, and - // one made only to learn the track races the fetches it was for. Draft-17 still - // SUBSCRIBEs: its TRACK_STATUS answer cannot say whether the track is timed. + // one made only to learn the track races the fetches it was for. let mut target = match request.subscription() { subscription - if !subscription + if subscription .as_ref() - .is_some_and(|subscription| !subscription.ranges.is_empty()) - && ietf::TrackStatusOk::describes_track(self.version) => + .is_none_or(|subscription| subscription.ranges.is_empty()) => { let Some(idle) = self .track_status(&broadcast_path, &track_name, request, &mut gate) @@ -3145,17 +2934,7 @@ impl Subscriber { .subscription() .map(|subscription| match target.producer() { Some(track) => resolved.resolve(track, &subscription), - None if needs_resolution(&subscription) => { - let time = subscription.ranges.iter().any(|range| { - matches!(range.start, track::Anchor::Time(_)) - || matches!(range.end, Some(track::Anchor::Time(_))) - }); - Err(if time && !ietf::Properties::sends_timescale(self.version) { - Error::TimestampMismatch - } else { - Error::Unsupported - }) - } + None if needs_resolution(&subscription) => Err(Error::Unsupported), None => Ok(subscription), }) .transpose(); @@ -3237,31 +3016,14 @@ impl Subscriber { }) .await { - Ok(stream) => super::request_stream::with_stats(stream, self.origin.stats(), self.version), + Ok(stream) => stream.with_stats(self.origin.stats()), Err(err) => { target.fail(err); return None; } }; - // Opening spends no ID. Once allocated, drafts 14-16 must receive the request - // to return its MAX_REQUEST_ID credit; the response wait cancels it instead. - let mut timeout = crate::time::Deadline::after(&self.runtime, Control::REQUEST_ID_TIMEOUT); - let request_id = kio::wait(|waiter| { - if gate.poll_denied(waiter).is_ready() { - return Poll::Ready(Err(Error::Unauthorized)); - } - validity.check(waiter)?; - self.control.poll_credit(waiter, &mut timeout) - }) - .await; - let request_id = match request_id { - Ok(id) => id, - Err(err) => { - target.fail(err); - return None; - } - }; + let request_id = self.control.next_request_id(); let messages = super::request_stream::Messages::new(self.session.clone(), self.control.client()); (request_id, stream, messages) } @@ -3308,9 +3070,7 @@ impl Subscriber { max_age_supported.then(|| super::max_age::millis(aggregate.as_ref().map_or(Duration::MAX, |s| s.budget()))); let order = aggregate.map(|s| s.order).unwrap_or_default(); // Without an Established subscription, a relay includes the request it holds. - let new_group = target - .new_group() - .filter(|_| super::subscribe::has_new_group_request(self.version)); + let new_group = target.new_group(); let written = match &held { // Resume the held subscription: its group order is the one it was made with, // since no update can change it. Its groups wait on the answer from here. @@ -3494,7 +3254,7 @@ impl Subscriber { // The publisher may already be serving before it answers. A session abort // already rejected the parked request; dropping what remains is not a second one. let aborted = self.remove_subscribe(request_id).is_none(); - self.cancel_subscribe(stream, request_id).await; + self.cancel_subscribe(stream).await; // A resumed copy goes back to lingering, unless the session took it. return resumed.filter(|_| !aborted); }; @@ -3520,7 +3280,7 @@ impl Subscriber { if let Some(idle) = resumed { let _ = idle.track.abort(err); } - self.cancel_subscribe(stream, request_id).await; + self.cancel_subscribe(stream).await; return None; } Ok(Some(accepted)) => accepted, @@ -3558,7 +3318,7 @@ impl Subscriber { let _ = idle.track.abort(err); } if cancel { - self.cancel_subscribe(stream, request_id).await; + self.cancel_subscribe(stream).await; } return None; } @@ -3621,9 +3381,7 @@ impl Subscriber { } }; track.received(); - if ietf::Properties::sends_timescale(self.version) { - track.anchored_cached(); - } + track.anchored_cached(); // Object IDs can start past zero or skip, while readers see frame ordinals, so only // the group carries over: its first frame is the edge a reader can count on. if let Some(largest) = largest { @@ -3661,7 +3419,7 @@ impl Subscriber { ); } else { tracing::warn!(track_alias = %alias, %err, "could not bind track alias"); - self.cancel_subscribe(stream, request_id).await; + self.cancel_subscribe(stream).await; } self.remove_subscribe(request_id); let _ = track.abort(err); @@ -3686,7 +3444,7 @@ impl Subscriber { // The NEW_GROUP_REQUEST last sent, the SUBSCRIBE's included. It stays outstanding // until the Largest Group reaches it, and joins meanwhile ask for the same group. let mut requested = new_group; - let dynamic_groups = track.info().refresh.is_some() && super::subscribe::has_new_group_request(self.version); + let dynamic_groups = track.info().refresh.is_some(); // The group the track's consumers want, `None` once nobody does. let mut wanted = None; // The subscriber priority upstream holds, and the one the aggregate now asks for. @@ -3696,17 +3454,8 @@ impl Subscriber { let mut priority = priority; let mut sent_bounds = (start, subscription.as_ref().and_then(|subscription| subscription.end())); let mut wanted_bounds = Ok(sent_bounds); - // Draft-14's SUBSCRIBE_UPDATE repeats the bounds it leaves alone, and must not move the - // start. A Largest Object subscription starts just past the answer's Largest. - let start_location = largest.map_or(ietf::Location { group: 0, object: 0 }, |largest| ietf::Location { - group: largest.group, - object: largest.object.saturating_add(1), - }); - // REQUEST_UPDATEs sent and not yet answered. Draft-14 answers none, and draft-15's - // answer names only the update's own Request ID, which reaches no subscription, so - // only draft-16 on count them; the adapter routes draft-16's back by that ID. + // REQUEST_UPDATEs sent and not yet answered. let unanswered = AtomicU64::new(0); - let answered_here = !matches!(self.version, Version::Draft14 | Version::Draft15); let mut fetch_done = fetching.is_none(); // Our grant stopped covering the track: it is aborted rather than kept lingering. @@ -3773,11 +3522,9 @@ impl Subscriber { .is_some_and(|subscription| !subscription.ranges.is_empty()); if let Some(subscription) = subscription { priority = subscription.priority; - if self.version != Version::Draft14 { - wanted_bounds = resolved - .resolve(&track, &subscription) - .map(|subscription| wire_bounds(&subscription)); - } + wanted_bounds = resolved + .resolve(&track, &subscription) + .map(|subscription| wire_bounds(&subscription)); if max_age_supported { wanted_max_age = Some(super::max_age::millis(subscription.budget())); } @@ -3877,10 +3624,9 @@ impl Subscriber { } else { None }; - let mut update = Update { + let update = Update { filter: ranges.as_ref().map(|join| join.filter), fill: ranges.and_then(|join| join.fill), - start_location, priority: (priority != sent_priority).then_some(priority), max_age: wanted_max_age.filter(|_| wanted_max_age != sent_max_age), new_group, @@ -3892,11 +3638,6 @@ impl Subscriber { { return Poll::Pending; } - // Draft-14's SUBSCRIBE_UPDATE cannot omit the priority, so it restates the one - // in force rather than resetting it to the omission default. - if self.version == Version::Draft14 { - update.priority = Some(priority); - } Poll::Ready(End::Update(update)) }) .await; @@ -3931,9 +3672,7 @@ impl Subscriber { if let Some(value) = new_group { requested = Some(value); } - if answered_here { - unanswered.fetch_add(1, Ordering::Relaxed); - } + unanswered.fetch_add(1, Ordering::Relaxed); } End::Revoked => { tracing::info!(broadcast = %crate::path::Rooted(self.origin.root(), broadcast_path), track = %track_name, "subscription no longer authorized"); @@ -4079,7 +3818,7 @@ impl Subscriber { { // Refusing the copy wakes its FETCH jobs too. Keep driving their cancels // while this stream waits for its FIN, even if the peer never ACKs it. - let mut cancelling = std::pin::pin!(self.cancel_subscribe(stream, request_id)); + let mut cancelling = std::pin::pin!(self.cancel_subscribe(stream)); kio::wait(|waiter| { let _ = group_fetches.poll(waiter); waiter.poll_future(cancelling.as_mut()) @@ -4246,15 +3985,15 @@ impl Subscriber { } } - /// Update an Established subscription: REQUEST_UPDATE (SUBSCRIBE_UPDATE through draft-16) - /// carrying only what changed, under a Request ID of its own. + /// Update an Established subscription: REQUEST_UPDATE carrying only what changed, under a + /// Request ID of its own. async fn write_update( &self, writer: &mut Writer, subscription: RequestId, update: Update, ) -> Result<(), Error> { - let request_id = self.control.next_request_id(&self.runtime).await?; + let request_id = self.control.next_request_id(); if update.fill.is_some() { let mut state = self.state.lock(); let held = state.subscribes.get_mut(&subscription).ok_or(Error::Cancel)?; @@ -4273,11 +4012,6 @@ impl Subscriber { .encode_message(&ietf::SubscribeUpdate { max_age: update.max_age, request_id, - subscription_request_id: matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(subscription), - start_location: update.start_location, - // Open ended, as every subscription we send. - end_group: 0, subscriber_priority: update.priority.map(super::priority::to_wire), forward: None, filter: update.filter, @@ -4294,52 +4028,26 @@ impl Subscriber { /// staying silent is what leaves the publisher serving a track nobody is reading and /// feeding an alias we already retired. /// - /// Two mechanisms, by version. Draft-14 through 16 carry requests over the control - /// stream adapter, whose virtual streams have no reset or stop of their own, so - /// UNSUBSCRIBE (draft-16 section 9.12) is the only thing the peer ever sees, and - /// draft-16 section 5.1.1 makes receiving it what frees the subscription. Draft-17 - /// removed the message, leaving the stream itself: a FIN is explicitly not a + /// There is no UNSUBSCRIBE message, only the stream itself: a FIN is explicitly not a /// cancellation (draft-22 section 6.4.2.2), so section 6.4.2.3's pair applies, an endpoint /// that has already FINed its sending direction cancels with STOP_SENDING on the /// receiving one. - async fn cancel_subscribe(&self, stream: Stream, request_id: RequestId) { - let Stream { mut writer, mut reader } = stream; - - if self.unsubscribes() - && let Err(err) = self.write_unsubscribe(&mut writer, request_id).await - { - tracing::debug!(%err, "failed to write unsubscribe"); - } + async fn cancel_subscribe(&self, stream: Stream) { + let Stream { writer, mut reader } = stream; // STOP_SENDING needs no acknowledgement, so it goes first and the wait below covers // only what we still have to deliver. reader.abort(&Error::Cancel); // Finishing alone would leave the writer's Drop free to RESET_STREAM, and a stream - // that has sent its FIN is still retransmitting: the reset would discard the - // UNSUBSCRIBE before the peer ever read it, which is the whole message. Closing - // consumes the writer, removing that fallback, and waits for the acknowledgement. + // that has sent its FIN is still retransmitting: the reset would discard what we + // already wrote. Closing consumes the writer, removing that fallback, and waits for + // the acknowledgement. if let Err(err) = writer.close().await { tracing::debug!(%err, "failed to close the subscribe stream"); } } - /// Whether this version cancels a subscription with an UNSUBSCRIBE message. - /// - /// Draft-17 removed it, leaving the stream reset as the only signal. - fn unsubscribes(&self) -> bool { - matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) - } - - async fn write_unsubscribe( - &self, - writer: &mut crate::coding::Writer, - request_id: RequestId, - ) -> Result<(), Error> { - writer.encode_message(&ietf::Unsubscribe { request_id }).await?; - Ok(()) - } - /// Send the joining FETCH that follows a draft-18 SUBSCRIBE, and keep reading its /// answer until the subscription ends. /// @@ -4371,20 +4079,8 @@ impl Subscriber { Stream::poll_open(&mut opening, this.version, &mut waiter.context()) }) .await?; - stream = super::request_stream::with_stats(stream, this.origin.stats(), this.version); - let mut timeout = crate::time::Deadline::after(&this.runtime, Control::REQUEST_ID_TIMEOUT); - let fetch_id = kio::wait(|waiter| { - validity.check(waiter)?; - if let Poll::Ready(err) = track.poll_aborted(waiter) { - return Poll::Ready(Err(err)); - } - this.control.poll_credit(waiter, &mut timeout) - }) - .await?; - // On the shared control streams of drafts 14-16, later IDs must - // not overtake this header even if its subscription cancels mid-write. - let sending = matches!(this.version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then(|| this.control.sending()); + stream = stream.with_stats(this.origin.stats()); + let fetch_id = this.control.next_request_id(); progress.lock().committed = true; { let mut state = this.state.lock(); @@ -4418,17 +4114,8 @@ impl Subscriber { .await { tracing::debug!(%err, "failed to write joining FETCH"); - if matches!(this.version, Version::Draft14 | Version::Draft15) { - // A sequential-ID peer cannot accept any later request when this - // header failed before it learned the allocated ID. - this.session.close( - super::error::to_session_code(&SessionError::Internal), - "failed to send joining FETCH", - ); - } return Err(err); } - drop(sending); this.finish_joining_fetch(stream, progress).await } .await; @@ -4490,10 +4177,7 @@ impl Subscriber { }; // The request stays open while the subscription still needs its fill. From draft 18 // it may carry a GOAWAY meanwhile; before that, nothing follows the answer here. - let mut finished = matches!( - self.version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ); + let mut finished = false; kio::wait(|waiter| { if !finished { let mut cx = waiter.context(); @@ -4532,17 +4216,6 @@ impl Subscriber { largest: msg.largest, })) } - // The rejection reaches the track as the reason the publisher gave, so a - // subscriber can tell a broadcast that is not there from one it may not have. - ietf::SubscribeError::ID if self.version == Version::Draft14 => { - let msg = ietf::SubscribeError::decode_msg(&mut data, self.version)?; - tracing::warn!(message = ?msg, "subscribe error"); - Err(request::from_code( - msg.error_code, - request::Kind::Subscribe, - self.version, - )) - } ietf::RequestError::ID => { let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; tracing::warn!(message = ?msg, "request error"); @@ -4583,23 +4256,8 @@ impl Subscriber { return None; } }; - stream = super::request_stream::with_stats(stream, self.origin.stats(), self.version); - let mut timeout = crate::time::Deadline::after(&self.runtime, Control::REQUEST_ID_TIMEOUT); - let request_id = match kio::wait(|waiter| { - if gate.poll_denied(waiter).is_ready() { - return Poll::Ready(Err(Error::Unauthorized)); - } - validity.check(waiter)?; - self.control.poll_credit(waiter, &mut timeout) - }) - .await - { - Ok(id) => id, - Err(err) => { - request.reject(err); - return None; - } - }; + stream = stream.with_stats(self.origin.stats()); + let request_id = self.control.next_request_id(); let written = async { stream @@ -4692,9 +4350,7 @@ impl Subscriber { let track = request.idle().stage(accepted_info(&ok.properties, self.version)); // Marked received before any reader sees it, so none resolves an anchor this wire dropped. track.received(); - if ietf::Properties::sends_timescale(self.version) { - track.anchored_cached(); - } + track.anchored_cached(); if let Some(largest) = ok.largest { track.report_object(largest.group, largest.object); } @@ -4719,15 +4375,10 @@ impl Subscriber { let mut data = body.decoder(self.version); let code = match type_id { - id if id == ietf::TrackStatusOk::id(self.version) => { + id if id == ietf::TrackStatusOk::ID => { return Ok(ietf::TrackStatusOk::decode_msg(&mut data, self.version)?); } - ietf::TRACK_STATUS_ERROR_14 if self.version == Version::Draft14 => { - ietf::SubscribeError::decode_msg(&mut data, self.version)?.error_code - } - ietf::RequestError::ID if self.version != Version::Draft14 => { - ietf::RequestError::decode_msg(&mut data, self.version)?.error_code - } + ietf::RequestError::ID if true => ietf::RequestError::decode_msg(&mut data, self.version)?.error_code, _ => return Err(Error::UnexpectedMessage), }; Err(request::from_code(code, request::Kind::TrackStatus, self.version)) @@ -4928,7 +4579,7 @@ impl Subscriber { let (payload, status) = match datagram.body { ietf::DatagramBody::Payload(payload) => (payload, None), - ietf::DatagramBody::Status(status) if super::object::status_defined(status, self.version) => { + ietf::DatagramBody::Status(status) if super::object::status_defined(status) => { (bytes::Bytes::new(), Some(status)) } ietf::DatagramBody::Status(_) => return Err(Error::Session(SessionError::ProtocolViolation)), @@ -4994,12 +4645,12 @@ impl Subscriber { /// Mark where the track ends, as an END_OF_TRACK object said. /// -/// Draft-14 on carries no end location in PUBLISH_DONE, so this is what lets a subscriber +/// PUBLISH_DONE carries no end location, so this is what lets a subscriber /// learn the end before the live edge reaches it. A boundary at or below a group already /// received is the publisher breaking its own end, which no later group can repair. A /// marker that lands after the subscription already ended (its grace expired) changes -/// nothing. Draft-14 lets the marker carry properties, but the end is track-level with -/// nowhere to keep them, so the track is refused rather than lose them. +/// nothing. A marker carrying properties has nowhere to keep them, since the end is +/// track-level, so the track is refused rather than lose them. fn end_track(track: &mut track::Producer, end: track::Position, properties: bool) -> Result<(), Error> { if properties { tracing::warn!(?end, "END_OF_TRACK properties are unsupported"); @@ -5487,7 +5138,7 @@ impl Subscriber { let mut skipped: Option<(u64, u64)> = None; let mut budget = kio::coop::Budget::new(32); let mut first = true; - while let Some(object) = decode_fetch_object(stream, self.version, std::mem::take(&mut first)).await? { + while let Some(object) = decode_fetch_object(stream, std::mem::take(&mut first)).await? { kio::wait(|waiter| budget.poll_yield(waiter)).await; if !object.datagram && !object.subgroup_ok { tracing::warn!("subgroup ID is not supported, dropping fill"); @@ -5495,7 +5146,7 @@ impl Subscriber { } // Object ID still keys off whether the wire Group ID field was present. - let group = resolve_fetch_group(self.version, prior_group, object.group)?; + let group = resolve_fetch_group(prior_group, object.group)?; if let (Some(prior), Some(group)) = (prior_group, group) && group < prior { @@ -5524,14 +5175,6 @@ impl Subscriber { }) .unwrap_or(0); let id = match (object.group.is_some(), object.object) { - (_, Some(id)) - if matches!( - self.version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) => - { - id - } (true, Some(id)) => id, (false, Some(delta)) => expected .checked_sub(1) @@ -5568,7 +5211,7 @@ impl Subscriber { } } if let Some((_, next)) = skipped.as_mut() { - let _ = self.skip_fetch_payload(stream).await?; + skip_fetch_payload(stream).await?; *next = id.checked_add(1).ok_or(Error::ProtocolViolation)?; continue; } @@ -5580,21 +5223,8 @@ impl Subscriber { wire.first_id = id; producer.set_wire(wire)?; } - if self - .recv_fetch_payload( - stream, - producer, - FetchPayload { - decoded, - raw, - timescale, - id, - }, - ) - .await? != Fetched::Frame - { - return Err(Error::Unsupported); - } + self.recv_fetch_payload(stream, producer, FetchPayload { decoded, timescale, id }) + .await?; *next = id.checked_add(1).ok_or(Error::ProtocolViolation)?; } @@ -5607,38 +5237,10 @@ impl Subscriber { stream: &mut Reader, producer: &mut crate::recv::Group, payload: FetchPayload, - ) -> Result { - let FetchPayload { - decoded, - raw, - timescale, - id, - } = payload; + ) -> Result<(), Error> { + let FetchPayload { decoded, timescale, id } = payload; producer.mark_gap(id, decoded.gap)?; let size = stream.varint().await?; - if size == 0 && matches!(self.version, Version::Draft14 | Version::Draft15) { - let status = stream.varint().await?; - // A status object has no frame to hold its time, so its block keeps it. - let unframed = match status { - 0 => None, - _ => { - let properties = super::object::unframed(&raw, self.version, timescale)?; - super::object::check_status(self.version, &properties)?; - Some(properties) - } - }; - match (status, unframed) { - (0, _) => {} - (END_OF_GROUP, Some(properties)) => { - producer.set_end_object(crate::model::object::Fields { id, properties })?; - return Ok(Fetched::EndOfGroup); - } - // END_OF_TRACK is track-level, so there is nowhere to keep its properties. - (END_OF_TRACK, Some(properties)) if !properties.is_empty() => return Err(Error::Unsupported), - (END_OF_TRACK, _) => return Ok(Fetched::EndOfTrack), - _ => return Err(Error::Unsupported), - } - } super::object::check_frame(&decoded.properties, timescale)?; let timestamp = object_time(timescale, decoded.timestamp)?; let plain = id == producer.frame_count() as u64 && decoded.properties.is_empty(); @@ -5659,7 +5261,7 @@ impl Subscriber { return Err(err); } producer.restore(frame.finish()?); - Ok(Fetched::Frame) + Ok(()) } /// Serve a fill a run at a time, newest run first for a descending reader, until it is @@ -5748,23 +5350,8 @@ impl Subscriber { Ok(stream) => stream, Err(err) => return fill.reject_groups(run, err), }; - stream = super::request_stream::with_stats(stream, self.origin.stats(), self.version); - let mut timeout = crate::time::Deadline::after(&self.runtime, Control::REQUEST_ID_TIMEOUT); - let fetch_id = match kio::wait(|waiter| { - validity.check(waiter)?; - if let Poll::Ready(err) = fill.poll_aborted(waiter) { - return Poll::Ready(Err(err)); - } - if fill.poll_unwanted_groups(run.clone(), waiter).is_ready() { - return Poll::Ready(Err(Error::Cancel)); - } - self.control.poll_credit(waiter, &mut timeout) - }) - .await - { - Ok(id) => id, - Err(err) => return fill.reject_groups(run, err), - }; + stream = stream.with_stats(self.origin.stats()); + let fetch_id = self.control.next_request_id(); // Registered before the FETCH goes out, since its stream can overtake FETCH_OK. let slot = kio::Producer::new(GroupFetch::Pending); @@ -5847,7 +5434,7 @@ impl Subscriber { SetupResponse::Cancelled(err) => { fill.reject_groups(run, err); drop(registered); - self.cancel_group_fetch(stream, fetch_id).await; + self.cancel_group_fetch(stream).await; return; } SetupResponse::Response(Err(err)) => { @@ -5940,12 +5527,7 @@ impl Subscriber { let reset = kio::wait(|waiter| { if open { let mut cx = std::task::Context::from_waker(waiter.waker()); - let closed = match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => { - super::request_stream::poll_legacy_end(&mut stream, self.version, &mut cx) - } - _ => messages.poll_end(&mut stream.reader, &mut cx), - }; + let closed = messages.poll_end(&mut stream.reader, &mut cx); match closed { Poll::Ready(Err(err)) => return Poll::Ready(Some(err)), Poll::Ready(Ok(())) => open = false, @@ -5993,7 +5575,7 @@ impl Subscriber { fill.reject_groups(covered, err); } drop(registered); - self.cancel_group_fetch(stream, fetch_id).await; + self.cancel_group_fetch(stream).await; return; } // A run already being written is the fetch stream's to settle; one still waiting for @@ -6009,23 +5591,9 @@ impl Subscriber { } /// Cancel a FETCH using the negotiated draft's existing cancellation signal. - async fn cancel_group_fetch(&self, mut stream: Stream, request_id: RequestId) { + async fn cancel_group_fetch(&self, mut stream: Stream) { stream.reader.abort(&Error::Cancel); - match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => { - // The adapter has no transport reset, so deliver FETCH_CANCEL without a - // subsequent writer drop discarding unacknowledged control bytes. - let res = async { - stream.writer.encode_message(&ietf::FetchCancel { request_id }).await?; - stream.writer.close().await - } - .await; - if let Err(err) = res { - tracing::debug!(%err, "failed to cancel group fetch"); - } - } - _ => stream.writer.abort(&Error::Cancel), - } + stream.writer.abort(&Error::Cancel) } /// Read the answer to a FETCH: FETCH_OK, or the publisher's refusal as an error. @@ -6039,10 +5607,6 @@ impl Subscriber { match type_id { ietf::FetchOk::ID => Ok(ietf::FetchOk::decode_msg(&mut data, self.version)?), - ietf::FetchError::ID if self.version == Version::Draft14 => { - let msg = ietf::FetchError::decode_msg(&mut data, self.version)?; - Err(request::from_code(msg.error_code, request::Kind::Fetch, self.version)) - } ietf::RequestError::ID => { let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; Err(request::from_code(msg.error_code, request::Kind::Fetch, self.version)) @@ -6129,21 +5693,14 @@ impl Subscriber { // The next Object ID expected in the current group. let mut next = 0; let mut prior_group = None; - // The current group's END_OF_GROUP arrived, or the track's END_OF_TRACK did. - let mut group_ended = false; - let mut track_ended = false; let mut first = true; - while let Some(object) = decode_fetch_object(stream, self.version, std::mem::take(&mut first)).await? { - if track_ended { - tracing::warn!("a group fetch continued past its end of track"); - return Err(Error::ProtocolViolation); - } + while let Some(object) = decode_fetch_object(stream, std::mem::take(&mut first)).await? { if !object.subgroup_ok && !object.datagram { tracing::warn!("subgroup ID is not supported, dropping group fetch"); return Err(Error::Unsupported); } - let group = resolve_fetch_group(self.version, prior_group, object.group)?; + let group = resolve_fetch_group(prior_group, object.group)?; if let Some(group) = group { prior_group = Some(group); } @@ -6151,24 +5708,12 @@ impl Subscriber { let sequence = group.or(writer.sequence()).ok_or(Error::ProtocolViolation)?; if writer.sequence() != Some(sequence) { next = writer.open(sequence, object.datagram)?; - group_ended = false; - } else if group_ended { - tracing::warn!(sequence, "a group fetch continued past its end marker"); - return Err(Error::ProtocolViolation); } else if object.datagram { // A datagram group has one object, so it never shares one with a stream. return Err(Error::ProtocolViolation); } let id = match (object.group.is_some(), object.object) { - (_, Some(id)) - if matches!( - self.version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) => - { - id - } (true, Some(id)) => id, (false, Some(delta)) => next .checked_sub(1) @@ -6185,32 +5730,15 @@ impl Subscriber { if id > next && decoded.gap.is_none_or(|gap| gap < id - next) { return Err(Error::Unsupported); } - let fetched = match writer.producer() { + match writer.producer() { Some(producer) => { - self.recv_fetch_payload( - stream, - producer, - FetchPayload { - decoded, - raw, - timescale, - id, - }, - ) - .await? + self.recv_fetch_payload(stream, producer, FetchPayload { decoded, timescale, id }) + .await? } // Cached already, or a datagram: read past it. - None => self.skip_fetch_payload(stream).await?, - }; - match fetched { - Fetched::Frame => next = id.checked_add(1).ok_or(Error::ProtocolViolation)?, - // The status is an object of the group, so the End Location counts it. - Fetched::EndOfGroup => { - next = id.checked_add(1).ok_or(Error::ProtocolViolation)?; - group_ended = true; - } - Fetched::EndOfTrack => track_ended = true, + None => skip_fetch_payload(stream).await?, } + next = id.checked_add(1).ok_or(Error::ProtocolViolation)?; if writer.bound(sequence).is_some_and(|end| next > end) { tracing::warn!(sequence, "a group fetch continued past FETCH_OK's End Location"); return Err(Error::ProtocolViolation); @@ -6232,49 +5760,25 @@ impl Subscriber { Ok(()) } - - /// Read past one fetch object's payload, for a group this fetch does not write. - async fn skip_fetch_payload( - &self, - stream: &mut Reader, - ) -> Result { - let size = stream.varint().await?; - if size == 0 && matches!(self.version, Version::Draft14 | Version::Draft15) { - return match stream.varint().await? { - 0 => Ok(Fetched::Frame), - END_OF_GROUP => Ok(Fetched::EndOfGroup), - END_OF_TRACK => Ok(Fetched::EndOfTrack), - _ => Err(Error::Unsupported), - }; - } - let mut left = size; - let mut budget = kio::coop::Budget::new(32); - while left > 0 { - kio::wait(|waiter| budget.poll_yield(waiter)).await; - let max = usize::try_from(left).unwrap_or(usize::MAX); - let chunk = std::future::poll_fn(|cx| stream.poll_read_chunk(cx, max)) - .await? - .ok_or(Error::ProtocolViolation)?; - left -= chunk.len() as u64; - } - Ok(Fetched::Frame) - } } -/// What a fetch object carried. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Fetched { - Frame, - /// A draft-14 or 15 END_OF_GROUP status object. - EndOfGroup, - /// A draft-14 or 15 END_OF_TRACK marker. - EndOfTrack, +/// Read past one fetch object's payload, for a group this fetch does not write. +async fn skip_fetch_payload(stream: &mut Reader) -> Result<(), Error> { + let mut left = stream.varint().await?; + let mut budget = kio::coop::Budget::new(32); + while left > 0 { + kio::wait(|waiter| budget.poll_yield(waiter)).await; + let max = usize::try_from(left).unwrap_or(usize::MAX); + let chunk = std::future::poll_fn(|cx| stream.poll_read_chunk(cx, max)) + .await? + .ok_or(Error::ProtocolViolation)?; + left -= chunk.len() as u64; + } + Ok(()) } struct FetchPayload { decoded: super::object::Decoded, - /// The property list `decoded` was read from, for a status object, which has no frame. - raw: bytes::Bytes, timescale: Option, id: u64, } @@ -6283,7 +5787,7 @@ struct FetchPayload { struct FetchedObject { group: Option, object: Option, - /// Draft-16 on lets a fetch carry an Object published as a datagram. A datagram group is + /// A fetch may carry an Object published as a datagram. A datagram group is /// never cached, so it is never filled from a fetch. datagram: bool, subgroup_ok: bool, @@ -6296,27 +5800,8 @@ struct FetchedObject { /// the prior object is a protocol violation. async fn decode_fetch_object( stream: &mut Reader, - version: Version, first: bool, ) -> Result, Error> { - if version == Version::Draft14 { - let Some(group) = stream.varint_maybe().await? else { - return Ok(None); - }; - let subgroup = stream.varint().await?; - let object = stream.varint().await?; - let _priority = stream.read_exact(1).await?; - let ObjectExtensionsLength(size) = stream.decode().await?; - let properties = stream.read_exact(size).await?.to_vec(); - return Ok(Some(FetchedObject { - group: Some(group), - object: Some(object), - datagram: false, - subgroup_ok: subgroup == 0, - properties: Some(properties), - })); - } - Ok(match stream.decode_maybe::().await? { None => None, Some(ietf::FetchObject::EndOfRange { .. }) => { @@ -6354,23 +5839,17 @@ async fn decode_fetch_object( /// Absolute Group ID from a fetch object's Group ID field. /// -/// The first object is always absolute. Draft-14 through draft-17 keep sending the -/// absolute ID when the field is present; draft-18 and later send a Group ID Delta, -/// resolved here in the ascending order this FETCH requested. -fn resolve_fetch_group(version: Version, prior: Option, wire: Option) -> Result, Error> { +/// The first object is always absolute; later ones send a Group ID Delta, resolved here +/// in the ascending order this FETCH requested. +fn resolve_fetch_group(prior: Option, wire: Option) -> Result, Error> { let Some(wire) = wire else { return Ok(None); }; let Some(prior) = prior else { return Ok(Some(wire)); }; - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 => Ok(Some(wire)), - _ => { - let step = wire.checked_add(1).ok_or(Error::Unsupported)?; - prior.checked_add(step).map(Some).ok_or(Error::Unsupported) - } - } + let step = wire.checked_add(1).ok_or(Error::Unsupported)?; + prior.checked_add(step).map(Some).ok_or(Error::Unsupported) } fn open_fill_group( @@ -6514,7 +5993,7 @@ impl GroupIngest { } // A status object has no frame to hold its time, so its block keeps it. self.properties = super::object::unframed(&self.raw, self.version, self.timescale)?; - super::object::check_status(self.version, &self.properties)?; + super::object::check_status(&self.properties)?; if status == END_OF_GROUP { group.mark_gap(self.prior_object.expect("properties follow Object ID"), self.gap)?; group.set_end_object(crate::model::object::Fields { @@ -6606,49 +6085,15 @@ mod tests { } } - /// Draft-14 frames a fetch object's properties with a bare length, which is refused at - /// the prefix rather than buffered while the peer trickles the rest in. - #[moq_clock::test] - async fn draft14_fetch_properties_are_capped() { - use crate::lite::test_transport::ScriptedSession; - use crate::transport::poll::Session as _; - use futures::FutureExt as _; - - const VERSION: Version = Version::Draft14; - let mut wire = Vec::new(); - for field in [0u64, 0, 0] { - crate::coding::Encoder::new(&mut wire, VERSION.into()) - .varint(field) - .unwrap(); - } - wire.push(0); - crate::coding::Encoder::new(&mut wire, VERSION.into()) - .varint((super::super::group::MAX_OBJECT_EXTENSIONS + 1) as u64) - .unwrap(); - - let mut session = ScriptedSession::new(wire); - let (_, recv) = session.open_bi(Default::default()).await.unwrap(); - let mut reader = Reader::new(crate::transport::boxed::RecvStream::new(recv), VERSION); - let result = decode_fetch_object(&mut reader, VERSION, true) - .now_or_never() - .expect("refused at the prefix, not parked on the body"); - assert!( - matches!(result, Err(Error::Decode(DecodeError::MessageTooLarge { .. }))), - "{:?}", - result.err() - ); - } - - fn fin_responses(clean: bool) -> Vec { - use crate::coding::Encode; - let mut responses = Vec::new(); - if clean { - crate::coding::Encoder::new(&mut responses, Version::Draft18.into()) - .varint(ietf::PublishDone::ID) + fn fin_responses(clean: bool) -> Vec { + use crate::coding::Encode; + let mut responses = Vec::new(); + if clean { + crate::coding::Encoder::new(&mut responses, Version::Draft18.into()) + .varint(ietf::PublishDone::ID) .unwrap(); ietf::PublishDone { - request_id: None, - status_code: ietf::PublishDoneStatus::TrackEnded.code(Version::Draft18), + status_code: ietf::PublishDoneStatus::TrackEnded.code(), stream_count: 0, reason_phrase: "done".into(), } @@ -6768,7 +6213,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer_setup, crate::Hop::new(1).unwrap(), @@ -6787,7 +6232,7 @@ mod tests { async fn an_announce_to_a_side_that_declined_solicit_is_fine() { for solicit in [Some(true), Some(false), None] { assert!( - !unsolicited_is_a_violation_to(false, solicit, Version::Draft17), + !unsolicited_is_a_violation_to(false, solicit, Version::Draft18), "{solicit:?}" ); } @@ -6802,11 +6247,11 @@ mod tests { #[moq_clock::test] async fn an_announce_from_a_peer_that_implements_solicit_is_fatal() { assert!( - unsolicited_is_a_violation(Some(true), Version::Draft17), + unsolicited_is_a_violation(Some(true), Version::Draft18), "a peer that requires solicitation itself" ); assert!( - unsolicited_is_a_violation(Some(false), Version::Draft17), + unsolicited_is_a_violation(Some(false), Version::Draft18), "an explicit 0 declares support, so ours binds it too" ); } @@ -6816,20 +6261,7 @@ mod tests { /// happens when nobody does. #[moq_clock::test] async fn an_announce_from_a_peer_that_declared_nothing_is_fine() { - assert!(!unsolicited_is_a_violation(None, Version::Draft17)); - } - - /// Draft-14/15 have no inline NAMESPACE, so a PUBLISH_NAMESPACE request is also how a - /// peer answers our own SUBSCRIBE_NAMESPACE. The message cannot say which it is, so - /// nothing there is enforceable: our own publisher advertises exactly this way. - #[moq_clock::test] - async fn a_legacy_announce_is_never_a_violation() { - for version in [Version::Draft14, Version::Draft15] { - assert!( - !unsolicited_is_a_violation(Some(true), version), - "{version:?} answers a subscription this way" - ); - } + assert!(!unsolicited_is_a_violation(None, Version::Draft18)); } /// A rooted subscriber asks the peer for its permitted SCOPE. The root names where @@ -6853,12 +6285,12 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), scoped, - Control::new(None, false), + Control::new(false), None, peer_setup, crate::Hop::new(1).unwrap(), None, - Version::Draft16, + Version::Draft18, tasks, Default::default(), ); @@ -6872,7 +6304,7 @@ mod tests { let prefix = namespaces.pop().unwrap(); let (_live, live) = live(); - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), Version::Draft16) + let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), Version::Draft18) .await .unwrap(); let mut run = @@ -6898,7 +6330,6 @@ mod tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: None, }) .await @@ -6941,7 +6372,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), scoped, - Control::new(None, false), + Control::new(false), None, peer_setup, crate::Hop::new(1).unwrap(), @@ -7001,7 +6432,6 @@ mod tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: None, }) .await @@ -7042,7 +6472,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer_setup, us, @@ -7092,7 +6522,6 @@ mod tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: None, }) .await @@ -7137,7 +6566,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer_setup, us, @@ -7188,7 +6617,6 @@ mod tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: count, }) .await @@ -7207,7 +6635,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer_setup, crate::Hop::new(1).unwrap(), @@ -7245,14 +6673,7 @@ mod tests { version, ); writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - largest: None, - request_id: None, - active, - }) - .await - .unwrap(); + writer.encode(&ietf::RequestOk { largest: None, active }).await.unwrap(); for entry in entries { match entry.strip_prefix('-') { Some(suffix) => { @@ -7300,7 +6721,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer_setup, crate::Hop::new(1).unwrap(), @@ -7432,7 +6853,7 @@ mod tests { .expect_err("a retired alias resolves to a cancellation"); assert_eq!( - crate::ietf::error::to_stream_code(&crate::StreamError::from(&err), Version::Draft22), + crate::ietf::error::to_stream_code(&crate::StreamError::from(&err)), crate::ietf::error::CANCELLED, "the code the dispatch loop maps this error onto", ); @@ -7480,7 +6901,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(crate::lite::test_transport::SinkSession::new(Default::default())), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -7550,7 +6971,6 @@ mod tests { &mut crate::coding::Encoder::new(&mut properties, Version::Draft18.into()), timestamp, Timescale::MILLI, - Version::Draft18, ) .unwrap(); ietf::ObjectDatagram { @@ -7618,7 +7038,6 @@ mod tests { &mut crate::coding::Encoder::new(&mut properties, Version::Draft18.into()), crate::Timestamp::new(96_000, Timescale::MILLI).unwrap(), Timescale::MILLI, - Version::Draft18, ) .unwrap(); ietf::ObjectDatagram { @@ -7680,7 +7099,7 @@ mod tests { /// turns a routine unsubscribe into an endless "unknown track alias" stream. #[moq_clock::test] async fn cancelling_a_subscription_stops_the_publisher() { - for version in [Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { let log = cancel_a_subscription(version).await; // CANCELLED, not the moq-lite cancel code: 0 on this wire is INTERNAL_ERROR, so a // routine unsubscribe would read to the publisher as a fault on our side. @@ -7697,33 +7116,13 @@ mod tests { } } - /// Draft-14 through 16 have an UNSUBSCRIBE message, and draft-16 section 5.1.1 makes it - /// the thing that lets the publisher destroy the subscription. Resetting the stream - /// without it leaves a peer that predates draft-17 serving the track forever. - #[moq_clock::test] - async fn a_legacy_cancel_sends_unsubscribe() { - let log = cancel_a_subscription(Version::Draft16).await; - assert!( - occurrences(&log, &[ietf::Unsubscribe::ID as u8]) > 0, - "draft-16 cancels with UNSUBSCRIBE", - ); - - // Draft-17 removed the message, so sending one would be a protocol violation. - let log = cancel_a_subscription(Version::Draft18).await; - assert_eq!( - occurrences(&log, &[ietf::Unsubscribe::ID as u8]), - 0, - "draft-17+ has no UNSUBSCRIBE", - ); - } - /// A rejection and the last consumer leaving can both be ready when the task is next /// polled. The publisher destroyed the request when it sent the error, so treating that /// as abandonment would cancel a request that no longer exists and name a dead id back at /// a peer entitled to object. The answer wins. #[moq_clock::test] async fn a_ready_rejection_beats_local_abandonment() { - const VERSION: Version = Version::Draft16; + const VERSION: Version = Version::Draft18; // A peer that rejects the subscribe outright. let rejection = { @@ -7735,8 +7134,7 @@ mod tests { writer.varint(ietf::RequestError::ID).await.unwrap(); writer .encode(&ietf::RequestError { - request_id: Some(RequestId(1)), - // DOES_NOT_EXIST, draft-16 section 13.4.2. + // DOES_NOT_EXIST. error_code: 0x10, reason_phrase: "not found".into(), retry_interval: 0, @@ -7755,7 +7153,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -7791,8 +7189,8 @@ mod tests { .unwrap(); assert!( - !control_message_types(&log, VERSION).contains(&ietf::Unsubscribe::ID), - "a rejected request is already gone; cancelling it names a dead id at the peer", + log.stops().is_empty(), + "a rejected request is already gone; there is nothing to cancel", ); } @@ -7818,7 +7216,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -7835,7 +7233,7 @@ mod tests { /// its task and track until a token that may never be answered. #[moq_clock::test] async fn an_abandoned_subscribe_stops_waiting_for_auth() { - const VERSION: Version = Version::Draft16; + const VERSION: Version = Version::Draft18; let (mut subscriber, log, _auth, _tokens, _task_set) = waiting_on_auth(VERSION); let producer = crate::broadcast::Info::default().produce(); @@ -7874,7 +7272,7 @@ mod tests { /// ends, judged against the union as it stands, so routing can recover. #[moq_clock::test] async fn a_withdrawn_route_ends_a_subscribe_waiting_for_auth() { - const VERSION: Version = Version::Draft16; + const VERSION: Version = Version::Draft18; let (mut subscriber, log, _auth, _tokens, _task_set) = waiting_on_auth(VERSION); let producer = crate::broadcast::Info::default().produce(); @@ -7916,7 +7314,7 @@ mod tests { /// serving a track nobody reads. #[moq_clock::test] async fn abandoning_before_subscribe_ok_still_cancels() { - const VERSION: Version = Version::Draft16; + const VERSION: Version = Version::Draft18; // A peer that accepts the stream and then says nothing at all. let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); @@ -7927,7 +7325,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -7964,14 +7362,10 @@ mod tests { .expect("run_subscribe parked waiting for a response that never came") .unwrap(); - assert!( - occurrences(&log, &[ietf::Unsubscribe::ID as u8]) > 0, - "a subscribe abandoned before SUBSCRIBE_OK must still be cancelled", - ); assert_eq!( log.stops(), vec![crate::ietf::error::CANCELLED], - "and must stop the direction the publisher writes", + "a subscribe abandoned before SUBSCRIBE_OK must still be cancelled", ); } @@ -7984,75 +7378,11 @@ mod tests { (auth, cred) } - /// A grant revoked while the SUBSCRIBE waits for request-ID credit refuses the request - /// at once, not after the ten-second give-up. - #[moq_clock::test] - async fn a_revocation_never_waits_behind_subscribe_credit() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - // MAX_REQUEST_ID admits nothing, so the SUBSCRIBE waits for credit. - let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); - let log = session.log.clone(); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let (auth, _cred) = revocable_auth(); - let mut subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(Some(RequestId(1)), false), - None, - peer::PeerSetup::declared(peer::Peer::default()), - crate::Hop::new(1).unwrap(), - None, - version, - tasks, - Default::default(), - ) - .with_auth(auth.clone()); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.requests(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - - let serving = moq_clock::sim::spawn(async move { - subscriber - .run_subscribe(Path::from_static("broadcast"), dynamic, None, request) - .await; - }); - settle().await; - assert!( - log.writes.lock().unwrap().is_empty(), - "{version}: a SUBSCRIBE went out without credit" - ); - - auth.ended(0, Error::Session(SessionError::Unauthorized)); - let result = moq_clock::timeout(Duration::from_secs(1), subscription) - .await - .unwrap_or_else(|_| panic!("{version}: the revocation waited behind the credit")); - assert!( - matches!(result, Err(Error::Unauthorized)), - "{version}: the request outlived its grant: {:?}", - result.err() - ); - moq_clock::timeout(Duration::from_secs(1), serving) - .await - .unwrap_or_else(|_| panic!("{version}: run_subscribe outlived its grant")) - .unwrap(); - assert!( - log.writes.lock().unwrap().is_empty(), - "{version}: nothing was sent without credit" - ); - assert!(log.closes().is_empty(), "{version}: the session stays up"); - } - } - /// A grant revoked while the SUBSCRIBE waits for its answer refuses the request and /// cancels the subscribe at once, not once the peer answers or acknowledges the cancel. #[moq_clock::test] async fn a_revocation_never_waits_behind_subscribe_ok() { - for version in [Version::Draft14, Version::Draft16, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { // A peer that accepts the stream and then says nothing at all, not even // acknowledging the cancellation's FIN. let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()).with_unacked_fin(); @@ -8063,7 +7393,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -8080,7 +7410,6 @@ mod tests { let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); let request = dynamic.requested_track().await.expect("no track requested"); - let unsubscribes = subscriber.unsubscribes(); let serving = moq_clock::sim::spawn(async move { subscriber @@ -8103,11 +7432,8 @@ mod tests { // Only the wire cleanup still waits on the peer's acknowledgement. settle().await; drop(serving); - // A subscribe revoked before SUBSCRIBE_OK must still be cancelled: by UNSUBSCRIBE - // where the draft has one, and by stopping the publisher's direction on every draft. - if unsubscribes { - assert!(occurrences(&log, &[ietf::Unsubscribe::ID as u8]) > 0, "{version}"); - } + // A subscribe revoked before SUBSCRIBE_OK must still be cancelled, by stopping the + // publisher's direction. assert_eq!(log.stops(), vec![crate::ietf::error::CANCELLED], "{version}"); assert!(log.closes().is_empty(), "{version}: the session stays up"); } @@ -8117,7 +7443,7 @@ mod tests { /// Retiring its source cancels it even while the caller keeps its demand. #[moq_clock::test] async fn a_retirement_before_subscribe_ok_refuses_the_subscription() { - const VERSION: Version = Version::Draft16; + const VERSION: Version = Version::Draft18; // A peer that accepts the stream and then says nothing at all. let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); @@ -8128,7 +7454,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -8160,7 +7486,6 @@ mod tests { drop(route); serving.await.unwrap(); assert!(matches!(subscription.await, Err(Error::Unroutable))); - assert!(occurrences(&log, &[ietf::Unsubscribe::ID as u8]) > 0); assert_eq!(log.stops(), vec![crate::ietf::error::CANCELLED]); } @@ -8168,10 +7493,8 @@ mod tests { enum SetupWait { SubscribeOpen, StatusOpen, - StatusCredit, StatusResponse, FetchOpen, - FetchCredit, FetchResponse, } @@ -8183,14 +7506,13 @@ mod tests { Expired, } - /// Drive each setup wait without credit or an answer. Refusal must be local even + /// Drive each setup wait without an open stream or an answer. Refusal must be local even /// when the peer never acknowledges the cancellation's FIN. async fn invalid_setup(stage: SetupWait, version: Version, cause: Invalidated) { let opening = matches!( stage, SetupWait::SubscribeOpen | SetupWait::StatusOpen | SetupWait::FetchOpen ); - let credit = matches!(stage, SetupWait::StatusCredit | SetupWait::FetchCredit); let open = kio::Producer::new(false); let session = if opening { crate::lite::test_transport::ScriptedSession::gated_open(Vec::new(), open.consume()) @@ -8200,7 +7522,7 @@ mod tests { let log = session.log.clone(); let (tasks, _task_set) = TaskSet::new(); let (auth, _cred) = revocable_auth(); - let control = Control::new(credit.then_some(RequestId(1)), false); + let control = Control::new(false); let mut subscriber = Subscriber::new( crate::time::sim(), crate::driver::Transport::new(session.clone()), @@ -8249,7 +7571,7 @@ mod tests { .is_none() ); }), - SetupWait::StatusOpen | SetupWait::StatusCredit | SetupWait::StatusResponse => Box::pin(async { + SetupWait::StatusOpen | SetupWait::StatusResponse => Box::pin(async { assert!( subscriber .track_status(&Path::from_static("broadcast"), "video", request, &mut gate) @@ -8275,7 +7597,7 @@ mod tests { }; let mut work = work; assert!(futures::poll!(work.as_mut()).is_pending(), "{stage:?}/{version}"); - if opening || credit { + if opening { assert!(log.writes.lock().unwrap().is_empty()); } let expected = match cause { @@ -8317,7 +7639,7 @@ mod tests { ), "{stage:?}/{version}/{cause:?}: {result:?}" ); - if opening || credit { + if opening { assert!(done, "{stage:?}/{version}/{cause:?}: cancelled wait stayed alive"); assert!(log.writes.lock().unwrap().is_empty()); } else { @@ -8329,11 +7651,7 @@ mod tests { } drop(work); if opening { - assert_eq!( - control.next_request_id(&subscriber.runtime).await.unwrap(), - RequestId(1), - "cancelled open spends no ID" - ); + assert_eq!(control.next_request_id(), RequestId(1), "cancelled open spends no ID"); } assert!(log.closes().is_empty(), "the session stays up"); } @@ -8351,141 +7669,11 @@ mod tests { }; } - setup_wait_test!( - invalid_subscribe_open_is_refused, - SubscribeOpen, - [Draft14, Draft16, Draft17, Draft22] - ); - setup_wait_test!( - invalid_status_open_is_refused, - StatusOpen, - [Draft14, Draft16, Draft17, Draft22] - ); - setup_wait_test!(invalid_status_credit_is_refused, StatusCredit, [Draft14, Draft16]); - setup_wait_test!( - invalid_status_response_is_refused, - StatusResponse, - [Draft14, Draft16, Draft17, Draft22] - ); - setup_wait_test!( - invalid_fetch_open_is_refused, - FetchOpen, - [Draft14, Draft16, Draft17, Draft22] - ); - setup_wait_test!(invalid_fetch_credit_is_refused, FetchCredit, [Draft14, Draft16]); - setup_wait_test!( - invalid_fetch_response_is_refused, - FetchResponse, - [Draft14, Draft16, Draft17, Draft22] - ); - - #[derive(Clone, Copy, Debug)] - enum Allocated { - Subscribe, - TrackStatus, - GroupFetch, - } - - /// Drafts 14-16 return MAX_REQUEST_ID credit only for requests the peer receives, so a - /// source retiring after its request ID was allocated must still send that request, - /// then cancel it if the draft can. Skipping it would leak the credit for good. - #[moq_clock::test] - async fn a_retirement_after_allocation_still_sends_the_request() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - retirement_after_allocation(version).await; - } - } - - async fn retirement_after_allocation(version: Version) { - for kind in [Allocated::Subscribe, Allocated::TrackStatus, Allocated::GroupFetch] { - // Every request write parks until the gate opens, after the retirement. - let gate = kio::Producer::new(false); - let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); - let log = session.log.clone(); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let mut subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::declared(peer::Peer::default()), - crate::Hop::new(1).unwrap(), - None, - version, - tasks, - Default::default(), - ); - let route = kio::Producer::new(()); - subscriber.validity = crate::model::broadcast::RequestValidity::token(Some(route.consume())); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.requests(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let _subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - - let mut fetch = None; - let serving = match kind { - Allocated::Subscribe => moq_clock::sim::spawn(async move { - subscriber - .run_subscribe(Path::from_static("broadcast"), dynamic, None, request) - .await; - }), - Allocated::TrackStatus => moq_clock::sim::spawn(async move { - let mut gate = crate::auth::Gate::new( - subscriber.auth.clone(), - Path::from_static("broadcast"), - crate::auth::Direction::Subscribe, - ); - assert!( - subscriber - .track_status(&Path::from_static("broadcast"), "video", request, &mut gate) - .await - .is_none() - ); - }), - Allocated::GroupFetch => { - let track = track::Producer::new( - std::sync::Arc::new(crate::broadcast::Info::default()), - "video", - track::Info::default().with_timescale(None), - ); - let requested = track.requests(); - let mut pending = Box::pin(track.consume().fetch_group(0, None)); - assert!(futures::poll!(pending.as_mut()).is_pending()); - let group = requested.requested_fill().await.expect("group request"); - fetch = Some((track, pending)); - moq_clock::sim::spawn(subscriber.run_group_fetch( - Path::from_static("broadcast"), - "video".into(), - group, - None, - )) - } - }; - - // The ID is allocated and its request parked mid-write when the source retires. - settle().await; - drop(route); - settle().await; - let Ok(mut open) = gate.write() else { - panic!("gate closed") - }; - *open = true; - drop(open); - serving.await.unwrap(); - drop(fetch); - - let expected = match kind { - Allocated::Subscribe => vec![ietf::Subscribe::ID, ietf::Unsubscribe::ID], - Allocated::TrackStatus => vec![ietf::TrackStatus::ID], - Allocated::GroupFetch => vec![ietf::Fetch::ID, ietf::FetchCancel::ID], - }; - assert_eq!(control_message_types(&log, version), expected, "{kind:?}"); - } - } + setup_wait_test!(invalid_subscribe_open_is_refused, SubscribeOpen, [Draft22]); + setup_wait_test!(invalid_status_open_is_refused, StatusOpen, [Draft22]); + setup_wait_test!(invalid_status_response_is_refused, StatusResponse, [Draft22]); + setup_wait_test!(invalid_fetch_open_is_refused, FetchOpen, [Draft22]); + setup_wait_test!(invalid_fetch_response_is_refused, FetchResponse, [Draft22]); /// The control messages that actually reached the wire, by type id. /// @@ -8512,269 +7700,19 @@ mod tests { types } - /// Drafts 14-16 carry every request over `ControlStreamAdapter`'s virtual streams, so a - /// cancellation only counts if it traverses the mux and reaches the real control stream - /// writer. A test that drives a direct stream proves the subscriber's own logic and - /// nothing about the path production takes: the virtual writer's reset is a no-op and - /// its close returns as soon as the bytes are queued, so an adapter that dropped them - /// would look identical. - #[moq_clock::test] - async fn a_legacy_cancel_reaches_the_control_stream() { - const VERSION: Version = Version::Draft16; - - // A peer that opens the control stream and then says nothing, so the subscribe is - // abandoned before it is accepted and cancelled from there. - let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); - let log = session.log.clone(); - - let control = Control::new(None, false); - let adapter = super::super::adapter::ControlStreamAdapter::new( - crate::driver::Transport::new(session.clone()), - control.clone(), - VERSION, - ); - - // The one real bidi everything is multiplexed onto. - let control_stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), VERSION) - .await - .unwrap(); - let running = adapter.clone(); - let (_goaway_handle, goaway) = crate::goaway::Handle::new(true); - moq_clock::sim::spawn(async move { - let _ = running.run(control_stream.reader, control_stream.writer, goaway).await; - }); - - let (tasks, _task_set) = crate::util::TaskSet::new(); - let mut subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(adapter), - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - control, - None, - peer::PeerSetup::declared(peer::Peer::default()), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks, - Default::default(), - ); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.requests(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let subscription = track.subscribe(None); - - let request = dynamic.requested_track().await.expect("no track requested"); - - let serving = moq_clock::sim::spawn(async move { - subscriber - .run_subscribe(Path::from_static("broadcast"), dynamic, None, request) - .await; - }); - - settle().await; - drop(subscription); - drop(track); - drop(consumer); - - moq_clock::timeout(std::time::Duration::from_secs(1), serving) - .await - .expect("run_subscribe did not finish") - .unwrap(); - - // Let the adapter's writer task drain the queue onto the control stream. - settle().await; - - let types = control_message_types(&log, VERSION); - assert!( - types.contains(&ietf::Subscribe::ID), - "the SUBSCRIBE reached the control stream: {types:?}" - ); - assert!( - types.contains(&ietf::Unsubscribe::ID), - "the UNSUBSCRIBE must traverse the adapter to the control stream, not stop at the \ - virtual writer: {types:?}" - ); - } - - /// Draft-14's SUBSCRIBE_UPDATE has no optional fields, so a priority update repeats the - /// subscription's bounds: a Largest Object subscription starts just past the answer's - /// Largest, open ended. Zeroes would move the start back, which the draft forbids. - #[moq_clock::test] - async fn a_draft14_priority_update_repeats_the_bounds() { - const VERSION: Version = Version::Draft14; - - let subscribe_ok = message( - ietf::SubscribeOk::ID, - &ietf::SubscribeOk { - request_id: Some(RequestId(0)), - track_alias: 7, - largest: Some(ietf::Location { group: 5, object: 2 }), - properties: Default::default(), - }, - VERSION, - ); - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok); - let log = session.log.clone(); - - let (tasks, _task_set) = crate::util::TaskSet::new(); - let mut subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::declared(peer::Peer::default()), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks, - Default::default(), - ); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.requests(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let _low = track.subscribe(track::Subscription::default().with_priority(1)); - let request = dynamic.requested_track().await.expect("no track requested"); - let _serving = moq_clock::sim::spawn(async move { - subscriber - .run_subscribe(Path::from_static("broadcast"), dynamic, None, request) - .await; - }); - settle().await; - - let _high = track.subscribe(track::Subscription::default().with_priority(72)); - settle().await; - - let writes = log.writes.lock().unwrap().clone(); - let mut buf = Decoder::new(&writes, VERSION.into()); - let (mut subscribe, mut updates) = (None, Vec::new()); - while !buf.is_empty() { - let type_id = buf.varint().unwrap(); - let size = buf.u16().unwrap() as usize; - let mut body = Decoder::new(buf.slice(size).unwrap(), VERSION.into()); - match type_id { - ietf::Subscribe::ID => subscribe = Some(RequestId::decode(&mut body, VERSION).unwrap()), - ietf::SubscribeUpdate::ID => { - updates.push(ietf::SubscribeUpdate::decode_msg(&mut body, VERSION).unwrap()) - } - _ => {} - } - } - - let [update] = updates.as_slice() else { - panic!("one update, not {updates:?}"); - }; - assert_eq!(update.subscription_request_id, subscribe); - assert_eq!(update.start_location, ietf::Location { group: 5, object: 3 }); - assert_eq!(update.end_group, 0, "still open ended"); - assert_eq!(update.subscriber_priority, Some(super::super::priority::to_wire(72))); - assert_eq!(update.forward, Some(true)); - } - - /// Draft-14's SUBSCRIBE_UPDATE cannot omit the priority, so an update for the max age - /// alone restates the unset priority in force instead of the omission default 128. - #[moq_clock::test] - async fn a_draft14_max_age_update_restates_the_priority() { - const VERSION: Version = Version::Draft14; - - let subscribe_ok = message( - ietf::SubscribeOk::ID, - &ietf::SubscribeOk { - request_id: Some(RequestId(0)), - track_alias: 7, - largest: None, - properties: Default::default(), - }, - VERSION, - ); - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok); - let log = session.log.clone(); - - let (tasks, _task_set) = crate::util::TaskSet::new(); - let mut subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(session), - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::declared(peer::Peer { - max_age: true, - ..Default::default() - }), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks, - Default::default(), - ); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.requests(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let _short = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(1))); - let request = dynamic.requested_track().await.expect("no track requested"); - let _serving = moq_clock::sim::spawn(async move { - subscriber - .run_subscribe(Path::from_static("broadcast"), dynamic, None, request) - .await; - }); - settle().await; - - let _long = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(5))); - settle().await; - - let writes = log.writes.lock().unwrap().clone(); - let mut buf = Decoder::new(&writes, VERSION.into()); - let mut updates = Vec::new(); - while !buf.is_empty() { - let type_id = buf.varint().unwrap(); - let size = buf.u16().unwrap() as usize; - let mut body = Decoder::new(buf.slice(size).unwrap(), VERSION.into()); - if type_id == ietf::SubscribeUpdate::ID { - updates.push(ietf::SubscribeUpdate::decode_msg(&mut body, VERSION).unwrap()); - } - } - - let [update] = updates.as_slice() else { - panic!("one update, not {updates:?}"); - }; - assert_eq!(update.max_age, Some(5_000)); - assert_eq!(update.subscriber_priority, Some(super::super::priority::to_wire(0))); - } - /// Establish a subscription on `version`, then drop its last consumer, and hand back /// what the session recorded on the way out. async fn cancel_a_subscription(version: Version) -> crate::lite::test_transport::Log { cancel_a_subscription_inner(version, false).await } - /// A publisher on draft-14 through 16 that legally hands a second subscription to one - /// track the alias the first already holds. We cannot demux that, so we walk away from - /// the new subscription. Those versions carry requests over the control stream adapter, - /// whose virtual streams drop silently, so UNSUBSCRIBE is the only way the publisher - /// ever learns to stop serving it. - #[moq_clock::test] - async fn a_legacy_shared_alias_is_unsubscribed() { - let log = cancel_a_subscription_inner(Version::Draft16, true).await; - - assert!( - occurrences(&log, &[ietf::Unsubscribe::ID as u8]) > 0, - "abandoning a shared alias must still tell the publisher to stop", - ); - } - /// Writing the UNSUBSCRIBE is not the same as delivering it. A stream that has only been /// finished is still retransmitting, so the writer's Drop reset would discard the message /// before the peer read it. Closing consumes the writer, which is what removes that /// fallback, so a reset here means the cancellation never landed. #[moq_clock::test] async fn cancelling_does_not_reset_away_the_unsubscribe() { - for version in [Version::Draft16, Version::Draft22] { + for version in [Version::Draft22] { let log = cancel_a_subscription(version).await; assert!( log.resets().is_empty(), @@ -8796,10 +7734,6 @@ mod tests { writer.varint(ietf::SubscribeOk::ID).await.unwrap(); writer .encode(&ietf::SubscribeOk { - request_id: match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(0)), - _ => None, - }, track_alias: 7, largest: None, properties: Default::default(), @@ -8818,7 +7752,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -8907,7 +7841,6 @@ mod tests { writer.varint(ietf::SubscribeOk::ID).await.unwrap(); writer .encode(&ietf::SubscribeOk { - request_id: None, track_alias: 7, largest, properties: ietf::Properties { @@ -8927,7 +7860,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -9032,7 +7965,6 @@ mod tests { message( ietf::RequestError::ID, &ietf::RequestError { - request_id: None, error_code: u32::MAX.into(), reason_phrase: "TRACK_STATUS is refused".into(), retry_interval: 0, @@ -9043,7 +7975,6 @@ mod tests { message( ietf::SubscribeOk::ID, &ietf::SubscribeOk { - request_id: None, track_alias: 7, largest: None, properties: Default::default(), @@ -9059,7 +7990,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(2).unwrap(), @@ -9106,38 +8037,18 @@ mod tests { /// including NOT_SUPPORTED and undefined codes, without opening a subscription. #[moq_clock::test] async fn a_refused_track_status_never_subscribes() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { for code in [3, u64::from(u32::MAX)] { for fetching in [false, true] { - let refused = if version == Version::Draft14 { - message( - ietf::TRACK_STATUS_ERROR_14, - &ietf::SubscribeError { - request_id: RequestId(0), - error_code: code, - reason_phrase: "TRACK_STATUS is refused".into(), - }, - version, - ) - } else { - message( - ietf::RequestError::ID, - &ietf::RequestError { - request_id: matches!(version, Version::Draft15 | Version::Draft16) - .then_some(RequestId(0)), - error_code: code, - reason_phrase: "TRACK_STATUS is refused".into(), - retry_interval: 0, - }, - version, - ) - }; + let refused = message( + ietf::RequestError::ID, + &ietf::RequestError { + error_code: code, + reason_phrase: "TRACK_STATUS is refused".into(), + retry_interval: 0, + }, + version, + ); let session = crate::lite::test_transport::ScriptedSession::new(refused); let log = session.log.clone(); let (tasks, _task_set) = crate::util::TaskSet::new(); @@ -9145,7 +8056,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -9204,7 +8115,6 @@ mod tests { let status_ok = message( ietf::TrackStatusOk::ID, &ietf::TrackStatusOk { - request_id: None, largest: Some(ietf::Location { group: 3, object: 42 }), properties: declares(true), }, @@ -9217,7 +8127,6 @@ mod tests { message( ietf::SubscribeOk::ID, &ietf::SubscribeOk { - request_id: None, track_alias: 7, largest: None, properties: declares(!opts_out), @@ -9231,7 +8140,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -9354,12 +8263,12 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), None, - Version::Draft14, + Version::Draft18, tasks, Default::default(), ); @@ -9412,12 +8321,12 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), Some(assigned), peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), None, - Version::Draft14, + Version::Draft18, tasks, Default::default(), ); @@ -9473,12 +8382,12 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), Some(peer), peer::PeerSetup::default(), self_origin, None, - Version::Draft14, + Version::Draft18, tasks, Default::default(), ); @@ -9513,12 +8422,12 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(crate::lite::test_transport::SinkSession::new(Default::default())), origin.clone(), - Control::new(None, false), + Control::new(false), Some(peer), peer::PeerSetup::default(), self_origin, None, - Version::Draft14, + Version::Draft18, tasks, Default::default(), ); @@ -9562,7 +8471,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin.clone(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), self_origin, @@ -9696,7 +8605,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer_setup, crate::Hop::new(1).unwrap(), @@ -9747,7 +8656,6 @@ mod tests { writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: None, }) .await @@ -9787,7 +8695,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer_setup, crate::Hop::new(1).unwrap(), @@ -9845,7 +8753,7 @@ mod tests { #[moq_clock::test] async fn publish_namespace_requester_fin_keeps_the_route() { - for version in [Version::Draft17, Version::Draft18, Version::Draft22] { + for version in [Version::Draft18, Version::Draft22] { for reset in [false, true] { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); @@ -9859,180 +8767,39 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), None, version, - tasks, - Default::default(), - ); - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) - .await - .unwrap(); - let msg = ietf::PublishNamespace { - request_id: RequestId(0), - track_namespace: crate::Path::from_static("room/host"), - params: Default::default(), - authorization_token: None, - }; - let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( - stream, - msg, - cluster::Peer::default(), - None - )); - assert_eq!( - futures::poll!(run.as_mut()).is_ready(), - reset || super::super::request_stream::fin_cancels(version), - "{version}" - ); - if !reset && !super::super::request_stream::fin_cancels(version) { - assert!(routed_now(&consumer, "room/host").is_some(), "{version}"); - } - } - } - } - - /// v14-16 withdraw a PUBLISH_NAMESPACE with PUBLISH_NAMESPACE_DONE, which the adapter - /// delivers as a message before it FINs the virtual stream. Reading it as a stray - /// message closes the whole session over a routine unannounce. - #[moq_clock::test] - async fn a_publish_namespace_done_retracts_without_faulting_the_session() { - const VERSION: Version = Version::Draft14; - - let path = crate::Path::from_static("room/host"); - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - VERSION, - ); - writer - .encode_message(&ietf::PublishNamespaceDone { - track_namespace: path.clone(), - request_id: RequestId(0), - }) - .await - .unwrap(); - let script = log.writes.lock().unwrap().clone(); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let consumer = origin.consume(); - let session = crate::lite::test_transport::ScriptedSession::eof(script); - let (tasks, task_set) = crate::util::TaskSet::new(); - std::mem::forget(task_set); - let mut subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - origin, - Control::new(None, false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks, - Default::default(), - ); - - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), VERSION) - .await - .unwrap(); - let msg = ietf::PublishNamespace { - request_id: RequestId(0), - track_namespace: path.clone(), - params: Default::default(), - authorization_token: None, - }; - subscriber - .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) - .await - .expect("a withdrawal is not a protocol violation"); - settle().await; - - assert!( - routed_now(&consumer, "room/host").is_none(), - "an explicit withdrawal must close the broadcast", - ); - } - - /// Drafts 14 and 15 have no update for an advertisement, so a SUBSCRIBE_UPDATE the - /// adapter routes onto one changes nothing and leaves it standing until the withdrawal. - /// Draft 15 still owes the update an answer: a REQUEST_ERROR naming it. - #[moq_clock::test] - async fn a_legacy_update_on_an_advertisement_is_answered() { - for version in [Version::Draft14, Version::Draft15] { - let path = crate::Path::from_static("room/host"); - // Own Request ID 10, aimed at the advertisement (0). Draft 14 carries its fixed - // start, end group, priority and forward before the parameters. - let body: &[u8] = match version { - Version::Draft14 => &[10, 0, 0, 0, 0, 128, 1, 0], - _ => &[10, 0, 0], - }; - let mut script = vec![ietf::SubscribeUpdate::ID as u8, 0, body.len() as u8]; - script.extend_from_slice(body); - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - version, - ); - writer - .encode_message(&ietf::PublishNamespaceDone { - track_namespace: path.clone(), - request_id: RequestId(0), - }) - .await - .unwrap(); - script.extend(log.writes.lock().unwrap().iter()); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let session = crate::lite::test_transport::ScriptedSession::eof(script); - let (tasks, task_set) = crate::util::TaskSet::new(); - std::mem::forget(task_set); - let mut subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - origin, - Control::new(None, false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - version, - tasks, - Default::default(), - ); - - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) - .await - .unwrap(); - let msg = ietf::PublishNamespace { - request_id: RequestId(0), - track_namespace: path.clone(), - params: Default::default(), - authorization_token: None, - }; - subscriber - .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) - .await - .unwrap_or_else(|err| panic!("{version}: {err}")); - - let written = session.log.writes.lock().unwrap().clone(); - let mut rest = written.as_slice(); - let mut sent = Vec::new(); - while !rest.is_empty() { - let id = crate::coding::decode_varint(&mut rest, version).unwrap(); - let len = usize::from(u16::from_be_bytes([rest[0], rest[1]])); - sent.push((id, rest.get(2).copied())); - rest = &rest[2 + len..]; + tasks, + Default::default(), + ); + let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) + .await + .unwrap(); + let msg = ietf::PublishNamespace { + request_id: RequestId(0), + track_namespace: crate::Path::from_static("room/host"), + params: Default::default(), + authorization_token: None, + }; + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + msg, + cluster::Peer::default(), + None + )); + assert_eq!( + futures::poll!(run.as_mut()).is_ready(), + reset || super::super::request_stream::fin_cancels(version), + "{version}" + ); + if !reset && !super::super::request_stream::fin_cancels(version) { + assert!(routed_now(&consumer, "room/host").is_some(), "{version}"); + } } - let expected = match version { - Version::Draft14 => vec![(ietf::PublishNamespaceOk::ID, Some(0))], - _ => vec![(ietf::RequestOk::ID, Some(0)), (ietf::RequestError::ID, Some(10))], - }; - assert_eq!(sent, expected, "{version}"); - assert!(session.log.closes().is_empty(), "{version}: the session stays up"); } } @@ -10066,7 +8833,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -10509,7 +9276,6 @@ mod tests { .encode(&ietf::PublishNamespaceUpdate { // Each update consumes a request id of the peer's parity. request_id: RequestId(3 + 2 * i as u64), - namespace_request_id: None, hops: Some(advert.hops.clone()), cost: Some(advert.cost), authorization_token: None, @@ -10551,7 +9317,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, true), + Control::new(true), None, peer::PeerSetup::default(), self_origin, @@ -10633,36 +9399,6 @@ mod tests { ) } - /// Namespace updates spend IDs from the same lifetime history as initial requests. - #[moq_clock::test] - async fn namespace_update_cannot_reuse_a_spent_request_id() { - let self_origin = crate::Hop::new(5).unwrap(); - let peer = peer_9(); - let (clean, _) = clean_and_looped(); - let script = publish_namespace_updates(std::slice::from_ref(&clean)).await; - let (mut subscriber, _consumer, mut stream, _driver, log) = - update_harness(self_origin, &peer, &clean, script).await; - // The initial request is already retired; its ID remains permanently spent. - assert!(subscriber.control.accept(RequestId(3)).unwrap().is_none()); - let mut attached = Some(0); - let err = subscriber - .run_publish_namespace_updates( - &mut stream, - &crate::Path::from_static("room/host"), - Held { - cluster: Some(clean), - epoch: None, - }, - peer, - &mut attached, - None, - ) - .await - .expect_err("a repeated update ID must close the session"); - assert!(matches!(err, Error::Session(crate::SessionError::InvalidRequestId))); - assert_eq!(log.closes()[0].0, crate::SessionError::InvalidRequestId.to_code()); - } - /// A reflected update detaches the route but MUST NOT end the stream. Updates ride /// the stream that already carries the advertisement, so closing it strands the /// namespace even when the peer's path goes clean again. It is also not ours to @@ -10778,7 +9514,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, true), + Control::new(true), None, peer::PeerSetup::default(), self_origin, @@ -10875,7 +9611,6 @@ mod tests { writer .encode(&ietf::PublishNamespaceUpdate { request_id: RequestId(3), - namespace_request_id: None, hops: None, cost: Some(0), authorization_token: None, @@ -11126,13 +9861,10 @@ mod tests { /// PUBLISH offers one track, but a source attaches per namespace and serves every /// track under it. Rather than invent a namespace-level source from a track-level - /// offer, decline the request and leave the session running. - /// - /// Draft-14 answers with PUBLISH_ERROR and its own registry; draft-15 folded the message - /// into REQUEST_ERROR, so both shapes have to carry NOT_SUPPORTED. + /// offer, decline the request with NOT_SUPPORTED and leave the session running. #[moq_clock::test] async fn publish_is_rejected_without_announcing() { - for version in [Version::Draft14, Version::Draft18] { + for version in [Version::Draft18] { // An open gate, so the rejection actually reaches the wire. let gate = kio::Producer::new(true); let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); @@ -11145,7 +9877,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -11191,31 +9923,15 @@ mod tests { version, ); - match version { - Version::Draft14 => { - writer.varint(ietf::PublishError::ID).await.unwrap(); - writer - .encode(&ietf::PublishError { - request_id: RequestId(1), - error_code: NOT_SUPPORTED, - reason_phrase: "PUBLISH is not supported".into(), - }) - .await - .unwrap(); - } - _ => { - writer.varint(ietf::RequestError::ID).await.unwrap(); - writer - .encode(&ietf::RequestError { - request_id: None, - error_code: NOT_SUPPORTED, - reason_phrase: "PUBLISH is not supported".into(), - retry_interval: 0, - }) - .await - .unwrap(); - } - } + writer.varint(ietf::RequestError::ID).await.unwrap(); + writer + .encode(&ietf::RequestError { + error_code: NOT_SUPPORTED, + reason_phrase: "PUBLISH is not supported".into(), + retry_interval: 0, + }) + .await + .unwrap(); log.writes.lock().unwrap().clone() }; @@ -11294,7 +10010,7 @@ mod tests { crate::driver::Transport::new(session.clone()), origin, // The announcing peer is the server here, so its update IDs are odd. - Control::new(None, true), + Control::new(true), None, peer_setup, crate::Hop::new(1).unwrap(), @@ -11345,7 +10061,6 @@ mod tests { ); let msg = ietf::PublishNamespaceUpdate { request_id: RequestId(3), - namespace_request_id: (version == Version::Draft16).then_some(RequestId(1)), hops: None, cost: None, authorization_token: Some(announce_token()), @@ -11365,7 +10080,6 @@ mod tests { ); let msg = ietf::PublishNamespaceUpdate { request_id: RequestId(3), - namespace_request_id: None, hops: Some(hop_path(&[7, 9])), cost: Some(0), authorization_token: Some(announce_token()), @@ -11415,17 +10129,10 @@ mod tests { /// An announce's grant ending while its first answer waits on a peer that stopped /// reading retracts the route at once, not once the answer drains: the peer is told - /// only afterwards, and on drafts 14-16 with a PUBLISH_NAMESPACE_CANCEL after the OK. + /// only afterwards. #[moq_clock::test] async fn a_revoked_token_retracts_an_announce_whose_ok_is_blocked() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { let gate = kio::Producer::new(false); let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); let (mut subscriber, mut requests, _cred, consumer, session) = @@ -11495,67 +10202,12 @@ mod tests { let len = usize::from(u16::from_be_bytes([rest[0], rest[1]])); rest = &rest[2 + len..]; } - let expected = match version { - Version::Draft14 => vec![ietf::PublishNamespaceOk::ID, ietf::PublishNamespaceCancel::ID], - Version::Draft15 | Version::Draft16 => vec![ietf::RequestOk::ID, ietf::PublishNamespaceCancel::ID], - _ => vec![ietf::RequestOk::ID], - }; + let expected = vec![ietf::RequestOk::ID]; assert_eq!(sent, expected, "{version}"); assert!(session.log.closes().is_empty(), "{version}: the session stays up"); } } - /// On drafts 14-16 the withdrawal arrives as a message before the FIN: a - /// PUBLISH_NAMESPACE_DONE waiting behind an unanswered verification ends the announce at - /// once, giving its slot back before the application ever takes the token. - #[moq_clock::test] - async fn a_legacy_withdrawal_during_verification_ends_the_announce() { - const VERSION: Version = Version::Draft16; - let done = { - let log = crate::lite::test_transport::Log::default(); - crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - VERSION, - ) - .encode_message(&ietf::PublishNamespaceDone { - track_namespace: crate::Path::from_static("room/alice"), - request_id: RequestId(1), - }) - .await - .unwrap(); - log.writes.lock().unwrap().clone() - }; - // The application never takes the token, so only the withdrawal can end the wait. - let (mut subscriber, _requests, _cred, consumer, session) = auth_announce_harness(VERSION, done); - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), VERSION) - .await - .unwrap(); - let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( - stream, - token_publish_namespace(), - cluster::Peer::default(), - None, - )); - - let mut result = None; - for _ in 0..100 { - if let Poll::Ready(res) = futures::poll!(run.as_mut()) { - result = Some(res); - break; - } - settle().await; - } - assert!( - matches!(result, Some(Ok(()))), - "a withdrawal during verification ends the announce: {result:?}" - ); - assert!( - routed_now(&consumer, "room/alice").is_none(), - "and it was never attached" - ); - assert!(session.log.closes().is_empty(), "the session stays up"); - } - /// A refused request token is answered UNAUTHORIZED and the announce is not attached; the /// session is untouched. #[moq_clock::test] @@ -11775,7 +10427,6 @@ mod tests { ); writer.varint(ietf::SubscribeOk::ID).await.unwrap(); let ok = ietf::SubscribeOk { - request_id: None, track_alias: 7, largest: None, properties: Default::default(), @@ -11862,7 +10513,7 @@ mod tests { /// re-arms the deadline. #[moq_clock::test] async fn a_publish_namespace_renewal_extends_past_the_old_expiry() { - for version in [Version::Draft16, Version::Draft18] { + for version in [Version::Draft18] { let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(version, publish_namespace_update_with_token(version).await); let rt = subscriber.runtime.clone(); @@ -12043,12 +10694,12 @@ mod tests { assert!(session.log.closes().is_empty(), "the session stays up"); } - /// Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused PUBLISH_NAMESPACE - /// renewal ends only that announce at once, by closing its stream (and cancelling it on - /// draft-16), not by waiting for the old 60s grant to lapse. The session stays up. + /// Per draft-18 section 10.9.1, a refused PUBLISH_NAMESPACE + /// renewal ends only that announce at once, by closing its stream, not by waiting for + /// the old 60s grant to lapse. The session stays up. #[moq_clock::test] async fn a_refused_publish_namespace_renewal_ends_the_announce() { - for version in [Version::Draft16, Version::Draft18] { + for version in [Version::Draft18] { let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(version, publish_namespace_update_with_token(version).await); let rt = subscriber.runtime.clone(); @@ -12103,37 +10754,6 @@ mod tests { "the session must stay up: {:?}", session.log.closes() ); - // Draft-16's request outlives its stream, so the refusal cancels it explicitly. - if version == Version::Draft16 { - let cancel = { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new( - log.clone(), - )), - version, - ); - writer - .encode_message(&ietf::PublishNamespaceCancel { - track_namespace: crate::Path::from_static("room/alice"), - request_id: RequestId(1), - error_code: request::to_code( - &Error::Unauthorized, - request::Kind::PublishNamespace, - version, - ), - reason_phrase: "no".into(), - }) - .await - .unwrap(); - log.writes.lock().unwrap().clone() - }; - let writes = session.log.writes.lock().unwrap().clone(); - assert!( - writes.windows(cancel.len()).any(|window| window == cancel.as_slice()), - "a refused draft-16 renewal sends PUBLISH_NAMESPACE_CANCEL" - ); - } } } @@ -12141,7 +10761,7 @@ mod tests { /// without one is refused rather than taken unread: it ends only that announce, unverified. #[moq_clock::test] async fn a_token_on_an_announce_admitted_without_one_is_refused() { - for version in [Version::Draft16, Version::Draft18] { + for version in [Version::Draft18] { let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(version, publish_namespace_update_with_token(version).await); let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) @@ -12252,7 +10872,6 @@ mod tests { ); let msg = ietf::PublishNamespaceUpdate { request_id: RequestId(rid), - namespace_request_id: None, hops, cost, authorization_token: Some(announce_token()), @@ -12651,7 +11270,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer_setup, crate::Hop::new(1).unwrap(), @@ -12829,7 +11448,6 @@ mod tests { writer .encode(&ietf::PublishNamespaceUpdate { request_id: RequestId(5), - namespace_request_id: None, hops: None, cost: None, authorization_token: None, @@ -12870,8 +11488,9 @@ mod tests { settle().await; } assert!(ended, "closing the stream ends the announce"); - assert!( - control.accept(RequestId(5)).is_err(), + assert_eq!( + control.goaway().unwrap(), + RequestId(7), "the queued update's request ID was left unspent" ); } @@ -12891,7 +11510,6 @@ mod tests { writer .encode(&ietf::PublishNamespaceUpdate { request_id: RequestId(5), - namespace_request_id: None, hops: None, cost: None, authorization_token: Some(crate::ietf::token::RequestToken(crate::setup::Token { @@ -13070,8 +11688,6 @@ struct Update { filter: Option, /// History newly covered by a draft-22 widening. fill: Option, - /// Draft-14's mandatory Start Location, repeated as it stands; unused on later drafts. - start_location: ietf::Location, /// The aggregate subscriber priority, in the model's higher-first terms. priority: Option, /// Subscriber age budget, in milliseconds. @@ -13305,13 +11921,7 @@ mod filter_tests { ); } - const JOINING_DRAFTS: [Version; 5] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ]; + const JOINING_DRAFTS: [Version; 1] = [Version::Draft18]; /// Every draft-18 join is Largest Object plus a joining FETCH, whatever its start, /// so the current group's head arrives on the fetch stream and the live tail on the @@ -13393,7 +12003,7 @@ mod stitch_tests { let payload = payload.as_ref(); let mut properties = Vec::new(); let w = &mut Encoder::new(&mut properties, VERSION.into()); - ietf::encode_object_time(w, timestamp(object_index), Timescale::MICRO, VERSION).unwrap(); + ietf::encode_object_time(w, timestamp(object_index), Timescale::MICRO).unwrap(); let properties = Some(properties); // The first object of the stream carries the absolute Group ID. From @@ -13462,7 +12072,6 @@ mod stitch_tests { &mut Encoder::new(&mut ext, VERSION.into()), timestamp(object), Timescale::MICRO, - VERSION, ) .unwrap(); let w = &mut crate::coding::Encoder::new(&mut buf, VERSION.into()); @@ -13514,7 +12123,6 @@ mod stitch_tests { &mut Encoder::new(&mut ext, version.into()), timestamp(object), Timescale::MICRO, - version, ) .unwrap(); crate::coding::Encoder::new(&mut buf, version.into()) @@ -13563,7 +12171,7 @@ mod stitch_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), origin, - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -13926,7 +12534,7 @@ mod stitch_tests { crate::time::sim(), crate::driver::Transport::new(downstream.clone()), served.consume(), - Control::new(None, false), + Control::new(false), None, peer, egress, @@ -14034,7 +12642,7 @@ mod stitch_tests { #[moq_clock::test] async fn passthrough_preserves_object_properties_through_dispatch() { - for version in [Version::Draft15, Version::Draft22] { + for version in [Version::Draft22] { let mut script = Vec::new(); ietf::GroupHeader { track_alias: ALIAS, @@ -14048,10 +12656,7 @@ mod stitch_tests { } .encode(&mut Encoder::new(&mut script, version.into()), version) .unwrap(); - let properties = match version { - Version::Draft15 => vec![2, 7, 5, 1, 42], - _ => vec![2, 7, 3, 1, 42], - }; + let properties = vec![2, 7, 3, 1, 42]; let mut encoder = Encoder::new(&mut script, version.into()); encoder.varint(0u64).unwrap(); encoder.bytes(&properties).unwrap(); @@ -14131,7 +12736,7 @@ mod stitch_tests { #[moq_clock::test] async fn passthrough_fetch_requires_marked_gaps_and_preserves_fields() { - for version in [Version::Draft15, Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { for marked in [false, true] { let properties = if marked { vec![0x3e, 6] } else { vec![] }; let second = if marked { vec![0x3e, 1] } else { vec![] }; @@ -14185,25 +12790,15 @@ mod stitch_tests { } } - /// Drafts 14 and 15 carry a relayed END_OF_GROUP object through FETCH, and the next - /// relay fills its cache from that answer. Later drafts end the answer at the last frame. + /// A relayed FETCH ends its answer at the last frame rather than carrying the + /// END_OF_GROUP object, and the next relay fills its cache from that answer. #[moq_clock::test] - async fn passthrough_fetch_keeps_the_end_of_group_object() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft22, - ] { - // Only draft-14 lets a status object carry properties, so later drafts cannot mark - // a gap before it. - let legacy = version == Version::Draft14; - let terminal = if legacy { (11, &[0x3e, 2][..]) } else { (9, &[][..]) }; + async fn passthrough_fetch_ends_at_the_last_frame() { + for version in [Version::Draft22] { let objects = [ (6, &[0x3e, 6][..], None), (8, &[0x3e, 1][..], None), - (terminal.0, terminal.1, Some(END_OF_GROUP)), + (9, &[][..], Some(END_OF_GROUP)), ]; let script = object_stream(version, true, &objects); let (bytes, log) = relay_passthrough(script, version, version, Some((0, 12))).await; @@ -14211,12 +12806,7 @@ mod stitch_tests { let mut decoder = Decoder::new(&bytes, version.into()); assert_eq!(decoder.varint().unwrap(), ietf::FetchOk::ID, "{version}"); let ok = ietf::FetchOk::decode(&mut decoder, version).unwrap(); - let carried = matches!(version, Version::Draft14 | Version::Draft15); - let last = match () { - _ if carried => terminal.0 + 1, - _ if Filter::is_draft22(version) => 8, - _ => 9, - }; + let last = if Filter::is_draft22(version) { 8 } else { 9 }; assert_eq!(ok.end_location.object, last, "{version}"); assert_eq!(decoder.varint().unwrap(), ietf::FetchHeader::TYPE); ietf::FetchHeader::decode(&mut decoder, version).unwrap(); @@ -14236,28 +12826,15 @@ mod stitch_tests { assert_eq!(frame.fields.as_ref().unwrap().id, id, "{version}"); } assert!(reader.read_frame().await.unwrap().is_none()); - let end_object = reader.end_object(); - assert_eq!( - end_object.as_ref().map(|end| end.id), - carried.then_some(terminal.0), - "{version}" - ); - if let Some(end) = end_object { - assert_eq!( - super::super::object::listed(&end.properties, version) - .first() - .map(|property| property.id), - legacy.then_some(0x3e) - ); - } + assert!(reader.end_object().is_none(), "{version}"); } } - /// Draft-15 on, only a Normal object may carry properties: a status object with them is - /// refused on ingest, and one relayed from draft-14 leaves without them. + /// Only a Normal object may carry properties: a status object with them is refused on + /// ingest. #[moq_clock::test] async fn passthrough_keeps_properties_off_status_objects() { - for version in [Version::Draft15, Version::Draft22] { + for version in [Version::Draft22] { for status in [END_OF_GROUP, END_OF_TRACK] { let script = object_stream(version, true, &[(0, &[], None), (1, &[0x20, 7], Some(status))]); let mut h = Harness::with_timescale(Fill::Done, vec![script], None); @@ -14270,170 +12847,6 @@ mod stitch_tests { ); } } - - // Draft-14 allows them, but END_OF_TRACK has nowhere to keep them: a standalone one - // is peeked before any group, and one after an object is ingested with the group. - let standalone: &[(u64, &[u8], Option)] = &[(0, &[0x20, 7], Some(END_OF_TRACK))]; - let trailing: &[(u64, &[u8], Option)] = &[(0, &[], None), (1, &[0x20, 7], Some(END_OF_TRACK))]; - for objects in [standalone, trailing] { - let script = object_stream(Version::Draft14, true, objects); - let mut h = Harness::with_timescale(Fill::Done, vec![script], None); - h.subscriber.version = Version::Draft14; - let mut stream = h.stream().await.with_version(Version::Draft14); - let result = h.subscriber.clone().recv_group(&mut stream).await; - assert!(matches!(result, Err(Error::Unsupported)), "{objects:?}: {result:?}"); - assert!( - matches!( - futures::FutureExt::now_or_never(h.track.closed()), - Some(Err(Error::Unsupported)) - ), - "{objects:?}: the subscription must end with the refusal" - ); - } - - let script = object_stream( - Version::Draft14, - true, - &[(0, &[], None), (1, &[0x20, 7], Some(END_OF_GROUP))], - ); - let (bytes, log) = relay_passthrough_stream(script, Version::Draft14, Version::Draft15).await; - assert!(log.resets().is_empty()); - let mut decoder = Decoder::new(&bytes, Version::Draft15.into()); - ietf::GroupHeader::decode(&mut decoder, Version::Draft15).unwrap(); - assert_eq!(decoder.varint().unwrap(), 0); - decoder.bytes().unwrap(); - assert_eq!(decoder.varint().unwrap(), 1); - assert_eq!(decoder.slice(1).unwrap(), &[99]); - assert_eq!(decoder.varint().unwrap(), 0); - assert!( - decoder.bytes().unwrap().is_empty(), - "the status object kept its properties" - ); - assert_eq!(decoder.varint().unwrap(), 0); - assert_eq!(decoder.varint().unwrap(), END_OF_GROUP); - assert!(decoder.is_empty()); - } - - /// Draft-14 lets a status object carry properties, and it has no frame to hold its - /// time, so a mutable Timestamp stays in its block on a timed track: through a subgroup - /// and a FETCH, and out again as it arrived. - #[moq_clock::test] - async fn a_status_object_keeps_its_timestamp() { - use futures::FutureExt; - - let version = Version::Draft14; - let status = [0x10, 7]; - let leaves = |properties: &crate::model::object::Properties| { - super::super::object::write(properties, Some(version), None, version, None).unwrap() - }; - - let script = object_stream( - version, - true, - &[(0, &[0x10, 5], None), (1, &status, Some(END_OF_GROUP))], - ); - let mut h = Harness::with_timescale(Fill::Done, vec![script], Some(Timescale::MILLI)); - h.subscriber.version = version; - let mut consumer = h.track.subscribe(None); - let mut stream = h.stream().await.with_version(version); - h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - let group = consumer.recv_group().now_or_never().unwrap().unwrap().unwrap(); - let end = group.end_object().expect("the END_OF_GROUP object"); - assert_eq!(leaves(&end.properties), status, "subgroup"); - - // A draft-14 FETCH object: Group, Subgroup, Object, priority, then its properties, - // an empty payload, and the status. - let mut objects = Vec::new(); - let w = &mut Encoder::new(&mut objects, version.into()); - for value in [SEQUENCE, 0, 0] { - w.varint(value).unwrap(); - } - w.u8(128); - w.bytes(&status).unwrap(); - w.varint(0u64).unwrap(); - w.varint(END_OF_GROUP).unwrap(); - let mut run = GroupFetchRun::new(version, objects).await; - let end = run - .recv_cached(Some(1), Some(Timescale::MILLI)) - .await - .unwrap() - .end_object() - .expect("the END_OF_GROUP object"); - assert_eq!(leaves(&end.properties), status, "fetch"); - } - - /// A draft-14 status object has no frame, so its block may fill the whole limit on a - /// timed track, though its list arrives before the status says so. A framed object's - /// block leaves room for the Timestamp egress restates. - #[moq_clock::test] - async fn a_status_object_fills_the_whole_block() { - let version = Version::Draft14; - let milli = Some(Timescale::MILLI); - // A block of `size` held bytes: one opaque property, its key, a four-byte length, - // then bytes. A framed object's list adds its Timestamp, which the frame holds. - let block = |size: usize, framed: bool| { - let mut block = Vec::new(); - let w = &mut Encoder::new(&mut block, version.into()); - if framed { - w.varint(0x10u64).unwrap(); - w.varint(5u64).unwrap(); - } - w.varint(0x21u64).unwrap(); - w.bytes(&vec![7; size - 5]).unwrap(); - block - }; - let full = block(super::super::object::MAX_BLOCK, false); - let limit = super::super::object::max_block(milli); - - for (properties, status, ok) in [ - (&full, Some(END_OF_GROUP), true), - (&block(limit + 1, true), None, false), - (&block(limit, true), None, true), - ] { - let script = object_stream(version, true, &[(0, properties, status)]); - let mut h = Harness::with_timescale(Fill::Done, vec![script], milli); - h.subscriber.version = version; - let mut stream = h.stream().await.with_version(version); - let result = h.subscriber.clone().recv_group(&mut stream).await; - match ok { - true => assert!(result.is_ok(), "subgroup {}: {result:?}", properties.len()), - false => assert!(matches!(result, Err(Error::Unsupported)), "subgroup: {result:?}"), - } - - // The fetch harness's track is untimed, so a framed object it keeps cannot be timed. - if ok && status.is_none() { - continue; - } - // A draft-14 FETCH object: Group, Subgroup, Object, priority, then its properties, - // and an empty payload with its status or a one-byte payload. - let mut objects = Vec::new(); - let w = &mut Encoder::new(&mut objects, version.into()); - for value in [SEQUENCE, 0, 0] { - w.varint(value).unwrap(); - } - w.u8(128); - w.bytes(properties).unwrap(); - match status { - Some(status) => { - w.varint(0u64).unwrap(); - w.varint(status).unwrap(); - } - None => { - w.varint(1u64).unwrap(); - w.u8(99); - } - } - let mut run = GroupFetchRun::new(version, objects).await; - let result = run.recv_cached(Some(1), milli).await; - match &result { - Ok(_) => assert!(ok, "fetch {}: cached", properties.len()), - Err(err) => assert!(!ok && matches!(err, Error::Unsupported), "fetch: {err:?}"), - } - if status.is_some() { - let end = result.unwrap().end_object().expect("the END_OF_GROUP object"); - assert_eq!(end.properties.heap(), full.len() as u64); - } - } } #[moq_clock::test] @@ -14451,7 +12864,7 @@ mod stitch_tests { #[moq_clock::test] async fn passthrough_fetch_ingest_keeps_marked_gaps_and_stitches_its_tail() { - for version in [Version::Draft15, Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { let mut objects = Vec::new(); for (index, id) in [6u64, 8].into_iter().enumerate() { let properties = vec![0x3e, if index == 0 { 6 } else { 1 }]; @@ -14517,7 +12930,7 @@ mod stitch_tests { #[moq_clock::test] async fn passthrough_preserves_ids_first_object_and_end_status() { - for version in [Version::Draft15, Version::Draft22] { + for version in [Version::Draft22] { for first in [false, true] { let objects = [ (6, &[][..], None), @@ -14529,7 +12942,7 @@ mod stitch_tests { assert!(log.resets().is_empty()); let mut decoder = Decoder::new(&bytes, version.into()); let header = ietf::GroupHeader::decode(&mut decoder, version).unwrap(); - assert_eq!(header.flags.first_object, version == Version::Draft15 || first); + assert_eq!(header.flags.first_object, first); for (delta, status) in [(6, None), (1, None), (2, Some(END_OF_GROUP))] { assert_eq!(decoder.varint().unwrap(), delta); assert!(decoder.bytes().unwrap().is_empty()); @@ -14546,40 +12959,21 @@ mod stitch_tests { } #[moq_clock::test] - async fn passthrough_preserves_immutable_bytes_and_refuses_incompatible_drafts() { - for ingress in [ - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + async fn passthrough_preserves_immutable_bytes() { + for ingress in [Version::Draft18, Version::Draft22] { let mut inner = Vec::new(); let mut encoder = Encoder::new(&mut inner, ingress.into()); - for value in [2u64, 96_000, if ingress == Version::Draft15 { 4 } else { 2 }, 128] { + for value in [2u64, 96_000, 2, 128, 1] { encoder.varint(value).unwrap(); } - encoder - .varint(if ingress == Version::Draft15 { 5u64 } else { 1u64 }) - .unwrap(); encoder.bytes(&[42; 130]).unwrap(); let mut properties = Vec::new(); let mut encoder = Encoder::new(&mut properties, ingress.into()); encoder.varint(0x0bu64).unwrap(); encoder.bytes(&inner).unwrap(); - for egress in [ - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for egress in [Version::Draft18, Version::Draft22] { let script = object_stream(ingress, true, &[(0, &properties, None)]); let (bytes, log) = relay_passthrough_stream(script, ingress, egress).await; - if !super::super::object::same_bytes(ingress, egress) { - assert!(!log.resets().is_empty(), "incompatible immutable bytes must be refused"); - continue; - } assert!(log.resets().is_empty()); let mut decoder = Decoder::new(&bytes, egress.into()); ietf::GroupHeader::decode(&mut decoder, egress).unwrap(); @@ -14627,237 +13021,127 @@ mod stitch_tests { #[moq_clock::test] async fn passthrough_preserves_datagram_objects_through_dispatch() { // Also through a second relay behind a moq-lite hop with the `ietf` extension. - for (version, ranged, hop) in [Version::Draft15, Version::Draft22] + for (version, ranged, hop) in [Version::Draft22] .into_iter() .flat_map(|version| [(version, false), (version, true)]) .filter(|(version, ranged)| !*ranged || *version == Version::Draft22) .flat_map(|(version, ranged)| [(version, ranged, false), (version, ranged, true)]) { - { - let origin = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); - let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); - let track = broadcast - .create_track("video", accepted_info(&Default::default(), version)) - .unwrap(); - let mut h = Harness::with_timescale(Fill::Done, vec![], None); - h.subscriber.version = version; - h.subscriber.state.lock().subscribes.get_mut(&REQUEST).unwrap().producer = Some(track.clone()); - let properties = if version == Version::Draft15 { - vec![2, 7, 5, 1, 42] - } else { - vec![2, 7, 3, 1, 42] - }; - let mut original = Vec::new(); - for id in [6, 7, 8] { - let datagram = ietf::ObjectDatagram { - track_alias: ALIAS, - group_id: SEQUENCE, - object_id: Some(id), - // An omitted priority inherits the track default, which the - // downstream response need not carry. - publisher_priority: (id != 6).then_some(200), - end_of_group: false, - properties: (id != 8).then(|| properties.clone()), - body: if id == 8 { - ietf::DatagramBody::Status(END_OF_GROUP) - } else { - ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object")) - }, - }; - h.subscriber - .recv_datagram(datagram.encode_bytes(version).unwrap()) - .unwrap(); - original.push(datagram); - } - track.finish().unwrap(); - let (served, _sessions) = match hop { - true => { - let (served, sessions) = crate::lite::ietf::test_hop(&origin, "room", [true, true]).await; - (served, Some(sessions)) - } - false => (origin, None), - }; - let downstream = ScriptedSession::per_stream(vec![vec![]]).with_datagrams(65535); - let peer = peer::PeerSetup::default(); - peer.set(peer::Peer::default()); - let publisher = crate::ietf::Publisher::new( - crate::time::sim(), - crate::driver::Transport::new(downstream.clone()), - served.consume(), - Control::new(None, false), - None, - peer, - version, - ); - let subscribe = ietf::Subscribe { - max_age: None, - new_group: None, - request_id: RequestId(3), - track_namespace: Path::from_static("room"), - track_name: "video".into(), - subscriber_priority: 128, - group_order: ietf::GroupOrder::Ascending, - filter: if ranged { - Filter::Absolute { - start: ietf::Location { - group: SEQUENCE, - object: 7, - }, - end: Some(ietf::EndLocation { - group: SEQUENCE, - object: Some(7), - }), - } - } else { - Filter::Unfiltered - }, - fill: None, - properties_wanted: true, - forward: true, - rendezvous: false, - range_filters: false, - authorization_token: None, - epoch: None, - }; - let mut body = Vec::new(); - subscribe - .encode_msg(&mut Encoder::new(&mut body, version.into()), version) - .unwrap(); - let stream = Stream::open(&mut crate::driver::Transport::new(downstream.clone()), version) - .await - .unwrap(); - publisher - .handle_stream(ietf::Subscribe::ID, ietf::Body(body.into()), stream, false) - .unwrap() - .await; - let sent = downstream.log.datagrams.lock().unwrap(); - original.retain(|datagram| !ranged || datagram.object_id == Some(7)); - assert_eq!(sent.len(), original.len(), "{version} ranged={ranged} hop={hop}"); - let inherited = super::super::priority::to_wire(track.publisher_priority()); - for (bytes, mut expected) in sent.iter().zip(original) { - expected.track_alias = 3; - expected.publisher_priority = expected.publisher_priority.or(Some(inherited)); - let (actual, _) = ietf::ObjectDatagram::decode_slice(bytes, version).unwrap(); - assert_eq!(actual, expected); - } - } - } - } - - /// A status datagram's properties are stripped, not the datagram dropped, where the - /// egress draft forbids them beside a non-Normal status. - #[moq_clock::test] - async fn passthrough_strips_status_datagram_properties_a_draft_forbids() { - for ingress in [Version::Draft14, Version::Draft15, Version::Draft16] { - let egresses = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; - let pairs = egresses - .into_iter() - .flat_map(|egress| [END_OF_GROUP, END_OF_TRACK].map(|status| (egress, status))); - for (egress, status) in pairs { - let origin = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); - let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); - let track = broadcast - .create_track("video", accepted_info(&Default::default(), ingress)) - .unwrap(); - let mut h = Harness::with_timescale(Fill::Done, vec![], None); - h.subscriber.version = ingress; - h.subscriber.state.lock().subscribes.get_mut(&REQUEST).unwrap().producer = Some(track.clone()); - let mut properties = Vec::new(); - let mut encoder = Encoder::new(&mut properties, ingress.into()); - encoder.varint(0x80u64).unwrap(); - encoder.varint(96_000u64).unwrap(); + let origin = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let track = broadcast + .create_track("video", accepted_info(&Default::default(), version)) + .unwrap(); + let mut h = Harness::with_timescale(Fill::Done, vec![], None); + h.subscriber.version = version; + h.subscriber.state.lock().subscribes.get_mut(&REQUEST).unwrap().producer = Some(track.clone()); + let properties = vec![2, 7, 3, 1, 42]; + let mut original = Vec::new(); + for id in [6, 7, 8] { let datagram = ietf::ObjectDatagram { track_alias: ALIAS, group_id: SEQUENCE, - object_id: Some(5), - publisher_priority: Some(200), + object_id: Some(id), + // An omitted priority inherits the track default, which the + // downstream response need not carry. + publisher_priority: (id != 6).then_some(200), end_of_group: false, - properties: Some(properties), - body: ietf::DatagramBody::Status(status), - }; - h.subscriber - .recv_datagram(datagram.encode_bytes(ingress).unwrap()) - .unwrap(); - track.finish().unwrap(); - let downstream = ScriptedSession::per_stream(vec![vec![]]).with_datagrams(65535); - let peer = peer::PeerSetup::default(); - peer.set(peer::Peer::default()); - let publisher = crate::ietf::Publisher::new( - crate::time::sim(), - crate::driver::Transport::new(downstream.clone()), - origin.consume(), - Control::new(None, false), - None, - peer, - egress, - ); - let subscribe = ietf::Subscribe { - max_age: None, - new_group: None, - request_id: RequestId(3), - track_namespace: Path::from_static("room"), - track_name: "video".into(), - subscriber_priority: 128, - group_order: ietf::GroupOrder::Ascending, - filter: Filter::Unfiltered, - fill: None, - properties_wanted: true, - forward: true, - rendezvous: false, - range_filters: false, - authorization_token: None, - epoch: None, - }; - let mut body = Vec::new(); - subscribe - .encode_msg(&mut Encoder::new(&mut body, egress.into()), egress) - .unwrap(); - let stream = Stream::open(&mut crate::driver::Transport::new(downstream.clone()), egress) - .await - .unwrap(); - publisher - .handle_stream(ietf::Subscribe::ID, ietf::Body(body.into()), stream, false) - .unwrap() - .await; - let sent = downstream.log.datagrams.lock().unwrap(); - let context = format!("{ingress} to {egress}, status {status}"); - let [bytes] = sent.as_slice() else { - panic!("{context}: sent {} datagrams", sent.len()); + properties: (id != 8).then(|| properties.clone()), + body: if id == 8 { + ietf::DatagramBody::Status(END_OF_GROUP) + } else { + ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object")) + }, }; - let (actual, _) = ietf::ObjectDatagram::decode_slice(bytes, egress).unwrap(); - let carried = matches!(egress, Version::Draft14 | Version::Draft15 | Version::Draft16); - assert_eq!( - (actual.object_id, actual.body, actual.properties.is_some()), - (Some(5), ietf::DatagramBody::Status(status), carried), - "{context}" - ); + h.subscriber + .recv_datagram(datagram.encode_bytes(version).unwrap()) + .unwrap(); + original.push(datagram); + } + track.finish().unwrap(); + let (served, _sessions) = match hop { + true => { + let (served, sessions) = crate::lite::ietf::test_hop(&origin, "room", [true, true]).await; + (served, Some(sessions)) + } + false => (origin, None), + }; + let downstream = ScriptedSession::per_stream(vec![vec![]]).with_datagrams(65535); + let peer = peer::PeerSetup::default(); + peer.set(peer::Peer::default()); + let publisher = crate::ietf::Publisher::new( + crate::time::sim(), + crate::driver::Transport::new(downstream.clone()), + served.consume(), + Control::new(false), + None, + peer, + version, + ); + let subscribe = ietf::Subscribe { + max_age: None, + new_group: None, + request_id: RequestId(3), + track_namespace: Path::from_static("room"), + track_name: "video".into(), + subscriber_priority: 128, + group_order: ietf::GroupOrder::Ascending, + filter: if ranged { + Filter::Absolute { + start: ietf::Location { + group: SEQUENCE, + object: 7, + }, + end: Some(ietf::EndLocation { + group: SEQUENCE, + object: Some(7), + }), + } + } else { + Filter::Unfiltered + }, + fill: None, + properties_wanted: true, + forward: true, + rendezvous: false, + range_filters: false, + authorization_token: None, + epoch: None, + }; + let mut body = Vec::new(); + subscribe + .encode_msg(&mut Encoder::new(&mut body, version.into()), version) + .unwrap(); + let stream = Stream::open(&mut crate::driver::Transport::new(downstream.clone()), version) + .await + .unwrap(); + publisher + .handle_stream(ietf::Subscribe::ID, ietf::Body(body.into()), stream, false) + .unwrap() + .await; + let sent = downstream.log.datagrams.lock().unwrap(); + original.retain(|datagram| !ranged || datagram.object_id == Some(7)); + assert_eq!(sent.len(), original.len(), "{version} ranged={ranged} hop={hop}"); + let inherited = super::super::priority::to_wire(track.publisher_priority()); + for (bytes, mut expected) in sent.iter().zip(original) { + expected.track_alias = 3; + expected.publisher_priority = expected.publisher_priority.or(Some(inherited)); + let (actual, _) = ietf::ObjectDatagram::decode_slice(bytes, version).unwrap(); + assert_eq!(actual, expected); } } } #[moq_clock::test] async fn passthrough_translates_mutable_property_encodings() { - for ingress in [Version::Draft14, Version::Draft15, Version::Draft17, Version::Draft22] { + for ingress in [Version::Draft22] { let mut properties = Vec::new(); let mut encoder = Encoder::new(&mut properties, ingress.into()); encoder.varint(0x80u64).unwrap(); encoder.varint(96_000u64).unwrap(); - encoder - .varint(if matches!(ingress, Version::Draft14 | Version::Draft15) { - 0x81u64 - } else { - 1u64 - }) - .unwrap(); + encoder.varint(1u64).unwrap(); encoder.bytes(&[42; 130]).unwrap(); - for egress in [Version::Draft15, Version::Draft17, Version::Draft22] { + for egress in [Version::Draft22] { let script = object_stream(ingress, true, &[(0, &properties, None)]); let (bytes, log) = relay_passthrough_stream(script, ingress, egress).await; assert!(log.resets().is_empty()); @@ -14867,10 +13151,7 @@ mod stitch_tests { let mut props = Decoder::new(decoder.bytes().unwrap(), egress.into()); assert_eq!(props.varint().unwrap(), 0x80); assert_eq!(props.varint().unwrap(), 96_000); - assert_eq!( - props.varint().unwrap(), - if egress == Version::Draft15 { 0x81 } else { 1 } - ); + assert_eq!(props.varint().unwrap(), 1); assert_eq!(props.bytes().unwrap(), &[42; 130]); assert!(props.is_empty()); } @@ -14879,7 +13160,7 @@ mod stitch_tests { #[moq_clock::test] async fn passthrough_accepts_nonzero_first_objects_and_gaps() { - for version in [Version::Draft15, Version::Draft22] { + for version in [Version::Draft22] { let mut script = Vec::new(); ietf::GroupHeader { track_alias: ALIAS, @@ -14908,28 +13189,27 @@ mod stitch_tests { /// A datagram's object number does not change its public payload view. #[moq_clock::test] async fn passthrough_accepts_datagrams_past_object_zero() { - for version in [Version::Draft15, Version::Draft22] { - let mut h = Harness::with_timescale(Fill::Done, vec![], None); - h.subscriber.version = version; - let mut consumer = h.track.subscribe(None); - let bytes = ietf::ObjectDatagram { - track_alias: ALIAS, - group_id: SEQUENCE, - object_id: Some(6), - publisher_priority: Some(128), - end_of_group: false, - properties: None, - body: ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object-six")), - } - .encode_bytes(version) - .unwrap(); - h.subscriber.recv_datagram(bytes).unwrap(); - let received = consumer.poll_recv_datagram(&kio::Waiter::noop()); - assert!( - matches!(&received, Poll::Ready(Ok(Some(data))) if data.payload == b"object-six"[..]), - "{version}: {received:?}" - ); + let version = Version::Draft22; + let mut h = Harness::with_timescale(Fill::Done, vec![], None); + h.subscriber.version = version; + let mut consumer = h.track.subscribe(None); + let bytes = ietf::ObjectDatagram { + track_alias: ALIAS, + group_id: SEQUENCE, + object_id: Some(6), + publisher_priority: Some(128), + end_of_group: false, + properties: None, + body: ietf::DatagramBody::Payload(bytes::Bytes::from_static(b"object-six")), } + .encode_bytes(version) + .unwrap(); + h.subscriber.recv_datagram(bytes).unwrap(); + let received = consumer.poll_recv_datagram(&kio::Waiter::noop()); + assert!( + matches!(&received, Poll::Ready(Ok(Some(data))) if data.payload == b"object-six"[..]), + "{version}: {received:?}" + ); } #[moq_clock::test] @@ -14947,47 +13227,6 @@ mod stitch_tests { ); } - /// Old drafts preserve a nonzero first Object ID without inventing missing frames. - #[moq_clock::test] - async fn old_drafts_accept_a_nonzero_first_object() { - for version in [Version::Draft14, Version::Draft16] { - let scripts = [2u64, 0] - .into_iter() - .enumerate() - .map(|(index, first)| { - let mut bytes = Vec::new(); - ietf::GroupHeader { - track_alias: ALIAS, - group_id: SEQUENCE + index as u64, - sub_group_id: 0, - publisher_priority: 0, - flags: ietf::GroupFlags { - first_object: true, - ..Default::default() - }, - } - .encode(&mut crate::coding::Encoder::new(&mut bytes, version.into()), version) - .unwrap(); - let mut encoder = crate::coding::Encoder::new(&mut bytes, version.into()); - encoder.varint(first).unwrap(); - encoder.varint(1u64).unwrap(); - bytes.push(42); - bytes - }) - .collect(); - let mut h = Harness::with_timescale(Fill::Done, scripts, None); - h.subscriber.version = version; - let mut consumer = h.track.subscribe(None); - let mut stream = h.stream().await.with_version(version); - h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - assert_eq!(h.track.latest_sequence(), Some(SEQUENCE)); - assert_eq!(read_group(&mut consumer).await, (SEQUENCE, vec![(None, vec![42])])); - let mut stream = h.stream().await.with_version(version); - h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - assert_eq!(read_group(&mut consumer).await, (SEQUENCE + 1, vec![(None, vec![42])])); - } - } - /// A subgroup 1 stream costs only itself: the track's subgroup 0 stream still arrives. #[moq_clock::test] async fn a_non_zero_subgroup_leaves_the_track_flowing() { @@ -15028,7 +13267,7 @@ mod stitch_tests { /// is subgroup 0. #[moq_clock::test] async fn a_compact_non_zero_subgroup_is_refused() { - for version in [Version::Draft14, Version::Draft22] { + for version in [Version::Draft22] { let compact = |sequence: u64, first: Option| { let mut bytes = Vec::new(); ietf::GroupHeader { @@ -15609,7 +13848,7 @@ mod stitch_tests { let payloads: [&[u8]; 5] = [b"o0", b"o1", b"o2", b"o3", b"o4"]; let bytes = end_marker(draft18_subgroup(SEQUENCE, true, 0, &payloads), END_OF_GROUP); - let flags = ietf::GroupFlags::decode(u64::from(bytes[0]), Version::Draft18).unwrap(); + let flags = ietf::GroupFlags::decode(u64::from(bytes[0])).unwrap(); assert!(flags.has_end, "the header marks the group's end"); let mut h = Harness::new(Fill::Done, vec![bytes]); @@ -15906,22 +14145,11 @@ mod stitch_tests { assert_eq!(frames[0].1, b"whole-0"); } - /// From draft-18 on, a later object's Group ID field is a delta: 7 then 8 is 0, not 8. - /// Draft-17 and earlier still send the absolute ID. + /// A later object's Group ID field is a delta: 7 then 8 is 0, not 8. #[test] fn a_later_fetch_group_field_is_a_delta() { - assert_eq!( - resolve_fetch_group(Version::Draft18, Some(7), Some(0)).unwrap(), - Some(8) - ); - assert_eq!( - resolve_fetch_group(Version::Draft22, Some(7), Some(0)).unwrap(), - Some(8) - ); - assert_eq!( - resolve_fetch_group(Version::Draft17, Some(7), Some(8)).unwrap(), - Some(8) - ); + assert_eq!(resolve_fetch_group(Some(7), Some(0)).unwrap(), Some(8)); + assert_eq!(resolve_fetch_group(Some(7), Some(1)).unwrap(), Some(9)); } /// A joining FETCH carries the one group its subscription joins, so a second group on @@ -15941,48 +14169,6 @@ mod stitch_tests { )); } - /// A draft-14/15 end-of-group marker ends a group fetch, so an object after it is a - /// violation rather than another frame. - #[moq_clock::test] - async fn a_group_fetch_refuses_an_object_past_its_end_marker() { - const DRAFT: Version = Version::Draft15; - let header = |object: u64| ietf::FetchObject::Object { - subgroup: ietf::FetchSubgroup::Zero, - group: Some(SEQUENCE), - object: Some(object), - priority: Some(0), - properties: None, - }; - let mut buf = Vec::new(); - header(0) - .encode(&mut crate::coding::Encoder::new(&mut buf, DRAFT.into()), DRAFT) - .unwrap(); - crate::coding::Encoder::new(&mut buf, DRAFT.into()) - .varint(1u64) - .unwrap(); - buf.extend_from_slice(b"a"); - header(1) - .encode(&mut crate::coding::Encoder::new(&mut buf, DRAFT.into()), DRAFT) - .unwrap(); - crate::coding::Encoder::new(&mut buf, DRAFT.into()) - .varint(0u64) - .unwrap(); - crate::coding::Encoder::new(&mut buf, DRAFT.into()) - .varint(END_OF_GROUP) - .unwrap(); - header(1) - .encode(&mut crate::coding::Encoder::new(&mut buf, DRAFT.into()), DRAFT) - .unwrap(); - crate::coding::Encoder::new(&mut buf, DRAFT.into()) - .varint(1u64) - .unwrap(); - buf.extend_from_slice(b"b"); - - let mut run = GroupFetchRun::new(DRAFT, buf.to_vec()).await; - let res = run.recv_objects().await; - assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); - } - /// The first object on a fetch stream has no prior object, so leaving any field to /// the prior one is a violation, not "the requested group, from its start". #[moq_clock::test] @@ -16003,7 +14189,7 @@ mod stitch_tests { header(Some(SEQUENCE), Some(0), Zero, None), ]; - for version in [Version::Draft15, VERSION] { + for version in [VERSION] { for header in &headers { let mut buf = Vec::new(); header @@ -16127,11 +14313,11 @@ mod stitch_tests { } } - /// One Object published as a datagram, as draft-16 on lets a fetch stream carry it. + /// One Object published as a datagram, as a fetch stream may carry it. fn datagram_object(sequence: u64) -> Vec { let mut properties = Vec::new(); let w = &mut Encoder::new(&mut properties, VERSION.into()); - ietf::encode_object_time(w, timestamp(0), Timescale::MICRO, VERSION).unwrap(); + ietf::encode_object_time(w, timestamp(0), Timescale::MICRO).unwrap(); let mut buf = Vec::new(); ietf::FetchObject::Object { subgroup: ietf::FetchSubgroup::Datagram, @@ -16166,13 +14352,7 @@ mod stitch_tests { #[moq_clock::test] async fn a_range_fetch_round_trips_with_the_groups_between_dropped() { use crate::lite::test_transport::Sent; - for version in [ - Version::Draft14, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { let origin = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); let track = broadcast @@ -16227,7 +14407,7 @@ mod stitch_tests { crate::time::sim(), crate::driver::Transport::new(downstream.clone()), origin.consume(), - Control::new(None, false), + Control::new(false), None, peer, version, @@ -16428,7 +14608,7 @@ mod stitch_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -16510,13 +14690,7 @@ mod joining_fetch_tests { }; use futures::FutureExt; - const JOINING_DRAFTS: [Version; 5] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ]; + const JOINING_DRAFTS: [Version; 1] = [Version::Draft18]; async fn settle() { moq_clock::sleep(Duration::from_millis(1)).await; @@ -16536,10 +14710,6 @@ mod joining_fetch_tests { message_bytes( ietf::SubscribeOk::ID, &ietf::SubscribeOk { - request_id: match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(1)), - _ => None, - }, track_alias: 7, largest, properties: Default::default(), @@ -16552,11 +14722,6 @@ mod joining_fetch_tests { message_bytes( ietf::FetchOk::ID, &ietf::FetchOk { - request_id: match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(3)), - _ => None, - }, - group_order: GroupOrder::Ascending, end_of_track: false, end_location: ietf::Location { group: 4, object: 2 }, properties: Default::default(), @@ -16566,37 +14731,15 @@ mod joining_fetch_tests { } fn fetch_error(version: Version) -> Vec { - match version { - Version::Draft14 => message_bytes( - ietf::FetchError::ID, - &ietf::FetchError { - request_id: RequestId(3), - error_code: request::NOT_SUPPORTED, - reason_phrase: "refused".into(), - }, - version, - ), - Version::Draft15 | Version::Draft16 => message_bytes( - ietf::RequestError::ID, - &ietf::RequestError { - request_id: Some(RequestId(3)), - error_code: request::NOT_SUPPORTED, - reason_phrase: "refused".into(), - retry_interval: 0, - }, - version, - ), - _ => message_bytes( - ietf::RequestError::ID, - &ietf::RequestError { - request_id: None, - error_code: request::NOT_SUPPORTED, - reason_phrase: "refused".into(), - retry_interval: 0, - }, - version, - ), - } + message_bytes( + ietf::RequestError::ID, + &ietf::RequestError { + error_code: request::NOT_SUPPORTED, + reason_phrase: "refused".into(), + retry_interval: 0, + }, + version, + ) } fn decode_messages(log: &crate::lite::test_transport::Log, version: Version) -> Vec<(u64, bytes::Bytes)> { @@ -16631,7 +14774,7 @@ mod joining_fetch_tests { impl JoinRun { async fn start(version: Version, start: Option, ok: Vec, fetch: Vec) -> Self { - Self::start_with_control(version, start, [ok, fetch], Control::new(None, false)).await + Self::start_with_control(version, start, [ok, fetch], Control::new(false)).await } async fn start_with_control( @@ -16701,45 +14844,13 @@ mod joining_fetch_tests { } } - /// A peer withholding joining-FETCH credit cannot keep an unauthorized track alive. - #[moq_clock::test] - async fn revocation_does_not_wait_for_joining_fetch_credit() { - let version = Version::Draft14; - let mut run = JoinRun::start_with_control( - version, - None, - [ - subscribe_ok(version, Some(ietf::Location { group: 4, object: 1 })), - fetch_ok(version), - ], - Control::new(Some(RequestId(3)), false), - ) - .await; - assert!(run.fill_outstanding(), "accepted subscription awaits its joining FETCH"); - assert!( - !decode_messages(&run.session.log, version) - .iter() - .any(|(id, _)| *id == ietf::Fetch::ID) - ); - run.subscriber.auth.handle().authorize(&crate::auth::Grant::default()); - settle().await; - let mut received = (&mut run._hold.2) - .now_or_never() - .expect("SUBSCRIBE_OK accepted the track") - .unwrap(); - assert!(matches!( - received.finished().now_or_never(), - Some(Err(Error::Unauthorized)) - )); - } - fn pending_join(session: Transport, version: Version) -> (Subscriber, track::Producer, TaskSet) { let (tasks, task_set) = TaskSet::new(); let subscriber = Subscriber::new( crate::time::sim(), session, crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -16766,7 +14877,7 @@ mod joining_fetch_tests { /// The parent keeps its child FETCH cancellation moving while its own FIN awaits ACK. async fn revoked_child_fetch(accepted: bool) { - for version in [Version::Draft14, Version::Draft16, Version::Draft17, Version::Draft22] { + for version in [Version::Draft22] { let session = ScriptedSession::per_stream(vec![ subscribe_ok(version, None), if accepted { fetch_ok(version) } else { Vec::new() }, @@ -16778,7 +14889,7 @@ mod joining_fetch_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -16821,13 +14932,6 @@ mod joining_fetch_tests { let stops = log.stops().len(); // One stop belongs to SUBSCRIBE, another to the cache-miss FETCH. assert!(stops >= 2, "{version}: only {stops} streams cancelled before ACK"); - if matches!(version, Version::Draft14 | Version::Draft16) { - assert!( - decode_messages(&log, version) - .iter() - .any(|(id, _)| *id == ietf::FetchCancel::ID) - ); - } drop(received); } } @@ -16885,37 +14989,22 @@ mod joining_fetch_tests { joining.poll(&kio::Waiter::noop()).is_ready(), "{version}: invalid joining open stayed alive" ); - assert_eq!( - subscriber.control.next_request_id(&subscriber.runtime).await.unwrap(), - RequestId(1) - ); + assert_eq!(subscriber.control.next_request_id(), RequestId(1)); assert_eq!(log.bi_opens(), 0); } } } #[moq_clock::test] - async fn a_joining_fetch_waits_without_spending_request_credit() { + async fn a_joining_fetch_waits_for_stream_credit() { for version in JOINING_DRAFTS { let session = ScriptedSession::new(Vec::new()).without_credit(); - let (mut subscriber, track, _tasks) = pending_join(crate::driver::Transport::new(session.clone()), version); - subscriber.control = Control::new( - match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(2)), - _ => None, - }, - false, - ); + let (subscriber, track, _tasks) = pending_join(crate::driver::Transport::new(session.clone()), version); let mut joining = subscriber.start_joining_fetch(RequestId(99), &track).unwrap(); assert!( joining.poll(&kio::Waiter::noop()).is_pending(), "{version}: a subscription owns its joining FETCH" ); - assert_eq!( - subscriber.control.poll_next_request_id(&kio::Waiter::noop()), - Poll::Ready(RequestId(1)), - "{version}: waiting spends neither an ID nor MAX_REQUEST_ID credit" - ); assert_eq!(session.log.bi_opens(), 0); session.grant_credit(); assert!(joining.poll(&kio::Waiter::noop()).is_pending()); @@ -16936,80 +15025,11 @@ mod joining_fetch_tests { drop(joining); *gate.write().ok().expect("gate is open") = true; let _ = tasks.poll(&kio::Waiter::noop()); - assert_eq!( - subscriber.control.next_request_id(&subscriber.runtime).await.unwrap(), - RequestId(1) - ); + assert_eq!(subscriber.control.next_request_id(), RequestId(1)); assert_eq!(log.bi_opens(), 0, "{version}: the abandoned open is gone"); } } - /// A cancelled partial legacy header still precedes the next unrelated request ID. - #[moq_clock::test] - async fn cancelling_a_partial_joining_fetch_preserves_request_id_order() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let gate = kio::Producer::new(false); - let session = crate::lite::test_transport::SinkSession::gated_bi_prefix(2, gate.consume()); - let log = session.log.clone(); - let (subscriber, track, mut tasks) = pending_join(crate::driver::Transport::new(session.clone()), version); - let mut joining = subscriber.start_joining_fetch(RequestId(99), &track).unwrap(); - assert!(joining.poll(&kio::Waiter::noop()).is_pending()); - assert_eq!(log.writes.lock().unwrap().len(), 2, "partial header"); - let mut unrelated = std::pin::pin!(subscriber.control.next_request_id(&subscriber.runtime)); - assert!(kio::Waiter::noop().poll_future(unrelated.as_mut()).is_pending()); - drop(joining); - let _ = tasks.poll(&kio::Waiter::noop()); - assert!(kio::Waiter::noop().poll_future(unrelated.as_mut()).is_pending()); - *gate.write().ok().expect("gate is open") = true; - let next = kio::wait(|waiter| { - let _ = tasks.poll(waiter); - waiter.poll_future(unrelated.as_mut()) - }) - .await - .unwrap(); - assert_eq!(next, RequestId(3), "{version}: no ID was skipped or reused"); - let messages = decode_messages(&log, version); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].0, ietf::Fetch::ID); - let mut body = messages[0].1.clone(); - let fetch = crate::coding::decode_buf(&mut body, version, ietf::Fetch::decode_msg).unwrap(); - assert_eq!(fetch.request_id, RequestId(1)); - assert!(log.resets().is_empty(), "header and FIN must survive cancellation"); - assert!( - log.trail() - .iter() - .any(|(_, sent)| matches!(sent, crate::lite::test_transport::Sent::Finish)) - ); - } - } - - /// A peer that never acknowledges cancellation cannot retain request-ID allocation. - #[moq_clock::test] - async fn joining_fetch_cleanup_does_not_hold_the_next_request_id() { - for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - let session = ScriptedSession::per_stream(Vec::new()).with_unacked_fin(); - let log = session.log.clone(); - let (subscriber, track, mut tasks) = pending_join(crate::driver::Transport::new(session.clone()), version); - let mut joining = subscriber.start_joining_fetch(RequestId(99), &track).unwrap(); - assert!(joining.poll(&kio::Waiter::noop()).is_pending()); - drop(joining); - assert!( - tasks.poll(&kio::Waiter::noop()).is_pending(), - "FIN remains unacknowledged" - ); - assert_eq!( - subscriber.control.next_request_id(&subscriber.runtime).await.unwrap(), - RequestId(3) - ); - assert!( - log.trail() - .iter() - .any(|(_, sent)| matches!(sent, crate::lite::test_transport::Sent::Finish)) - ); - assert!(log.resets().is_empty()); - } - } - /// Every draft-18 live join is Largest Object on the SUBSCRIBE and a relative /// joining FETCH at offset 0 that names the subscribe's request id. #[moq_clock::test] @@ -17121,9 +15141,7 @@ mod joining_fetch_tests { ok.extend(message_bytes( ietf::PublishDone::ID, &ietf::PublishDone { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(1)), - status_code: ietf::PublishDoneStatus::TrackEnded.code(version), + status_code: ietf::PublishDoneStatus::TrackEnded.code(), stream_count: 0, reason_phrase: "done".into(), }, @@ -17144,23 +15162,15 @@ mod joining_fetch_tests { let w = &mut Encoder::new(&mut objects, version.into()); w.varint(ietf::FetchHeader::TYPE).unwrap(); ietf::FetchHeader { request_id: fetch_id }.encode(w, version).unwrap(); - if version == Version::Draft14 { - w.varint(4).unwrap(); // group - w.varint(0).unwrap(); // subgroup - w.varint(0).unwrap(); // object - w.u8(0); // priority - w.bytes(&[]).unwrap(); // properties - } else { - ietf::FetchObject::Object { - subgroup: ietf::FetchSubgroup::Zero, - group: Some(4), - object: Some(0), - priority: Some(0), - properties: None, - } - .encode(w, version) - .unwrap(); + ietf::FetchObject::Object { + subgroup: ietf::FetchSubgroup::Zero, + group: Some(4), + object: Some(0), + priority: Some(0), + properties: None, } + .encode(w, version) + .unwrap(); w.bytes(b"x").unwrap(); let data = ScriptedSession::new(objects); let (_, recv) = data.clone().open_bi(Default::default()).await.unwrap(); @@ -17202,8 +15212,6 @@ mod joining_fetch_tests { ok.extend(message_bytes( ietf::PublishDone::ID, &ietf::PublishDone { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(1)), status_code: 0x3, stream_count: 0, reason_phrase: "done".into(), @@ -17252,7 +15260,7 @@ mod joining_fetch_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -17325,9 +15333,6 @@ mod joining_fetch_tests { session.push(&message_bytes( ietf::FetchOk::ID, &ietf::FetchOk { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(1)), - group_order: GroupOrder::Ascending, end_of_track: false, end_location: ietf::Location { group: GROUP, @@ -17348,31 +15353,15 @@ mod joining_fetch_tests { None } else { let mut objects = Vec::new(); - if version == Version::Draft14 { - crate::coding::Encoder::new(&mut objects, version.into()) - .varint(GROUP) - .unwrap(); - crate::coding::Encoder::new(&mut objects, version.into()) - .varint(0u64) - .unwrap(); // subgroup - crate::coding::Encoder::new(&mut objects, version.into()) - .varint(0u64) - .unwrap(); // object - crate::coding::Encoder::new(&mut objects, version.into()).u8(0u8); // priority - crate::coding::Encoder::new(&mut objects, version.into()) - .bytes(&Vec::::new()) - .unwrap(); - } else { - ietf::FetchObject::Object { - subgroup: ietf::FetchSubgroup::Zero, - group: Some(GROUP), - object: Some(0), - priority: Some(0), - properties: None, - } - .encode(&mut crate::coding::Encoder::new(&mut objects, version.into()), version) - .unwrap(); + ietf::FetchObject::Object { + subgroup: ietf::FetchSubgroup::Zero, + group: Some(GROUP), + object: Some(0), + priority: Some(0), + properties: None, } + .encode(&mut crate::coding::Encoder::new(&mut objects, version.into()), version) + .unwrap(); crate::coding::Encoder::new(&mut objects, version.into()) .varint(1u64) .unwrap(); @@ -17436,20 +15425,7 @@ mod joining_fetch_tests { return; } assert_eq!(session.log.stops(), [crate::ietf::error::CANCELLED]); - let messages = decode_messages(&session.log, version); - let cancels: Vec<_> = messages.iter().filter(|(id, _)| *id == ietf::FetchCancel::ID).collect(); - if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - assert_eq!(cancels.len(), 1, "legacy FETCH_CANCEL"); - let mut body = Decoder::new(&cancels[0].1, version.into()); - assert_eq!( - ietf::FetchCancel::decode_msg(&mut body, version).unwrap().request_id, - RequestId(1) - ); - assert!(session.log.resets().is_empty(), "deliver FETCH_CANCEL before closing"); - } else { - assert!(cancels.is_empty(), "FETCH_CANCEL was removed"); - assert_eq!(session.log.resets(), [crate::ietf::error::CANCELLED]); - } + assert_eq!(session.log.resets(), [crate::ietf::error::CANCELLED]); assert!(subscriber.state.lock().group_fetches.is_empty(), "request retired"); } @@ -17516,8 +15492,6 @@ mod joining_fetch_tests { let ok = message_bytes( ietf::FetchOk::ID, &ietf::FetchOk { - request_id: None, - group_order: GroupOrder::Ascending, end_of_track: false, end_location: ietf::Location { group: GROUP, @@ -17534,7 +15508,7 @@ mod joining_fetch_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -17585,8 +15559,6 @@ mod joining_fetch_tests { let ok = message_bytes( ietf::FetchOk::ID, &ietf::FetchOk { - request_id: None, - group_order: GroupOrder::Ascending, end_of_track, end_location, properties: Default::default(), @@ -17600,7 +15572,7 @@ mod joining_fetch_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -17690,9 +15662,6 @@ mod joining_fetch_tests { let ok = message_bytes( ietf::FetchOk::ID, &ietf::FetchOk { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(1)), - group_order: GroupOrder::Ascending, end_of_track: ended, end_location, properties: Default::default(), @@ -17712,24 +15681,15 @@ mod joining_fetch_tests { .unwrap(); for (index, payload) in [b"c", b"d"].iter().enumerate() { let first = index == 0; - if version == Version::Draft14 { - let w = &mut crate::coding::Encoder::new(&mut objects, version.into()); - w.varint(GROUP).unwrap(); - w.varint(0u64).unwrap(); // Subgroup - w.varint(START + index as u64).unwrap(); - w.u8(0); // Priority - w.varint(0u64).unwrap(); // Properties - } else { - ietf::FetchObject::Object { - subgroup: ietf::FetchSubgroup::Zero, - group: first.then_some(GROUP), - object: first.then_some(START), - priority: first.then_some(0), - properties: None, - } - .encode(&mut crate::coding::Encoder::new(&mut objects, version.into()), version) - .unwrap(); + ietf::FetchObject::Object { + subgroup: ietf::FetchSubgroup::Zero, + group: first.then_some(GROUP), + object: first.then_some(START), + priority: first.then_some(0), + properties: None, } + .encode(&mut crate::coding::Encoder::new(&mut objects, version.into()), version) + .unwrap(); crate::coding::Encoder::new(&mut objects, version.into()) .varint(1u64) .unwrap(); @@ -17742,7 +15702,7 @@ mod joining_fetch_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), @@ -17868,7 +15828,7 @@ mod new_group_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -17949,7 +15909,6 @@ mod new_group_tests { stream .writer .encode(&ietf::SubscribeOk { - request_id: None, track_alias: 7, largest: Some(ietf::Location { group: largest, @@ -17980,7 +15939,6 @@ mod new_group_tests { .writer .encode(&ietf::RequestOk { largest: None, - request_id: None, active: None, }) .await @@ -18081,7 +16039,6 @@ mod new_group_tests { stream .writer .encode(&ietf::RequestError { - request_id: None, error_code: 0, reason_phrase: "refused".into(), retry_interval: 0, @@ -18198,7 +16155,7 @@ mod track_property_tests { }; /// The newest draft and an older one, both with a request stream per subscription. - const VERSIONS: [Version; 2] = [Version::Draft17, Version::Draft22]; + const VERSIONS: [Version; 2] = [Version::Draft18, Version::Draft22]; /// Subscribe to one track through a relay on `version`, whose publisher answers with /// `block` as SUBSCRIBE_OK's Track Properties, returning what the subscriber got. @@ -18209,7 +16166,7 @@ mod track_property_tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -18238,7 +16195,6 @@ mod track_property_tests { let mut body = Vec::new(); ietf::SubscribeOk { - request_id: None, track_alias: 7, largest: None, properties: Default::default(), @@ -18397,22 +16353,12 @@ mod compression_negotiation_tests { compress: true, ..Default::default() }; - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ] { + for version in [Version::Draft18, Version::Draft22] { assert!(matches!( refused(&properties, version, false), Some(Error::UnsupportedExtension) )); - assert_eq!( - refused(&properties, version, true).is_none(), - super::super::flate::supported(version) - ); + assert!(refused(&properties, version, true).is_none(), "{version}"); } let info = accepted_info(&properties, Version::Draft22); assert!(info.compress); diff --git a/rs/moq-net/src/ietf/subscriber/tracks.rs b/rs/moq-net/src/ietf/subscriber/tracks.rs index ed0595000fc..3ac61deb0e9 100644 --- a/rs/moq-net/src/ietf/subscriber/tracks.rs +++ b/rs/moq-net/src/ietf/subscriber/tracks.rs @@ -31,7 +31,7 @@ pub(super) type TrackKey = (Path, Arc); /// status, INTERNAL_ERROR included, fails the listing, so it asks again rather than hide a /// track that may still be live. pub(super) fn listing_end(done: &ietf::PublishDone, version: Version) -> Result<(), Error> { - match done.status_code == ietf::PublishDoneStatus::TrackEnded.code(version) { + match done.status_code == ietf::PublishDoneStatus::TrackEnded.code() { true => Ok(()), false => Err(done .end(version) @@ -425,9 +425,8 @@ impl Subscriber { let Some(stream) = opened else { return Ok(()); }; - let mut stream = request_stream::with_stats(stream?, self.origin.stats(), self.version); - // No draft with SUBSCRIBE_TRACKS has MAX_REQUEST_ID, so this never waits on credit. - let request_id = kio::wait(|waiter| self.control.poll_next_request_id(waiter)).await; + let mut stream = stream?.with_stats(self.origin.stats()); + let request_id = self.control.next_request_id(); stream.writer.buffer_message( ietf::SubscribeTracks::ID, &ietf::SubscribeTracks { @@ -622,8 +621,8 @@ impl Subscriber { let Some(stream) = opened else { return Ok(None); }; - let mut stream = request_stream::with_stats(stream?, self.origin.stats(), self.version); - let request_id = kio::wait(|waiter| self.control.poll_next_request_id(waiter)).await; + let mut stream = stream?.with_stats(self.origin.stats()); + let request_id = self.control.next_request_id(); self.state.lock().subscribes.insert( request_id, TrackState { @@ -674,7 +673,7 @@ impl Subscriber { Ok(Some(accepted)) => accepted, Ok(None) => { self.remove_subscribe(request_id); - self.cancel_subscribe(stream, request_id).await; + self.cancel_subscribe(stream).await; return Ok(None); } Err(err) => { @@ -684,12 +683,12 @@ impl Subscriber { }; if let Some(err) = refused(&accepted.properties, self.version, self.peer_setup.get().await.flate) { self.remove_subscribe(request_id); - self.cancel_subscribe(stream, request_id).await; + self.cancel_subscribe(stream).await; return Err(err); } if let Err(err) = self.bind_alias(request_id, accepted.alias) { self.remove_subscribe(request_id); - self.cancel_subscribe(stream, request_id).await; + self.cancel_subscribe(stream).await; return Err(err); } let info = accepted_info(&accepted.properties, self.version); @@ -709,7 +708,7 @@ impl Subscriber { }; if !listed { self.remove_subscribe(request_id); - self.cancel_subscribe(stream, request_id).await; + self.cancel_subscribe(stream).await; return Ok(None); } Ok(Some(Upstream { @@ -828,7 +827,6 @@ impl Subscriber { Offer::Refuse(code, reason) => { tracing::debug!(broadcast = %key.0, track = %key.1, reason, "refusing publish"); let error = ietf::RequestError { - request_id: None, error_code: code, reason_phrase: reason.into(), retry_interval: 0, @@ -857,7 +855,6 @@ impl Subscriber { stream.writer.buffer_message( id, &ietf::PublishOk { - request_id: None, forward: false, subscriber_priority: None, group_order: None, @@ -939,12 +936,12 @@ impl Subscriber { } }; self.remove_subscribe(upstream.request_id); - self.cancel_subscribe(upstream.stream, upstream.request_id).await; + self.cancel_subscribe(upstream.stream).await; return; } Step::Gone => { self.remove_subscribe(upstream.request_id); - self.cancel_subscribe(upstream.stream, upstream.request_id).await; + self.cancel_subscribe(upstream.stream).await; return; } Step::Message(Ok(Some(Framed(id, body)))) => match id { @@ -1074,7 +1071,7 @@ impl Subscriber { true => this.watch(key, upstream, handoff).await, false => { this.remove_subscribe(upstream.request_id); - this.cancel_subscribe(upstream.stream, upstream.request_id).await; + this.cancel_subscribe(upstream.stream).await; } } }); @@ -1136,14 +1133,11 @@ impl Subscriber { writer: &mut Writer, forward: &Forward, ) -> Result<(), Error> { - let request_id = self.control.next_request_id(&self.runtime).await?; + let request_id = self.control.next_request_id(); writer .encode_message(&ietf::SubscribeUpdate { max_age: forward.max_age, request_id, - subscription_request_id: None, - start_location: ietf::Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: forward.priority.map(super::super::priority::to_wire), forward: Some(forward.forward), filter: forward.filter, @@ -1269,7 +1263,7 @@ mod tests { crate::time::sim(), crate::driver::Transport::new(session.clone()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), + Control::new(false), None, peer::PeerSetup::declared(peer::Peer::default()), crate::Hop::new(1).unwrap(), @@ -1337,7 +1331,6 @@ mod tests { assert_eq!(msg.prefix.as_str(), "bcast"); assert!(!msg.forward, "the listing asks for every track's objects"); let ok = ietf::RequestOk { - request_id: None, active: None, largest: None, }; @@ -1393,7 +1386,6 @@ mod tests { let resume: ietf::SubscribeUpdate = body.message(self.version).unwrap(); assert_eq!(resume.forward, Some(true)); let ok = ietf::RequestOk { - request_id: None, active: None, largest: None, }; @@ -1493,8 +1485,7 @@ mod tests { assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); let done = ietf::PublishDone { - request_id: None, - status_code: ietf::PublishDoneStatus::TrackEnded.code(version), + status_code: ietf::PublishDoneStatus::TrackEnded.code(), stream_count: 0, reason_phrase: "".into(), }; @@ -1518,8 +1509,7 @@ mod tests { assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); let done = ietf::PublishDone { - request_id: None, - status_code: status.code(version), + status_code: status.code(), stream_count: 0, reason_phrase: "".into(), }; @@ -1546,7 +1536,6 @@ mod tests { // SUBSCRIPTION_ENDED, which draft 18 registers. let done = ietf::PublishDone { - request_id: None, status_code: 0x3, stream_count: 0, reason_phrase: "".into(), @@ -1589,7 +1578,6 @@ mod tests { assert!(!msg.forward, "{version}: the SUBSCRIBE forwards"); let ok = ietf::SubscribeOk { - request_id: None, track_alias: 3, largest: None, properties: ietf::Properties { @@ -1622,7 +1610,6 @@ mod tests { .unwrap(); let _ = read(&mut stream).await; let refused = ietf::RequestError { - request_id: None, error_code: 0x3, reason_phrase: "".into(), retry_interval: 0, @@ -1705,8 +1692,7 @@ mod tests { Some(true) ); let done = ietf::PublishDone { - request_id: None, - status_code: ietf::PublishDoneStatus::TrackEnded.code(version), + status_code: ietf::PublishDoneStatus::TrackEnded.code(), stream_count: 0, reason_phrase: "".into(), }; @@ -1739,8 +1725,7 @@ mod tests { assert!(!serving.is_finished(), "the copy did not linger"); let done = ietf::PublishDone { - request_id: None, - status_code: ietf::PublishDoneStatus::InternalError.code(version), + status_code: ietf::PublishDoneStatus::InternalError.code(), stream_count: 0, reason_phrase: "".into(), }; @@ -1793,7 +1778,6 @@ mod tests { let _held = match case { "refused" => { let refused = ietf::RequestError { - request_id: None, error_code: 0x3, reason_phrase: "".into(), retry_interval: 0, @@ -1832,7 +1816,6 @@ mod tests { let msg: ietf::Subscribe = body.message(version).unwrap(); assert!(msg.forward, "{case}: a paused SUBSCRIBE"); let ok = ietf::SubscribeOk { - request_id: None, track_alias: 7, largest: None, properties: ietf::Properties::default(), diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 2c852b38d4c..cbb3d5d58ab 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -12,11 +12,11 @@ use crate::driver::Transport; use crate::{ DecodeError, Error, SessionError, - coding::{Decode as _, Decoder, EncodeError, Encoder}, + coding::{Decoder, EncodeError, Encoder}, setup::Token, }; -use super::{Param, ParameterBytes, Parameters, RequestId, Version, error::request}; +use super::{Param, ParameterBytes, Parameters, Version, error::request}; /// Retire a registered alias. const DELETE: u64 = 0x0; @@ -106,27 +106,16 @@ pub(super) fn reason(err: &DecodeError) -> &'static str { /// Refuse a request whose tokens decoded to `err`, one [`refuses_request`] accepts, ending /// its stream. /// -/// Section 8.9 rejects the message rather than the session. Every request starts with its -/// Request ID, so it is read from the undecoded `body`. +/// Section 8.9 rejects the message rather than the session. #[cfg(feature = "async")] pub(super) async fn reject( mut stream: Stream, kind: request::Kind, - body: super::Body, err: DecodeError, version: Version, ) -> Result<(), Error> { - let request_id = RequestId::decode(&mut body.decoder(version), version)?; let reason = reason(&err); - super::request_stream::write_error( - &mut stream.writer, - kind, - request_id, - &Error::Decode(err), - reason, - version, - ) - .await?; + super::request_stream::write_error(&mut stream.writer, kind, &Error::Decode(err), reason, version).await?; // The peer dropping the stream once it has the rejection is a normal end. let _ = stream.writer.close().await; Ok(()) @@ -212,14 +201,7 @@ mod tests { )); } - const VERSIONS: [Version; 6] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; + const VERSIONS: [Version; 2] = [Version::Draft18, Version::Draft22]; fn token() -> Token { // A kind past one varint byte and a value that is not text, so neither is mistaken @@ -266,18 +248,14 @@ mod tests { kind: 300, value: vec![0x00, 0xff], }; - for (version, expected) in [ - (Version::Draft14, [0x03, 0x41, 0x2c, 0x00, 0xff]), - (Version::Draft17, [0x03, 0x81, 0x2c, 0x00, 0xff]), - ] { - let mut params = Parameters::default(); - into_setup(&mut params, &token, version).unwrap(); - assert_eq!( - params.get_bytes(ParameterBytes::AuthorizationToken), - Some(&expected[..]), - "{version:?}" - ); - } + let (version, expected) = (Version::Draft18, [0x03, 0x81, 0x2c, 0x00, 0xff]); + let mut params = Parameters::default(); + into_setup(&mut params, &token, version).unwrap(); + assert_eq!( + params.get_bytes(ParameterBytes::AuthorizationToken), + Some(&expected[..]), + "{version:?}" + ); } #[test] @@ -334,17 +312,9 @@ mod tests { .unwrap(); let key = u64::from(ParameterBytes::AuthorizationToken); - let (count, keys): (Option, [u64; 2]) = match version { - Version::Draft14 | Version::Draft15 => (Some(2), [key, key]), - // Delta-encoded from draft-16, so the repeat is a delta of zero. - Version::Draft16 => (Some(2), [key, 0]), - _ => (None, [key, 0]), - }; let mut raw = Vec::new(); - if let Some(count) = count { - Encoder::new(&mut raw, version.into()).varint(count).unwrap(); - } - for key in keys { + // Delta-encoded, so the repeat is a delta of zero. + for key in [key, 0] { Encoder::new(&mut raw, version.into()).varint(key).unwrap(); Encoder::new(&mut raw, version.into()).bytes(&value).unwrap(); } diff --git a/rs/moq-net/src/ietf/track.rs b/rs/moq-net/src/ietf/track.rs index 33ea838f416..c9937969f0b 100644 --- a/rs/moq-net/src/ietf/track.rs +++ b/rs/moq-net/src/ietf/track.rs @@ -7,7 +7,7 @@ use num_enum::{IntoPrimitive, TryFromPrimitive}; use crate::{ Path, coding::*, - ietf::{Filter, GroupOrder, Location, Properties, RequestId, Subscribe, SubscribeOk}, + ietf::{Filter, Location, Properties, RequestId, Subscribe}, }; use super::Message; @@ -15,9 +15,8 @@ use super::namespace::encode_namespace; use super::Version; -/// TrackStatus message (0x0d) -/// v14: own format (TrackStatusRequest-like with subscribe fields) -/// v15: same wire format as SUBSCRIBE. Response is REQUEST_OK. +/// TrackStatus message (0x0d), with the same wire format as SUBSCRIBE. The response is +/// REQUEST_OK. #[derive(Clone, Debug)] pub struct TrackStatus<'a> { pub request_id: RequestId, @@ -26,7 +25,7 @@ pub struct TrackStatus<'a> { /// Whether the requester wants Track Properties on the answer (INCLUDE_PROPERTIES, /// draft-22). pub properties_wanted: bool, - /// The publisher instance the requester expects (MoQ Epoch, draft-17+). + /// The publisher instance the requester expects (MoQ Epoch). pub epoch: Option, } @@ -35,31 +34,17 @@ impl Message for TrackStatus<'_> { fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { self.request_id.encode(w, version)?; - if version == Version::Draft17 { - w.varint(0)?; // required_request_id_delta = 0 - } encode_namespace(w, &self.track_namespace)?; w.string(&self.track_name)?; - match version { - Version::Draft14 => { - w.u8(0); // subscriber priority - GroupOrder::Descending.encode(w, version)?; - w.bool(false); // forward - Filter::NextObject.encode(w, version)?; // filter - w.u8(0); // no parameters - } - _ => { - // Only the opt-out is worth bytes, and an older peer would read the - // parameter as unknown, which is a protocol violation. - let include_properties = (!self.properties_wanted && Filter::is_draft22(version)).then_some(false); - let epoch = self.epoch.clone().filter(|_| super::epoch::supported(version)); - encode_params!(w, version, - 0x35 => include_properties, - super::epoch::EPOCH_PARAM => epoch, - ); - } - } + // Only the opt-out is worth bytes, and an older peer would read the + // parameter as unknown, which is a protocol violation. + let include_properties = (!self.properties_wanted && Filter::is_draft22(version)).then_some(false); + let epoch = self.epoch.clone(); + encode_params!(w, version, + 0x35 => include_properties, + super::epoch::EPOCH_PARAM => epoch, + ); Ok(()) } @@ -78,132 +63,41 @@ impl Message for TrackStatus<'_> { } } -/// TRACK_STATUS_OK: what a SUBSCRIBE_OK would say about the track, with no subscription. -/// -/// Draft-14 gives it its own type ([`Self::ID_14`]) and the SUBSCRIBE_OK body, with a -/// Track Alias of 0. Later drafts answer with a REQUEST_OK, which gained Track Properties -/// in draft-18; before that only the message parameters carry anything. +/// TRACK_STATUS_OK: what a SUBSCRIBE_OK would say about the track, with no subscription, +/// sent as a REQUEST_OK with Track Properties. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TrackStatusOk { - /// Present on drafts 14-16 only. - pub request_id: Option, - /// The largest Location in the track (LARGEST_OBJECT), absent while it has no content. pub largest: Option, - /// The Track Properties, on the drafts whose answer has room for them. + /// The Track Properties. pub properties: Properties, } -/// TRACK_STATUS_ERROR on draft-14, with the SUBSCRIBE_ERROR body. Later drafts refuse -/// with REQUEST_ERROR. -pub const TRACK_STATUS_ERROR_14: u64 = 0x0F; - -impl TrackStatusOk { - /// The draft-14 message type. - pub const ID_14: u64 = 0x0E; - - /// The message type on `version`. - pub fn id(version: Version) -> u64 { - match version { - Version::Draft14 => Self::ID_14, - _ => Self::ID, - } - } - - /// Whether this answer describes the track as fully as SUBSCRIBE_OK on `version`. - /// Draft-17 is the gap: SUBSCRIBE_OK declares TIMESCALE, but REQUEST_OK has no - /// properties block to say whether the track is timed. - pub(crate) fn describes_track(version: Version) -> bool { - Self::has_properties(version) || !super::Properties::sends_timescale(version) - } - - /// Whether the answer carries a Track Properties block on `version`. - fn has_properties(version: Version) -> bool { - !matches!( - version, - Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 - ) - } -} - impl Message for TrackStatusOk { - /// REQUEST_OK; see [`Self::id`]. + /// REQUEST_OK. const ID: u64 = 0x07; fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - if version == Version::Draft14 { - return SubscribeOk { - request_id: self.request_id, - track_alias: 0, - largest: self.largest, - properties: self.properties.clone(), - } - .encode_msg(w, version); - } - - if matches!(version, Version::Draft15 | Version::Draft16) { - self.request_id - .expect("request_id required for draft14-16") - .encode(w, version)?; - } else { - assert!(self.request_id.is_none(), "request_id must be None for draft17+"); - } - - // The parameters SUBSCRIBE_OK carries: GROUP_ORDER is one only on draft-15, and a - // Track Property after. - let group_order = match version { - Version::Draft15 => self.properties.group_order, - _ => None, - }; + // The parameters SUBSCRIBE_OK carries. encode_params!(w, version, 0x09 => self.largest, - 0x22 => group_order, ); // Track Properties are the final field, so nothing may follow. - if Self::has_properties(version) { - self.properties.encode(w, version)?; - } + self.properties.encode(w, version)?; Ok(()) } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - if version == Version::Draft14 { - let ok = SubscribeOk::decode_msg(r, version)?; - return Ok(Self { - request_id: ok.request_id, - largest: ok.largest, - properties: ok.properties, - }); - } - - let request_id = match version { - Version::Draft15 | Version::Draft16 => Some(RequestId::decode(r, version)?), - _ => None, - }; - // Read as SUBSCRIBE_OK's are: EXPIRES is ignored, and MAX_CACHE_DURATION is a - // parameter only on draft-15. + // Read as SUBSCRIBE_OK's are: EXPIRES is ignored. decode_params!(r, version, - 0x04 => max_cache_duration: Option where version == Version::Draft15, 0x08 => _expires: Option, 0x09 => largest: Option, - 0x22 => group_order: Option where version == Version::Draft15, ); - let mut properties = match Self::has_properties(version) { - true => Properties::decode(r, version)?, - false => Properties::default(), - }; - properties.group_order = properties.group_order.or(group_order); - if version == Version::Draft15 { - properties.max_cache_duration = max_cache_duration.map(std::time::Duration::from_millis); - } + let properties = Properties::decode(r, version)?; - Ok(Self { - request_id, - largest, - properties, - }) + Ok(Self { largest, properties }) } } @@ -232,6 +126,7 @@ impl Decode for TrackStatusCode { #[cfg(test)] mod tests { use super::*; + use crate::ietf::GroupOrder; fn encode_message(msg: &M, version: Version) -> Vec { let mut buf = Vec::new(); @@ -240,18 +135,9 @@ mod tests { buf.to_vec() } - /// GROUP_ORDER and MAX_CACHE_DURATION are TRACK_STATUS_OK parameters only on draft-15, - /// as on SUBSCRIBE_OK. Draft-16 ignores a GROUP_ORDER it defines elsewhere, even with a - /// value no field could hold, and rejects 0x04, which it does not define at all. Draft-18 - /// rejects a known parameter on a message that does not define it. + /// TRACK_STATUS_OK rejects a known parameter on a message that does not define it. #[test] fn track_status_ok_parameters_follow_the_draft() { - // Request 1, then GROUP_ORDER as the varint 256. - let ok = decode_message::(&[0x01, 0x01, 0x22, 0x41, 0x00], Version::Draft16) - .expect("draft-16 ignores GROUP_ORDER"); - assert_eq!(ok.properties.group_order, None); - // Request 1, then MAX_CACHE_DURATION of 5. - assert!(decode_message::(&[0x01, 0x01, 0x04, 0x05], Version::Draft16).is_err()); // GROUP_ORDER = Ascending, then an empty properties block. assert!(decode_message::(&[0x01, 0x22, 0x01], Version::Draft18).is_err()); } @@ -261,78 +147,6 @@ mod tests { crate::coding::decode_buf(&mut buf, version, M::decode_msg) } - #[test] - fn test_track_status_v14_round_trip() { - let msg = TrackStatus { - request_id: RequestId(1), - track_namespace: Path::from_static("test/ns"), - track_name: "video".into(), - properties_wanted: true, - epoch: None, - }; - - let encoded = encode_message(&msg, Version::Draft14); - let decoded: TrackStatus = decode_message(&encoded, Version::Draft14).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test/ns"); - assert_eq!(decoded.track_name, "video"); - } - - #[test] - fn test_track_status_v15_round_trip() { - let msg = TrackStatus { - request_id: RequestId(1), - track_namespace: Path::from_static("test/ns"), - track_name: "video".into(), - properties_wanted: true, - epoch: None, - }; - - let encoded = encode_message(&msg, Version::Draft15); - let decoded: TrackStatus = decode_message(&encoded, Version::Draft15).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test/ns"); - assert_eq!(decoded.track_name, "video"); - } - - #[test] - fn test_track_status_v17_round_trip() { - let msg = TrackStatus { - request_id: RequestId(1), - track_namespace: Path::from_static("test/ns"), - track_name: "video".into(), - properties_wanted: true, - epoch: None, - }; - - let encoded = encode_message(&msg, Version::Draft17); - let decoded: TrackStatus = decode_message(&encoded, Version::Draft17).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test/ns"); - assert_eq!(decoded.track_name, "video"); - } - - #[test] - fn test_track_status_v16_round_trip() { - let msg = TrackStatus { - request_id: RequestId(1), - track_namespace: Path::from_static("test/ns"), - track_name: "video".into(), - properties_wanted: true, - epoch: None, - }; - - let encoded = encode_message(&msg, Version::Draft16); - let decoded: TrackStatus = decode_message(&encoded, Version::Draft16).unwrap(); - - assert_eq!(decoded.request_id, RequestId(1)); - assert_eq!(decoded.track_namespace.as_str(), "test/ns"); - assert_eq!(decoded.track_name, "video"); - } - #[test] fn test_track_status_v18_round_trip() { let msg = TrackStatus { @@ -351,14 +165,7 @@ mod tests { assert_eq!(decoded.track_name, "video"); } - const ALL: [Version; 6] = [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft22, - ]; + const ALL: [Version; 2] = [Version::Draft18, Version::Draft22]; /// INCLUDE_PROPERTIES only exists from draft-22, so the opt-out is dropped before it, /// and the default (1) is never written. @@ -381,9 +188,8 @@ mod tests { } } - /// TRACK_STATUS_OK carries what SUBSCRIBE_OK would, as far as each draft's answer - /// has room: draft-14 is a SUBSCRIBE_OK, drafts 15-17 a REQUEST_OK with only - /// parameters, and from draft-18 the REQUEST_OK ends with the Track Properties. + /// TRACK_STATUS_OK carries what SUBSCRIBE_OK would: a REQUEST_OK ending with the Track + /// Properties. #[test] fn test_track_status_ok_round_trip() { let properties = Properties { @@ -399,50 +205,18 @@ mod tests { for version in ALL { let msg = TrackStatusOk { - request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - .then_some(RequestId(3)), largest, properties: properties.clone(), }; let decoded: TrackStatusOk = decode_message(&encode_message(&msg, version), version).unwrap_or_else(|e| panic!("{version}: {e}")); - let expected = match version { - Version::Draft14 | Version::Draft15 => Properties { - group_order: Some(GroupOrder::Descending), - ..Default::default() - }, - Version::Draft16 | Version::Draft17 => Properties::default(), - _ => properties.clone(), - }; - assert_eq!(decoded.request_id, msg.request_id, "{version}"); + let expected = properties.clone(); assert_eq!(decoded.largest, largest, "{version}"); assert_eq!(decoded.properties, expected, "{version}"); } } - /// Draft-14's TRACK_STATUS_OK is a SUBSCRIBE_OK with Track Alias 0, under its own type. - #[test] - fn test_track_status_ok_draft_14_is_a_subscribe_ok() { - let msg = TrackStatusOk { - request_id: Some(RequestId(3)), - largest: None, - properties: Properties::default(), - }; - assert_eq!(TrackStatusOk::id(Version::Draft14), 0x0E); - assert_eq!(TrackStatusOk::id(Version::Draft15), 0x07); - let subscribe_ok = SubscribeOk { - request_id: Some(RequestId(3)), - track_alias: 0, - largest: None, - properties: Properties::default(), - }; - assert_eq!( - encode_message(&msg, Version::Draft14), - encode_message(&subscribe_ok, Version::Draft14) - ); - } - /// TRACK_STATUS is identical to SUBSCRIBE on every draft, so it carries whatever /// fields and parameters a SUBSCRIBE can. A peer that sends them must still be /// answered rather than having its session closed. @@ -454,19 +228,7 @@ mod tests { ]; #[rustfmt::skip] - let cases: [(Version, &[u8]); 3] = [ - (Version::Draft14, &[ - 0x80, // Subscriber Priority - 0x02, // Group Order - 0x00, // Forward - 0x03, 0x05, 0x01, // Filter Type AbsoluteStart, at {5, 1} - 0x00, // Number of Parameters - ]), - (Version::Draft15, &[ - 0x02, // Number of Parameters - 0x10, 0x00, // FORWARD = 0 - 0x20, 0x01, // SUBSCRIBER_PRIORITY = 1 - ]), + let cases: [(Version, &[u8]); 1] = [ (Version::Draft22, &[ 0x02, // Number of Parameters 0x03, 0x03, 0x03, 0x00, 0xAA, // AUTHORIZATION TOKEN diff --git a/rs/moq-net/src/ietf/version.rs b/rs/moq-net/src/ietf/version.rs index ad989016f9c..5dc59eaa762 100644 --- a/rs/moq-net/src/ietf/version.rs +++ b/rs/moq-net/src/ietf/version.rs @@ -4,10 +4,6 @@ use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Version { - Draft14, - Draft15, - Draft16, - Draft17, Draft18, Draft22, } @@ -15,10 +11,6 @@ pub enum Version { impl fmt::Display for Version { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Draft14 => write!(f, "moq-transport-14"), - Self::Draft15 => write!(f, "moq-transport-15"), - Self::Draft16 => write!(f, "moq-transport-16"), - Self::Draft17 => write!(f, "moq-transport-17"), Self::Draft18 => write!(f, "moq-transport-18"), Self::Draft22 => write!(f, "moq-transport-22"), } diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 4c0b6951a00..ad9f94dd32a 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -34,7 +34,7 @@ //! keeping it small rather than polluting it with half-baked features. //! //! The library is forwards-compatible with the full IETF specification and supports -//! moq-transport drafts 14+ via version negotiation. Everything will work perfectly, +//! moq-transport drafts 18+ via version negotiation. Everything will work perfectly, //! so long as your application uses the API as defined above. //! //! For example, there's no concept of "sub-group". When connecting to a moq-transport diff --git a/rs/moq-net/src/lite/compress.rs b/rs/moq-net/src/lite/compress.rs index 963fce0a0f8..ad4094548a5 100644 --- a/rs/moq-net/src/lite/compress.rs +++ b/rs/moq-net/src/lite/compress.rs @@ -598,7 +598,7 @@ mod tests { #[test] fn a_tie_stays_literal_where_hop_100_sizes_differ() { let quic = Form::Quic; - let leading = Form::LeadingOnes { seven: true }; + let leading = Form::LeadingOnes; assert_eq!(varint_size(100, quic), 2); assert_eq!(varint_size(100, leading), 1); diff --git a/rs/moq-net/src/lite/ietf.rs b/rs/moq-net/src/lite/ietf.rs index 7af29148219..5ddd92f5e37 100644 --- a/rs/moq-net/src/lite/ietf.rs +++ b/rs/moq-net/src/lite/ietf.rs @@ -206,11 +206,11 @@ pub(crate) fn decode_properties(body: &[u8], framing: super::Framing, framed: bo } /// Decode the END_OF_GROUP record's body on a track `framing` describes. It is a status -/// object, so a block needs a draft that lets one carry properties. +/// object, so it may not carry properties. #[cfg(feature = "async")] pub(crate) fn decode_end(body: &[u8], framing: super::Framing) -> Result { let decoded = decode_properties(body, framing, false)?; - if decoded.properties.block.is_some() && !framing.ietf.is_some_and(crate::ietf::object::status_has_properties) { + if decoded.properties.block.is_some() { return Err(DecodeError::InvalidValue); } Ok(decoded) @@ -340,10 +340,6 @@ impl Message for TrackProperties { let Some(crate::Version::Ietf(version)) = crate::Version::from_code(code) else { return Err(DecodeError::InvalidValue); }; - // Track Properties start at draft-16. - if matches!(version, crate::ietf::Version::Draft14 | crate::ietf::Version::Draft15) && !r.is_empty() { - return Err(DecodeError::InvalidValue); - } let properties = crate::model::object::TrackProperties { version, values: decode_list(r, version)?, @@ -417,7 +413,7 @@ pub(crate) fn decode_datagram( return Err(DecodeError::InvalidValue); } // A track published over moq-lite has no moq-transport state. - let version = framing.ietf.ok_or(DecodeError::InvalidValue)?; + framing.ietf.ok_or(DecodeError::InvalidValue)?; let object_id = (state & OBJECT_ID != 0).then(|| r.varint()).transpose()?; let priority = (state & PRIORITY != 0).then(|| r.u8()).transpose()?; let status = (state & STATUS != 0).then(|| r.varint()).transpose()?; @@ -433,7 +429,7 @@ pub(crate) fn decode_datagram( properties, }; // A relay holds and forwards it, so it must be one its publisher's draft could send. - if !wire.defined(version) { + if !wire.defined() { return Err(DecodeError::InvalidValue); } Ok(Some(wire)) @@ -507,9 +503,9 @@ mod tests { #[test] fn properties_round_trip() { - // An immutable block in draft-16 holding a gap of 2 (0x3e) and a timestamp. + // An immutable block holding a gap of 2 (0x3e) and a timestamp. let mut sealed = Vec::new(); - let e = &mut Encoder::new(&mut sealed, Draft::Draft16.into()); + let e = &mut Encoder::new(&mut sealed, Draft::Draft18.into()); for value in [0x10u64, 77, 0x3e - 0x10, 2] { e.varint(value).unwrap(); } @@ -525,16 +521,16 @@ mod tests { ]; let properties = Properties { sealed_time: Some(crate::Timestamp::new(77, Timescale::MILLI).unwrap()), - block: crate::ietf::object::block(&values, Draft::Draft16).unwrap(), + block: crate::ietf::object::block(&values, Draft::Draft18).unwrap(), }; let body = encode_properties(&properties); - let decoded = decode_properties(body, from(Draft::Draft16, Some(Timescale::MILLI)), true).unwrap(); + let decoded = decode_properties(body, from(Draft::Draft18, Some(Timescale::MILLI)), true).unwrap(); assert_eq!(decoded.properties, properties); assert_eq!(decoded.gap, Some(2)); // None at all is an empty body. assert!(encode_properties(&Properties::default()).is_empty()); - let empty = decode_properties(&[], from(Draft::Draft16, Some(Timescale::MILLI)), true).unwrap(); + let empty = decode_properties(&[], from(Draft::Draft18, Some(Timescale::MILLI)), true).unwrap(); assert_eq!(empty.properties, Properties::default()); // An untimed track's Timestamp has no frame to hold it, so it stays in the block. @@ -565,7 +561,7 @@ mod tests { /// after a large first ID, crosses the hop byte for byte and is held at its wire size. #[test] fn a_full_block_crosses_at_its_wire_size() { - for version in [Draft::Draft16, Draft::Draft22] { + for version in [Draft::Draft22] { let mut list = Vec::new(); let e = &mut Encoder::new(&mut list, version.into()); // A Timestamp, then IDs from 2^40 on, each repeated as a two-byte zero delta. @@ -625,15 +621,13 @@ mod tests { let decoded = round_trip(&TrackProperties(Some(Arc::new(properties.clone())))).unwrap(); assert_eq!(decoded.0.as_deref(), Some(&properties)); - // Drafts 14 and 15 have no Track Properties, but their objects may have properties. - for version in [Draft::Draft14, Draft::Draft22] { - let bare = crate::model::object::TrackProperties { - version, - values: Vec::new(), - }; - let decoded = round_trip(&TrackProperties(Some(Arc::new(bare.clone())))).unwrap(); - assert_eq!(decoded.0.as_deref(), Some(&bare), "{version}"); - } + // An empty list still names its draft. + let bare = crate::model::object::TrackProperties { + version: Draft::Draft22, + values: Vec::new(), + }; + let decoded = round_trip(&TrackProperties(Some(Arc::new(bare.clone())))).unwrap(); + assert_eq!(decoded.0.as_deref(), Some(&bare)); let none = crate::coding::Encode::encode_bytes(&TrackProperties(None), V).unwrap(); assert_eq!(&none[1..], [0], "a moq-lite track is a lone 0"); @@ -677,13 +671,6 @@ mod tests { decode(&wire(&[opaque(0x40)], 0)).is_err(), "properties on a moq-lite track" ); - for version in [Draft::Draft14, Draft::Draft15] { - assert!(decode(&wire(&[], code(version))).is_ok(), "{version}"); - assert!( - decode(&wire(&[opaque(0x40)], code(version))).is_err(), - "{version} has no Track Properties" - ); - } for code in [1, 0xff000099, crate::Version::Lite(V).code()] { assert!(decode(&wire(&[], code)).is_err(), "{code:#x} is no moq-transport draft"); } @@ -730,7 +717,7 @@ mod tests { /// message would no longer fit a lite one. #[test] fn track_properties_cross_at_their_wire_size() { - for version in [Draft::Draft16, Draft::Draft17, Draft::Draft22] { + for version in [Draft::Draft22] { // As many repeats of an ID from 2^60 as moq-transport forwards, then one value // that fills the rest of a lite message beside its Version. let id = (1 << 60) + 1; @@ -788,39 +775,6 @@ mod tests { } } - /// Dropping an understood property can widen the delta that spans it, so a list that - /// could not cross is refused where it arrives rather than where it would be sent. - #[test] - fn track_properties_that_cannot_cross_are_refused_at_ingress() { - let version = Draft::Draft16; - // The most a draft-16 answer holds beside a Request ID and an empty parameter count. - let size = super::super::message::MAX_MESSAGE_SIZE - 2; - // DYNAMIC_GROUPS, then the last ID whose delta from it fits four bytes. - let id = 0x30 + (1 << 30) - 1; - let mut list = Vec::new(); - let e = &mut Encoder::new(&mut list, version.into()); - for value in [0x30, 1, id - 0x30] { - e.varint(value).unwrap(); - } - let fill = size - list.len() - 4; - let e = &mut Encoder::new(&mut list, version.into()); - e.bytes(&vec![0; fill]).unwrap(); - assert_eq!(list.len(), size); - - // The delta from zero takes eight bytes once DYNAMIC_GROUPS leaves the list. - let ingress = crate::ietf::Properties::decode(&mut Decoder::new(&list, version.into()), version).unwrap(); - assert!(ingress.dynamic_groups); - let forwarded = crate::model::object::TrackProperties { - version, - values: ingress.unknown, - }; - assert!(!fits(&forwarded)); - assert!(matches!( - forwarded.refused(&crate::track::Info::default()), - Some(crate::Error::Unsupported) - )); - } - fn datagram(wire: &crate::model::datagram::Wire) -> Vec { let mut buf = Vec::new(); encode_datagram(&mut Encoder::new(&mut buf, V.into()), Some(wire)).unwrap(); @@ -831,8 +785,7 @@ mod tests { /// would close the session it reached: it is refused where it arrives. #[test] fn datagram_statuses_are_ones_moq_transport_defines() { - for (status, ok) in [(0, true), (1, true), (2, false), (3, true), (4, true), (5, false)] { - // Draft 14 sends a status datagram's Object ID and priority. + for (status, ok) in [(0, true), (1, false), (2, false), (3, true), (4, true), (5, false)] { let wire = crate::model::datagram::Wire { object_id: Some(3), priority: Some(9), @@ -842,7 +795,7 @@ mod tests { }; let decoded = decode_datagram( &mut Decoder::new(&datagram(&wire), V.into()), - from(Draft::Draft14, None), + from(Draft::Draft18, None), ); assert_eq!(decoded.is_ok(), ok, "{status}"); } @@ -966,40 +919,21 @@ mod tests { } } - /// Only draft-14 lets a status object carry properties, so the END_OF_GROUP record has a - /// block only on a draft-14 track. + /// A status object carries no properties, so the END_OF_GROUP record has no block. #[cfg(feature = "async")] #[test] - fn an_end_record_block_needs_a_draft_that_carries_one() { - for version in [Draft::Draft14, Draft::Draft15, Draft::Draft16, Draft::Draft22] { - let framing = from(version, None); - assert_eq!( - decode_end(&[0x20, 1], framing).is_ok(), - version == Draft::Draft14, - "{version}" - ); - assert!(decode_end(&[], framing).is_ok(), "{version}"); - } + fn an_end_record_has_no_block() { + let framing = from(Draft::Draft22, None); + assert!(decode_end(&[0x20, 1], framing).is_err()); + assert!(decode_end(&[], framing).is_ok()); } - /// A datagram must be one its track's draft defines: from draft-17 on a non-zero Status - /// carries no properties, and only drafts 14 and 15 define DOES_NOT_EXIST. + /// A datagram must be one its track's draft defines: a non-zero Status carries no + /// properties. #[test] fn a_datagram_block_needs_a_draft_that_sends_its_shape() { - let drafts = [ - Draft::Draft14, - Draft::Draft15, - Draft::Draft16, - Draft::Draft17, - Draft::Draft22, - ]; - for (status, carried) in [ - (0, &drafts[..]), - (1, &drafts[..2]), - (3, &drafts[..3]), - (4, &drafts[..3]), - ] { - for version in drafts { + for (status, carried) in [(0, true), (1, false), (3, false), (4, false)] { + for version in [Draft::Draft18, Draft::Draft22] { // Object 3 at priority 9, which every draft can send beside a status. let mut buf = Vec::new(); let w = &mut Encoder::new(&mut buf, V.into()); @@ -1009,7 +943,7 @@ mod tests { w.varint(status).unwrap(); w.bytes(&[0x20, 1]).unwrap(); let decoded = decode_datagram(&mut Decoder::new(&buf, V.into()), from(version, None)); - assert_eq!(decoded.is_ok(), carried.contains(&version), "{status}: {version}"); + assert_eq!(decoded.is_ok(), carried, "{status}: {version}"); } } } diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 7ff0f7b66e1..fa3c4f92aaf 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -689,14 +689,6 @@ impl SinkSession { } } - /// Write this prefix on each bidi stream before holding its remaining bytes. - pub fn gated_bi_prefix(prefix: usize, gate: kio::Consumer) -> Self { - Self { - bi_prefix: prefix, - ..Self::gated_bi(gate) - } - } - /// Accept bidi streams from the peer, holding every write until `gate` flips to true. /// /// The accepted half of a control stream carries the answers (track info, diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 7591f2735d4..9551dc406af 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -507,10 +507,9 @@ pub struct Route { /// /// Only a session built on the handle that issued it carries it, as the token on its /// moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE answering a - /// solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and 15 answer with a - /// PUBLISH_NAMESPACE, which carries it), and moq-lite (which rides the session's scope) + /// solicited SUBSCRIBE_NAMESPACE, and moq-lite (which rides the session's scope) /// advertise the route unnamed. Swapping it is an update that renews the request with - /// REQUEST_UPDATE from draft 16, or ends and makes it again before. A handle passed to + /// REQUEST_UPDATE. A handle passed to /// `Client::with_auth` presents it on every session it backs, so the route keeps it /// across a reconnect. Local, like [`Self::source`]: a relay never forwards it. pub auth: Option, @@ -1696,7 +1695,7 @@ impl TableCursor { /// Whether the hidden rule lets this cursor discover a route at `prefix`. fn discovers(&self, prefix: &Path) -> bool { - self.hidden.discovers(&self.heads, prefix) + self.hidden.reaches(&self.heads, prefix) } } @@ -1868,35 +1867,20 @@ pub(super) struct Hidden { include: bool, /// Measure visibility from the request prefix instead of authorization heads. from: Option, - /// Report only what a feed scoped to these heads hides, for a stream that tops - /// up a feed already carrying everything visible from them. - beyond: Option>, } impl Hidden { - /// Whether a cursor hanging at `heads` reports a route at `prefix`. - fn discovers(&self, heads: &[Path], prefix: &Path) -> bool { - self.reaches(heads, prefix) && self.beyond.as_ref().is_none_or(|outer| hides(outer, prefix)) - } - - /// Whether a cursor hanging at `heads` could report a route at or beneath `prefix`. - /// A hidden segment hides everything beneath it, while a top-up feed's rule turns - /// on the rest of the path, so only the first can rule a prefix out. + /// Whether a cursor hanging at `heads` reports a route at or beneath `prefix`. A hidden + /// segment hides everything beneath it. fn reaches(&self, heads: &[Path], prefix: &Path) -> bool { self.include || !hides(self.from.as_ref().map(std::slice::from_ref).unwrap_or(heads), prefix) } - /// This rule for a cursor reading through `mount`, whose heads sit on the - /// target: a feed it tops up hid everything under the mount, or hid what it - /// hides under the target. + /// This rule for a cursor reading through `mount`, whose heads sit on the target. fn translate(&self, mount: &Mount) -> Self { - let beyond = self.beyond.as_ref().and_then(|outer| { - (!hides(outer, &mount.at)).then(|| outer.iter().filter_map(|head| mount.translate_head(head)).collect()) - }); Self { include: self.include, from: self.from.as_ref().and_then(|head| mount.translate_head(head)), - beyond, } } } @@ -6179,21 +6163,6 @@ impl Consumer { self } - /// A clone whose [`announced`](Self::announced) reports only the routes a feed - /// from `outer` hides, for a stream topping up that feed. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn beyond(mut self, outer: &Consumer) -> Self { - self.hidden.beyond = Some( - outer - .hidden - .from - .clone() - .map(|head| vec![head]) - .unwrap_or_else(|| interest_prefixes(&outer.scope.allowed)), - ); - self - } - /// Set wire discovery visibility relative to this consumer's requested root. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn discovery(mut self, hidden: bool) -> Self { @@ -7727,20 +7696,6 @@ mod tests { let mut announced = consumer.scope("", &scopes(&["room/.internal"])).unwrap().announced(); announced.expect_next_active("room/.internal").await; announced.assert_next_wait(); - - // A top-up feed reports only what a feed from the root hid, filtered by its own - // prefix and opt-in. - let mut announced = consumer.clone().with_hidden(true).beyond(&consumer).announced(); - announced.expect_next_active(".stats/node").await; - announced.expect_next_active("room/.internal").await; - announced.assert_next_wait(); - let room = consumer.scope("", &scopes(&["room"])).unwrap().beyond(&consumer); - let mut announced = room.announced(); - announced.assert_next_wait(); - let stats = consumer.scope("", &scopes(&[".stats"])).unwrap().beyond(&consumer); - let mut announced = stats.announced(); - announced.expect_next_active(".stats/node").await; - announced.assert_next_wait(); } /// Hiding narrows discovery only: an exact request resolves without an opt-in. diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index c22912cb0e3..96b2bf9970b 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -121,9 +121,9 @@ pub enum Order { /// it answers, never from the groups an ended feed left in its cache. /// /// Over moq-lite-07, a copy resolves them from the reported live edge and media time. -/// A Rust moq-transport copy on draft 17 and later accepts anchors its cache can already -/// resolve, translating them to group bounds upstream; an unknown time end is refused. -/// Older wires accept absolute anchors but refuse time anchors and relative anchors other +/// A Rust moq-transport copy accepts anchors its cache can already resolve, translating +/// them to group bounds upstream; an unknown time end is refused. Older moq-lite versions +/// accept absolute anchors but refuse time anchors and relative anchors other /// than [`Range::live`] and [`Range::oldest`] with [`Error::Unsupported`](crate::Error::Unsupported) /// rather than guess from a partial copy. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 36d145e51eb..5983a4b632d 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -112,10 +112,9 @@ pub struct Info { /// carries a timestamp, none on an untimed one does, and a write that doesn't match /// is refused with [`Error::TimestampMismatch`]. Defaults to `None` (untimed). On /// Lite05+ it is reported in TRACK_INFO and the publisher zigzag-delta encodes - /// per-frame timestamps at this scale on the wire; on moq-transport draft 17 and - /// later it is the TIMESCALE Track Property. A track received without one - /// (pre-Lite05 moq-lite, moq-transport drafts 14-16, or moq-transport without - /// TIMESCALE) is untimed. + /// per-frame timestamps at this scale on the wire; on moq-transport it is the + /// TIMESCALE Track Property. A track received without one (pre-Lite05 moq-lite, or + /// moq-transport without TIMESCALE) is untimed. pub timescale: Option, /// How long a superseded group stays fresh. The newest group is never stale. /// @@ -151,7 +150,7 @@ pub struct Info { /// /// Writers and readers see plain frames either way: the producer compresses each /// frame as it is written and the cache stores the compressed form. A moq-lite-07 peer or a - /// moq-transport draft-17+ peer negotiating the Flate extension receives the compressed + /// moq-transport peer negotiating the Flate extension receives the compressed /// bytes and the flag. Other sessions receive one shared inflated copy per group, /// unless a sealed compression declaration makes that fallback unsupported. /// Worth it for small, repetitive payloads like JSON; wasted on media. diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 8dc3c2f61f2..0eb8d2b6d3c 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -6,9 +6,9 @@ use crate::origin; use crate::time::{Deadlines, Instant}; use crate::transport::poll::Session as _; use crate::{ - ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_22, ALPN_LITE, ALPN_LITE_03, ALPN_LITE_04, ALPN_LITE_05, - ALPN_LITE_06, ALPN_LITE_07_WIP, Consume, Error, NEGOTIATED, Session, SessionError, Version, Versions, - coding::{Decode, Encode, Stream}, + ALPN_18, ALPN_22, ALPN_LITE, ALPN_LITE_03, ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07_WIP, Consume, + Error, NEGOTIATED, Session, SessionError, Version, Versions, + coding::{Encode, Stream}, ietf, lite, setup::{self, Role}, stats, @@ -250,7 +250,7 @@ impl Server { PausedHandshake::LiteBare { version, .. } | PausedHandshake::LiteSetup { version, .. } => { version.has_auth() } - PausedHandshake::IetfModern(_) | PausedHandshake::Legacy(_) => false, + PausedHandshake::Ietf(_) | PausedHandshake::Negotiated(_) => false, }); let token = match &handshake { PausedHandshake::LiteSetup { client_setup, .. } => client_setup.setup.token.clone(), @@ -318,37 +318,15 @@ impl Server { } async fn handshake(&self, runtime: Deadlines, mut session: Transport) -> Result { - let (encoding, supported) = match session.protocol() { - Some(alpn @ (ALPN_22 | ALPN_18 | ALPN_17)) => { + let supported = match session.protocol() { + Some(alpn @ (ALPN_22 | ALPN_18)) => { let draft = match alpn { ALPN_22 => ietf::Version::Draft22, - ALPN_18 => ietf::Version::Draft18, - _ => ietf::Version::Draft17, + _ => ietf::Version::Draft18, }; self.versions.select(Version::Ietf(draft)).ok_or(Error::Version)?; - return self.accept_ietf_modern(runtime, session, draft).await; - } - Some(ALPN_16) => { - let v = self - .versions - .select(Version::Ietf(ietf::Version::Draft16)) - .ok_or(Error::Version)?; - (v, v.into()) - } - Some(ALPN_15) => { - let v = self - .versions - .select(Version::Ietf(ietf::Version::Draft15)) - .ok_or(Error::Version)?; - (v, v.into()) - } - Some(ALPN_14) => { - let v = self - .versions - .select(Version::Ietf(ietf::Version::Draft14)) - .ok_or(Error::Version)?; - (v, v.into()) + return self.accept_ietf(runtime, session, draft).await; } Some(alpn @ (ALPN_LITE_07_WIP | ALPN_LITE_06 | ALPN_LITE_05 | ALPN_LITE_04 | ALPN_LITE_03)) => { let version = match alpn { @@ -360,17 +338,15 @@ impl Server { }; return self.handshake_lite(runtime, session, version).await; } - Some(ALPN_LITE) | None => { - let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; - (Version::Ietf(ietf::Version::Draft14), supported) - } + // No ALPN, or `moql`: negotiate moq-lite in a bidi SETUP. + Some(ALPN_LITE) | None => self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?, Some(p) => return Err(Error::UnknownAlpn(p.to_string())), }; - // Legacy bidi SETUP exchange (IETF 14-16, lite 01/02). Read the client's - // SETUP to choose the version; `ok()` sends the server SETUP and starts. + // Read the client's SETUP to choose the version; `ok()` sends the server SETUP and starts. // Stats attach in `ok()`: a caller may still replace them on the handshake. - let mut stream = Stream::accept(&mut session, encoding).await?; + // The SETUP keeps lite-01's framing whichever version it offers. + let mut stream = Stream::accept(&mut session, Version::Lite(lite::Version::Lite01)).await?; let (client, client_setup_size) = stream.reader.decode_sized::().await?; let version = client @@ -379,89 +355,50 @@ impl Server { .flat_map(|v| Version::try_from(*v).ok()) .find(|v| supported.contains(v)) .ok_or(Error::Version)?; + let Version::Lite(version) = version else { + return Err(Error::Version); + }; // Answer, close the stream, and continue as that version's ALPN would. Waiting for the // acknowledgement keeps the reply from being discarded by the reset a dropped stream sends. - if let Version::Lite(v) = version - && version.hands_off() - { + if Version::Lite(version).hands_off() { let Stream { mut writer, .. } = stream; writer .encode(&setup::Server { - version: version.into(), - parameters: ietf::Parameters::default().encode_bytes(ietf::Version::Draft14)?, + version: Version::Lite(version).into(), + parameters: lite::Parameters::default().encode_bytes(version)?, }) .await?; writer.close().await?; - return self.handshake_lite(runtime, session, v).await; + return self.handshake_lite(runtime, session, version).await; } - // Pull the request path and max request ID out now (IETF only) so `ok()` - // doesn't re-decode the consumed parameters. moq-transport carries the path - // in its SETUP just like lite-05. - let (path, token, request_id_max, peer_declared, peer_implementation) = match version { - Version::Ietf(v) => { - let (params, _) = ietf::Parameters::decode_slice(&client.parameters, v)?; - let path = match params.get_bytes(ietf::ParameterBytes::Path) { - Some(bytes) => Some( - std::str::from_utf8(bytes) - .map_err(|_| Error::Decode(crate::DecodeError::InvalidValue))? - .to_owned(), - ), - None => None, - }; - let token = ietf::token::from_setup(¶ms, v)?; - let request_id_max = params - .get_varint(ietf::ParameterVarInt::MaxRequestId) - .map(ietf::RequestId); - let peer_declared = ietf::peer::Peer { - solicit: ietf::solicit::from_setup(¶ms, v)?, - hidden: ietf::hidden::from_setup(¶ms, v), - max_age: ietf::max_age::from_setup(¶ms, v), - active_count: ietf::active_count::from_setup(¶ms, v), - flate: ietf::flate::from_setup(¶ms, v)?, - ..Default::default() - }; - let implementation = - setup::decode_implementation(params.get_bytes(ietf::ParameterBytes::Implementation))?; - (path, token, request_id_max, peer_declared, implementation) - } - Version::Lite(_) => (None, None, None, ietf::peer::Peer::default(), None), - }; - Ok(Handshake { - path, + path: None, role: None, origin: None, - token, - peer_implementation: peer_implementation.clone(), + token: None, + peer_implementation: None, assigned_hop: crate::Hop::random(), - // Lite 01/02 and moq-transport 14-16 carry no AUTH, though a moq-transport request - // still carries a token. - auth: match version { - Version::Ietf(_) => crate::auth::Link::ietf(false), - Version::Lite(_) => crate::auth::Link::new(false), - }, + // Lite 01/02 carry no AUTH. + auth: crate::auth::Link::new(false), inner: Some(RequestInner { server: self.clone(), runtime, deadline: None, - handshake: PausedHandshake::Legacy(PausedLegacy { + handshake: PausedHandshake::Negotiated(PausedNegotiated { session, stream, version, - request_id_max, - peer_declared, - peer_implementation, client_setup_size, }), }), }) } - /// Read a draft-17/18 client's SETUP (with its request path) off its uni stream, + /// Read a moq-transport client's SETUP (with its request path) off its uni stream, /// then pause. `ok()` starts the session and hands the stream back for GOAWAY. - async fn accept_ietf_modern( + async fn accept_ietf( &self, runtime: Deadlines, mut session: Transport, @@ -484,7 +421,7 @@ impl Server { server: self.clone(), runtime, deadline: None, - handshake: PausedHandshake::IetfModern(PausedIetfModern { + handshake: PausedHandshake::Ietf(PausedIetf { session, version, peer_setup, @@ -543,20 +480,20 @@ enum PausedHandshake { version: lite::Version, client_setup: lite::AcceptedSetup, }, - IetfModern(PausedIetfModern), - Legacy(PausedLegacy), + Ietf(PausedIetf), + Negotiated(PausedNegotiated), } -/// Modern IETF (17+): the client's SETUP (with its request path) has been +/// moq-transport: the client's SETUP (with its request path) has been /// read off its uni stream; `ok` starts the session, handing that stream back /// for GOAWAY monitoring. -struct PausedIetfModern { +struct PausedIetf { session: Transport, version: ietf::Version, peer_setup: ietf::PeerSetup, } -impl PausedIetfModern { +impl PausedIetf { /// Complete the handshake with the final server config. fn ok( self, @@ -581,8 +518,7 @@ impl PausedIetfModern { stats: server.stats.clone(), limits: server.limits, session: session.clone(), - setup: None, - request_id_max: None, + client: false, publish, subscribe, @@ -621,23 +557,17 @@ impl PausedIetfModern { } } -/// Legacy IETF (draft 14-16) and lite 01/02: the client SETUP has been read -/// off the bidi stream (including its request path) but the server SETUP -/// hasn't been sent; `ok` finishes it. -struct PausedLegacy { +/// moq-lite 01/02: the client SETUP has been read off the bidi stream but the server +/// SETUP hasn't been sent; `ok` finishes it. +struct PausedNegotiated { session: Transport, stream: Stream, - version: Version, - request_id_max: Option, - /// What the client's SETUP declared, for the options `ok` acts on. - peer_declared: ietf::peer::Peer, - /// The implementation the client's SETUP advertised. - peer_implementation: Option, + version: lite::Version, /// The client SETUP's encoded size, counted under the final stats. client_setup_size: usize, } -impl PausedLegacy { +impl PausedNegotiated { /// Send the server SETUP and start the session. async fn ok( self, @@ -650,129 +580,50 @@ impl PausedLegacy { session, stream, version, - request_id_max, - peer_declared, - peer_implementation, client_setup_size, } = self; let mut stream = stream.with_stats(&server.stats); server.stats.control(stats::Message::Session, client_setup_size, true); let (publish, subscribe) = server.stat_tagged_origins(); - // Encode parameters using the version-appropriate type. - let parameters = match version { - Version::Ietf(v) => { - let mut parameters = ietf::Parameters::default(); - // The client's requests, admitted up to our limits and granted back as they close. - parameters.set_varint( - ietf::ParameterVarInt::MaxRequestId, - ietf::initial_max_request_id(server.limits.window(), true), - ); - if let Some(implementation) = server.implementation() { - parameters.set_bytes(ietf::ParameterBytes::Implementation, implementation.into_bytes()); - } - if server.extensions.solicit { - ietf::solicit::into_setup(&mut parameters, v); - } - ietf::hidden::into_setup(&mut parameters, v); - ietf::max_age::into_setup(&mut parameters, v); - ietf::active_count::into_setup(&mut parameters, v); - ietf::flate::into_setup(&mut parameters, v); - parameters.encode_bytes(v)? - } - Version::Lite(v) => lite::Parameters::default().encode_bytes(v)?, - }; - let server_setup = setup::Server { - version: version.into(), - parameters, + version: Version::Lite(version).into(), + parameters: lite::Parameters::default().encode_bytes(version)?, }; stream.writer.encode(&server_setup).await?; - let (recv_bw, protocol, goaway, auth, credential, estimate, peer_implementation) = match version { - Version::Lite(v) => { - let stream = stream.with_version(v); - // Pre-lite-05: no Setup Stream, so nothing to advertise or seed. - let start = lite::start(lite::Config { - runtime: runtime.clone(), - stats: server.stats.clone(), - client: false, - limits: server.limits, - session: session.clone(), - setup_stream: Some(stream), - publish, - subscribe, - peer_hop, - version: v, - our_setup: lite::Setup::default(), - peer_setup: None, - auth, - })?; - ( - start.recv_bandwidth, - crate::driver::Protocol::Lite(Box::new(start.driver)), - start.goaway, - start.auth, - start.credential, - Some(start.estimate), - start.peer_implementation, - ) - } - Version::Ietf(v) => { - let stream = stream.with_version(v); - let peer_implementation = setup::PeerImplementation::known(peer_implementation); - // Draft 14-16: path came in the bidi SETUP, no uni SETUP to hand back. - let (protocol, goaway, credential) = ietf::start(ietf::Config { - runtime: runtime.clone(), - stats: server.stats.clone(), - limits: server.limits, - session: session.clone(), - setup: Some(stream), - request_id_max, - client: false, - publish, - subscribe, - peer_hop, - cost: None, - version: v, - path: None, - authority: None, - token: None, - peer_setup_stream: None, - peer_declared: Some(peer_declared), - auth: auth.clone(), - early_unis: Vec::new(), - extensions: server.extensions, - // Already sent on the bidi SETUP above. - implementation: None, - peer_implementation: peer_implementation.clone(), - })?; - ( - None, - crate::driver::Protocol::Ietf(protocol), - goaway, - auth, - credential, - None, - peer_implementation, - ) - } - }; + let stream = stream.with_version(version); + // Pre-lite-05: no Setup Stream, so nothing to advertise or seed. + let start = lite::start(lite::Config { + runtime: runtime.clone(), + stats: server.stats.clone(), + client: false, + limits: server.limits, + session: session.clone(), + setup_stream: Some(stream), + publish, + subscribe, + peer_hop, + version, + our_setup: lite::Setup::default(), + peer_setup: None, + auth, + })?; Ok(Session::new( runtime, session, - version, - recv_bw, - protocol, + version.into(), + start.recv_bandwidth, + crate::driver::Protocol::Lite(Box::new(start.driver)), crate::session::Handles { stats: server.stats.clone(), - goaway, - auth, - credential, - estimate, + goaway: start.goaway, + auth: start.auth, + credential: start.credential, + estimate: Some(start.estimate), bandwidth: crate::bandwidth::Allocator::default(), - peer_implementation, + peer_implementation: start.peer_implementation, }, )) } @@ -804,7 +655,7 @@ impl Handshake { /// The Hop ID declared by the peer, when the negotiated protocol carries one. /// /// A moq-lite-05+ endpoint declares this when it attaches a publish or subscribe - /// origin; a `moqt-17`+ endpoint declares it via the MoQ Cluster extension. Older + /// origin; a moq-transport endpoint declares it via the MoQ Cluster extension. Older /// versions and endpoints without one return `None`. /// /// Self-declared, so treat it as a correlation hint rather than an @@ -939,9 +790,9 @@ impl Handshake { server.stats.control(stats::Message::Session, client_setup.size, true); server.start_lite(runtime, session, version, Some(client_setup), peer_hop, auth) } - PausedHandshake::IetfModern(paused) => paused.ok(server, runtime, peer_hop, auth), + PausedHandshake::Ietf(paused) => paused.ok(server, runtime, peer_hop, auth), // Boxed so the bidi SETUP write of these old versions doesn't size every caller's future. - PausedHandshake::Legacy(paused) => Box::pin(paused.ok(server, runtime, peer_hop, auth)).await, + PausedHandshake::Negotiated(paused) => Box::pin(paused.ok(server, runtime, peer_hop, auth)).await, } }; let res = match deadline { @@ -998,8 +849,8 @@ impl PausedHandshake { Self::LiteBare { session, version } | Self::LiteSetup { session, version, .. } => { (session.clone(), (*version).into()) } - Self::IetfModern(paused) => (paused.session.clone(), paused.version.into()), - Self::Legacy(paused) => (paused.session.clone(), paused.version), + Self::Ietf(paused) => (paused.session.clone(), paused.version.into()), + Self::Negotiated(paused) => (paused.session.clone(), paused.version.into()), } } } @@ -1268,7 +1119,7 @@ mod tests { buf } - /// Encode a draft-17+ Setup Stream: the unified SETUP message, whose parameters + /// Encode a moq-transport Setup Stream: the unified SETUP message, whose parameters /// carry the request path the same way lite-05's does. fn ietf_setup(version: ietf::Version, path: Option<&str>) -> Vec { let mut params = ietf::Parameters::default(); @@ -1291,17 +1142,15 @@ mod tests { buf } - /// Encode a draft 14-16 CLIENT_SETUP, sent on the control bidi stream. - fn legacy_setup(version: ietf::Version, params: ietf::Parameters) -> Vec { + /// A moq-lite-02 CLIENT_SETUP, sent on the bidi stream the `moql` ALPN negotiates over. + fn moql_setup() -> Vec { + let version = crate::Version::Lite(lite::Version::Lite02); let mut buf = Vec::new(); setup::Client { - versions: crate::coding::Versions::from([crate::Version::Ietf(version).into()]), - parameters: params.encode_bytes(version).unwrap(), + versions: crate::coding::Versions::from([version.into()]), + parameters: Bytes::new(), } - .encode( - &mut crate::coding::Encoder::new(&mut buf, (crate::Version::Ietf(version)).into()), - crate::Version::Ietf(version), - ) + .encode(&mut crate::coding::Encoder::new(&mut buf, version.into()), version) .unwrap(); buf } @@ -1323,21 +1172,13 @@ mod tests { /// session the driver then runs, through the REAL accept path (`accept_request` -> `ok`). /// The session's own `auth()` (the handle the driver polls) routes a request token to that /// pre-ok consumer instead of falling to the `Unsupported` default, and granting admits. - /// Both a no-AUTH-extension legacy session (draft-14, `Handle::new(false)`) and the modern - /// uni-SETUP path (draft-18); a QUIC server has no other point to install the verifier. + /// A QUIC server has no other point to install the verifier. #[moq_clock::test] async fn a_pre_ok_verifier_governs_the_session() { - let cases = [ - ( - FakeSession::new(ALPN_14, []) - .with_bi(legacy_setup(ietf::Version::Draft14, ietf::Parameters::default())), - ietf::Version::Draft14, - ), - ( - FakeSession::new(ALPN_18, [ietf_setup(ietf::Version::Draft18, Some("room/alice"))]), - ietf::Version::Draft18, - ), - ]; + let cases = [( + FakeSession::new(ALPN_18, [ietf_setup(ietf::Version::Draft18, Some("room/alice"))]), + ietf::Version::Draft18, + )]; for (session, version) in cases { let mut request = Server::new() @@ -1371,9 +1212,9 @@ mod tests { #[moq_clock::test] async fn a_reply_the_peer_never_takes_fails_at_the_deadline() { let registry = stats::Registry::new(Default::default()); - let version = ietf::Version::Draft16; - let session = FakeSession::new(ALPN_16, []) - .with_bi(legacy_setup(version, ietf::Parameters::default())) + let version = Version::Lite(lite::Version::Lite02); + let session = FakeSession::new(ALPN_LITE, []) + .with_bi(moql_setup()) .withholding_credit(); let start = moq_clock::now(); let deadline = start + std::time::Duration::from_secs(10); @@ -1392,10 +1233,7 @@ mod tests { }; assert!(matches!(err, Error::Timeout), "{err:?}"); assert_eq!(moq_clock::now(), deadline); - assert_eq!( - session.close_code(), - Some(Version::Ietf(version).session_code(&SessionError::Timeout)) - ); + assert_eq!(session.close_code(), Some(version.session_code(&SessionError::Timeout))); let failed = registry.snapshot().sessions()[0].1.sessions_failed; assert_eq!(failed.count(stats::Failure::Application), 1, "{failed:?}"); } @@ -1405,9 +1243,9 @@ mod tests { #[moq_clock::test] async fn an_abandoned_reply_fails_like_the_deadline() { let registry = stats::Registry::new(Default::default()); - let version = ietf::Version::Draft16; - let session = FakeSession::new(ALPN_16, []) - .with_bi(legacy_setup(version, ietf::Parameters::default())) + let version = Version::Lite(lite::Version::Lite02); + let session = FakeSession::new(ALPN_LITE, []) + .with_bi(moql_setup()) .withholding_credit(); let request = Server::new() .accept_request(moq_clock::now(), session.clone()) @@ -1420,10 +1258,7 @@ mod tests { ); let abandoned = moq_clock::timeout(std::time::Duration::from_secs(1), request.ok()).await; assert!(abandoned.is_err(), "a reply the peer never took completed"); - assert_eq!( - session.close_code(), - Some(Version::Ietf(version).session_code(&SessionError::Timeout)) - ); + assert_eq!(session.close_code(), Some(version.session_code(&SessionError::Timeout))); let failed = registry.snapshot().sessions()[0].1.sessions_failed; assert_eq!(failed.count(stats::Failure::Application), 1, "{failed:?}"); } @@ -1459,21 +1294,15 @@ mod tests { #[moq_clock::test] async fn accept_request_exposes_the_setup_token() { - let modern = FakeSession::new( + let ietf = FakeSession::new( ALPN_22, [ietf_setup_with( ietf::Version::Draft22, token_params(ietf::Version::Draft22), )], ); - let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup( - ietf::Version::Draft16, - token_params(ietf::Version::Draft16), - )); - for (name, session) in [("draft-22", modern), ("draft-16", legacy)] { - let request = Server::new().accept_request(moq_clock::now(), session).await.unwrap(); - assert_eq!(request.token(), Some(&setup_token()), "{name}"); - } + let request = Server::new().accept_request(moq_clock::now(), ietf).await.unwrap(); + assert_eq!(request.token(), Some(&setup_token())); } #[moq_clock::test] @@ -1486,8 +1315,7 @@ mod tests { } } - /// A SETUP the server refuses closes the session with the code naming why, on both - /// the draft-17+ uni stream and the draft 14-16 bidi stream. + /// A SETUP the server refuses closes the session with the code naming why. #[moq_clock::test] async fn a_refused_setup_token_closes_with_its_code() { let delete = [0x0, 0x7]; // DELETE alias 7 @@ -1499,25 +1327,18 @@ mod tests { let mut params = ietf::Parameters::default(); params.set_bytes(ietf::ParameterBytes::AuthorizationToken, raw.to_vec()); - let modern = FakeSession::new(ALPN_22, [ietf_setup_with(ietf::Version::Draft22, params.clone())]); - let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup(ietf::Version::Draft16, params)); - for (name, session) in [("draft-22", modern), ("draft-16", legacy)] { - let result = Server::new().accept_request(moq_clock::now(), session.clone()).await; - assert!(result.is_err(), "{name}"); - assert_eq!(session.close_code(), Some(code.to_code()), "{name} {code}"); - } + let session = FakeSession::new(ALPN_22, [ietf_setup_with(ietf::Version::Draft22, params)]); + let result = Server::new().accept_request(moq_clock::now(), session.clone()).await; + assert!(result.is_err()); + assert_eq!(session.close_code(), Some(code.to_code()), "{code}"); } } #[moq_clock::test] async fn accept_request_reads_ietf_path() { - // Every draft-17+ version gates on the SETUP stream before starting, so the + // Every draft gates on the SETUP stream before starting, so the // path is known at authorization time just like lite-05. - for (alpn, version) in [ - (ALPN_17, ietf::Version::Draft17), - (ALPN_18, ietf::Version::Draft18), - (ALPN_22, ietf::Version::Draft22), - ] { + for (alpn, version) in [(ALPN_18, ietf::Version::Draft18), (ALPN_22, ietf::Version::Draft22)] { let session = FakeSession::new(alpn, [ietf_setup(version, Some("/team/room"))]); let request = Server::new().accept_request(moq_clock::now(), session).await.unwrap(); assert_eq!(request.path(), "/team/room", "{alpn}"); @@ -1672,7 +1493,7 @@ mod tests { server: Server::new().with_publisher(&origin), runtime: Deadlines::new(moq_clock::now()), deadline: None, - handshake: PausedHandshake::IetfModern(PausedIetfModern { + handshake: PausedHandshake::Ietf(PausedIetf { session: Transport::new(transport), version, peer_setup: ietf::PeerSetup { diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index e0a811157e1..7af1a79edea 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -278,7 +278,7 @@ impl Session { /// is still live never finishes, so finish or abort tracks before closing. /// /// Both protocols withdraw this session's announcements and wait for their - /// delivery. IETF drafts 14 through 16 send withdrawals without waiting. + /// delivery. pub async fn close(self) -> Result<(), Error> { if let Ok(mut close) = self.close.write() && close.is_none() @@ -367,9 +367,9 @@ impl Session { /// The handle passed to [`Client::with_auth`](crate::Client::with_auth), or else one /// this session created for itself, which ends with it. /// - /// On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate the - /// MoQ Auth extension, each side presents its connection's credential right after - /// setup. Older versions, and peers that do not negotiate it, leave the grant `None`. + /// On moq-lite-07-wip, and on moq-transport when both sides negotiate the MoQ Auth + /// extension, each side presents its connection's credential right after setup. + /// Older versions, and peers that do not negotiate it, leave the grant `None`. pub fn auth(&self) -> auth::Handle { self.auth.clone() } @@ -888,9 +888,8 @@ impl Drop for Withdrawing { /// Bounds on what one peer can make a session hold, where its transport does not already. /// -/// Each moq-lite request, and each moq-transport request from draft 17 on, holds a stream -/// of its own, so the transport's stream credit bounds those. A peer that announces past -/// `announces`, or overruns the drafts 14 to 16 request window, loses the session, closed with +/// Each request holds a stream of its own, so the transport's stream credit bounds those. +/// A peer that announces past `announces` loses the session, closed with /// `TOO_MANY_REQUESTS`. The stats sessions rows report announcement and subscription peaks. /// /// ``` @@ -903,32 +902,12 @@ impl Drop for Withdrawing { pub struct Limits { /// Broadcasts (moq-lite) or namespaces (moq-transport) the peer may have announced to us. pub announces: usize, - /// Other moq-transport requests the peer may hold at once, such as SUBSCRIBE and FETCH. - /// - /// On drafts 14 to 16 this and `announces` size the request window, which every request - /// counts against. Later drafts still refuse a reused request ID, so a peer may leave at - /// most twice the sum of both runs of skipped IDs; one more closes the session with - /// `INTERNAL_ERROR` rather than forgetting any. moq-lite ignores it. - pub requests: usize, } impl Default for Limits { - /// Generous enough for a relay mesh carrying a large origin; lower them for untrusted peers. + /// Generous enough for a relay mesh carrying a large origin; lower it for untrusted peers. fn default() -> Self { - Self { - announces: 100_000, - requests: 20_000, - } - } -} - -impl Limits { - /// How many requests a moq-transport peer may hold open at once (drafts 14 to 16). - /// - /// Twice what the limits admit, so a peer within them is never blocked by request IDs - /// still waiting to be granted back. - pub(crate) fn window(&self) -> u64 { - (self.announces.saturating_add(self.requests) as u64).saturating_mul(2) + Self { announces: 100_000 } } } diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index a077fea58e1..c61e4df1985 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -9,7 +9,7 @@ use bytes::Bytes; use crate::{ Version, coding::{self, Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}, - ietf, lite, + lite, }; /// The session direction a client advertises in its SETUP (moq-lite-05+). @@ -21,8 +21,8 @@ pub(crate) const MAX_SETUP_SIZE: usize = u16::MAX as usize; const CLIENT_SETUP: u8 = 0x20; const SERVER_SETUP: u8 = 0x21; -/// Draft-17 unified SETUP message type (varint 0x2F00) -pub(crate) const SETUP_V17: u64 = 0x2F00; +/// The moq-transport SETUP message type (varint 0x2F00). +pub(crate) const SETUP: u64 = 0x2F00; /// Decode a SETUP Implementation value, refusing one that is not UTF-8. pub(crate) fn decode_implementation(bytes: Option<&[u8]>) -> Result, crate::DecodeError> { @@ -79,14 +79,13 @@ pub(crate) fn implementation(value: &str) -> Option { #[serde(default, deny_unknown_fields)] #[non_exhaustive] pub struct Extensions { - /// The MoQ Auth extension: tokens presented and granted on their own stream - /// (moq-transport draft-17+ only). + /// The MoQ Auth extension: tokens presented and granted on their own stream. pub auth: bool, /// The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than /// announcing unasked (moq-transport only). pub solicit: bool, /// The MoQ Epoch extension: announcements and requests carry the publisher's - /// [`Epoch`](crate::Epoch), as moq-lite 07 does (moq-transport draft-17+ only). + /// [`Epoch`](crate::Epoch), as moq-lite 07 does. pub epoch: bool, /// The MoQ IETF extension, off by default: a moq-lite 07 hop between relays carries the /// moq-transport object state a relay preserves, such as Object IDs and properties. @@ -128,27 +127,19 @@ impl std::fmt::Debug for Token { } } -/// Draft-17+ unified SETUP message, with the same encoding for both client and server. +/// The unified SETUP message, with the same encoding for both client and server. #[derive(Debug, Clone)] pub(crate) struct Setup { pub parameters: Bytes, } -impl Setup { - fn check_version(v: Version) { - match v { - Version::Ietf(ietf::Version::Draft14 | ietf::Version::Draft15 | ietf::Version::Draft16) - | Version::Lite(_) => unreachable!("Setup is draft-17+ only"), - _ => {} - } - } -} - impl Encode for Setup { fn encode(&self, w: &mut Encoder<'_>, v: Version) -> Result<(), EncodeError> { w.control = Some(crate::stats::Message::Session); - Self::check_version(v); - w.varint(SETUP_V17)?; + if v.is_lite() { + return Err(EncodeError::Version); + } + w.varint(SETUP)?; let prefix = w.prefix_u16(); w.slice(&self.parameters); w.fill(prefix) @@ -158,9 +149,11 @@ impl Encode for Setup { impl Decode for Setup { fn decode(r: &mut Decoder<'_>, v: Version) -> Result { r.control = Some(crate::stats::Message::Session); - Self::check_version(v); + if v.is_lite() { + return Err(DecodeError::Version); + } let kind = r.varint()?; - if kind != SETUP_V17 { + if kind != SETUP { return Err(DecodeError::InvalidValue); } let size = r.u16()? as usize; @@ -169,75 +162,39 @@ impl Decode for Setup { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SetupVersion { - Draft14, - Draft15Plus, - /// Draft17+ uses ALPN-only negotiation with no legacy SETUP message. - Modern, - LiteLegacy, - Unsupported, +/// Whether `v` encodes the `moql` bidi SETUP exchange: moq-lite 01/02, whose draft-14 framing +/// and QUIC varints it keeps whichever moq-lite version it negotiates. +fn framed(v: Version) -> bool { + matches!(v, Version::Lite(lite::Version::Lite01 | lite::Version::Lite02)) } -impl SetupVersion { - fn from_version(v: Version) -> Self { - match v { - Version::Ietf(ietf::Version::Draft14) => Self::Draft14, - Version::Ietf(ietf::Version::Draft15) | Version::Ietf(ietf::Version::Draft16) => Self::Draft15Plus, - Version::Ietf(ietf::Version::Draft17) - | Version::Ietf(ietf::Version::Draft18) - | Version::Ietf(ietf::Version::Draft22) => Self::Modern, - Version::Lite(lite::Version::Lite01) | Version::Lite(lite::Version::Lite02) => Self::LiteLegacy, - Version::Lite(_) => Self::Unsupported, - } - } -} - -/// A version-agnostic setup message sent by the client. +/// The client half of the `moql` bidi SETUP exchange, offering moq-lite versions. +/// +/// The framing is moq-transport draft-14's CLIENT_SETUP, which moq-lite borrowed: a +/// u16 size, the offered versions, then parameters. #[derive(Debug, Clone)] pub(crate) struct Client { /// The list of supported versions in preferred order. pub versions: coding::Versions, - /// Parameters, unparsed because the IETF draft changed the encoding. + /// Parameters, unparsed: moq-lite defines none here, so neither side reads them. pub parameters: Bytes, } -impl Client { - fn encode_inner(&self, w: &mut Encoder<'_>, v: Version) -> Result<(), EncodeError> { - match SetupVersion::from_version(v) { - SetupVersion::Draft15Plus => { - // Draft15+: no versions list, parameters only. - } - SetupVersion::Draft14 | SetupVersion::LiteLegacy => self.versions.encode(w, v)?, - SetupVersion::Modern | SetupVersion::Unsupported => return Err(EncodeError::Version), - }; - w.slice(&self.parameters); - Ok(()) - } -} - impl Decode for Client { - /// Decode a client setup message (draft-14 through draft-16 only). fn decode(r: &mut Decoder<'_>, v: Version) -> Result { r.control = Some(crate::stats::Message::Session); + if !framed(v) { + return Err(DecodeError::Version); + } let kind = r.u8()?; if kind != CLIENT_SETUP { return Err(DecodeError::InvalidValue); } - let mut msg = decode_body(r, v)?; - - let versions = match SetupVersion::from_version(v) { - SetupVersion::Draft15Plus => { - // Draft15+: no versions list, parameters only. - coding::Versions::from([v.into()]) - } - SetupVersion::Draft14 | SetupVersion::LiteLegacy => { - coding::Versions::decode(&mut msg, v).map_err(DecodeError::complete)? - } - SetupVersion::Modern | SetupVersion::Unsupported => return Err(DecodeError::Version), - }; + let size = r.u16()? as usize; + let mut msg = r.sub(size)?; + let versions = coding::Versions::decode(&mut msg, v).map_err(DecodeError::complete)?; Ok(Self { versions, @@ -247,42 +204,20 @@ impl Decode for Client { } impl Encode for Client { - /// Encode a client setup message (draft-14 through draft-16 only). fn encode(&self, w: &mut Encoder<'_>, v: Version) -> Result<(), EncodeError> { w.control = Some(crate::stats::Message::Session); + if !framed(v) { + return Err(EncodeError::Version); + } w.u8(CLIENT_SETUP); - let prefix = prefix_body(w, v)?; - self.encode_inner(w, v)?; + let prefix = w.prefix_u16(); + self.versions.encode(w, v)?; + w.slice(&self.parameters); fill_body(w, prefix) } } -/// Read a pre-draft-17 SETUP body: its size, then that many bytes. -fn decode_body<'a>(r: &mut Decoder<'a>, v: Version) -> Result, DecodeError> { - let size = match SetupVersion::from_version(v) { - SetupVersion::Draft14 | SetupVersion::Draft15Plus => r.u16()? as usize, - SetupVersion::LiteLegacy => usize::try_from(r.varint()?).map_err(|_| DecodeError::BoundsExceeded)?, - SetupVersion::Modern | SetupVersion::Unsupported => return Err(DecodeError::Version), - }; - if size > MAX_SETUP_SIZE { - return Err(DecodeError::MessageTooLarge { - size, - max: MAX_SETUP_SIZE, - }); - } - r.sub(size) -} - -/// Reserve the size prefix of a pre-draft-17 SETUP body. -fn prefix_body(w: &mut Encoder<'_>, v: Version) -> Result { - match SetupVersion::from_version(v) { - SetupVersion::Draft14 | SetupVersion::Draft15Plus => Ok(w.prefix_u16()), - SetupVersion::LiteLegacy => Ok(w.prefix_varint()), - SetupVersion::Modern | SetupVersion::Unsupported => Err(EncodeError::Version), - } -} - -/// Size a pre-draft-17 SETUP body, refusing one our own receiver would refuse. +/// Size a SETUP body, refusing one our own receiver would refuse. fn fill_body(w: &mut Encoder<'_>, prefix: coding::Prefix) -> Result<(), EncodeError> { if w.since(&prefix) > MAX_SETUP_SIZE { w.discard(prefix); @@ -291,58 +226,47 @@ fn fill_body(w: &mut Encoder<'_>, prefix: coding::Prefix) -> Result<(), EncodeEr w.fill(prefix) } -/// Sent by the server in response to a client setup. +/// The server half of the `moql` bidi SETUP exchange, selecting a moq-lite version. +/// +/// The framing is moq-transport draft-14's SERVER_SETUP: a u16 size, the selected version, +/// then parameters. #[derive(Debug, Clone)] pub(crate) struct Server { - /// The list of supported versions in preferred order. + /// The selected version. pub version: coding::Version, - /// Supported extensions. + /// Parameters, unparsed: moq-lite defines none here, so neither side reads them. pub parameters: Bytes, } -impl Server { - fn encode_inner(&self, w: &mut Encoder<'_>, v: Version) -> Result<(), EncodeError> { - match SetupVersion::from_version(v) { - SetupVersion::Draft15Plus => { - // Draft15+: No version field, parameters only. - } - SetupVersion::Draft14 | SetupVersion::LiteLegacy => self.version.encode(w, v)?, - SetupVersion::Modern | SetupVersion::Unsupported => return Err(EncodeError::Version), - }; - w.slice(&self.parameters); - Ok(()) - } -} - impl Encode for Server { - /// Encode a server setup message (draft-14 through draft-16 only). fn encode(&self, w: &mut Encoder<'_>, v: Version) -> Result<(), EncodeError> { w.control = Some(crate::stats::Message::Session); + if !framed(v) { + return Err(EncodeError::Version); + } w.u8(SERVER_SETUP); - let prefix = prefix_body(w, v)?; - self.encode_inner(w, v)?; + let prefix = w.prefix_u16(); + self.version.encode(w, v)?; + w.slice(&self.parameters); fill_body(w, prefix) } } impl Decode for Server { - /// Decode a server setup message (draft-14 through draft-16 only). fn decode(r: &mut Decoder<'_>, v: Version) -> Result { r.control = Some(crate::stats::Message::Session); + if !framed(v) { + return Err(DecodeError::Version); + } let kind = r.u8()?; if kind != SERVER_SETUP { return Err(DecodeError::InvalidValue); } - let mut msg = decode_body(r, v)?; - let version = match SetupVersion::from_version(v) { - SetupVersion::Draft15Plus => v.into(), - SetupVersion::Draft14 | SetupVersion::LiteLegacy => { - coding::Version::decode(&mut msg, v).map_err(DecodeError::complete)? - } - SetupVersion::Modern | SetupVersion::Unsupported => return Err(DecodeError::Version), - }; + let size = r.u16()? as usize; + let mut msg = r.sub(size)?; + let version = coding::Version::decode(&mut msg, v).map_err(DecodeError::complete)?; Ok(Self { version, @@ -355,6 +279,29 @@ impl Decode for Server { mod tests { use super::*; + /// Deployed lite-01/02 peers frame both halves with draft-14's u16 size, not a varint. + #[test] + fn the_moql_setup_has_a_u16_size() { + for v in [lite::Version::Lite01, lite::Version::Lite02] { + let v = Version::Lite(v); + let client = Client { + versions: coding::Versions::from([v.into()]), + parameters: Bytes::from_static(&[0]), + }; + let buf = client.encode_bytes(v).unwrap(); + assert_eq!(buf[..3], [CLIENT_SETUP, 0, (buf.len() - 3) as u8], "{v}"); + assert_eq!(Client::decode_slice(&buf, v).unwrap().0.versions, client.versions); + + let server = Server { + version: v.into(), + parameters: Bytes::from_static(&[0]), + }; + let buf = server.encode_bytes(v).unwrap(); + assert_eq!(buf[..3], [SERVER_SETUP, 0, (buf.len() - 3) as u8], "{v}"); + assert_eq!(Server::decode_slice(&buf, v).unwrap().0.version, server.version); + } + } + /// Never emit a legacy SETUP our own receiver would refuse. #[test] fn encode_enforces_the_setup_limit() { diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index 900aa595d1b..68c639c2862 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -36,15 +36,8 @@ fn ietf_range_updates_interop() { responses: Vec>, streams: Vec>, } - for version in [ - ietf::Version::Draft15, - ietf::Version::Draft16, - ietf::Version::Draft17, - ietf::Version::Draft18, - ietf::Version::Draft22, - ] { + for version in [ietf::Version::Draft18, ietf::Version::Draft22] { let draft22 = Filter::is_draft22(version); - let legacy = matches!(version, ietf::Version::Draft15 | ietf::Version::Draft16); let range = |start, end: Option| Filter::Absolute { start: Location { group: start, @@ -80,9 +73,6 @@ fn ietf_range_updates_interop() { w.varint(ietf::SubscribeUpdate::ID).unwrap(); ietf::SubscribeUpdate { request_id: RequestId(id), - subscription_request_id: legacy.then_some(RequestId(0)), - start_location: Location { group: 0, object: 0 }, - end_group: 0, subscriber_priority: None, forward: None, filter: Some(filter), @@ -127,7 +117,6 @@ fn ietf_range_updates_interop() { } else { assert_eq!(r.varint().unwrap(), ietf::RequestOk::ID); let ok = ietf::RequestOk::decode(&mut r, version).unwrap(); - assert_eq!(ok.request_id, legacy.then_some(RequestId(index as u64 * 2))); assert_eq!( ok.largest, if draft22 { @@ -224,7 +213,7 @@ fn varint_interop() { values.push(varint::MAX_QUIC); let quic = Form::Quic; - let leading_ones = Form::from(ietf::Version::Draft17); + let leading_ones = Form::from(ietf::Version::Draft18); let encode = |v: u64, form: Form| { let mut buf = Vec::new(); Encoder::new(&mut buf, form).varint(v).unwrap(); @@ -440,23 +429,11 @@ fn ietf_datagram_interop() { use crate::coding::Decode; use crate::{Timescale, Timestamp}; - let all = [ - ietf::Version::Draft14, - ietf::Version::Draft15, - ietf::Version::Draft16, - ietf::Version::Draft17, - ietf::Version::Draft18, - ietf::Version::Draft22, - ]; - for version in all { - let draft = crate::Version::from(version).code() & 0xff; - let legacy = matches!( - version, - ietf::Version::Draft14 | ietf::Version::Draft15 | ietf::Version::Draft16 - ); + let all = [ietf::Version::Draft18, ietf::Version::Draft22]; + for (draft, version) in [18, 22].into_iter().zip(all) { let mut properties = Vec::new(); let mut w = Encoder::new(&mut properties, version.into()); - ietf::encode_object_time(&mut w, Timestamp::from_millis(1234).unwrap(), Timescale::MILLI, version).unwrap(); + ietf::encode_object_time(&mut w, Timestamp::from_millis(1234).unwrap(), Timescale::MILLI).unwrap(); // What a publisher sends: Object 0, explicit priority, ending its group, stamped. let mut datagrams = vec![ietf::ObjectDatagram { @@ -477,28 +454,15 @@ fn ietf_datagram_interop() { properties: None, body: ietf::DatagramBody::Status(0), }); - if version != ietf::Version::Draft14 { - datagrams.push(ietf::ObjectDatagram { - track_alias: (1 << 40) + 1, - group_id: (1 << 50) + 1, - object_id: Some(1 << 20), - publisher_priority: None, - end_of_group: true, - properties: None, - body: ietf::DatagramBody::Payload(bytes::Bytes::new()), - }); - } - if legacy { - datagrams.push(ietf::ObjectDatagram { - track_alias: 1, - group_id: 2, - object_id: Some(0), - publisher_priority: Some(0), - end_of_group: false, - properties: Some(properties.clone()), - body: ietf::DatagramBody::Status(3), - }); - } + datagrams.push(ietf::ObjectDatagram { + track_alias: (1 << 40) + 1, + group_id: (1 << 50) + 1, + object_id: Some(1 << 20), + publisher_priority: None, + end_of_group: true, + properties: None, + body: ietf::DatagramBody::Payload(bytes::Bytes::new()), + }); let encoded: Vec> = datagrams .iter() .map(|datagram| datagram.encode_bytes(version).unwrap().to_vec()) @@ -630,20 +594,8 @@ fn ietf_track_status_interop() { use crate::coding::Decode; use crate::ietf::error::request; - let all = [ - ietf::Version::Draft14, - ietf::Version::Draft15, - ietf::Version::Draft16, - ietf::Version::Draft17, - ietf::Version::Draft18, - ietf::Version::Draft22, - ]; - for version in all { - let draft = crate::Version::from(version).code() & 0xff; - let legacy = matches!( - version, - ietf::Version::Draft14 | ietf::Version::Draft15 | ietf::Version::Draft16 - ); + let all = [ietf::Version::Draft18, ietf::Version::Draft22]; + for (draft, version) in [18, 22].into_iter().zip(all) { let request = |id: u64, namespace: &str, wanted: bool| { let mut buf = Vec::new(); let mut w = Encoder::new(&mut buf, version.into()); @@ -669,17 +621,15 @@ fn ietf_track_status_interop() { for (index, wanted) in [true, false].into_iter().enumerate() { let mut r = Decoder::new(&responses[index], version.into()); - assert_eq!(r.varint().unwrap(), ietf::TrackStatusOk::id(version), "{version}"); + assert_eq!( + r.varint().unwrap(), + ::ID, + "{version}" + ); let ok = ietf::TrackStatusOk::decode(&mut r, version).unwrap(); assert!(r.is_empty(), "{version}: trailing bytes"); let expected = match version { - // GROUP_ORDER is a field on draft-14 and a parameter on draft-15. - ietf::Version::Draft14 | ietf::Version::Draft15 => ietf::Properties { - group_order: Some(ietf::GroupOrder::Descending), - ..Default::default() - }, - ietf::Version::Draft16 | ietf::Version::Draft17 => ietf::Properties::default(), // Only draft-22 can carry the opt-out. _ if !wanted && ietf::Filter::is_draft22(version) => ietf::Properties::default(), _ => ietf::Properties { @@ -696,7 +646,6 @@ fn ietf_track_status_interop() { assert_eq!( ok, ietf::TrackStatusOk { - request_id: legacy.then_some(ietf::RequestId(1 + 2 * index as u64)), largest: Some(ietf::Location { group: 4, object: 1 }), properties: expected, }, @@ -706,10 +655,7 @@ fn ietf_track_status_interop() { let mut r = Decoder::new(&responses[2], version.into()); let code = match r.varint().unwrap() { - ietf::TRACK_STATUS_ERROR_14 if version == ietf::Version::Draft14 => { - ietf::SubscribeError::decode(&mut r, version).unwrap().error_code - } - id if id == ::ID && version != ietf::Version::Draft14 => { + id if id == ::ID => { ietf::RequestError::decode(&mut r, version).unwrap().error_code } id => panic!("{version}: unexpected refusal type {id}"), diff --git a/rs/moq-net/src/transport/boxed.rs b/rs/moq-net/src/transport/boxed.rs index 83bb4d59e5f..726d276ab3e 100644 --- a/rs/moq-net/src/transport/boxed.rs +++ b/rs/moq-net/src/transport/boxed.rs @@ -105,6 +105,7 @@ pub(crate) struct SendStream(Box); impl SendStream { /// Box a stream, keeping its own send-group type behind the boundary. + #[cfg(test)] pub(crate) fn new(stream: impl poll::SendStream + MaybeSend + MaybeSync) -> Self { Self(Box::new(stream)) } @@ -114,6 +115,7 @@ impl SendStream { pub(crate) struct RecvStream(Box); impl RecvStream { + #[cfg(test)] pub(crate) fn new(stream: impl poll::RecvStream + MaybeSend + MaybeSync) -> Self { Self(Box::new(stream)) } diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index 115eeff1b30..bd6eb43d45e 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -8,9 +8,9 @@ use crate::{coding, ietf, lite}; /// /// Ordered by preference, with the client's preference taking priority. Lite03 and newer /// hand off (see [`Version::hands_off`]). -/// Draft15 and newer have no version list in SETUP, so they negotiate only by ALPN. +/// moq-transport negotiates only by ALPN. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] -pub(crate) const NEGOTIATED: [Version; 8] = [ +pub(crate) const NEGOTIATED: [Version; 7] = [ Version::Lite(lite::Version::Lite07), Version::Lite(lite::Version::Lite06), Version::Lite(lite::Version::Lite05), @@ -18,7 +18,6 @@ pub(crate) const NEGOTIATED: [Version; 8] = [ Version::Lite(lite::Version::Lite03), Version::Lite(lite::Version::Lite02), Version::Lite(lite::Version::Lite01), - Version::Ietf(ietf::Version::Draft14), ]; /// ALPN strings for supported versions, most-preferred first. `ALPNS[0]` is the @@ -35,10 +34,6 @@ pub const ALPNS: &[&str] = &[ ALPN_LITE, ALPN_22, ALPN_18, - ALPN_17, - ALPN_16, - ALPN_15, - ALPN_14, ]; // ALPN constants @@ -48,14 +43,10 @@ pub(crate) const ALPN_LITE_04: &str = "moq-lite-04"; pub(crate) const ALPN_LITE_05: &str = "moq-lite-05"; pub(crate) const ALPN_LITE_06: &str = "moq-lite-06"; pub(crate) const ALPN_LITE_07_WIP: &str = "moq-lite-07-wip"; -pub(crate) const ALPN_14: &str = "moq-00"; -pub(crate) const ALPN_15: &str = "moqt-15"; -pub(crate) const ALPN_16: &str = "moqt-16"; -pub(crate) const ALPN_17: &str = "moqt-17"; pub(crate) const ALPN_18: &str = "moqt-18"; pub(crate) const ALPN_22: &str = "moqt-22"; -const ALL: [Version; 13] = [ +const ALL: [Version; 9] = [ Version::Lite(lite::Version::Lite07), Version::Lite(lite::Version::Lite06), Version::Lite(lite::Version::Lite05), @@ -65,10 +56,6 @@ const ALL: [Version; 13] = [ Version::Lite(lite::Version::Lite01), Version::Ietf(ietf::Version::Draft22), Version::Ietf(ietf::Version::Draft18), - Version::Ietf(ietf::Version::Draft17), - Version::Ietf(ietf::Version::Draft16), - Version::Ietf(ietf::Version::Draft15), - Version::Ietf(ietf::Version::Draft14), ]; /// A MoQ protocol version. @@ -110,10 +97,6 @@ impl Version { Self::Lite(lite::Version::Lite05) => "moq-lite-05", Self::Lite(lite::Version::Lite06) => "moq-lite-06", Self::Lite(lite::Version::Lite07) => "moq-lite-07-wip", - Self::Ietf(ietf::Version::Draft14) => "moq-transport-14", - Self::Ietf(ietf::Version::Draft15) => "moq-transport-15", - Self::Ietf(ietf::Version::Draft16) => "moq-transport-16", - Self::Ietf(ietf::Version::Draft17) => "moq-transport-17", Self::Ietf(ietf::Version::Draft18) => "moq-transport-18", Self::Ietf(ietf::Version::Draft22) => "moq-transport-22", } @@ -129,10 +112,6 @@ impl Version { 0xff0dad05 => Some(Self::Lite(lite::Version::Lite05)), 0xff0dad06 => Some(Self::Lite(lite::Version::Lite06)), 0xff0dad07 => Some(Self::Lite(lite::Version::Lite07)), - 0xff00000e => Some(Self::Ietf(ietf::Version::Draft14)), - 0xff00000f => Some(Self::Ietf(ietf::Version::Draft15)), - 0xff000010 => Some(Self::Ietf(ietf::Version::Draft16)), - 0xff000011 => Some(Self::Ietf(ietf::Version::Draft17)), 0xff000012 => Some(Self::Ietf(ietf::Version::Draft18)), 0xff000016 => Some(Self::Ietf(ietf::Version::Draft22)), _ => None, @@ -149,10 +128,6 @@ impl Version { Self::Lite(lite::Version::Lite05) => 0xff0dad05, Self::Lite(lite::Version::Lite06) => 0xff0dad06, Self::Lite(lite::Version::Lite07) => 0xff0dad07, - Self::Ietf(ietf::Version::Draft14) => 0xff00000e, - Self::Ietf(ietf::Version::Draft15) => 0xff00000f, - Self::Ietf(ietf::Version::Draft16) => 0xff000010, - Self::Ietf(ietf::Version::Draft17) => 0xff000011, Self::Ietf(ietf::Version::Draft18) => 0xff000012, Self::Ietf(ietf::Version::Draft22) => 0xff000016, } @@ -167,10 +142,6 @@ impl Version { Self::Lite(lite::Version::Lite04) => ALPN_LITE_04, Self::Lite(lite::Version::Lite03) => ALPN_LITE_03, Self::Lite(lite::Version::Lite01 | lite::Version::Lite02) => ALPN_LITE, - Self::Ietf(ietf::Version::Draft14) => ALPN_14, - Self::Ietf(ietf::Version::Draft15) => ALPN_15, - Self::Ietf(ietf::Version::Draft16) => ALPN_16, - Self::Ietf(ietf::Version::Draft17) => ALPN_17, Self::Ietf(ietf::Version::Draft18) => ALPN_18, Self::Ietf(ietf::Version::Draft22) => ALPN_22, } diff --git a/rs/moq-net/tests/integration/announce_covering_route.rs b/rs/moq-net/tests/integration/announce_covering_route.rs index 0c3e8a4a09c..c8ce94e79c4 100644 --- a/rs/moq-net/tests/integration/announce_covering_route.rs +++ b/rs/moq-net/tests/integration/announce_covering_route.rs @@ -108,14 +108,7 @@ async fn remote_lite_peer_hears_the_covering_route() { async fn remote_ietf_peer_hears_the_covering_route() { let mut expected = EXPECTED.to_vec(); expected.push("session open: true"); - for version in [ - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", - ] { + for version in ["moq-transport-18", "moq-transport-22"] { assert_eq!(covering(Some(version)).await, expected, "{version}"); } } diff --git a/rs/moq-net/tests/integration/announce_to_serve.rs b/rs/moq-net/tests/integration/announce_to_serve.rs index 5b901e63c91..ef48d02197f 100644 --- a/rs/moq-net/tests/integration/announce_to_serve.rs +++ b/rs/moq-net/tests/integration/announce_to_serve.rs @@ -234,16 +234,9 @@ async fn remote_lite_consumer_sees_what_a_local_one_does() { #[moq_clock::test] async fn remote_ietf_consumer_sees_what_a_local_one_does() { - // Drafts 14 to 16 carry each request over the control stream; 17 and later give it - // its own stream. Both have to end a finished track the way moq-lite does. - for version in [ - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", - ] { + // Each request has its own stream, which has to end a finished track the way + // moq-lite does. + for version in ["moq-transport-18", "moq-transport-22"] { assert_eq!(lifecycle(Observer::Remote(version)).await, EXPECTED, "{version}"); } } diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 415f61d7088..e18d797106d 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -31,8 +31,8 @@ const LITE_07: &str = "moq-lite-07-wip"; const LITE_06: &str = "moq-lite-06"; /// The lite Auth Stream's type byte. const AUTH_STREAM: u8 = 0x7; -/// The first draft that negotiates MoQ Auth, and the newest. -const MOQT_17: &str = "moq-transport-17"; +/// The oldest draft we speak, which negotiates MoQ Auth, and the newest. +const MOQT_18: &str = "moq-transport-18"; const MOQT_22: &str = "moq-transport-22"; /// Run each case on every version that exchanges AUTH. @@ -41,8 +41,8 @@ macro_rules! cases { mod lite_07 { $(#[moq_clock::test] async fn $case() { super::$case(super::LITE_07).await })* } - mod moqt_17 { - $(#[moq_clock::test] async fn $case() { super::$case(super::MOQT_17).await })* + mod moqt_18 { + $(#[moq_clock::test] async fn $case() { super::$case(super::MOQT_18).await })* } mod moqt_22 { $(#[moq_clock::test] async fn $case() { super::$case(super::MOQT_22).await })* @@ -88,8 +88,8 @@ async fn lite_07_presents_a_transiently_refused_token_again() { /// moq-transport's u16 message size is the one a test can overflow quickly. #[moq_clock::test] -async fn moqt_17_a_grant_too_large_for_one_message_is_unsupported() { - a_grant_too_large_for_one_message_is_unsupported(MOQT_17).await +async fn moqt_18_a_grant_too_large_for_one_message_is_unsupported() { + a_grant_too_large_for_one_message_is_unsupported(MOQT_18).await } #[moq_clock::test] @@ -126,37 +126,11 @@ async fn lite_05_has_no_grant() { older_versions_have_no_grant("moq-lite-05").await } -/// The #6566 matrix's drafts: one without MoQ Auth, and one with it. -#[moq_clock::test] -async fn moqt_16_admits_exact_and_subtree_grants() { - exact_and_subtree_grants_admit_their_broadcast("moq-transport-16").await -} - -#[moq_clock::test] -async fn moqt_18_admits_exact_and_subtree_grants() { - exact_and_subtree_grants_admit_their_broadcast("moq-transport-18").await -} - -#[moq_clock::test] -async fn moqt_16_has_no_grant() { - older_versions_have_no_grant("moq-transport-16").await -} - -#[moq_clock::test] -async fn moqt_16_route_auth_renews_its_announce() { - a_route_auth_renews_its_announce("moq-transport-16").await -} - #[moq_clock::test] async fn moqt_22_route_auth_renews_its_announce() { a_route_auth_renews_its_announce(MOQT_22).await } -#[moq_clock::test] -async fn moqt_16_route_auth_survives_a_reconnect() { - a_route_auth_survives_a_reconnect("moq-transport-16").await -} - #[moq_clock::test] async fn moqt_22_route_auth_survives_a_reconnect() { a_route_auth_survives_a_reconnect(MOQT_22).await @@ -1128,10 +1102,7 @@ async fn exact_and_subtree_grants_admit_their_broadcast(version: &'static str) { let mut sub = remote.track("video").unwrap().subscribe(prefs).await.unwrap(); sub.recv_group().await.unwrap().expect("a group"); - match version == "moq-transport-16" { - true => assert_eq!(pair.client.auth().grant().peek(), None), - false => assert_eq!(granted(&pair.client).await.subscribe, scope), - } + assert_eq!(granted(&pair.client).await.subscribe, scope); assert_eq!(pair.client_transport.close_reason(), None); }) .await @@ -1549,11 +1520,8 @@ macro_rules! limit_cases { mod limit_lite_07 { $(#[moq_clock::test] async fn $case() { super::$case(super::LITE_07).await })* } - mod limit_moqt_16 { - $(#[moq_clock::test] async fn $case() { super::$case("moq-transport-16").await })* - } - mod limit_moqt_17 { - $(#[moq_clock::test] async fn $case() { super::$case(super::MOQT_17).await })* + mod limit_moqt_18 { + $(#[moq_clock::test] async fn $case() { super::$case(super::MOQT_18).await })* } }; } @@ -1582,7 +1550,7 @@ async fn lite_07_narrowing_resets_a_fetch_in_flight() { /// Whether `version` exchanges AUTH, so the peer also hears of a narrowing. fn speaks_auth(version: &str) -> bool { - matches!(version, LITE_07 | MOQT_17 | MOQT_22) + matches!(version, LITE_07 | MOQT_18 | MOQT_22) } /// A revocation as the reader sees it: the local gate's own error, or the peer's diff --git a/rs/moq-net/tests/integration/broadcast_close.rs b/rs/moq-net/tests/integration/broadcast_close.rs index ae12b438f02..eae57feab10 100644 --- a/rs/moq-net/tests/integration/broadcast_close.rs +++ b/rs/moq-net/tests/integration/broadcast_close.rs @@ -59,7 +59,7 @@ async fn close_ends_a_local_consumer() { #[moq_clock::test] async fn close_ends_a_remote_consumer() { - for version in ["moq-lite-05", "moq-transport-14", "moq-transport-18"] { + for version in ["moq-lite-05", "moq-transport-18"] { let publisher = produce_origin(1); let subscriber = produce_origin(2); let mut options = MockConnectOptions::new(version.parse::().unwrap()); diff --git a/rs/moq-net/tests/integration/catalog_acquisition.rs b/rs/moq-net/tests/integration/catalog_acquisition.rs index 03b914e524e..8111b27b9f9 100644 --- a/rs/moq-net/tests/integration/catalog_acquisition.rs +++ b/rs/moq-net/tests/integration/catalog_acquisition.rs @@ -80,6 +80,6 @@ async fn epochless_replacement_refuses_pending_catalog() { #[moq_clock::test] async fn same_epoch_replacement_acquires_pending_catalog() { acquisition("moq-lite-07-wip", true).await; - acquisition("moq-transport-17", true).await; + acquisition("moq-transport-18", true).await; acquisition("moq-transport-22", true).await; } diff --git a/rs/moq-net/tests/integration/compress.rs b/rs/moq-net/tests/integration/compress.rs index 3ddde6d5f9e..a7d8acd47ee 100644 --- a/rs/moq-net/tests/integration/compress.rs +++ b/rs/moq-net/tests/integration/compress.rs @@ -19,10 +19,6 @@ const VERSIONS: &[&str] = &[ "moq-lite-07-wip", "moq-lite-06", "moq-lite-03", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", "moq-transport-18", "moq-transport-22", ]; @@ -119,14 +115,8 @@ async fn relay(published: Published, hops: [&str; 2], subscription: moq_net::tra } } -/// Hops that carry a start group and negotiate compression: lite-07, and moq-transport -/// draft-17 and later. -const RANGED: &[&str] = &[ - "moq-lite-07-wip", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", -]; +/// Hops that carry a start group and negotiate compression: lite-07 and moq-transport. +const RANGED: &[&str] = &["moq-lite-07-wip", "moq-transport-18", "moq-transport-22"]; /// A subscription starting at a group delivers it whole from its first frame across every /// pair of hops, compressed or not, whether the group began before it subscribed or after. @@ -198,10 +188,7 @@ async fn relay_serves_every_version() { let mut fixture = connect("moq-lite-07-wip", version).await; assert_eq!( fixture.subscriber.info().compress, - !matches!( - version, - "moq-lite-06" | "moq-lite-03" | "moq-transport-14" | "moq-transport-15" | "moq-transport-16" - ), + !matches!(version, "moq-lite-06" | "moq-lite-03"), "{version}: compression declared" ); @@ -246,13 +233,7 @@ async fn relay_serves_every_version() { /// compressed or inflated. #[moq_clock::test] async fn datagrams_serve_every_version() { - for &version in &[ - "moq-lite-07-wip", - "moq-lite-06", - "moq-transport-16", - "moq-transport-17", - "moq-transport-22", - ] { + for &version in &["moq-lite-07-wip", "moq-lite-06", "moq-transport-18", "moq-transport-22"] { moq_clock::timeout(TIMEOUT, async { let mut fixture = connect("moq-lite-07-wip", version).await; fixture @@ -347,20 +328,11 @@ async fn datagrams_meter_the_bytes_on_the_wire() { /// Negotiated IETF ingress stays compressed across a relay to IETF or lite-07 egress. #[moq_clock::test] async fn ietf_ingress_stays_compressed_across_protocols() { - for upstream in ["moq-transport-17", "moq-transport-18", "moq-transport-22"] { - for downstream in [ - "moq-lite-07-wip", - "moq-transport-16", - "moq-transport-22", - "moq-transport-14", - ] { + for upstream in ["moq-transport-18", "moq-transport-22"] { + for downstream in ["moq-lite-07-wip", "moq-transport-22"] { moq_clock::timeout(TIMEOUT, async { let mut fixture = connect(upstream, downstream).await; - assert_eq!( - fixture.subscriber.info().compress, - !matches!(downstream, "moq-transport-14" | "moq-transport-16"), - "{upstream} -> {downstream}" - ); + assert!(fixture.subscriber.info().compress, "{upstream} -> {downstream}"); let mut group = fixture.track.append_group().unwrap(); for index in 0..3 { group @@ -401,7 +373,7 @@ async fn ietf_ingress_stays_compressed_across_protocols() { /// A finished compressed track remains fetchable without subscribing to its live feed. #[moq_clock::test] async fn ietf_fetch_keeps_the_stored_form() { - for version in ["moq-transport-16", "moq-transport-18", "moq-transport-22"] { + for version in ["moq-transport-18", "moq-transport-22"] { moq_clock::timeout(TIMEOUT, async { let publisher = produce_origin(1); let broadcast = publisher.create_broadcast("bcast").unwrap(); @@ -423,7 +395,7 @@ async fn ietf_fetch_keeps_the_stored_form() { origin.consume().routed("bcast").await.unwrap(); let remote = origin.consume().request_broadcast("bcast", None).await.unwrap(); let consumer = remote.track("data").unwrap(); - assert_eq!(consumer.info().await.unwrap().compress, version != "moq-transport-16"); + assert!(consumer.info().await.unwrap().compress, "{version}"); let mut fetched = consumer.fetch_group(0, moq_net::group::Fetch::default()).await.unwrap(); for index in 0..3 { assert_eq!( diff --git a/rs/moq-net/tests/integration/control_stats.rs b/rs/moq-net/tests/integration/control_stats.rs index 01239c176ec..4e83721804f 100644 --- a/rs/moq-net/tests/integration/control_stats.rs +++ b/rs/moq-net/tests/integration/control_stats.rs @@ -19,15 +19,7 @@ fn origin() -> origin::Producer { #[moq_clock::test] async fn control_classes_cross_lite_and_ietf_sessions_without_counting_payload() { - for version in [ - "moq-lite-03", - "moq-lite-06", - "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-22", - ] { + for version in ["moq-lite-03", "moq-lite-06", "moq-lite-07-wip", "moq-transport-22"] { let publisher = origin(); let subscriber = origin(); let registry = Registry::new(Default::default()); @@ -76,7 +68,7 @@ async fn control_classes_cross_lite_and_ietf_sessions_without_counting_payload() /// admitted with, even for a session with neither origin. #[moq_clock::test] async fn setup_counts_under_the_admitted_stats() { - for version in ["moq-lite-07-wip", "moq-transport-14", "moq-transport-22"] { + for version in ["moq-lite-07-wip", "moq-transport-22"] { let version: moq_net::Version = version.parse().unwrap(); let registry = Registry::new(Default::default()); let tier = Tier::new(moq_net::Path::from_static("internal")); diff --git a/rs/moq-net/tests/integration/datagram.rs b/rs/moq-net/tests/integration/datagram.rs index 72c17b3fea4..08b7219addb 100644 --- a/rs/moq-net/tests/integration/datagram.rs +++ b/rs/moq-net/tests/integration/datagram.rs @@ -124,12 +124,7 @@ async fn datagrams_reach_the_subscriber_in_order() { /// datagram with its sequence, alongside the groups on streams. #[moq_clock::test] async fn ietf_delivers_datagrams() { - for version in [ - "moq-transport-14", - "moq-transport-16", - "moq-transport-17", - "moq-transport-22", - ] { + for version in ["moq-transport-18", "moq-transport-22"] { moq_clock::timeout(TEST_TIMEOUT, ietf_delivers_datagrams_on(version)) .await .unwrap_or_else(|_| panic!("{version}: timed out")); @@ -160,15 +155,8 @@ async fn ietf_delivers_datagrams_on(version: &str) { .unwrap(); let mut before = subscriber.recv_group().await.unwrap().unwrap(); assert_eq!(before.sequence, 0, "{version}"); - // Drafts 14 to 16 can't carry TIMESCALE, so the track arrives untimed, datagrams - // included. - let timed = !matches!(version, "moq-transport-14" | "moq-transport-16"); let first = before.next_frame().await.unwrap().unwrap().timestamp; - assert_eq!( - first.map(|timestamp| timestamp.is_zero()), - timed.then_some(true), - "{version}" - ); + assert_eq!(first.map(|timestamp| timestamp.is_zero()), Some(true), "{version}"); producer .insert_datagram( @@ -185,7 +173,7 @@ async fn ietf_delivers_datagrams_on(version: &str) { datagram .timestamp .map(|timestamp| timestamp.convert(expected.scale()).unwrap()), - timed.then_some(expected), + Some(expected), "{version}" ); diff --git a/rs/moq-net/tests/integration/dial_split_horizon.rs b/rs/moq-net/tests/integration/dial_split_horizon.rs index 5dc11bff6e3..46498de80d0 100644 --- a/rs/moq-net/tests/integration/dial_split_horizon.rs +++ b/rs/moq-net/tests/integration/dial_split_horizon.rs @@ -1,12 +1,11 @@ #![cfg(feature = "async")] -//! A dialed moq-transport session whose peer declares no hop must not be offered -//! a route learned from that peer, and a SUBSCRIBE that arrives for that route -//! must not be sent back to the peer that announced it. +//! A dialed session must not be offered a route learned from its peer, and a +//! SUBSCRIBE that arrives for that route must not be sent back to the peer that +//! announced it. //! -//! Draft 16 has no cluster extension, so neither side declares a hop. That is the -//! anonymous dial: the accepting side already stamps one, and the dialing side -//! has to as well. +//! The anonymous dial is the hard case: when the peer declares no hop, the +//! accepting side already stamps one, and the dialing side has to as well. use crate::support; @@ -21,12 +20,8 @@ use support::{ /// Long enough, in virtual time, for an announce or subscribe to cross the mock link. const SETTLE: Duration = Duration::from_secs(1); -fn version() -> Version { - "moq-transport-16".parse().unwrap() -} - -fn produce(hop: u64) -> origin::Producer { - let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); +fn produce(hop: Hop) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(hop)); spawn(driver); producer } @@ -40,8 +35,7 @@ struct Link { registry: stats::Registry, } -async fn dial(dialer: &origin::Producer, edge: &origin::Producer) -> Link { - let version = version(); +async fn dial(version: Version, dialer: &origin::Producer, edge: &origin::Producer) -> Link { let registry = stats::Registry::new(stats::Config::new()); let session_stats = registry .tier(stats::Tier::default()) @@ -124,17 +118,33 @@ async fn learned_hop(dialer: &origin::Producer, path: &str) -> Hop { } } -/// Draft-16 edges dialed by one origin each get their own hop. The edge that +fn hop(id: u64) -> Hop { + Hop::new(id).unwrap() +} + +/// Edges that declare their own hop over the MoQ Cluster extension. +#[moq_clock::test] +async fn dialed_named_session_does_not_echo_or_route_subscribe_back() { + split_horizon("moq-transport-18", [hop(2), hop(3)]).await; +} + +/// moq-lite-01 carries no hop, so neither side declares one. +#[moq_clock::test] +async fn dialed_anonymous_session_does_not_echo_or_route_subscribe_back() { + split_horizon("moq-lite-01", [hop(2), hop(3)]).await; +} + +/// Edges dialed by one origin each get their own hop. The edge that /// announced a namespace is not told about it, and the other edge is. A /// subscribe from that other edge is fetched from the announcer and is not /// sent back to the subscriber. -#[moq_clock::test] -async fn dialed_anonymous_session_does_not_echo_or_route_subscribe_back() { - let dialer = produce(1); - let publisher = produce(2); - let subscriber = produce(3); - let to_publisher = dial(&dialer, &publisher).await; - let to_subscriber = dial(&dialer, &subscriber).await; +async fn split_horizon(version: &str, [publisher_hop, subscriber_hop]: [Hop; 2]) { + let version: Version = version.parse().unwrap(); + let dialer = produce(hop(1)); + let publisher = produce(publisher_hop); + let subscriber = produce(subscriber_hop); + let to_publisher = dial(version, &dialer, &publisher).await; + let to_subscriber = dial(version, &dialer, &subscriber).await; let broadcast = publisher.create_broadcast("room").unwrap(); let track = broadcast.create_track("video", None).unwrap(); diff --git a/rs/moq-net/tests/integration/empty_group.rs b/rs/moq-net/tests/integration/empty_group.rs index ad7e1dd7c65..3b853a60a96 100644 --- a/rs/moq-net/tests/integration/empty_group.rs +++ b/rs/moq-net/tests/integration/empty_group.rs @@ -1,8 +1,6 @@ #![cfg(feature = "async")] -//! A group finished with no frames reaches the subscriber as an empty group. On drafts 14 -//! to 17 its stream is a bare header with no FIRST_OBJECT bit, which must not read as a -//! group missing its head. +//! A group finished with no frames reaches the subscriber as an empty group. use crate::support; @@ -18,13 +16,7 @@ fn timed() -> moq_net::track::Info { const TIMEOUT: Duration = Duration::from_secs(10); -const VERSIONS: &[&str] = &[ - "moq-lite-06", - "moq-transport-14", - "moq-transport-16", - "moq-transport-17", - "moq-transport-22", -]; +const VERSIONS: &[&str] = &["moq-lite-06", "moq-transport-18", "moq-transport-22"]; fn produce_origin(hop: u64) -> origin::Producer { let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); diff --git a/rs/moq-net/tests/integration/extensions.rs b/rs/moq-net/tests/integration/extensions.rs index 43f92d0ddf2..16e2aee3537 100644 --- a/rs/moq-net/tests/integration/extensions.rs +++ b/rs/moq-net/tests/integration/extensions.rs @@ -16,17 +16,10 @@ use support::harness::{MockConnectOptions, MockPair, connect_mock}; const SETTLE: Duration = Duration::from_secs(1); /// Every moq-transport draft, each of which carries the SOLICIT option. -const MOQT: [&str; 6] = [ - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", -]; - -/// The first draft that negotiates MoQ Auth, and the newest. -const AUTH: [&str; 2] = ["moq-transport-17", "moq-transport-22"]; +const MOQT: [&str; 2] = ["moq-transport-18", "moq-transport-22"]; + +/// The oldest draft we speak, which negotiates MoQ Auth, and the newest. +const AUTH: [&str; 2] = ["moq-transport-18", "moq-transport-22"]; fn produce_origin(hop: u64) -> origin::Producer { let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); @@ -173,14 +166,12 @@ fn route(origin: &origin::Producer, path: &str) -> Option { } /// MoQ Epoch carries the publisher's epoch, in either direction, only when both sides -/// offer it on draft-17 or later. A side declining it, or an older draft, leaves the -/// route epochless exactly as before the extension, and nothing on the wire breaks the -/// session. +/// offer it. A side declining it leaves the route epochless exactly as before the +/// extension, and nothing on the wire breaks the session. #[moq_clock::test] async fn an_epoch_crosses_only_when_both_offer_it() { for name in MOQT { let version: Version = name.parse().unwrap(); - let negotiable = !matches!(name, "moq-transport-14" | "moq-transport-15" | "moq-transport-16"); for server_publishes in [true, false] { for declines in [None, Some("server"), Some("client")] { let context = format!("{name}, server publishes: {server_publishes}, declines: {declines:?}"); @@ -214,7 +205,7 @@ async fn an_epoch_crosses_only_when_both_offer_it() { moq_clock::sleep(SETTLE).await; let route = route(&subscriber, "room/cam").unwrap_or_else(|| panic!("{context}: never announced")); - let expected = (negotiable && declines.is_none()).then_some(epoch); + let expected = declines.is_none().then_some(epoch); assert_eq!(route.epoch, expected, "{context}"); assert_open(&pair, &context); } diff --git a/rs/moq-net/tests/integration/fetch_only.rs b/rs/moq-net/tests/integration/fetch_only.rs index 7bd1a4c5f08..71e318198b3 100644 --- a/rs/moq-net/tests/integration/fetch_only.rs +++ b/rs/moq-net/tests/integration/fetch_only.rs @@ -88,15 +88,7 @@ fn frames(sequence: u64) -> Vec> { (0..2).map(|frame| format!("{sequence}-{frame}").into_bytes()).collect() } -/// Draft-17 is absent: its TRACK_STATUS answer cannot say whether the track is timed, -/// so it still SUBSCRIBEs to learn the track. -const VERSIONS: &[&str] = &[ - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-18", - "moq-transport-22", -]; +const VERSIONS: &[&str] = &["moq-transport-18", "moq-transport-22"]; async fn direct(version: &str) { let version: Version = version.parse().unwrap(); diff --git a/rs/moq-net/tests/integration/finite_relay.rs b/rs/moq-net/tests/integration/finite_relay.rs index ff700f21e43..8d16ffad4f9 100644 --- a/rs/moq-net/tests/integration/finite_relay.rs +++ b/rs/moq-net/tests/integration/finite_relay.rs @@ -33,8 +33,7 @@ const VERSIONS: &[&str] = &[ "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-17", + "moq-transport-18", "moq-transport-22", ]; diff --git a/rs/moq-net/tests/integration/goaway.rs b/rs/moq-net/tests/integration/goaway.rs index d74b2ac3bbc..0c14c298d73 100644 --- a/rs/moq-net/tests/integration/goaway.rs +++ b/rs/moq-net/tests/integration/goaway.rs @@ -31,17 +31,8 @@ fn produce_origin(hop: Hop) -> moq_net::origin::Producer { const TEST_TIMEOUT: Duration = Duration::from_secs(10); /// Every version with GOAWAY support, across both wires and their distinct -/// channels: the lite Goaway control stream, the IETF draft-14-16 shared -/// control stream, and the IETF draft-17+ SETUP uni streams. -const GOAWAY_VERSIONS: &[&str] = &[ - "moq-lite-04", - "moq-lite-05", - "moq-transport-14", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", -]; +/// channels: the lite Goaway control stream and the IETF SETUP uni streams. +const GOAWAY_VERSIONS: &[&str] = &["moq-lite-04", "moq-lite-05", "moq-transport-18", "moq-transport-22"]; /// A full receive window or missing FIN ACK cannot hold a drain beyond its local deadline. async fn closes_stalled_goaway(version: Version, writes: bool) { @@ -137,12 +128,12 @@ async fn goaway_send_receive_all_versions() { } } -/// The IETF draft-17+ GOAWAY carries a timeout on the wire; the receiver -/// observes the sender's advertised deadline. +/// The IETF GOAWAY carries a timeout on the wire; the receiver observes the +/// sender's advertised deadline. #[moq_clock::test] -async fn goaway_wire_timeout_moq_transport_17() { +async fn goaway_wire_timeout_moq_transport() { moq_clock::timeout(TEST_TIMEOUT, async { - let version: Version = "moq-transport-17".parse().unwrap(); + let version: Version = "moq-transport-18".parse().unwrap(); let pair = connect_mock(MockConnectOptions::new(version)).await; pair.server @@ -257,7 +248,7 @@ async fn goaway_client_redirect_refused_moq_transport_18() { #[moq_clock::test] async fn goaway_timeout_force_close_moq_transport_17() { moq_clock::timeout(TEST_TIMEOUT, async { - let version: Version = "moq-transport-17".parse().unwrap(); + let version: Version = "moq-transport-18".parse().unwrap(); let pair = connect_mock(MockConnectOptions::new(version)).await; pair.server diff --git a/rs/moq-net/tests/integration/group_order.rs b/rs/moq-net/tests/integration/group_order.rs index 581fe3c63bc..9b11f292572 100644 --- a/rs/moq-net/tests/integration/group_order.rs +++ b/rs/moq-net/tests/integration/group_order.rs @@ -160,8 +160,7 @@ async fn a_backlog_is_served_in_the_subscription_order() { "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-17", + "moq-transport-18", "moq-transport-22", ] { for relay in [false, true] { @@ -186,7 +185,7 @@ async fn a_backlog_is_served_in_the_subscription_order() { /// its own subscriber's backlog in that subscriber's order. #[moq_clock::test] async fn merged_subscribers_keep_their_own_order() { - for version in ["moq-lite-07-wip", "moq-transport-17"] { + for version in ["moq-lite-07-wip", "moq-transport-18"] { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); let broadcast = publisher.create_broadcast("bcast").unwrap(); diff --git a/rs/moq-net/tests/integration/history_groups.rs b/rs/moq-net/tests/integration/history_groups.rs index e7ada798343..fd813fb17c2 100644 --- a/rs/moq-net/tests/integration/history_groups.rs +++ b/rs/moq-net/tests/integration/history_groups.rs @@ -25,8 +25,7 @@ const VERSIONS: &[&str] = &[ "moq-lite-03", "moq-lite-05", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-17", + "moq-transport-18", "moq-transport-22", ]; diff --git a/rs/moq-net/tests/integration/idle_session.rs b/rs/moq-net/tests/integration/idle_session.rs index 8429bd0340c..bbcc800bc8c 100644 --- a/rs/moq-net/tests/integration/idle_session.rs +++ b/rs/moq-net/tests/integration/idle_session.rs @@ -110,7 +110,7 @@ async fn busy_viewer_sees_the_estimate_move() { /// A transport estimate demanded locally still costs one poll per sampling deadline. #[moq_clock::test] async fn ietf_stats_refresh_costs_one_driver_poll() { - for version in ["moq-transport-16", "moq-transport-22"] { + for version in ["moq-transport-22"] { let polls = Arc::new(AtomicUsize::new(0)); let mut options = MockConnectOptions::new(version.parse::().unwrap()); options.stats = STATS; @@ -175,7 +175,7 @@ async fn probe_follows_consumer_lifetime() { /// A stats tick reads the transport's wire counters and adds no sampler timer or session wake. #[moq_clock::test] async fn wire_collection_never_arms_the_idle_sampler() { - for version in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-16", "moq-transport-22"] { + for version in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-22"] { let registry = moq_net::stats::Registry::new(Default::default()); let polls = Arc::new(AtomicUsize::new(0)); let mut options = MockConnectOptions::new(version.parse().unwrap()); diff --git a/rs/moq-net/tests/integration/implementation.rs b/rs/moq-net/tests/integration/implementation.rs index 5973c7bf265..2832bb43dc5 100644 --- a/rs/moq-net/tests/integration/implementation.rs +++ b/rs/moq-net/tests/integration/implementation.rs @@ -10,7 +10,7 @@ use moq_net::Version; use support::harness::{MockConnectOptions, connect_mock}; /// Every version, and whether its SETUP carries an implementation. -const VERSIONS: [(&str, bool); 13] = [ +const VERSIONS: [(&str, bool); 9] = [ ("moq-lite-01", false), ("moq-lite-02", false), ("moq-lite-03", false), @@ -18,10 +18,6 @@ const VERSIONS: [(&str, bool); 13] = [ ("moq-lite-05", true), ("moq-lite-06", true), ("moq-lite-07-wip", true), - ("moq-transport-14", true), - ("moq-transport-15", true), - ("moq-transport-16", true), - ("moq-transport-17", true), ("moq-transport-18", true), ("moq-transport-22", true), ]; @@ -59,7 +55,7 @@ async fn each_side_reads_the_others_implementation() { /// Unconfigured, each side names this crate; configured empty, it advertises nothing. #[moq_clock::test] async fn the_default_names_the_crate_and_empty_omits_it() { - for name in ["moq-lite-06", "moq-transport-16", "moq-transport-22"] { + for name in ["moq-lite-06", "moq-transport-22"] { let version: Version = name.parse().unwrap(); let (seen_by_server, seen_by_client) = exchange(version, None, Some("")).await; assert_eq!(seen_by_server.as_deref(), Some(moq_net::IMPLEMENTATION), "{name}"); diff --git a/rs/moq-net/tests/integration/late_lower_group.rs b/rs/moq-net/tests/integration/late_lower_group.rs index ccdfc8f7abf..db34d444489 100644 --- a/rs/moq-net/tests/integration/late_lower_group.rs +++ b/rs/moq-net/tests/integration/late_lower_group.rs @@ -25,7 +25,7 @@ const FOREVER: Duration = Duration::from_millis((1 << 53) - 1); const LITE: &[&str] = &["moq-lite-05", "moq-lite-06", "moq-lite-07-wip"]; -const TRANSPORT: &[&str] = &["moq-transport-14", "moq-transport-17", "moq-transport-22"]; +const TRANSPORT: &[&str] = &["moq-transport-18", "moq-transport-22"]; /// From `start`, or the oldest group still around when there is none. fn floored(start: Option) -> Range { diff --git a/rs/moq-net/tests/integration/lazy_discovery.rs b/rs/moq-net/tests/integration/lazy_discovery.rs index 476469ea66d..e93c48af9c8 100644 --- a/rs/moq-net/tests/integration/lazy_discovery.rs +++ b/rs/moq-net/tests/integration/lazy_discovery.rs @@ -359,17 +359,8 @@ async fn serving_the_peer_asks_nothing() { } } -/// Every moq-transport draft. Not every one marks where an initial set ends (draft-14 and -/// 15 answer with PUBLISH_NAMESPACE requests after the OK), so these read what arrived once -/// everything in flight has. -const IETF: &[&str] = &[ - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", -]; +/// Every moq-transport draft. These read what arrived once everything in flight has. +const IETF: &[&str] = &["moq-transport-18", "moq-transport-22"]; /// Apply what `announced` reports once everything in flight has arrived to `held`, and /// say whether any of it ended a prefix. diff --git a/rs/moq-net/tests/integration/mesh_withdraw.rs b/rs/moq-net/tests/integration/mesh_withdraw.rs index e4c858f2f4f..b547e95aa61 100644 --- a/rs/moq-net/tests/integration/mesh_withdraw.rs +++ b/rs/moq-net/tests/integration/mesh_withdraw.rs @@ -631,7 +631,6 @@ async fn hop_limit_bounds_receive_and_send() { "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-17", "moq-transport-18", "moq-transport-22", ] { @@ -697,13 +696,7 @@ async fn hop_limit_bounds_receive_and_send() { #[moq_clock::test] async fn hop_limit_falls_back_to_a_forwardable_route() { // Only wires carrying a route cost can rank a longer route first. - for name in [ - "moq-lite-06", - "moq-lite-07-wip", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", - ] { + for name in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-18", "moq-transport-22"] { let version = name.parse().unwrap(); let near = produce_origin(1); let far = produce_origin(4); diff --git a/rs/moq-net/tests/integration/multi_cdn.rs b/rs/moq-net/tests/integration/multi_cdn.rs index 81e15958e7c..4da239fbcbb 100644 --- a/rs/moq-net/tests/integration/multi_cdn.rs +++ b/rs/moq-net/tests/integration/multi_cdn.rs @@ -8,9 +8,7 @@ //! preference puts `A` ahead. Losing `A` moves `S` to `B`, and `A` returning moves it //! back. Between lite-07 routes with the same epoch each move resumes the subscription. //! A moq-transport `B` with the MoQ Epoch extension carries the epoch, but its copy's -//! track info records the draft, so the move onto it is refused. A moq-transport `B` -//! that cannot carry the epoch (before draft-17) makes each move a restart and a -//! re-request. +//! track info records the draft, so the move onto it is refused. use crate::support; @@ -355,59 +353,5 @@ async fn transport_secondary_is_refused() { /// The same over the first draft that can carry the epoch. #[moq_clock::test] async fn transport_17_secondary_is_refused() { - refused_across_protocols("moq-transport-17").await; -} - -/// A moq-transport `B` before draft-17 carries no epoch, so `S` cannot resume onto it: -/// losing `A` restarts the path and ends the subscription, and a re-request lands on `B`. -/// `A` returning is another restart, and a re-request lands back on `A`. -#[moq_clock::test] -async fn epochless_secondary_restarts() { - moq_clock::timeout(TEST_TIMEOUT, async { - let mut topology = Topology::new("moq-transport-16".parse().unwrap()).await; - let mut announced = topology.subscriber.consume().announced(); - let start = announced.next().await.expect("announced"); - assert!(via_a(&start), "{start:?}"); - - let mut rx = topology.subscribe().await; - let seen = topology.group_through_a(&mut rx).await; - assert_continuous(&seen, 1); - - topology.lose_a().await; - let restart = announced.next().await.expect("announced"); - assert!( - matches!(restart, announce::Event::Restart(_)) && !via_a(&restart), - "no epoch over B: {restart:?}" - ); - topology.group(); - settle().await; - match moq_clock::timeout(Duration::from_secs(10), rx.next()) - .await - .expect("reader hung") - { - None | Some((_, Err(_))) => {} - Some((group, Ok(_))) => panic!("resumed onto B at group {group}"), - } - - // A re-request lands on B, the only CDN left. It may start at the latest group, - // written while nobody was subscribed, before reaching the next one. - let mut rx = topology.subscribe().await; - topology.group(); - while next(&mut rx).await.0 != 2 {} - - topology.restore_a().await; - let restart = announced.next().await.expect("announced"); - assert!( - matches!(restart, announce::Event::Restart(_)) && via_a(&restart), - "back on A: {restart:?}" - ); - // A re-request lands on A: the next group arrives while B holds back its own. - let mut rx = topology.subscribe().await; - topology.s_to_b.server_transport.hold_unis(); - topology.group(); - while next(&mut rx).await.0 != 3 {} - topology.s_to_b.server_transport.release_unis(); - }) - .await - .expect("timed out"); + refused_across_protocols("moq-transport-18").await; } diff --git a/rs/moq-net/tests/integration/partial_group.rs b/rs/moq-net/tests/integration/partial_group.rs index 3623943e96a..90136b30881 100644 --- a/rs/moq-net/tests/integration/partial_group.rs +++ b/rs/moq-net/tests/integration/partial_group.rs @@ -17,16 +17,7 @@ const TIMEOUT: Duration = Duration::from_secs(10); const BUDGET: Duration = Duration::from_millis(500); /// IETF peers negotiate the same superseded-group budget as Lite peers. -const VERSIONS: &[&str] = &[ - "moq-lite-06", - "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", -]; +const VERSIONS: &[&str] = &["moq-lite-06", "moq-lite-07-wip", "moq-transport-18", "moq-transport-22"]; fn produce_origin(hop: u64) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); diff --git a/rs/moq-net/tests/integration/quiet_catalog.rs b/rs/moq-net/tests/integration/quiet_catalog.rs index 8c7851b22f4..bdb367f0805 100644 --- a/rs/moq-net/tests/integration/quiet_catalog.rs +++ b/rs/moq-net/tests/integration/quiet_catalog.rs @@ -19,7 +19,7 @@ fn timed() -> moq_net::track::Info { const TIMEOUT: Duration = Duration::from_secs(5); -const VERSIONS: &[&str] = &["moq-lite-06", "moq-lite-07-wip", "moq-transport-16", "moq-transport-22"]; +const VERSIONS: &[&str] = &["moq-lite-06", "moq-lite-07-wip", "moq-transport-22"]; /// The versions that forward a resume's floor upstream; the IETF relay subscribes without one. const LITE: &[&str] = &["moq-lite-06", "moq-lite-07-wip"]; diff --git a/rs/moq-net/tests/integration/range_anchors.rs b/rs/moq-net/tests/integration/range_anchors.rs index bb71924cb1e..71850dd535b 100644 --- a/rs/moq-net/tests/integration/range_anchors.rs +++ b/rs/moq-net/tests/integration/range_anchors.rs @@ -24,9 +24,7 @@ const VERSIONS: &[&str] = &[ "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-16", - "moq-transport-17", + "moq-transport-18", "moq-transport-22", ]; @@ -131,9 +129,7 @@ async fn each_wire_refuses_only_the_anchors_it_drops() { // A wire without timestamps refuses a time anchor for that first. let timed = !matches!( version, - Some( - "moq-lite-03" | "moq-lite-04" | "moq-lite-05" | "moq-lite-06" | "moq-transport-14" | "moq-transport-16" - ) + Some("moq-lite-03" | "moq-lite-04" | "moq-lite-05" | "moq-lite-06") ); for (range, held) in anchored() { let result = read(version, range).await; diff --git a/rs/moq-net/tests/integration/relay_upstream_close.rs b/rs/moq-net/tests/integration/relay_upstream_close.rs index d38de9c48b2..4c9eb212682 100644 --- a/rs/moq-net/tests/integration/relay_upstream_close.rs +++ b/rs/moq-net/tests/integration/relay_upstream_close.rs @@ -38,8 +38,7 @@ const VERSIONS: &[&str] = &[ "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-17", + "moq-transport-18", "moq-transport-22", ]; diff --git a/rs/moq-net/tests/integration/replacement.rs b/rs/moq-net/tests/integration/replacement.rs index 98c33b1bc87..313542a6219 100644 --- a/rs/moq-net/tests/integration/replacement.rs +++ b/rs/moq-net/tests/integration/replacement.rs @@ -171,10 +171,10 @@ async fn epoch_lite07() { .expect("timed out"); } -/// moq-transport carries the epoch with the MoQ Epoch extension, from draft-17. +/// moq-transport carries the epoch with the MoQ Epoch extension. #[moq_clock::test] async fn epoch_ietf() { - for version in ["moq-transport-17", "moq-transport-22"] { + for version in ["moq-transport-18", "moq-transport-22"] { moq_clock::timeout(TEST_TIMEOUT, replace(version, Some(epoch()))) .await .expect("timed out"); diff --git a/rs/moq-net/tests/integration/request_caps.rs b/rs/moq-net/tests/integration/request_caps.rs index 2b05b770346..79f9586dcf5 100644 --- a/rs/moq-net/tests/integration/request_caps.rs +++ b/rs/moq-net/tests/integration/request_caps.rs @@ -1,10 +1,9 @@ #![cfg(feature = "async")] -//! A server bounds what one client can make it hold: flow control bounds its requests, a -//! client past [`moq_net::session::Limits::announces`] loses the session with -//! TOO_MANY_REQUESTS, and on moq-transport drafts 14 to 16 the MAX_REQUEST_ID window the -//! limits size is granted back as requests close, so a client that keeps opening and closing -//! requests within them never stalls. +//! A server bounds what one client can make it hold: stream flow control bounds its +//! requests, and a client past [`moq_net::session::Limits::announces`] loses the session +//! with TOO_MANY_REQUESTS. Credit is granted back as requests close, so a client that keeps +//! opening and closing requests never stalls. use crate::support; @@ -15,14 +14,8 @@ use support::harness::{MockConnectOptions, MockPair, connect_mock}; const TIMEOUT: Duration = Duration::from_secs(10); -/// Drafts with MAX_REQUEST_ID, a draft without it, and the lite draft production runs. -const VERSIONS: [&str; 5] = [ - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-lite-06", -]; +/// A moq-transport draft and the lite draft production runs. +const VERSIONS: [&str; 2] = ["moq-transport-18", "moq-lite-06"]; fn produce_origin(hop: u64) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); @@ -65,23 +58,20 @@ async fn setup(version: &str, limits: Limits, tracks: usize) -> Setup { } } -fn limits(announces: usize, requests: usize) -> Limits { +fn limits(announces: usize) -> Limits { let mut limits = Limits::default(); limits.announces = announces; - limits.requests = requests; limits } -/// Many more requests than the window admits at once, opened and closed in turn, all +/// Many more requests than the stream credit admits at once, opened and closed in turn, all /// succeed: each one that closes is granted back. #[moq_clock::test] async fn closed_requests_are_granted_back() { const ROUNDS: usize = 64; for version in VERSIONS { - // A window of 8 requests, far fewer than the rounds below. Doubling the limit leaves - // room for an UNSUBSCRIBE still in flight when the next SUBSCRIBE lands. - let setup = setup(version, limits(0, 4), ROUNDS).await; + let setup = setup(version, limits(0), ROUNDS).await; let broadcast = moq_clock::timeout(TIMEOUT, setup.client.consume().routed_broadcast("bcast")) .await .expect("announce timeout") @@ -107,11 +97,11 @@ async fn closed_requests_are_granted_back() { } /// With no subscription cap, a client asking for more subscriptions than the server admits -/// still holds no more than the request window (drafts 14 to 16) or the stream credit -/// (moq-lite, draft 17 and later) at once. The rest wait rather than close the session. +/// still holds no more than the stream credit at once. The rest wait rather than close the +/// session. #[moq_clock::test] async fn flow_control_bounds_the_subscriptions_a_peer_holds() { - // A window of 8 requests, and 8 bidi streams toward the server. + // 8 bidi streams toward the server. const BOUND: usize = 8; const TRACKS: usize = 3 * BOUND; @@ -129,7 +119,7 @@ async fn flow_control_bounds_the_subscriptions_a_peer_holds() { options.transport.max_concurrent_bi_streams = Some(BOUND); options.server_publish = Some(server.consume()); options.client_subscribe = Some(client.clone()); - options.server_limits = Some(limits(0, BOUND / 2)); + options.server_limits = Some(limits(0)); let pair = connect_mock(options).await; let remote = moq_clock::timeout(TIMEOUT, client.consume().routed_broadcast("bcast")) @@ -177,7 +167,7 @@ async fn an_announce_past_the_cap_closes_the_session() { let mut options = MockConnectOptions::new(version); options.client_publish = Some(client.consume()); options.server_subscribe = Some(server.clone()); - options.server_limits = Some(limits(1, 1)); + options.server_limits = Some(limits(1)); let pair = connect_mock(options).await; let err = moq_clock::timeout(TIMEOUT, pair.client.closed()) diff --git a/rs/moq-net/tests/integration/route_change.rs b/rs/moq-net/tests/integration/route_change.rs index 9b53013a6a2..f7a7ca97ef9 100644 --- a/rs/moq-net/tests/integration/route_change.rs +++ b/rs/moq-net/tests/integration/route_change.rs @@ -8,8 +8,8 @@ //! every frame exactly once, in order, whether the route changes between groups or //! in the middle of one, and however it changes. //! -//! moq-transport carries the epoch with the MoQ Epoch extension (draft-17+), so it -//! behaves the same. Older wire versions cannot carry the epoch, so `R` cannot tell the +//! moq-transport carries the epoch with the MoQ Epoch extension, so it behaves the +//! same. Older lite versions cannot carry the epoch, so `R` cannot tell the //! two routes serve the same bytes: its subscription stays on its route and ends with //! it, while a better route restarts the path for new requests. //! @@ -468,7 +468,6 @@ macro_rules! route_change_tests { route_change_tests! { lite_07: "moq-lite-07-wip", - ietf_17: "moq-transport-17", ietf_18: "moq-transport-18", ietf_22: "moq-transport-22", } @@ -512,8 +511,6 @@ pinned_tests! { lite_06: "moq-lite-06", lite_05: "moq-lite-05", lite_04: "moq-lite-04", - ietf_14: "moq-transport-14", - ietf_16: "moq-transport-16", } /// `P` feeds `A` slowly and `B` promptly, and `R` reads through `A` until that route dies @@ -614,7 +611,7 @@ async fn lite_resumes_after_a_lagging_route_dies() { #[moq_clock::test] async fn ietf_17_resumes_after_a_lagging_route_dies() { - lagging_route_dies("moq-transport-17".parse().unwrap()).await; + lagging_route_dies("moq-transport-18".parse().unwrap()).await; } #[moq_clock::test] diff --git a/rs/moq-net/tests/integration/session_close.rs b/rs/moq-net/tests/integration/session_close.rs index 421cc5416c2..178a26945b1 100644 --- a/rs/moq-net/tests/integration/session_close.rs +++ b/rs/moq-net/tests/integration/session_close.rs @@ -229,7 +229,7 @@ async fn abort_cuts_a_drain_short_for(version: &str) { #[moq_clock::test] async fn close_withdraws_announcements_before_closing_the_transport() { - for version in ["moq-lite-01", "moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + for version in ["moq-lite-01", "moq-lite-07-wip", "moq-transport-18", "moq-transport-22"] { let publisher = produce_origin(1); let broadcast = publisher.create_broadcast("bcast").unwrap(); broadcast.announce(Default::default()).unwrap(); @@ -250,7 +250,7 @@ async fn close_withdraws_announcements_before_closing_the_transport() { #[moq_clock::test] async fn close_times_out_waiting_for_announcement_acknowledgements() { - for version in ["moq-lite-01", "moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + for version in ["moq-lite-01", "moq-lite-07-wip", "moq-transport-18", "moq-transport-22"] { let publisher = produce_origin(1); let broadcast = publisher.create_broadcast("bcast").unwrap(); broadcast.announce(Default::default()).unwrap(); @@ -332,7 +332,7 @@ async fn earlier_lite_peer_close_reads_buffered_tail() { #[moq_clock::test] async fn ietf_peer_close_reads_buffered_tail() { - for version in ["moq-transport-14", "moq-transport-17", "moq-transport-22"] { + for version in ["moq-transport-18", "moq-transport-22"] { peer_close_reads_buffered_tail(version).await; } } @@ -362,8 +362,7 @@ async fn peer_close_before_receiver_poll_keeps_buffered_tail() { "moq-lite-02", "moq-lite-05", "moq-lite-06", - "moq-transport-14", - "moq-transport-17", + "moq-transport-18", ] { let gate = kio::Producer::new(true); let Setup { @@ -385,7 +384,7 @@ async fn peer_close_before_receiver_poll_keeps_buffered_tail() { /// A locally unsupported feature uses only the negotiated session registry. #[moq_clock::test] async fn unsupported_abort_uses_the_negotiated_session_registry() { - for protocol in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + for protocol in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-18", "moq-transport-22"] { let version: Version = protocol.parse().unwrap(); let pair = connect_mock(MockConnectOptions::new(version)).await; pair.client.abort(Error::Unsupported); @@ -398,7 +397,7 @@ async fn unsupported_abort_uses_the_negotiated_session_registry() { /// A Lite-only close code arriving on IETF stays unknown instead of gaining Lite semantics. #[moq_clock::test] async fn unsupported_close_is_decoded_by_the_negotiated_session_registry() { - for protocol in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + for protocol in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-18", "moq-transport-22"] { let version: Version = protocol.parse().unwrap(); let mut pair = connect_mock(MockConnectOptions::new(version)).await; moq_net::transport::poll::Session::close(&mut pair.server_transport, 0x30, "unsupported"); @@ -416,7 +415,7 @@ async fn unsupported_close_is_decoded_by_the_negotiated_session_registry() { /// wire, while a code outside it passes through. #[moq_clock::test] async fn an_unknown_lite_range_abort_is_unspecified_on_every_wire() { - for protocol in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + for protocol in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-18", "moq-transport-22"] { for (unknown, expected) in [(0x31, 0x1), (0x20, 0x20)] { let version: Version = protocol.parse().unwrap(); let pair = connect_mock(MockConnectOptions::new(version)).await; @@ -454,7 +453,7 @@ async fn ended(version: &str, close: impl FnOnce(&MockPair)) -> (Error, moq_net: #[moq_clock::test] async fn a_session_records_how_it_ended() { use moq_net::stats::Failures; - for version in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-16", "moq-transport-22"] { + for version in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-22"] { let (err, failed) = ended(version, |pair| pair.client.abort(Error::Cancel)).await; assert!(matches!(err, Error::Cancel), "{version}: {err:?}"); assert_eq!(failed, Failures::default(), "{version}"); diff --git a/rs/moq-net/tests/integration/subscribe_priority.rs b/rs/moq-net/tests/integration/subscribe_priority.rs index ad4becdf21f..a8b4bc9b3b0 100644 --- a/rs/moq-net/tests/integration/subscribe_priority.rs +++ b/rs/moq-net/tests/integration/subscribe_priority.rs @@ -13,16 +13,7 @@ use support::harness::peer; const TIMEOUT: Duration = Duration::from_secs(10); -const VERSIONS: &[&str] = &[ - "moq-lite-06", - "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - "moq-transport-22", -]; +const VERSIONS: &[&str] = &["moq-lite-06", "moq-lite-07-wip", "moq-transport-18", "moq-transport-22"]; const LOW: i8 = -100; const HIGH: i8 = 100; diff --git a/rs/moq-net/tests/integration/subscribe_ranges.rs b/rs/moq-net/tests/integration/subscribe_ranges.rs index 600b551ba54..b5c50cfe806 100644 --- a/rs/moq-net/tests/integration/subscribe_ranges.rs +++ b/rs/moq-net/tests/integration/subscribe_ranges.rs @@ -15,7 +15,7 @@ const TIMEOUT: Duration = Duration::from_secs(10); const LITE: &[&str] = &["moq-lite-05", "moq-lite-06", "moq-lite-07-wip"]; -const TRANSPORT: &[&str] = &["moq-transport-14", "moq-transport-17", "moq-transport-22"]; +const TRANSPORT: &[&str] = &["moq-transport-18", "moq-transport-22"]; fn produce_origin(hop: u64) -> moq_net::origin::Producer { let (producer, driver) = @@ -265,7 +265,7 @@ async fn range_fixture_history(version: &str, relay: bool, history: u64) -> Rang /// Mixed fixed and live ranges retain history for both live and finished cached sources. #[moq_clock::test] async fn initial_mixed_ranges_keep_the_absolute_history() { - for version in ["moq-transport-17", "moq-transport-22"] { + for version in ["moq-transport-18", "moq-transport-22"] { for relay in [false, true] { for finished in [false, true] { let fixture = range_fixture_version(version, relay).await; @@ -457,83 +457,77 @@ async fn draft22_can_widen_while_the_current_group_is_quiet() { } } -/// Legacy peers already join live and fetch the head. Their supported filters can -/// narrow that established subscription and widen it over groups published meanwhile. +/// Draft-18 peers already join live and fetch the head. Their filters can narrow that +/// established subscription and widen it over groups published meanwhile. #[moq_clock::test] -async fn legacy_updates_narrow_then_widen() { - for version in [ - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - ] { - for relay in [false, true] { - let mut fixture = range_fixture_version(version, relay).await; - let initial = Subscription::default().with_range(0..).with_max_delay(None); - let mut reader = subscribe(&fixture.remote, initial.clone()).await; - let mut seen = Vec::new(); - for _ in 0..6 { - seen.push(next(&mut reader).await.expect("initial history")); - } - seen.sort_unstable(); - assert_eq!(seen, [0, 1, 2, 3, 4, 5], "{version} relay={relay}"); - reader.update(initial.clone().with_range(4..5)).unwrap(); - moq_clock::timeout( - TIMEOUT, - kio::wait(|waiter| { - assert!(reader.poll_recv_group(waiter).is_pending()); - loop { - if fixture - .track - .subscription() - .is_some_and(|subscription| subscription.ranges == [Range::groups(4..5)]) - { - return std::task::Poll::Ready(()); - } - if fixture.track.poll_subscription_changed(waiter).is_pending() { - return std::task::Poll::Pending; - } +async fn draft18_updates_narrow_then_widen() { + let version = "moq-transport-18"; + for relay in [false, true] { + let mut fixture = range_fixture_version(version, relay).await; + let initial = Subscription::default().with_range(0..).with_max_delay(None); + let mut reader = subscribe(&fixture.remote, initial.clone()).await; + let mut seen = Vec::new(); + for _ in 0..6 { + seen.push(next(&mut reader).await.expect("initial history")); + } + seen.sort_unstable(); + assert_eq!(seen, [0, 1, 2, 3, 4, 5], "{version} relay={relay}"); + reader.update(initial.clone().with_range(4..5)).unwrap(); + moq_clock::timeout( + TIMEOUT, + kio::wait(|waiter| { + assert!(reader.poll_recv_group(waiter).is_pending()); + loop { + if fixture + .track + .subscription() + .is_some_and(|subscription| subscription.ranges == [Range::groups(4..5)]) + { + return std::task::Poll::Ready(()); } - }), - ) - .await - .unwrap_or_else(|_| panic!("{version} relay={relay}: narrowing did not reach the publisher")); - for sequence in 6..8 { - let mut group = fixture.track.create_group(moq_net::group::Info { sequence }).unwrap(); - group - .write_frame( - Timestamp::from_millis(sequence * 1000).unwrap(), - sequence.to_be_bytes().as_slice(), - ) - .unwrap(); - group.finish().unwrap(); - } - reader.update(initial).unwrap(); - let mut seen = Vec::new(); - for _ in 0..2 { - let mut group = moq_clock::timeout(TIMEOUT, reader.recv_group()) - .await - .unwrap_or_else(|_| panic!("{version} relay={relay}: widening lost history; got {seen:?}")) - .unwrap() - .unwrap(); - seen.push(group.sequence); - let frame = moq_clock::timeout(TIMEOUT, group.read_frame()) + if fixture.track.poll_subscription_changed(waiter).is_pending() { + return std::task::Poll::Pending; + } + } + }), + ) + .await + .unwrap_or_else(|_| panic!("{version} relay={relay}: narrowing did not reach the publisher")); + for sequence in 6..8 { + let mut group = fixture.track.create_group(moq_net::group::Info { sequence }).unwrap(); + group + .write_frame( + Timestamp::from_millis(sequence * 1000).unwrap(), + sequence.to_be_bytes().as_slice(), + ) + .unwrap(); + group.finish().unwrap(); + } + reader.update(initial).unwrap(); + let mut seen = Vec::new(); + for _ in 0..2 { + let mut group = moq_clock::timeout(TIMEOUT, reader.recv_group()) + .await + .unwrap_or_else(|_| panic!("{version} relay={relay}: widening lost history; got {seen:?}")) + .unwrap() + .unwrap(); + seen.push(group.sequence); + let frame = moq_clock::timeout(TIMEOUT, group.read_frame()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(frame.payload, group.sequence.to_be_bytes().as_slice()); + assert!( + moq_clock::timeout(TIMEOUT, group.read_frame()) .await .unwrap() .unwrap() - .unwrap(); - assert_eq!(frame.payload, group.sequence.to_be_bytes().as_slice()); - assert!( - moq_clock::timeout(TIMEOUT, group.read_frame()) - .await - .unwrap() - .unwrap() - .is_none() - ); - } - seen.sort_unstable(); - assert_eq!(seen, [6, 7], "{version} relay={relay}"); + .is_none() + ); } + seen.sort_unstable(); + assert_eq!(seen, [6, 7], "{version} relay={relay}"); } } @@ -541,7 +535,7 @@ async fn legacy_updates_narrow_then_widen() { /// the copy and joins the new live group, without disturbing a sibling reader. #[moq_clock::test] async fn empty_ranges_retire_the_wire_and_can_widen_again() { - for version in ["moq-transport-14", "moq-transport-17", "moq-transport-22"] { + for version in ["moq-transport-18", "moq-transport-22"] { for relay in [false, true] { let mut fixture = range_fixture_version(version, relay).await; let live = Subscription::default().with_ranges([Range::live()]); @@ -666,7 +660,7 @@ async fn draft22_cold_widening_finishes_every_filled_group() { /// A pending refusal is local to the new reader even before it is awaited. #[moq_clock::test] async fn unresolved_time_reader_never_changes_sibling_demand() { - for version in ["moq-transport-17", "moq-transport-18", "moq-transport-22"] { + for version in ["moq-transport-18", "moq-transport-22"] { for relay in [false, true] { let mut fixture = range_fixture_version(version, relay).await; let mut healthy = subscribe(&fixture.remote, Subscription::default()).await; diff --git a/rs/moq-net/tests/integration/subscribe_tracks.rs b/rs/moq-net/tests/integration/subscribe_tracks.rs index 0a68632d231..34bf4203ff2 100644 --- a/rs/moq-net/tests/integration/subscribe_tracks.rs +++ b/rs/moq-net/tests/integration/subscribe_tracks.rs @@ -163,21 +163,3 @@ async fn subscribe_tracks_needs_forward_0_on_draft_18() { 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() { - let setup = setup("moq-transport-17".parse().unwrap()).await; - let (mut send, _recv) = setup - .pair - .server_transport - .clone() - .open_bi(Default::default()) - .await - .expect("open_bi"); - send.write(SUBSCRIBE_TRACKS).await.expect("write"); - - moq_clock::timeout(TIMEOUT, setup.pair.client.closed()) - .await - .expect("the session stayed open"); -} diff --git a/rs/moq-net/tests/integration/subscriber_max_age.rs b/rs/moq-net/tests/integration/subscriber_max_age.rs index e01241bc9e6..c809cf9d44c 100644 --- a/rs/moq-net/tests/integration/subscriber_max_age.rs +++ b/rs/moq-net/tests/integration/subscriber_max_age.rs @@ -32,7 +32,7 @@ async fn budget(track: &mut track::Producer, millis: u64, version: Version) { #[moq_clock::test] async fn relay_forwards_the_most_tolerant_subscriber_max_age() { - for draft in [14, 15, 16, 17, 18, 22] { + for draft in [18, 22] { let version: Version = format!("moq-transport-{draft}").parse().unwrap(); let publisher = origin(1); let relay = origin(2); diff --git a/rs/moq-net/tests/integration/subscription_end_integrity.rs b/rs/moq-net/tests/integration/subscription_end_integrity.rs index 3814ece5db2..7cf37edc556 100644 --- a/rs/moq-net/tests/integration/subscription_end_integrity.rs +++ b/rs/moq-net/tests/integration/subscription_end_integrity.rs @@ -194,8 +194,7 @@ const DEATH_VERSIONS: &[&str] = &[ "moq-lite-03", "moq-lite-05", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-17", + "moq-transport-18", "moq-transport-22", ]; diff --git a/rs/moq-net/tests/integration/support/harness.rs b/rs/moq-net/tests/integration/support/harness.rs index 5928483d24e..52cac7f185a 100644 --- a/rs/moq-net/tests/integration/support/harness.rs +++ b/rs/moq-net/tests/integration/support/harness.rs @@ -206,7 +206,7 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { } // Run both handshakes concurrently and spawn each side's driver - // the moment its handshake resolves: on draft-17+ the server's accept blocks + // the moment its handshake resolves: on moq-transport the server's accept blocks // on the client's SETUP, which only reaches the wire once the client's // machine is polled (and vice versa for the server's own SETUP). let client_fut = async { diff --git a/rs/moq-net/tests/integration/synced.rs b/rs/moq-net/tests/integration/synced.rs index b6696e3e197..e906897dd0b 100644 --- a/rs/moq-net/tests/integration/synced.rs +++ b/rs/moq-net/tests/integration/synced.rs @@ -72,8 +72,6 @@ async fn an_exact_boundary_catches_up_with_the_initial_set() { "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-16", - "moq-transport-17", "moq-transport-18", "moq-transport-22", ] { @@ -84,7 +82,7 @@ async fn an_exact_boundary_catches_up_with_the_initial_set() { /// Versions without a boundary still sync, so nothing waiting on it hangs. #[moq_clock::test] async fn a_best_effort_boundary_still_catches_up() { - for version in ["moq-lite-03", "moq-lite-04", "moq-transport-14", "moq-transport-15"] { + for version in ["moq-lite-03", "moq-lite-04"] { synced(version).await; } } diff --git a/rs/moq-net/tests/integration/track_tail.rs b/rs/moq-net/tests/integration/track_tail.rs index 4dd360e96d1..248541ee2a5 100644 --- a/rs/moq-net/tests/integration/track_tail.rs +++ b/rs/moq-net/tests/integration/track_tail.rs @@ -29,14 +29,13 @@ const PAYLOAD: &[u8] = b"frame"; /// How long a subscriber waits for a group it cannot account for, with no max delay set. const GRACE: Duration = Duration::from_secs(1); -/// moq-lite drafts with and without SUBSCRIBE_END, and IETF drafts over the control stream -/// adapter (14), on their own streams (17), and with subscription fills (22). +/// moq-lite drafts with and without SUBSCRIBE_END, and IETF drafts with a joining +/// FETCH (18) and with subscription fills (22). const VERSIONS: &[&str] = &[ "moq-lite-03", "moq-lite-05", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-17", + "moq-transport-18", "moq-transport-22", ]; @@ -257,9 +256,8 @@ async fn lite07_empty_track_ends_without_the_grace() { assert!(outcome.elapsed < GRACE / 10, "ended after {:?}", outcome.elapsed); } -/// IETF drafts over the control stream adapter (14), on their own streams (17), and with -/// subscription fills (22). -const IETF: &[&str] = &["moq-transport-14", "moq-transport-17", "moq-transport-22"]; +/// IETF drafts with a joining FETCH (18) and with subscription fills (22). +const IETF: &[&str] = &["moq-transport-18", "moq-transport-22"]; /// A subscriber that leaves while END_OF_TRACK waits for stream credit ends the publisher's /// request, instead of parking it until credit that may never come. diff --git a/rs/moq-net/tests/integration/tracks_ietf.rs b/rs/moq-net/tests/integration/tracks_ietf.rs index f580145ae81..6eab3284ac4 100644 --- a/rs/moq-net/tests/integration/tracks_ietf.rs +++ b/rs/moq-net/tests/integration/tracks_ietf.rs @@ -178,37 +178,6 @@ async fn a_listing_carries_track_info() { assert_eq!(info.priority, 9); } -/// Before draft-18 there is no SUBSCRIBE_TRACKS, so the listing is refused rather than -/// answered empty, and a reader still subscribes. -#[moq_clock::test] -async fn before_draft_18_the_listing_is_refused() { - let publisher = produce_origin(1); - let broadcast = publisher.create_broadcast("bcast").unwrap(); - let a = broadcast.create_track("a", None).unwrap(); - broadcast.announce(Default::default()).unwrap(); - - let (subscriber, _pair) = connect(&publisher, "moq-transport-17").await; - let remote = resolve(&subscriber, "bcast").await; - let mut tracks = remote.tracks().unwrap(); - assert_eq!( - next(&mut tracks).await, - format!("error: {}", moq_net::Error::Unsupported) - ); - - let mut sub = moq_clock::timeout(TIMEOUT, remote.track("a").unwrap().subscribe(None)) - .await - .expect("subscribe timeout") - .expect("subscribe"); - moq_clock::timeout(TIMEOUT, support::wanted(a.demand())) - .await - .expect("never subscribed") - .unwrap(); - let mut group = a.append_group().unwrap(); - group.write_frame(None, &b"frame"[..]).unwrap(); - group.finish().unwrap(); - assert_eq!(read(&mut sub).await, Some((0, b"frame".to_vec()))); -} - /// A reader of a listed track resumes its held PUBLISH, so its data arrives with no /// SUBSCRIBE at all; draft-18 would refuse one as a second subscription to the track. /// Once the reader leaves, the PUBLISH pauses and stays listed, and the next reader diff --git a/rs/moq-net/tests/integration/untimed.rs b/rs/moq-net/tests/integration/untimed.rs index 99a13b8fd7a..c313a31f23a 100644 --- a/rs/moq-net/tests/integration/untimed.rs +++ b/rs/moq-net/tests/integration/untimed.rs @@ -35,8 +35,6 @@ fn expected(version: &str) -> Arrives { match version { // Lite-05 and lite-06 stamp untimed tracks with send times, so their receivers drop them. "moq-lite-03" | "moq-lite-04" | "moq-lite-05" | "moq-lite-06" => Arrives::Untimed, - // Drafts 14 to 16 can't carry TIMESCALE. - "moq-transport-14" | "moq-transport-16" => Arrives::Untimed, _ => Arrives::Faithful, } } @@ -47,9 +45,7 @@ const VERSIONS: &[&str] = &[ "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-16", - "moq-transport-17", + "moq-transport-18", "moq-transport-22", ]; @@ -147,29 +143,27 @@ async fn frames_arrive_as_published() { /// does not gain one downstream. #[moq_clock::test] async fn a_relay_forwards_an_untimed_track_untimed() { - for hops in [&["moq-transport-17", "moq-transport-22"][..], &["moq-lite-07-wip"][..]] { + for hops in [ + &["moq-transport-18", "moq-transport-22"][..], + &["moq-lite-07-wip", "moq-lite-07-wip"][..], + ] { let (frame, info) = frame_through(hops, untimed()).await; assert_eq!(frame, None, "{hops:?}: the relay did not stamp it"); assert_eq!(info.timescale, None, "{hops:?}: the relay must not claim a timeline"); } - for hops in [ - &["moq-lite-03", "moq-transport-17"][..], - &["moq-transport-16", "moq-transport-22"][..], - ] { - let (frame, info) = frame_through(hops, timed()).await; - assert_eq!(frame, None, "{hops:?}: the first hop has no timestamps to forward"); - assert_eq!(info.timescale, None, "{hops:?}: the relay must not claim a timeline"); - } + let hops = ["moq-lite-03", "moq-transport-18"]; + let (frame, info) = frame_through(&hops, timed()).await; + assert_eq!(frame, None, "{hops:?}: the first hop has no timestamps to forward"); + assert_eq!(info.timescale, None, "{hops:?}: the relay must not claim a timeline"); } /// An explicit start on an untimed track is honored the same way on lite and /// moq-transport: only an unfloored subscriber jumps to the latest group. /// /// The subscription asks for history the way a resume does, with a budget covering it. -/// moq-transport before draft -/// 20 can only join a past group through a joining FETCH, which our publisher refuses for -/// groups before the live one, so draft 22 stands in for it. +/// moq-transport before draft 22 can only join a past group through a joining FETCH, +/// which our publisher refuses for groups before the live one, so draft 22 stands in for it. #[moq_clock::test] async fn an_explicit_start_holds_on_an_untimed_track() { for version in ["moq-lite-03", "moq-lite-06", "moq-lite-07-wip", "moq-transport-22"] { @@ -207,7 +201,7 @@ async fn an_explicit_start_holds_on_an_untimed_track() { /// time that the receiver drops. #[moq_clock::test] async fn datagrams_arrive_as_published() { - for version in ["moq-lite-05", "moq-lite-07-wip", "moq-transport-16", "moq-transport-22"] { + for version in ["moq-lite-05", "moq-lite-07-wip", "moq-transport-22"] { let chain = Chain::new(&[version]).await; let broadcast = chain.nodes[0].create_broadcast("room").unwrap(); let mut producer = broadcast.create_track("data", untimed()).unwrap(); diff --git a/rs/moq-quic/src/connection/mux/params.rs b/rs/moq-quic/src/connection/mux/params.rs index cbca21117e7..96eb296ca1c 100644 --- a/rs/moq-quic/src/connection/mux/params.rs +++ b/rs/moq-quic/src/connection/mux/params.rs @@ -186,7 +186,7 @@ mod tests { let params = Params { tp, max_record_size: VarInt::from_u32(32_768), - protocols: Some(vec!["moq-lite-03".into(), "moqt-16".into()]), + protocols: Some(vec!["moq-lite-03".into(), "moqt-18".into()]), }; let mut buf = Vec::new(); params.encode(&mut buf); diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 638dc2edcad..b229f7fe9e3 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -990,9 +990,9 @@ struct LanAuth { #[derive(Default)] #[non_exhaustive] pub struct Options { - /// Request caps for ordinary client sessions. + /// Session limits for ordinary client sessions. pub session: crate::limits::Client, - /// Request caps and QUIC limits for authenticated peers and outbound cluster links. + /// Session and QUIC limits for authenticated peers and outbound cluster links. pub peer: crate::limits::Peer, /// Cluster identity, peers, and discovery. pub config: Config, @@ -2327,14 +2327,12 @@ mod tests { use crate::Config as RelayConfig; #[tokio::test] - async fn request_limits_follow_the_admitted_grant() { + async fn session_limits_follow_the_admitted_grant() { let mut options = Options::default(); options.session.announces = 2; - options.session.requests = 3; options.peer.announces = 20; - options.peer.requests = 30; let cluster = Cluster::new(options).unwrap(); - for (peer, upstream, expected) in [(false, false, (2, 3)), (true, false, (20, 30)), (true, true, (20, 30))] { + for (peer, upstream, expected) in [(false, false, 2), (true, false, 20), (true, true, 20)] { let mut grant = moq_auth::Grant::new(moq_auth::Patterns::new(), moq_auth::Patterns::new()); grant.peer = peer; grant.upstream = upstream; @@ -2342,7 +2340,7 @@ mod tests { // A cluster-looking path alone never selects the peer policy. let lease = auth::Lease::new("/.cluster/untrusted", consumer).unwrap(); let limits = cluster.limits(&lease); - assert_eq!((limits.announces, limits.requests), expected); + assert_eq!(limits.announces, expected); } } diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index f2f0315e62b..5ba0fb061f2 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -531,39 +531,39 @@ mod tests { #[test] fn session_limits_merge_without_crossing_peer_policy() { - if !test_env::Env::without(&[ - "MOQ_LIMITS_CLIENT_ANNOUNCES", - "MOQ_LIMITS_CLIENT_REQUESTS", - "MOQ_LIMITS_PEER_ANNOUNCES", - "MOQ_LIMITS_PEER_REQUESTS", - ]) - .isolate() - { + if !test_env::Env::without(&["MOQ_LIMITS_CLIENT_ANNOUNCES", "MOQ_LIMITS_PEER_ANNOUNCES"]).isolate() { return; } let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("limits.toml"); - std::fs::write( - &path, - "[limits.client]\nannounces = 7\nrequests = 8\n[limits.peer]\nannounces = 90\nrequests = 91\n", - ) - .unwrap(); + std::fs::write(&path, "[limits.client]\nannounces = 7\n[limits.peer]\nannounces = 90\n").unwrap(); let config = Config::parse_and_merge([std::ffi::OsString::from("moq-relay"), path.clone().into()]).unwrap(); assert_eq!(config.limits.client.resolve().announces, 7); - assert_eq!(config.limits.client.resolve().requests, 8); assert_eq!(config.limits.peer.resolve().announces, 90); - assert_eq!(config.limits.peer.resolve().requests, 91); let config = Config::parse_and_merge([ std::ffi::OsString::from("moq-relay"), path.into(), "--limits-client-announces=0".into(), - "--limits-peer-requests=1000".into(), ]) .unwrap(); assert_eq!(config.limits.client.resolve().announces, 0); - assert_eq!(config.limits.client.resolve().requests, 8); assert_eq!(config.limits.peer.resolve().announces, 90); - assert_eq!(config.limits.peer.resolve().requests, 1000); + } + + /// Sizing the removed request window refuses to start rather than run without the + /// setting. + #[test] + fn removed_request_limits_are_refused() { + if !test_env::Env::without(&["MOQ_LIMITS_CLIENT_REQUESTS", "MOQ_LIMITS_PEER_REQUESTS"]).isolate() { + return; + } + let error = Config::parse_and_merge(["moq-relay", "--limits-peer-requests=1000"]).unwrap_err(); + assert!(error.to_string().contains("--limits-peer-requests"), "{error}"); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("limits.toml"); + std::fs::write(&path, "[limits.client]\nrequests = 8\n").unwrap(); + assert!(Config::parse_and_merge([std::ffi::OsString::from("moq-relay"), path.into()]).is_err()); } /// `[limits.peer.quic]` mirrors `[quic]`'s keys without touching `[quic]` itself. diff --git a/rs/moq-relay/src/limits.rs b/rs/moq-relay/src/limits.rs index 15634ecc581..72414f4e13d 100644 --- a/rs/moq-relay/src/limits.rs +++ b/rs/moq-relay/src/limits.rs @@ -32,28 +32,17 @@ pub struct Client { setting = "limits.client.announces" )] pub announces: usize, - /// Maximum other simultaneously held moq-transport requests, sizing the drafts 14 to 16 request window. - #[usage( - long = "limits-client-requests", - env = "MOQ_LIMITS_CLIENT_REQUESTS", - default = "10000", - setting = "limits.client.requests" - )] - pub requests: usize, } impl Default for Client { fn default() -> Self { - Self { - announces: 1000, - requests: 10000, - } + Self { announces: 1000 } } } impl Client { pub(crate) fn resolve(&self) -> moq_net::session::Limits { - resolve(self.announces, self.requests) + resolve(self.announces) } } @@ -70,14 +59,6 @@ pub struct Peer { setting = "limits.peer.announces" )] pub announces: usize, - /// Maximum other simultaneously held moq-transport requests, sizing the drafts 14 to 16 request window. - #[usage( - long = "limits-peer-requests", - env = "MOQ_LIMITS_PEER_REQUESTS", - default = "20000", - setting = "limits.peer.requests" - )] - pub requests: usize, /// QUIC limits a peer session widens to (`[limits.peer.quic]`). #[usage(flatten)] pub quic: Quic, @@ -87,7 +68,6 @@ impl Default for Peer { fn default() -> Self { Self { announces: 100000, - requests: 20000, quic: Quic::default(), } } @@ -95,7 +75,7 @@ impl Default for Peer { impl Peer { pub(crate) fn resolve(&self) -> moq_net::session::Limits { - resolve(self.announces, self.requests) + resolve(self.announces) } } @@ -170,10 +150,9 @@ impl Quic { } } -fn resolve(announces: usize, requests: usize) -> moq_net::session::Limits { +fn resolve(announces: usize) -> moq_net::session::Limits { let mut limits = moq_net::session::Limits::default(); limits.announces = announces; - limits.requests = requests; limits } @@ -189,6 +168,10 @@ pub(crate) struct Legacy { peer_quic_receive_window: Option, #[usage(long = "peer-quic-send-window", env = "MOQ_PEER_QUIC_SEND_WINDOW", hide = true)] peer_quic_send_window: Option, + #[usage(long = "limits-client-requests", env = "MOQ_LIMITS_CLIENT_REQUESTS", hide = true)] + client_requests: Option, + #[usage(long = "limits-peer-requests", env = "MOQ_LIMITS_PEER_REQUESTS", hide = true)] + peer_requests: Option, } impl Legacy { @@ -229,6 +212,13 @@ impl Legacy { "--limits-peer-quic-send-window", ); } + const REQUESTS: &str = "stream flow control bounds requests"; + if self.client_requests.is_some() { + found.removed("--limits-client-requests", Some("MOQ_LIMITS_CLIENT_REQUESTS"), REQUESTS); + } + if self.peer_requests.is_some() { + found.removed("--limits-peer-requests", Some("MOQ_LIMITS_PEER_REQUESTS"), REQUESTS); + } found } } diff --git a/rs/moq-relay/src/settings.rs b/rs/moq-relay/src/settings.rs index 52622b36b72..35fed361a8e 100644 --- a/rs/moq-relay/src/settings.rs +++ b/rs/moq-relay/src/settings.rs @@ -268,8 +268,6 @@ struct Limits { struct Client { #[usage(env = "MOQ_LIMITS_CLIENT_ANNOUNCES", cli("--limits-client-announces"))] announces: Option, - #[usage(env = "MOQ_LIMITS_CLIENT_REQUESTS", cli("--limits-client-requests"))] - requests: Option, } #[derive(usage_rs::Config)] @@ -277,8 +275,6 @@ struct Client { struct Peer { #[usage(env = "MOQ_LIMITS_PEER_ANNOUNCES", cli("--limits-peer-announces"))] announces: Option, - #[usage(env = "MOQ_LIMITS_PEER_REQUESTS", cli("--limits-peer-requests"))] - requests: Option, #[usage(flatten)] quic: PeerQuic, } diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 7b0b409ce3d..83f9179ad34 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -431,21 +431,21 @@ mod tests { #[test] fn supported_subprotocols_only_lists_configured_alpns() { - let list = supported_subprotocols(&["moqt-16"]); + let list = supported_subprotocols(&["moqt-18"]); - assert!(list.contains(&"qmux-01.moqt-16".to_string())); - assert!(list.contains(&"qmux-00.moqt-16".to_string())); + assert!(list.contains(&"qmux-01.moqt-18".to_string())); + assert!(!list.contains(&"qmux-00.moqt-18".to_string())); assert!(list.iter().all(|entry| !entry.contains("moq-lite"))); assert!(list.iter().all(|entry| !entry.contains("moqt-22"))); } #[test] fn supported_subprotocols_preserves_moq_preference_across_qmux_versions() { - let list = supported_subprotocols(&["moqt-16", "moqt-18"]); + let list = supported_subprotocols(&["moq-lite-06", "moqt-18"]); let preferred = list .iter() - .position(|entry| entry == "qmux-00.moqt-16") - .expect("missing preferred moqt-16 pair"); + .position(|entry| entry == "qmux-00.moq-lite-06") + .expect("missing preferred moq-lite-06 pair"); let newer_qmux = list .iter() .position(|entry| entry == "qmux-01.moqt-18") diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index 49d4d056d4f..2fcd90c8104 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -451,34 +451,38 @@ async fn forwards_the_setup_token() { spawn_relay(auth).await }; - // Draft-16 CLIENT_SETUP: AUTHORIZATION TOKEN (3), USE_VALUE (3), Token Type 0. + // Draft-18 SETUP (0x2F00): AUTHORIZATION TOKEN (3), USE_VALUE (3), Token Type 0. let encoded = [&[0x03, 0x00][..], &value].concat(); - let params = [&[0x01, 0x03, encoded.len() as u8][..], &encoded].concat(); - let setup = [&[0x20][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); + let params = [&[0x03, encoded.len() as u8][..], &encoded].concat(); + let setup = [&[0xaf, 0x00][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); let mut session = if websocket { qmux::ws::Client::new() - .with_protocol("moqt-16", &[qmux::Version::QMux01]) + .with_protocol("moqt-18", &[qmux::Version::QMux01]) .connect(&format!("ws://127.0.0.1:{port}/room?source=setup")) .await .expect("connect WebSocket") } else { qmux::tcp::connect(("127.0.0.1", port), { let mut config = qmux::Config::new(qmux::Version::QMux01); - config.protocol = qmux::Protocol::Negotiate(vec!["moqt-16".into()]); + config.protocol = qmux::Protocol::Negotiate(vec!["moqt-18".into()]); config }) .await .expect("connect TCP") }; - let (mut send, mut recv) = session - .open_bi(Default::default()) + let mut send = session + .open_uni(Default::default()) .await - .expect("open the control stream"); + .expect("open the setup stream"); write_all(&mut send, &setup).await; + let mut recv = tokio::time::timeout(TIMEOUT, session.accept_uni()) + .await + .expect("SETUP timeout") + .expect("the server's setup stream"); let reply = tokio::time::timeout(TIMEOUT, recv.read_chunk(1)) .await - .expect("SERVER_SETUP timeout") - .expect("SERVER_SETUP"); + .expect("SETUP timeout") + .expect("SETUP"); assert_eq!(reply.map(|chunk| chunk.len()), Some(1)); let seen = script.seen.lock().unwrap().clone(); let connect = seen.iter().find(|r| r.event == Event::Connect).expect("a connect"); @@ -490,7 +494,7 @@ async fn forwards_the_setup_token() { // Plain `ws://` has no SNI; the `Host` header never stands in for one. assert_eq!(connect.server_name, None); assert!(connect.remote.is_some_and(|addr| addr.ip().is_loopback())); - assert_eq!(connect.alpn.as_deref(), Some("qmux-01.moqt-16")); + assert_eq!(connect.alpn.as_deref(), Some("qmux-01.moqt-18")); } relay.abort(); } diff --git a/rs/moq-relay/tests/integration/cluster_unknown.rs b/rs/moq-relay/tests/integration/cluster_unknown.rs index f215b4aa772..54e2d990d50 100644 --- a/rs/moq-relay/tests/integration/cluster_unknown.rs +++ b/rs/moq-relay/tests/integration/cluster_unknown.rs @@ -189,9 +189,9 @@ async fn publish_version(port: u16, version: &str) -> Publisher { } async fn publish_unknown(port: u16) -> Publisher { - // Draft-14 has no Cluster extension, so the accepting relay must represent + // Lite-03 declares no origin identity, so the accepting relay must represent // this external publisher with the wire-defined UNKNOWN Hop ID. - publish_version(port, "moq-transport-14").await + publish_version(port, "moq-lite-03").await } /// Subscribe to [`PATH`] through the relay on `port` and read its first frame. @@ -308,7 +308,7 @@ async fn assert_unknown_publisher_stays_announced(cluster_version: Option tokio::t /// only after its lease recorded the shutdown. #[tokio::test(start_paused = true)] async fn a_session_ignoring_the_goaway_is_closed_at_the_deadline() { - for alpn in ["moq-lite-06", "moq-lite-03", "moq-transport-17"] { + for alpn in ["moq-lite-06", "moq-lite-03", "moq-transport-18"] { let (trigger, observer) = shutdown::Observer::new(WINDOW); let session = admit(&observer, alpn.parse().unwrap(), false).await; let started = tokio::time::Instant::now(); diff --git a/rs/moq-relay/tests/integration/hidden_cluster.rs b/rs/moq-relay/tests/integration/hidden_cluster.rs index f9e4ef7cd24..2a6ecad6a69 100644 --- a/rs/moq-relay/tests/integration/hidden_cluster.rs +++ b/rs/moq-relay/tests/integration/hidden_cluster.rs @@ -41,7 +41,7 @@ async fn listen_tcp(version: moq_net::Version) -> (u16, moq_tokio::Listener) { #[test] fn old_peers_keep_hidden_paths() { - for version in ["moq-lite-05", "moq-lite-06", "moq-transport-17"] { + for version in ["moq-lite-05", "moq-lite-06", "moq-transport-18"] { run_cluster_test(old_peer_keeps_hidden_paths(version.parse().unwrap())); } } diff --git a/rs/moq-relay/tests/integration/runtime_uring.rs b/rs/moq-relay/tests/integration/runtime_uring.rs index 3e98f82740b..75fcebe8ed5 100644 --- a/rs/moq-relay/tests/integration/runtime_uring.rs +++ b/rs/moq-relay/tests/integration/runtime_uring.rs @@ -248,14 +248,9 @@ async fn uring_workers_serve_every_configured_version() { let publisher = connect(client.with_publisher(&origin), raw.parse().unwrap()).await; assert_eq!(publisher.version(), Some(version), "publisher version"); - // moq-transport-14 and moq-lite-02 negotiate in the SETUP; 16 has its own - // ALPN but still a bidi SETUP; 22 reads its SETUP off a uni stream. - for name in [ - "moq-transport-22", - "moq-transport-16", - "moq-transport-14", - "moq-lite-02", - ] { + // moq-lite-02 negotiates in the SETUP; moq-transport-22 reads its SETUP off a + // uni stream. + for name in ["moq-transport-22", "moq-lite-02"] { for scheme in ["moql", "https"] { let url: url::Url = format!("{scheme}://127.0.0.1:{port}/versions").parse().unwrap(); let (version, client) = pinned(name); @@ -364,7 +359,7 @@ async fn uring_workers_report_link_facts() { /// threads joined and the port free. /// /// What an arrival is told is left of the window only reaches the wire on -/// moq-transport-17+, which this test does not dial; `shutdown_signal.rs` +/// moq-transport, which this test does not dial; `shutdown_signal.rs` /// reads it through the shared runtime, whose supervision this path shares. #[tokio::test] async fn uring_workers_drain_on_the_trigger() { @@ -886,7 +881,7 @@ async fn uring_forwards_the_setup_token() { tls.alpn_protocols = vec![if webtransport { b"h3".to_vec() } else { - b"moqt-16".to_vec() + b"moqt-18".to_vec() }]; let crypto = driver::crypto::rustls::QuicClientConfig::try_from(tls).unwrap(); let conn = client @@ -900,17 +895,21 @@ async fn uring_forwards_the_setup_token() { .unwrap(); let session = if webtransport { let url: url::Url = format!("https://{addr}/room?source=setup").parse().unwrap(); - let request = web::proto::ConnectRequest::new(url).with_protocol("moqt-16".to_string()); + let request = web::proto::ConnectRequest::new(url).with_protocol("moqt-18".to_string()); web::Session::connect(conn, request).await.unwrap() } else { web::Session::raw(conn) }; - // Draft-16 CLIENT_SETUP: AUTHORIZATION TOKEN (3), USE_VALUE (3), Token Type 0. + // Draft-18 SETUP (0x2F00): AUTHORIZATION TOKEN (3), USE_VALUE (3), Token Type 0. let encoded = [&[0x03, 0x00][..], &token.value].concat(); - let params = [&[0x01, 0x03, encoded.len() as u8][..], &encoded].concat(); - let setup = [&[0x20][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); - let (mut send, mut recv) = session.open_bi(Default::default()).await.unwrap(); + let params = [&[0x03, encoded.len() as u8][..], &encoded].concat(); + let setup = [&[0xaf, 0x00][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); + let mut send = session.open_uni(Default::default()).await.unwrap(); send.write_all(&setup).await.unwrap(); + let mut recv = tokio::time::timeout(TIMEOUT, session.accept_uni()) + .await + .unwrap() + .unwrap(); let mut reply = [0u8; 1]; assert_eq!( tokio::time::timeout(TIMEOUT, recv.read(&mut reply)) @@ -918,7 +917,7 @@ async fn uring_forwards_the_setup_token() { .unwrap() .unwrap(), Some(1), - "SERVER_SETUP" + "SETUP" ); } deciding.await.unwrap(); diff --git a/rs/moq-relay/tests/integration/shutdown_signal.rs b/rs/moq-relay/tests/integration/shutdown_signal.rs index 63e2a81d90b..7efa62bc93a 100644 --- a/rs/moq-relay/tests/integration/shutdown_signal.rs +++ b/rs/moq-relay/tests/integration/shutdown_signal.rs @@ -161,7 +161,7 @@ async fn sigint_drains_sessions_before_exiting_inner() { .expect("no GOAWAY within 5s of SIGINT") .expect("session closed without a GOAWAY"); // Empty URI: the relay is restarting, not moving. The window itself is the - // sender's own timer here (only moq-transport draft-17+ puts it on the wire), + // sender's own timer here (only moq-transport puts it on the wire), // so it is the elapsed time below that proves it was honored. assert_eq!(goaway.uri(), "", "expected a reconnect-to-me GOAWAY"); @@ -303,9 +303,9 @@ async fn a_session_arriving_mid_drain_gets_what_is_left_inner() { let port = relay.tcp_addr().expect("TCP listener bound").port(); let trigger = relay.shutdown_trigger().clone(); let run = tokio::spawn(relay.run()); - // moq-transport-17 is the first version whose GOAWAY carries its deadline on - // the wire, which is what this test reads. - let client = client(vec!["moq-transport-17".parse().expect("parse version")]); + // moq-transport's GOAWAY carries its deadline on the wire, which is what this + // test reads. + let client = client(vec!["moq-transport-18".parse().expect("parse version")]); let established = connect(&client, port).await; let draining = established.draining().expect("connected"); diff --git a/rs/moq-relay/tests/integration/smoke.rs b/rs/moq-relay/tests/integration/smoke.rs index 72bf4123b7f..8df533b45a6 100644 --- a/rs/moq-relay/tests/integration/smoke.rs +++ b/rs/moq-relay/tests/integration/smoke.rs @@ -376,7 +376,7 @@ async fn hidden_broadcasts_need_a_lite07_opt_in() { /// `--server-version` applies to the WebSocket fallback as well as QUIC. #[tokio::test] async fn relay_websocket_honors_server_version() { - let allowed: moq_net::Version = "moq-transport-16".parse().expect("parse allowed version"); + let allowed: moq_net::Version = "moq-transport-18".parse().expect("parse allowed version"); let excluded = newest_lite_version(); let (port, web_handle) = spawn_versioned_relay(vec![allowed]).await; let url: url::Url = format!("ws://127.0.0.1:{port}/smoke").parse().expect("parse url"); @@ -792,21 +792,13 @@ async fn internal_unix_round_trip() { } /// Every version whose SETUP carries a request path the server reads: moq-lite-05/06 -/// (Setup Stream) and moq-transport 14-18 (the `Path` SETUP parameter, in-band on -/// the bidi stream for 14-16 and the uni Setup Stream for 17-18). +/// (Setup Stream) and moq-transport 18 (the `Path` SETUP parameter on the uni Setup +/// Stream). fn path_versions() -> Vec { - [ - "moq-lite-05", - "moq-lite-06", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", - "moq-transport-18", - ] - .iter() - .map(|alpn| alpn.parse().expect("parse version alpn")) - .collect() + ["moq-lite-05", "moq-lite-06", "moq-transport-18"] + .iter() + .map(|alpn| alpn.parse().expect("parse version alpn")) + .collect() } /// Publisher and subscriber (both pinned to `version`) that announce/observe diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index b05c53ec3f0..4cc3ed12aed 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -215,10 +215,6 @@ pub(crate) struct Legacy { "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", "moq-transport-18", "moq-transport-22" ), @@ -620,10 +616,6 @@ pub struct Config { "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", "moq-transport-18", "moq-transport-22" ) diff --git a/rs/moq-tokio/src/deprecated.rs b/rs/moq-tokio/src/deprecated.rs index 16ed82488d5..4a245c8093b 100644 --- a/rs/moq-tokio/src/deprecated.rs +++ b/rs/moq-tokio/src/deprecated.rs @@ -46,6 +46,12 @@ impl Deprecated { self.entry(&Self::spelling(old, env), new, Some(note)); } + /// Record a released flag that was removed without a replacement, with a note saying + /// what covers it now. + pub fn removed(&mut self, old: &str, env: Option<&str>, note: &str) { + self.entry(&Self::spelling(old, env), "removed", Some(note)); + } + /// Record a released config-file key or table, which has no environment variable. pub fn toml(&mut self, old: &str, new: &str, note: Option<&str>) { self.entry(old, new, note); @@ -70,7 +76,7 @@ impl std::fmt::Display for Deprecated { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "these settings were renamed and are no longer applied; update them and try again:" + "these settings were renamed or removed and are no longer applied; update them and try again:" )?; for entry in &self.0 { write!(f, "\n {entry}")?; diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index d8a0d6f7dca..82779c4e759 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -133,10 +133,6 @@ pub struct Config { "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", "moq-transport-18", "moq-transport-22" ) @@ -293,10 +289,6 @@ pub(crate) struct Legacy { "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - "moq-transport-17", "moq-transport-18", "moq-transport-22" ), diff --git a/rs/moq-tokio/src/quic/backend.rs b/rs/moq-tokio/src/quic/backend.rs index d27041eb808..28f8951374d 100644 --- a/rs/moq-tokio/src/quic/backend.rs +++ b/rs/moq-tokio/src/quic/backend.rs @@ -469,7 +469,7 @@ impl Client { let session = match url.scheme() { "https" => { let mut request = web::proto::ConnectRequest::new(url.clone()); - for protocol in web_protocols(versions) { + for protocol in versions.alpns() { request = request.with_protocol(protocol.to_string()); } web::Session::connect(connection, request) @@ -542,22 +542,6 @@ impl Error { } } -/// The WebTransport subprotocols to offer for `versions`. -/// -/// Draft-14 predates WebTransport subprotocols: `moq-00` is only its QUIC ALPN, and a peer that -/// accepts it over WebTransport picks draft-14 over a newer draft both support. It rides `moql` -/// and negotiates in SETUP instead, as `js/net` does. -fn web_protocols(versions: &moq_net::Versions) -> Vec<&'static str> { - let mut protocols = Vec::new(); - for alpn in versions.alpns() { - let protocol = if alpn == "moq-00" { "moql" } else { alpn }; - if !protocols.contains(&protocol) { - protocols.push(protocol); - } - } - protocols -} - fn map_client_error(err: web::ClientError) -> Error { match client_status(&err).and_then(crate::ConnectError::from_status_u16) { Some(rejected) => rejected.into(), @@ -1062,16 +1046,6 @@ mod tests { use super::*; use url::Url; - #[test] - fn web_protocols_never_offer_moq_00() { - let all = moq_net::Versions::all(); - assert!(!web_protocols(&all).contains(&"moq-00")); - assert_eq!(web_protocols(&all).len(), all.alpns().len() - 1); - - let draft14: moq_net::Version = "moq-transport-14".parse().unwrap(); - assert_eq!(web_protocols(&draft14.into()), ["moql"]); - } - #[tokio::test] async fn pinned_ipv6_connection() { pinned_connection("[::1]:0", None).await; diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index bc1dd0bfec0..176d1fcd092 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -2448,19 +2448,21 @@ mod tests { ); } - /// Answering SETUP is part of the handshake: a moq-transport peer that sends - /// its SETUP but never grants credit for the reply is refused at the same + /// Answering SETUP is part of the handshake: a peer that sends its SETUP but + /// never grants credit for the reply is refused at the same /// deadline, rather than held open by keep-alives, and the session counts it. #[cfg(feature = "websocket")] #[tokio::test(start_paused = true)] async fn stalled_setup_reply_is_closed_at_the_deadline() { - // Record a real moq-transport-14 CLIENT_SETUP. The client then waits on a - // reply that never comes. - let recorder = Scripted::new("moq-00"); + // Record a real CLIENT_SETUP offering only lite-02, whose reply waits for + // `ok()`; a newer lite version is answered during accept instead. The client + // then waits on a reply that never comes. + let recorder = Scripted::new("moql"); let client = tokio::spawn({ let recorder = crate::transport::Session::new(recorder.clone(), moq_net::transport::Address::Uri); async move { let _ = moq_net::Client::new() + .with_versions("moq-lite-02".parse::().unwrap().into()) .connect(moq_net::time::Instant::now(), recorder) .await; } @@ -2470,7 +2472,7 @@ mod tests { let bytes = std::mem::take(&mut *recorder.written.lock().unwrap()); assert!(!bytes.is_empty(), "the client never wrote its SETUP"); - let mut peer = Scripted::new("moq-00"); + let mut peer = Scripted::new("moql"); peer.setup = Some(bytes.into()); let timeout = std::time::Duration::from_secs(10); let setup = Setup { diff --git a/rs/moq-tokio/tests/integration/broadcast.rs b/rs/moq-tokio/tests/integration/broadcast.rs index b5c4e2cc277..d6cbe68f8ef 100644 --- a/rs/moq-tokio/tests/integration/broadcast.rs +++ b/rs/moq-tokio/tests/integration/broadcast.rs @@ -470,18 +470,6 @@ async fn transport_fetch_roundtrip(version: &str) { .expect("server task failed"); } -#[tracing_test::traced_test] -#[tokio::test] -async fn fetch_moq_transport_14() { - transport_fetch_roundtrip("moq-transport-14").await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn fetch_moq_transport_16() { - transport_fetch_roundtrip("moq-transport-16").await; -} - #[tracing_test::traced_test] #[tokio::test] async fn fetch_moq_transport_18() { @@ -1560,30 +1548,6 @@ async fn broadcast_moq_lite_07() { broadcast_test("moqt", Some("moq-lite-07-wip"), Some("moq-lite-07-wip")).await; } -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_moq_transport_14() { - broadcast_test("moqt", Some("moq-transport-14"), Some("moq-transport-14")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_moq_transport_15() { - broadcast_test("moqt", Some("moq-transport-15"), Some("moq-transport-15")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_moq_transport_16() { - broadcast_test("moqt", Some("moq-transport-16"), Some("moq-transport-16")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_moq_transport_17() { - broadcast_test("moqt", Some("moq-transport-17"), Some("moq-transport-17")).await; -} - #[tracing_test::traced_test] #[tokio::test] async fn broadcast_moq_transport_18() { @@ -1616,30 +1580,6 @@ async fn broadcast_negotiate_server_all_client_lite_03() { broadcast_test("moqt", Some("moq-lite-03"), None).await; } -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_negotiate_server_all_client_transport_14() { - broadcast_test("moqt", Some("moq-transport-14"), None).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_negotiate_server_all_client_transport_15() { - broadcast_test("moqt", Some("moq-transport-15"), None).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_negotiate_server_all_client_transport_16() { - broadcast_test("moqt", Some("moq-transport-16"), None).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_negotiate_server_all_client_transport_17() { - broadcast_test("moqt", Some("moq-transport-17"), None).await; -} - #[tracing_test::traced_test] #[tokio::test] async fn broadcast_negotiate_server_all_client_transport_18() { @@ -1666,30 +1606,6 @@ async fn broadcast_negotiate_client_all_server_lite_03() { broadcast_test("moqt", None, Some("moq-lite-03")).await; } -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_negotiate_client_all_server_transport_14() { - broadcast_test("moqt", None, Some("moq-transport-14")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_negotiate_client_all_server_transport_15() { - broadcast_test("moqt", None, Some("moq-transport-15")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_negotiate_client_all_server_transport_16() { - broadcast_test("moqt", None, Some("moq-transport-16")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_negotiate_client_all_server_transport_17() { - broadcast_test("moqt", None, Some("moq-transport-17")).await; -} - #[tracing_test::traced_test] #[tokio::test] async fn broadcast_negotiate_client_all_server_transport_18() { @@ -1778,7 +1694,6 @@ async fn broadcast_max_age() { "moq-lite-05", "moq-lite-06", "moq-lite-07-wip", - "moq-transport-17", "moq-transport-18", "moq-transport-22", ] { @@ -1790,12 +1705,7 @@ async fn broadcast_max_age() { #[tokio::test] async fn broadcast_max_age_legacy_omits_property() { - for version in [ - "moq-lite-01", - "moq-transport-14", - "moq-transport-15", - "moq-transport-16", - ] { + for version in ["moq-lite-01"] { assert_eq!( max_age_test(version, Some(Duration::from_secs(30))).await, None, @@ -1830,30 +1740,6 @@ async fn broadcast_webtransport_moq_lite_03() { broadcast_test("https", Some("moq-lite-03"), Some("moq-lite-03")).await; } -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_moq_transport_14() { - broadcast_test("https", Some("moq-transport-14"), Some("moq-transport-14")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_moq_transport_15() { - broadcast_test("https", Some("moq-transport-15"), Some("moq-transport-15")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_moq_transport_16() { - broadcast_test("https", Some("moq-transport-16"), Some("moq-transport-16")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_moq_transport_17() { - broadcast_test("https", Some("moq-transport-17"), Some("moq-transport-17")).await; -} - #[tracing_test::traced_test] #[tokio::test] async fn broadcast_webtransport_moq_transport_18() { @@ -1886,30 +1772,6 @@ async fn broadcast_webtransport_negotiate_server_all_client_lite_03() { broadcast_test("https", Some("moq-lite-03"), None).await; } -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_negotiate_server_all_client_transport_14() { - broadcast_test("https", Some("moq-transport-14"), None).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_negotiate_server_all_client_transport_15() { - broadcast_test("https", Some("moq-transport-15"), None).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_negotiate_server_all_client_transport_16() { - broadcast_test("https", Some("moq-transport-16"), None).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_negotiate_server_all_client_transport_17() { - broadcast_test("https", Some("moq-transport-17"), None).await; -} - #[tracing_test::traced_test] #[tokio::test] async fn broadcast_webtransport_negotiate_server_all_client_transport_18() { @@ -1936,30 +1798,6 @@ async fn broadcast_webtransport_negotiate_client_all_server_lite_03() { broadcast_test("https", None, Some("moq-lite-03")).await; } -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_negotiate_client_all_server_transport_14() { - broadcast_test("https", None, Some("moq-transport-14")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_negotiate_client_all_server_transport_15() { - broadcast_test("https", None, Some("moq-transport-15")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_negotiate_client_all_server_transport_16() { - broadcast_test("https", None, Some("moq-transport-16")).await; -} - -#[tracing_test::traced_test] -#[tokio::test] -async fn broadcast_webtransport_negotiate_client_all_server_transport_17() { - broadcast_test("https", None, Some("moq-transport-17")).await; -} - #[tracing_test::traced_test] #[tokio::test] async fn broadcast_webtransport_negotiate_client_all_server_transport_18() { @@ -3290,7 +3128,7 @@ fn expect_connect_err(result: moq_tokio::Result<(moq_tokio::Client, moq_tokio::C /// observes the URI (and the deadline on versions that carry one) and keeps an /// existing subscription flowing before leaving. /// -/// Real-QUIC coverage matters beyond the mock: on draft-17+ the GOAWAY rides +/// Real-QUIC coverage matters beyond the mock: on moq-transport the GOAWAY rides /// the SETUP uni streams, where dropping the reader mid-session would emit a /// STOP_SENDING a strict peer treats as a protocol violation. async fn goaway_test(scheme: &str, version: &str, expect_wire_timeout: bool) { @@ -3409,7 +3247,7 @@ async fn goaway_test(scheme: &str, version: &str, expect_wire_timeout: bool) { assert_eq!( goaway.timeout(), Some(Duration::from_secs(5)), - "draft-17+ carries the deadline" + "moq-transport carries the deadline" ); } else { assert_eq!(goaway.timeout(), None, "no wire timeout on this version"); @@ -3435,16 +3273,6 @@ async fn goaway_moq_lite_05_webtransport() { goaway_test("https", "moq-lite-05", false).await; } -#[tokio::test] -async fn goaway_moq_transport_14_quic() { - goaway_test("moqt", "moq-transport-14", false).await; -} - -#[tokio::test] -async fn goaway_moq_transport_17_quic() { - goaway_test("moqt", "moq-transport-17", true).await; -} - #[tokio::test] async fn goaway_moq_transport_22_quic() { goaway_test("moqt", "moq-transport-22", true).await; diff --git a/rs/moq-tokio/tests/integration/max_age.rs b/rs/moq-tokio/tests/integration/max_age.rs index fe1233b0aac..6addcf7612f 100644 --- a/rs/moq-tokio/tests/integration/max_age.rs +++ b/rs/moq-tokio/tests/integration/max_age.rs @@ -125,7 +125,7 @@ async fn relay(version: moq_net::Version, javascript: Option) -> anyhow::R #[tokio::test] async fn max_age_relay_rust() { - for version in ["moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + for version in ["moq-lite-07-wip", "moq-transport-18", "moq-transport-22"] { tokio::time::timeout(TIMEOUT, relay(version.parse().unwrap(), None)) .await .unwrap() @@ -136,7 +136,7 @@ async fn max_age_relay_rust() { #[tokio::test] #[ignore = "requires Bun; run by just test max-age in the interop workflow"] async fn max_age_relay_javascript() { - for version in ["moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + for version in ["moq-lite-07-wip", "moq-transport-18", "moq-transport-22"] { for publish in [false, true] { tokio::time::timeout(TIMEOUT, relay(version.parse().unwrap(), Some(publish))) .await diff --git a/rs/moq-tokio/tests/integration/receive_credit.rs b/rs/moq-tokio/tests/integration/receive_credit.rs index 62212443a3e..127afed3e27 100644 --- a/rs/moq-tokio/tests/integration/receive_credit.rs +++ b/rs/moq-tokio/tests/integration/receive_credit.rs @@ -301,7 +301,7 @@ async fn lite_partial_frame_gives_way() { #[tokio::test] async fn ietf_partial_frame_gives_way() { - the_less_urgent_partial_frame_gives_way("moq-transport-16", None).await; + the_less_urgent_partial_frame_gives_way("moq-transport-18", None).await; } /// The same recovery when a slow link with a deep queue sits between them. @@ -609,7 +609,7 @@ async fn lite_largest_unit_completes_under_pressure() { #[tokio::test] async fn ietf_largest_unit_completes_under_pressure() { - the_largest_unit_completes_under_pressure("moq-transport-16").await; + the_largest_unit_completes_under_pressure("moq-transport-18").await; } /// A Bun publisher (`test/receive-credit/sender.ts`) dials a WebTransport server whose @@ -711,7 +711,7 @@ async fn javascript_partial_frame_gives_way(version: &str) { #[tokio::test] #[ignore = "requires Bun; run by just test receive-credit in the interop workflow"] async fn javascript_partial_frame_gives_way_tokio() { - for version in ["moq-lite-06", "moq-transport-16"] { + for version in ["moq-lite-06", "moq-transport-18"] { javascript_partial_frame_gives_way(version).await; } } diff --git a/rs/moq-wasm/src/transport.rs b/rs/moq-wasm/src/transport.rs index 21c34845f6d..6549215f5e6 100644 --- a/rs/moq-wasm/src/transport.rs +++ b/rs/moq-wasm/src/transport.rs @@ -25,12 +25,8 @@ pub struct Options { /// Advertise every version `moq-net` supports, so the peer picks one via ALPN. /// Without this the browser negotiates no subprotocol and the session has no /// version to start from. -/// -/// Draft-14 predates WebTransport subprotocols: `moq-00` is only its QUIC ALPN, and a -/// peer that accepts it here picks draft-14 over a newer draft both support. It rides -/// `moql` (also offered) and negotiates in SETUP instead, as `js/net` does. fn builder() -> ClientBuilder { - ClientBuilder::new().with_protocols(moq_net::ALPNS.iter().copied().filter(|alpn| *alpn != "moq-00")) + ClientBuilder::new().with_protocols(moq_net::ALPNS.iter().copied()) } /// Open a browser WebTransport connection to `url`. diff --git a/rs/qmux/src/alpn.rs b/rs/qmux/src/alpn.rs index 87fe2807fa1..a3b1860eec7 100644 --- a/rs/qmux/src/alpn.rs +++ b/rs/qmux/src/alpn.rs @@ -79,11 +79,11 @@ mod tests { let out = build( [ ("moq-lite-04", &[Version::QMux01][..]), - ("moq-transport-17", &[Version::QMux00][..]), + ("moq-transport-18", &[Version::QMux00][..]), ], true, ); - assert_eq!(out, vec!["qmux-01.moq-lite-04", "qmux-00.moq-transport-17"]); + assert_eq!(out, vec!["qmux-01.moq-lite-04", "qmux-00.moq-transport-18"]); } #[test] @@ -126,8 +126,8 @@ mod tests { (Version::QMux01, Some("moq-lite-04".to_string())) ); assert_eq!( - parse(Some("qmux-00.moq-transport-17")).unwrap(), - (Version::QMux00, Some("moq-transport-17".to_string())) + parse(Some("qmux-00.moq-transport-18")).unwrap(), + (Version::QMux00, Some("moq-transport-18".to_string())) ); } diff --git a/sh/gh/nightly.sh b/sh/gh/nightly.sh index d2344d0d634..c50a5357a18 100755 --- a/sh/gh/nightly.sh +++ b/sh/gh/nightly.sh @@ -134,7 +134,7 @@ case "$1" in done ;; js) - for benchmark in broadcasts request-ids requests fills forward reader frames drift ledger varint lite-varint track resume priority subscribe-drop subscribe-tracks refresh observed compression; do + for benchmark in broadcasts requests fills forward reader frames drift ledger varint lite-varint track resume priority subscribe-drop subscribe-tracks refresh observed compression; do javascript "$benchmark" just js bench "$benchmark" done javascript catalog just js catalog-bench diff --git a/test/ietf-conformance/README.md b/test/ietf-conformance/README.md index 401a23671cd..d0de3ceccf2 100644 --- a/test/ietf-conformance/README.md +++ b/test/ietf-conformance/README.md @@ -6,12 +6,10 @@ builds the Moxygen revision and matching Meta dependencies pinned in `conformance_test.sh` through this checkout's relay. This is separate from the published-package smoke matrix. -Sections 1-7 run on both shared drafts, 16 and 18, through one relay, and on -draft 18 through a two-relay cluster joined by native lite-07. Draft 16 has no -cluster cell: its TRACK\_STATUS\_OK has no Track Properties block, so the -cluster's origin cannot learn the track's priority from it. Each cell +Sections 1-7 run on draft 18, the one draft both support, through one relay +and through a two-relay cluster joined by native lite-07. Each cell runs 29 upstream SUBSCRIBE cases. A direct-origin control runs the same cases -once per draft, separating suite or pin failures from relay behavior. FETCH is +once, separating suite or pin failures from relay behavior. FETCH is skipped using upstream's `SKIP_FETCH`; section 8 uses PUBLISH and is outside the relay's support. Cases using forwarding modes 1 or 2 send nonzero subgroups and are explicitly excluded. Modes 0 and 3 are retained. @@ -22,7 +20,7 @@ these exclusions and failure sensitivity, and refuse a changed script anchor. The publisher advertises the `moq-test-00` prefix. The client subscribes to 16-field namespaces below it. Mock-clock integration tests in [`ietf_conformance.rs`](../../rs/moq-net-tests/tests/integration/ietf_conformance.rs) -prove this routing path on both drafts, through one relay and through two +prove this routing path on draft 18, through one relay and through two relays joined with native lite-07. The live harness waits for that prefix at the relay before starting cases and rejects upstream publisher reconnects, so a recovered session cannot @@ -61,7 +59,6 @@ direct-origin and one-relay cells: | Draft | Direct origin | One relay | Two relays | | --- | --- | --- | --- | -| 16 | 29 pass | 29 pass | not run | | 18 | 29 pass | 29 pass | 29 pass | ## Local Moxygen patches diff --git a/test/ietf-conformance/run.sh b/test/ietf-conformance/run.sh index 0b17af77f82..4135ed8f175 100755 --- a/test/ietf-conformance/run.sh +++ b/test/ietf-conformance/run.sh @@ -112,45 +112,40 @@ run_suite() { fi } -# The pinned MoQVersions.h supports drafts 16 and 18; this relay supports both. +# The pinned MoQVersions.h supports drafts 16 and 18; this relay supports only 18. failed=0 -for version in 16 18; do - # A draft-16 TRACK_STATUS_OK has no Track Properties, so a cluster's origin - # cannot learn the track's priority from it; draft 16 is not a cluster target. - topologies=(single) - [[ "$version" == 16 ]] || topologies+=(cluster) - for topology in "${topologies[@]}"; do - label="$topology-$version" - start_relay "$label-origin" "$version" - ingress="$HARNESS_PORT" - ingress_pid="$HARNESS_PID" - egress="$ingress" - egress_pid="" - if [[ "$topology" == cluster ]]; then - start_relay "$label-edge" "$version" "$ingress" - egress="$HARNESS_PORT" - egress_pid="$HARNESS_PID" - fi - harness_port "$label-publisher" - publisher_port="$HARNESS_PORT" - harness_spawn "$label-publisher" "$HARNESS_RUN/$label-publisher.log" \ - "$package/bin/moqtest_server" --port="$HARNESS_PORT" \ - --cert="$HARNESS_RUN/cert.pem" --key="$HARNESS_RUN/key.pem" \ - --relay_url="https://127.0.0.1:$ingress/" --versions="$version" --transport=quic --logging=INFO - publisher_pid="$HARNESS_PID" - namespace_ready - # A direct-origin control distinguishes suite/pin failures from relay behavior. - if [[ "$topology" == single ]]; then - run_suite "reference-$version" "https://127.0.0.1:$publisher_port/test" - fi - run_suite "$label" "https://127.0.0.1:$egress/" - harness_reap "$publisher_pid" - [[ -z "$egress_pid" ]] || harness_reap "$egress_pid" - harness_reap "$ingress_pid" - done +version=18 +for topology in single cluster; do + label="$topology-$version" + start_relay "$label-origin" "$version" + ingress="$HARNESS_PORT" + ingress_pid="$HARNESS_PID" + egress="$ingress" + egress_pid="" + if [[ "$topology" == cluster ]]; then + start_relay "$label-edge" "$version" "$ingress" + egress="$HARNESS_PORT" + egress_pid="$HARNESS_PID" + fi + harness_port "$label-publisher" + publisher_port="$HARNESS_PORT" + harness_spawn "$label-publisher" "$HARNESS_RUN/$label-publisher.log" \ + "$package/bin/moqtest_server" --port="$HARNESS_PORT" \ + --cert="$HARNESS_RUN/cert.pem" --key="$HARNESS_RUN/key.pem" \ + --relay_url="https://127.0.0.1:$ingress/" --versions="$version" --transport=quic --logging=INFO + publisher_pid="$HARNESS_PID" + namespace_ready + # A direct-origin control distinguishes suite/pin failures from relay behavior. + if [[ "$topology" == single ]]; then + run_suite "reference-$version" "https://127.0.0.1:$publisher_port/test" + fi + run_suite "$label" "https://127.0.0.1:$egress/" + harness_reap "$publisher_pid" + [[ -z "$egress_pid" ]] || harness_reap "$egress_pid" + harness_reap "$ingress_pid" done if ((failed != 0)); then echo "Moxygen conformance failed; see each cell's client and endpoint logs" >&2 exit 1 fi -echo "Moxygen conformance: every draft and topology passed" +echo "Moxygen conformance: draft 18 passed through every topology" diff --git a/test/interop/README.md b/test/interop/README.md index 36670919acd..3330a443373 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -183,7 +183,7 @@ delivers them as minted: a publisher is granted its exact broadcast, a subscribe `--versions` runs the matrix and the tails once per version, against the same relay. `auto`, the default, is everything above. Any other version pins every client in its pass to offer that version alone, and `ietf` expands to the even -drafts `moq-transport-14` through `moq-transport-22`. A pinned pass keeps the +drafts `moq-transport-18` through `moq-transport-22`. A pinned pass keeps the media cells, the relay close check, the CLI FETCH of group 0, and the finite tails. The grant, refusal, close-code, and drop cases are moq-lite's and run in `auto` alone. @@ -202,14 +202,8 @@ The in-tree control lanes also run with `--tail`: rather than best-effort burst capacity. The publisher stays open until the read is acknowledged, keeping a reliable track end from overtaking the datagrams. - A JS wire requester gets the Rust publisher's exact largest location through - TRACK\_STATUS on every draft. From draft 18, Rust also queries the JS publisher's + TRACK\_STATUS on every draft. Rust also queries the JS publisher's non-default priority before subscribing, when the answer can describe a track. -- On drafts 14 and 16, a JS publisher advertises one usable track slot beyond namespace discovery, holds the first - Rust subscription open through a data round trip while a second is queued, and - grants credit when the first is cancelled. Sending the second without waiting - violates the peer adapter's live-request limit. A JS subscriber runs the same - held-first/queued-second exchange over QMux against a controlled JS peer, whose - grant and replenishment codecs are also exercised by the Rust subscriber. - On drafts 18, 20, and 22, a Rust GOAWAY names a second WebSocket endpoint serving the same publisher epoch. The JS reconnect loop retains its reader across the move. A separate read through the replacement proves it serves data before the @@ -217,13 +211,9 @@ The in-tree control lanes also run with `--tail`: The reverse JS-server-to-Rust-client case uses a marker broadcast excluded from the old endpoint's scope, then cuts that endpoint before publishing the final group. Both directions use the original publisher stream. -- Drafts 14 and 16 carry no epoch, so nothing proves the replacement is the same - publisher. The same GOAWAY lanes there check that the held reader ends with the - original transport instead, and that requesting the path again subscribes - through the new URI and reads the final group. The standalone control endpoints use OS-assigned ports and production QMux, -SETUP codecs, and request adapters. They verify their negotiated version directly. +SETUP codecs, and request sessions. They verify their negotiated version directly. The datagram and query lanes through moq-relay use its usual version-log check. Some cases change shape on moq-transport: @@ -232,7 +222,7 @@ Some cases change shape on moq-transport: so a tail reader started before the publisher's groups may begin past group 0. The tails take any contiguous run of groups through group 3, each verified byte for byte, and still require the clean end at 4. -- The AUTH extension (draft-17 on) carries prefixes alone, and a relay refuses a +- The AUTH extension carries prefixes alone, and a relay refuses a grant narrower than a subtree, closing the session as Unauthorized. Pinned passes mint `name/**` for both roles instead of the exact and `**/name` patterns above. @@ -263,7 +253,7 @@ just test interop --tail just test interop --all --versions ietf # Or pick the versions, `auto` being the default moq-lite run. -just test interop --versions auto,moq-transport-14,moq-transport-22 +just test interop --versions auto,moq-transport-18,moq-transport-22 # Subscription termination: Rust/JS response bytes over in-memory transports. just test bare-fin @@ -455,7 +445,7 @@ playback delay, and one page sample plus one driver poll. Publisher preparation is not part of it. The CLI integration suite's `epochs` cases exercise the real QUIC relay with -lite-07, lite-06, and IETF drafts 14 and 22. Held catalog snapshots and media +lite-07, lite-06, and IETF draft 22. Held catalog snapshots and media subscriptions keep A while fresh catalog handles resolve B with fresh sibling pins. Later metadata and FETCH work cannot join B through A's retired handle; withdrawing B reveals the still-live A to fresh readers. Announcement followers @@ -520,7 +510,7 @@ clients/ `.github/workflows/interop.yml` runs the full matrix nightly (and on demand, and on release PRs that touch `test/interop/`), then again with `--versions ietf`. A -release PR's moq-transport step runs only drafts 14 and 22 between Rust and native +release PR's moq-transport step runs only drafts 18 and 22 between Rust and native JS. A red cell means a real interop break in the current tree. The scheduled run on `main` publishes its report to ; diff --git a/test/interop/bare-fin.ts b/test/interop/bare-fin.ts index fc1c74e3902..2448935fe64 100644 --- a/test/interop/bare-fin.ts +++ b/test/interop/bare-fin.ts @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import { ProtocolViolation } from "../../js/net/src/error.ts"; import { randomHop } from "../../js/net/src/hop.ts"; -import { NativeSession } from "../../js/net/src/ietf/adapter.ts"; import { PublishDone } from "../../js/net/src/ietf/publish.ts"; +import { NativeSession } from "../../js/net/src/ietf/session.ts"; import { Subscribe as IetfSubscribe, SubscribeOk } from "../../js/net/src/ietf/subscribe.ts"; import { Subscriber as IetfSubscriber } from "../../js/net/src/ietf/subscriber.ts"; import { Version as IetfVersion } from "../../js/net/src/ietf/version.ts"; @@ -62,9 +62,9 @@ if (version !== undefined) { peer = await Stream.accept(pair.server, version); assert(peer); assert.equal(await peer.reader.u53(), IetfSubscribe.id); - const request = await IetfSubscribe.decode(peer.reader, version); + await IetfSubscribe.decode(peer.reader, version); await peer.writer.u53(SubscribeOk.id); - await new SubscribeOk({ requestId: request.requestId, trackAlias: 0n }).encode(peer.writer, version); + await new SubscribeOk({ trackAlias: 0n }).encode(peer.writer, version); if (input.clean) { await output.u53(PublishDone.id); await new PublishDone({ statusCode: 0x2, streamCount: 0n, reasonPhrase: "done" }).encode(output, version); diff --git a/test/interop/clients/js-native/control.ts b/test/interop/clients/js-native/control.ts index 5db6b2776d7..11ddda7b9f4 100644 --- a/test/interop/clients/js-native/control.ts +++ b/test/interop/clients/js-native/control.ts @@ -1,116 +1,47 @@ -/** Real JS wire peer for metadata queries and a deliberately small legacy request window. */ +/** Real JS wire peer for a TRACK_STATUS query. */ import assert from "node:assert/strict"; -import { createInterface } from "node:readline"; import * as Moq from "@moq/net"; import { offered } from "../../../../js/net/src/connection/extensions.ts"; import { exchangeSetup } from "../../../../js/net/src/connection/handshake.ts"; -import { ControlStreamAdapter, NativeSession } from "../../../../js/net/src/ietf/adapter.ts"; -import { Connection } from "../../../../js/net/src/ietf/connection.ts"; -import * as Ietf from "../../../../js/net/src/ietf/index.ts"; +import type * as Ietf from "../../../../js/net/src/ietf/index.ts"; +import { NativeSession } from "../../../../js/net/src/ietf/session.ts"; import { TrackStatusOk, TrackStatusRequest } from "../../../../js/net/src/ietf/track.ts"; -import * as Lite from "../../../../js/net/src/lite/index.ts"; -import { Stream } from "../../../../js/net/src/stream.ts"; import Qmux from "../../../../js/qmux/src/index.ts"; console.debug = console.error; console.info = console.error; -const [mode, address, name] = process.argv.slice(2); -assert(address && name && ["status", "credit"].includes(mode), "usage: control.ts status|credit URL VERSION"); +const [address, name] = process.argv.slice(2); +assert(address && name, "usage: control.ts URL VERSION"); const version = (0xff000000 + Number(name.split("-").at(-1))) as Ietf.IetfVersion; -const legacy = version === Ietf.Version.DRAFT_14 || version === Ietf.Version.DRAFT_16; -const protocol = version === Ietf.Version.DRAFT_14 ? "moql" : `moqt-${Number(version) - 0xff000000}`; -const url = new URL(address); -const quic = new Qmux(url, { +const protocol = `moqt-${Number(version) - 0xff000000}`; +const quic = new Qmux(new URL(address), { protocols: [protocol], - versions: { [protocol]: legacy ? "qmux-00" : "qmux-01" }, + versions: { [protocol]: "qmux-01" }, requireProtocol: true, }); await quic.ready; const extensions = offered({ auth: false, epoch: false, solicit: false }); -// The ordinary client offers a large window. This peer deliberately advertises one usable track -// slot: draft 16 also holds namespace discovery open on its native request stream. -const window = mode === "credit" ? (version === Ietf.Version.DRAFT_16 ? 2n : 1n) : Ietf.REQUEST_WINDOW; -let control: Stream; -let maxRequestId = 0n; -if (legacy) { - // Reuse the production codecs and adapter. Only the small SETUP grant differs from connect(). - control = await Stream.open(quic, { version }); - await control.writer.u53(Lite.StreamId.ClientCompat); - const parameters = new Ietf.SetupOptions(); - parameters.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(false, window)); - await new Ietf.ClientSetup({ versions: [version], parameters }).encode(control.writer, version); - assert.equal(await control.reader.u53(), Lite.StreamId.ServerCompat); - const setup = await Ietf.ServerSetup.decode(control.reader, version); - assert.equal(setup.version, version, "wrong negotiated draft"); - maxRequestId = setup.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n; -} else { - ({ control } = await exchangeSetup(quic, version, "interop-control", { client: true, extensions })); -} +await exchangeSetup(quic, version, "interop-control", { client: true, extensions }); -if (mode === "status") { - const session = legacy - ? new ControlStreamAdapter(quic, control, version, maxRequestId, true) - : new NativeSession(quic, version, true); - const driver = session instanceof ControlStreamAdapter ? session.run() : undefined; - try { - const requestId = await session.nextRequestId(); - assert(requestId !== undefined); - const stream = await session.openBi(); - await stream.writer.u53(TrackStatusRequest.id); - await new TrackStatusRequest({ - requestId, - trackNamespace: Moq.Path.from("control"), - trackName: "video", - }).encode(stream.writer, version); - assert.equal(await stream.reader.u53(), TrackStatusOk.id(version), "TRACK_STATUS was not answered"); - const status = await TrackStatusOk.decode(stream.reader, version); - if (legacy) assert.equal(status.requestId, requestId); - assert.deepEqual(status.largest, { groupId: 4n, objectId: 1n }, "wrong publisher location"); - // Rust publishes priority 2, which moq-transport carries as 127 - 2. - if (!legacy) assert.equal(status.properties.priority, 125, "wrong publisher priority"); - stream.close(); - console.error("wire track status answered"); - } finally { - session.close(); - quic.close(); - await driver?.catch(() => {}); - } -} else { - assert(legacy, "request credit only exists on drafts 14 and 16"); - const origin = new Moq.Origin.Producer(); - const broadcastPublication = origin.createBroadcast(Moq.Path.from("control")); - const broadcast = broadcastPublication.broadcast; - const first = broadcast.createTrack("first"); - const second = broadcast.createTrack("second"); - broadcastPublication.announce(); - const connection = new Connection({ - url, - quic, - control, - version, - maxRequestId, - client: true, - publish: origin.consume(), - requestWindow: window, - extensions, - }); - const lines = createInterface({ input: process.stdin }); - const commands = lines[Symbol.asyncIterator](); - try { - assert.equal((await commands.next()).value, "probe", "missing queued-request barrier"); - const group = first.appendGroup(); - group.writeFrame({ payload: new Uint8Array([1]) }); - group.finish(); - const demand = second.demand(); - while (demand.wanted.peek() === undefined) await demand.wanted.changed(); - const next = second.appendGroup(); - next.writeFrame({ payload: new Uint8Array([2]) }); - next.finish(); - assert.equal((await commands.next()).value, "done"); - console.error("request credit resumed"); - } finally { - lines.close(); - connection.abort(); - origin.close(); - } +const session = new NativeSession(quic, version, true); +try { + const requestId = await session.nextRequestId(); + assert(requestId !== undefined); + const stream = await session.openBi(); + await stream.writer.u53(TrackStatusRequest.id); + await new TrackStatusRequest({ + requestId, + trackNamespace: Moq.Path.from("control"), + trackName: "video", + }).encode(stream.writer, version); + assert.equal(await stream.reader.u53(), TrackStatusOk.id, "TRACK_STATUS was not answered"); + const status = await TrackStatusOk.decode(stream.reader, version); + assert.deepEqual(status.largest, { groupId: 4n, objectId: 1n }, "wrong publisher location"); + // Rust publishes priority 2, which moq-transport carries as 127 - 2. + assert.equal(status.properties.priority, 125, "wrong publisher priority"); + stream.close(); + console.error("wire track status answered"); +} finally { + session.close(); + quic.close(); } diff --git a/test/interop/clients/js-native/goaway.ts b/test/interop/clients/js-native/goaway.ts index 4207e5ee912..16e36a0eac4 100644 --- a/test/interop/clients/js-native/goaway.ts +++ b/test/interop/clients/js-native/goaway.ts @@ -1,4 +1,4 @@ -/** Follow a Rust server's GOAWAY redirect: retain the reader where an epoch proves continuity, else re-subscribe. */ +/** Follow a Rust server's GOAWAY redirect, retaining the reader since the epoch proves continuity. */ import assert from "node:assert/strict"; import { Milli } from "@moq/clock"; import * as Moq from "@moq/net"; @@ -9,9 +9,6 @@ console.debug = console.error; console.info = console.error; const [first, second, version] = process.argv.slice(2); assert(first && second && version, "usage: goaway.ts FIRST_URL SECOND_URL VERSION"); -// Drafts 14 and 16 carry no epoch, so nothing proves the replacement serves the same -// publisher: the held reader ends with the old route and the application requests again. -const legacy = version === "moq-transport-14" || version === "moq-transport-16"; const origin = new Moq.Origin.Producer(); const connection = new Reload({ url: new URL(first), @@ -31,19 +28,9 @@ async function active(request: (typeof requests)[number]) { return broadcast; } try { - let track = (await active(requests[0])).track("video").subscribe({ groups: { start: { included: 0 } } }); + const track = (await active(requests[0])).track("video").subscribe({ groups: { start: { included: 0 } } }); try { for (let sequence = 0; sequence < 3; sequence++) { - if (sequence === 2 && legacy) { - assert.equal(await track.recvGroup(), undefined, "an epochless route spliced the held reader"); - track.close(); - const request = origin.request(Moq.Path.from("handover"), { announced: true }); - requests.push(request); - track = (await active(request)).track("video").subscribe({ groups: { start: { included: 2 } } }); - await track.info(); - // The server only writes group 2 after this, and only the replacement remains. - process.stdout.write("resubscribed\n"); - } const group = await track.recvGroup(); assert(group, "subscription ended during redirect"); try { @@ -74,20 +61,17 @@ try { } // A direct read from the replacement proves its SUBSCRIBE/data path is ready, // beyond SETUP or namespace discovery, while the original reader stays held. - // The legacy re-subscribe proves that itself, with its own SUBSCRIBE. - if (!legacy) { - replacementProbe = wireOf(replacement) - .consume(Moq.Path.from("handover")) - .track("video") - .subscribe({ groups: { start: { included: 1 } } }); - const probe = await replacementProbe.recvGroup(); - assert(probe, "replacement has no group"); - try { - assert.equal(probe.sequence, 1, "replacement served the wrong group"); - assert.deepEqual((await probe.readFrame())?.payload, new Uint8Array(32).fill(1)); - } finally { - probe.close(); - } + replacementProbe = wireOf(replacement) + .consume(Moq.Path.from("handover")) + .track("video") + .subscribe({ groups: { start: { included: 1 } } }); + const probe = await replacementProbe.recvGroup(); + assert(probe, "replacement has no group"); + try { + assert.equal(probe.sequence, 1, "replacement served the wrong group"); + assert.deepEqual((await probe.readFrame())?.payload, new Uint8Array(32).fill(1)); + } finally { + probe.close(); } process.stdout.write("replacement ready\n"); } diff --git a/test/interop/clients/js-native/server.ts b/test/interop/clients/js-native/server.ts index d98bfb74ded..24c6d03f57c 100644 --- a/test/interop/clients/js-native/server.ts +++ b/test/interop/clients/js-native/server.ts @@ -1,31 +1,24 @@ -/** Controlled real QMux peers for reverse GOAWAY and the JS subscriber's credit wait. */ +/** Controlled real QMux peers for reverse GOAWAY. */ import assert from "node:assert/strict"; import { createInterface } from "node:readline"; -import { Milli } from "@moq/clock"; import * as Moq from "@moq/net"; import * as Extensions from "../../../../js/net/src/connection/extensions.ts"; import { exchangeSetup } from "../../../../js/net/src/connection/handshake.ts"; import { Connection } from "../../../../js/net/src/ietf/connection.ts"; import { GoAway } from "../../../../js/net/src/ietf/goaway.ts"; import * as Ietf from "../../../../js/net/src/ietf/index.ts"; -import * as Lite from "../../../../js/net/src/lite/index.ts"; -import { Stream } from "../../../../js/net/src/stream.ts"; import Qmux, { selectSubprotocol } from "../../../../js/qmux/src/index.ts"; console.debug = console.error; console.info = console.error; const [mode, name] = process.argv.slice(2); -assert(name && ["goaway", "credit"].includes(mode), "usage: server.ts goaway|credit VERSION"); +assert(name && mode === "goaway", "usage: server.ts goaway VERSION"); const version = (0xff000000 + Number(name.split("-").at(-1))) as Ietf.IetfVersion; -const legacy = version === Ietf.Version.DRAFT_14 || version === Ietf.Version.DRAFT_16; -// JS offers draft 14 behind `moql`, negotiated in SETUP; Rust offers its own `moq-00` ALPN. -const alpns = version === Ietf.Version.DRAFT_14 ? ["moql", "moq-00"] : [`moqt-${Number(version) - 0xff000000}`]; +const alpns = [`moqt-${Number(version) - 0xff000000}`]; const extensions = Extensions.offered(); const origin = new Moq.Origin.Producer(); const broadcastPublication = origin.createBroadcast(Moq.Path.from("handover")); const broadcast = broadcastPublication.broadcast; -const firstTrack = broadcast.createTrack("first"); -const secondTrack = broadcast.createTrack("second"); const video = broadcast.createTrack("video"); broadcastPublication.announce({ epoch: Moq.Epoch.mint() }); const markerPublication = origin.createBroadcast(Moq.Path.from("replacement")); @@ -43,15 +36,15 @@ type Socket = { end?: (code: number, reason: string) => void; }; -function listen(publish: Moq.Origin.Consumer, window?: bigint) { - const accepted = Promise.withResolvers<{ connection: Connection; control: Stream }>(); +function listen(publish: Moq.Origin.Consumer) { + const accepted = Promise.withResolvers>>(); const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request, server) { const protocol = selectSubprotocol(request.headers.get("sec-websocket-protocol"), { protocols: alpns, - versions: Object.fromEntries(alpns.map((alpn) => [alpn, legacy ? "qmux-00" : "qmux-01"])), + versions: Object.fromEntries(alpns.map((alpn) => [alpn, "qmux-01"])), requireProtocol: true, }); if (!protocol) return new Response("no protocol", { status: 400 }); @@ -92,7 +85,7 @@ function listen(publish: Moq.Origin.Consumer, window?: bigint) { }, { protocol: socket.data.protocol }, ); - void accept(quic, new URL(socket.data.url), publish, window).then(accepted.resolve, accepted.reject); + void accept(quic, new URL(socket.data.url), publish).then(accepted.resolve, accepted.reject); }, message(socket, data) { assert(typeof data !== "string"); @@ -106,138 +99,56 @@ function listen(publish: Moq.Origin.Consumer, window?: bigint) { return { server, url: `http://127.0.0.1:${server.port}/`, accepted: accepted.promise }; } -async function accept(quic: WebTransport, url: URL, publish: Moq.Origin.Consumer, window?: bigint) { - if (!legacy) { - const setup = await exchangeSetup(quic, version, "interop-js-server", { client: false, extensions }); - return { - control: setup.control, - connection: new Connection({ - url, - quic, - version, - client: false, - maxRequestId: 0n, - publish, - extensions, - ...setup, - }), - }; - } - const control = await Stream.accept(quic, version); - assert(control, "missing SETUP"); - assert.equal(await control.reader.u53(), Lite.StreamId.ClientCompat); - const client = await Ietf.ClientSetup.decode(control.reader, version); - const parameters = new Ietf.SetupOptions(); - parameters.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(true, window)); - Extensions.intoSetup(parameters, extensions, version); - await control.writer.u53(Lite.StreamId.ServerCompat); - await new Ietf.ServerSetup({ version, parameters }).encode(control.writer, version); +async function accept(quic: WebTransport, url: URL, publish: Moq.Origin.Consumer) { + const setup = await exchangeSetup(quic, version, "interop-js-server", { client: false, extensions }); return { - control, + control: setup.control, connection: new Connection({ url, quic, - control, version, client: false, publish, extensions, - requestWindow: window, - maxRequestId: client.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n, - solicit: Ietf.solicitFromSetup(client.parameters), + ...setup, }), }; } -if (mode === "credit") { - assert(legacy, "request credit exists on drafts 14 and 16"); - // Namespace discovery holds one slot; the first track holds the other. - const peer = listen(origin.consume(), 2n); - const received = new Moq.Origin.Producer(); - const client = await Moq.Connection.connect({ - url: new URL(peer.url), - versions: [name], - websocket: { delay: Milli(0) }, - consume: received, - }); - const server = await peer.accepted; - const request = received.request(Moq.Path.from("handover"), { announced: true }); - try { - let remote = request.active.peek(); - while (!remote) { - await request.active.changed(); - remote = request.active.peek(); - } - const first = remote.track("first").subscribe(); - await first.info(); - const second = remote.track("second").subscribe(); - const queued = second.info(); - try { - const probe = firstTrack.appendGroup(); - probe.writeFrame({ payload: new Uint8Array([1]) }); - probe.finish(); - const data = await first.recvGroup(); - assert(data); - assert.deepEqual((await data.readFrame())?.payload, new Uint8Array([1])); - data.close(); - first.close(); - await queued; - const next = secondTrack.appendGroup(); - next.writeFrame({ payload: new Uint8Array([2]) }); - next.finish(); - const resumed = await second.recvGroup(); - assert(resumed); - assert.deepEqual((await resumed.readFrame())?.payload, new Uint8Array([2])); - resumed.close(); - console.error("js request credit resumed"); - } finally { - first.close(); - second.close(); - } - } finally { - request.close(); - await client.close(); - server.connection.abort(); - peer.server.stop(true); - received.close(); - origin.close(); - } -} else { - const scoped = origin.scope(Moq.Path.empty(), new Moq.Path.Patterns([Moq.Path.Pattern.literal("handover")])); - const first = listen(scoped.consume()); - const second = listen(origin.consume()); - process.stdout.write(`${JSON.stringify([first.url, second.url])}\n`); - const lines = createInterface({ input: process.stdin }); - const commands = lines[Symbol.asyncIterator](); - let initial: Awaited | undefined; - let replacement: Awaited | undefined; - try { - initial = await first.accepted; - const demand = video.demand(); - while (demand.wanted.peek() === undefined) await demand.wanted.changed(); - const group = video.appendGroup(); - group.writeFrame({ payload: new Uint8Array(32) }); - group.finish(); - assert.equal((await commands.next()).value, "first group"); - await initial.control.writer.u53(GoAway.id); - await new GoAway({ - newSessionUri: second.url, - requestId: version === Ietf.Version.DRAFT_18 ? 0n : undefined, - }).encode(initial.control.writer, version); - replacement = await second.accepted; - assert.equal((await commands.next()).value, "replacement ready"); - initial.connection.abort(); - const next = video.appendGroup(); - next.writeFrame({ payload: new Uint8Array(32).fill(1) }); - next.finish(); - assert.equal((await commands.next()).value, "done"); - console.error("js goaway redirect resumed"); - } finally { - lines.close(); - initial?.connection.abort(); - replacement?.connection.abort(); - first.server.stop(true); - second.server.stop(true); - origin.close(); - } +const scoped = origin.scope(Moq.Path.empty(), new Moq.Path.Patterns([Moq.Path.Pattern.literal("handover")])); +const first = listen(scoped.consume()); +const second = listen(origin.consume()); +process.stdout.write(`${JSON.stringify([first.url, second.url])}\n`); +const lines = createInterface({ input: process.stdin }); +const commands = lines[Symbol.asyncIterator](); +let initial: Awaited | undefined; +let replacement: Awaited | undefined; +try { + initial = await first.accepted; + const demand = video.demand(); + while (demand.wanted.peek() === undefined) await demand.wanted.changed(); + const group = video.appendGroup(); + group.writeFrame({ payload: new Uint8Array(32) }); + group.finish(); + assert.equal((await commands.next()).value, "first group"); + await initial.control.writer.u53(GoAway.id); + await new GoAway({ + newSessionUri: second.url, + requestId: version === Ietf.Version.DRAFT_18 ? 0n : undefined, + }).encode(initial.control.writer, version); + replacement = await second.accepted; + assert.equal((await commands.next()).value, "replacement ready"); + initial.connection.abort(); + const next = video.appendGroup(); + next.writeFrame({ payload: new Uint8Array(32).fill(1) }); + next.finish(); + assert.equal((await commands.next()).value, "done"); + console.error("js goaway redirect resumed"); +} finally { + lines.close(); + initial?.connection.abort(); + replacement?.connection.abort(); + first.server.stop(true); + second.server.stop(true); + origin.close(); } diff --git a/test/interop/clients/rust/control.rs b/test/interop/clients/rust/control.rs index f56217d3279..c99b47cd7a9 100644 --- a/test/interop/clients/rust/control.rs +++ b/test/interop/clients/rust/control.rs @@ -1,41 +1,31 @@ -//! Real Rust server for a JS TRACK_STATUS requester and small-window publisher. -use std::process::Stdio; - +//! Real Rust server for a JS TRACK_STATUS requester. use anyhow::{Context, ensure}; -use futures::FutureExt; use moq_tokio::moq_net; -use tokio::io::AsyncWriteExt; #[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { let args: Vec<_> = std::env::args().collect(); - ensure!(args.len() == 4, "usage: interop-control status|credit VERSION node|bun"); + ensure!( + args.len() == 4 && args[1] == "status", + "usage: interop-control status VERSION node|bun" + ); let version: moq_net::Version = args[2].parse().map_err(anyhow::Error::msg)?; let origin = moq_tokio::origin::spawn(); - let consumer = origin.consume(); let broadcast = origin.create_broadcast("control")?; - let _published = if args[1] == "status" { - let track = broadcast.create_track("video", moq_net::track::Info::default().with_priority(2))?; - let mut group = track.create_group(moq_net::group::Info { sequence: 4 })?; - for byte in [1, 2] { - group.write_frame(None, vec![byte])?; - } - group.finish()?; - broadcast.announce(Default::default())?; - Some(track) - } else { - None - }; + let track = broadcast.create_track("video", moq_net::track::Info::default().with_priority(2))?; + let mut group = track.create_group(moq_net::group::Info { sequence: 4 })?; + for byte in [1, 2] { + group.write_frame(None, vec![byte])?; + } + group.finish()?; + broadcast.announce(Default::default())?; + let socket = moq_tokio::websocket::Listener::bind("127.0.0.1:0".parse()?).await?; let url = format!("http://{}/", socket.local_addr()?); let mut config = moq_tokio::server::Config::default(); config.listen.version.push(version); config.websocket = Some(socket); - if args[1] == "status" { - config.publisher = Some(origin.consume()); - } else { - config.subscriber = Some(origin); - } + config.publisher = Some(origin.consume()); let mut listener = config.init_streams()?.listen().await?; let mut command = tokio::process::Command::new(&args[3]); if args[3] == "node" { @@ -47,47 +37,17 @@ async fn main() -> anyhow::Result<()> { env!("CARGO_MANIFEST_DIR"), "/../../test/interop/clients/js-native/control.ts" ), - &args[1], &url, &args[2], ]) - .stdin(Stdio::piped()) .kill_on_drop(true) .spawn()?; let session = listener.accept().await.context("accept")?.ok().await?; ensure!(session.version() == version, "wrong negotiated version"); - if args[1] == "credit" { - // The JS peer has one usable track slot beyond any namespace discovery. - let remote = consumer.routed_broadcast("control").await?; - let mut first = remote.track("first")?.subscribe(None).await?; - let second = remote.track("second")?; - let mut pending = Box::pin(second.subscribe(None)); - ensure!( - pending.as_mut().now_or_never().is_none(), - "second track bypassed request credit" - ); - let input = child.stdin.as_mut().context("child stdin")?; - input.write_all(b"probe\n").await?; - // This round trip keeps the first request alive while the second is queued. - // Sending it without credit would close the JS peer before UNSUBSCRIBE can free a slot. - let mut group = first.recv_group().await?.context("probe group")?; - ensure!( - group.read_frame().await?.context("probe frame")?.payload.as_ref() == [1], - "wrong probe" - ); - drop(group); - drop(first); - let mut second = pending.await?; - let mut group = second.recv_group().await?.context("second group")?; - ensure!( - group.read_frame().await?.context("second frame")?.payload.as_ref() == [2], - "wrong resumed track" - ); - input.write_all(b"done\n").await?; - } ensure!(child.wait().await?.success(), "JS peer failed"); session.abort(moq_net::Error::Cancel); let _ = session.closed().await; - eprintln!("{} interop passed {version}", args[1]); + drop(track); + eprintln!("status interop passed {version}"); Ok(()) } diff --git a/test/interop/clients/rust/goaway-client.rs b/test/interop/clients/rust/goaway-client.rs index 5984d6bb272..781d360ba69 100644 --- a/test/interop/clients/rust/goaway-client.rs +++ b/test/interop/clients/rust/goaway-client.rs @@ -1,4 +1,4 @@ -//! Follow a real JS QMux server's redirect: retain the reader with an epoch, else re-subscribe. +//! Follow a real JS QMux server's redirect, retaining the reader across the epoch-proven handover. use std::process::Stdio; use anyhow::{Context, ensure}; @@ -40,23 +40,10 @@ async fn main() -> anyhow::Result<()> { connection.version() == Some(version.parse().map_err(anyhow::Error::msg)?), "wrong negotiated version" ); - // Drafts 14 and 16 carry no epoch, so nothing proves the replacement serves the same - // publisher: the held reader ends with the old route and the application requests again. - let legacy = ["moq-transport-14", "moq-transport-16"].contains(&version.as_str()); let broadcast = consumer.routed_broadcast("handover").await?; let mut track = broadcast.track("video")?.subscribe(None).await?; let input = child.stdin.as_mut().context("stdin")?; for sequence in 0..2 { - if sequence == 1 && legacy { - ensure!( - !matches!(track.recv_group().await, Ok(Some(_))), - "an epochless route spliced the held reader" - ); - // The server wrote this group just after cutting the original, so only the - // replacement, already its largest, can serve it. - let broadcast = consumer.routed_broadcast("handover").await?; - track = broadcast.track("video")?.subscribe(None).await?; - } let mut group = track.recv_group().await?.context("reader ended during GOAWAY")?; ensure!(group.sequence == sequence, "wrong group after migration"); ensure!( diff --git a/test/interop/clients/rust/goaway.rs b/test/interop/clients/rust/goaway.rs index ac2c9e49d67..79863b955e1 100644 --- a/test/interop/clients/rust/goaway.rs +++ b/test/interop/clients/rust/goaway.rs @@ -31,8 +31,6 @@ async fn main() -> anyhow::Result<()> { let args: Vec<_> = std::env::args().collect(); ensure!(args.len() == 3, "usage: interop-goaway VERSION node|bun"); let version: moq_net::Version = args[1].parse().map_err(anyhow::Error::msg)?; - // Without an epoch, JS re-subscribes instead of retaining its reader across the move. - let legacy = ["moq-transport-14", "moq-transport-16"].contains(&args[1].as_str()); let origin = moq_tokio::origin::spawn(); let broadcast = origin.create_broadcast("handover")?; let mut track = broadcast.create_track("video", moq_net::track::Info::default())?; @@ -76,12 +74,6 @@ async fn main() -> anyhow::Result<()> { // Only the replacement can deliver the next group after this transport is cut. session.abort(moq_net::Error::Cancel); let _ = session.closed().await; - if legacy { - ensure!( - output.next_line().await?.as_deref() == Some("resubscribed"), - "JS did not re-subscribe through the replacement" - ); - } write(&mut track, 2)?; ensure!( output.next_line().await?.as_deref() == Some("goaway resumed"), diff --git a/test/interop/compat/resolve.test.ts b/test/interop/compat/resolve.test.ts index 3d1ea03416b..48d4f0bda06 100644 --- a/test/interop/compat/resolve.test.ts +++ b/test/interop/compat/resolve.test.ts @@ -46,7 +46,7 @@ test("an approved wire break can retain its protocol name, but new drafts still }); test("IETF drafts never enter the released comparison", () => { - expect(matrix([OLD, "moq-transport-14"], [OLD, "moq-transport-14", "moq-transport-15"], {}).shared).toEqual([OLD]); + expect(matrix([OLD, "moq-transport-18"], [OLD, "moq-transport-14", "moq-transport-18"], {}).shared).toEqual([OLD]); }); test("a cell-scoped break skips only its cells and expires with its releases", () => { diff --git a/test/interop/ietf-compression.ts b/test/interop/ietf-compression.ts index 236178391ea..c3d479dec18 100644 --- a/test/interop/ietf-compression.ts +++ b/test/interop/ietf-compression.ts @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import { offered } from "../../js/net/src/connection/extensions.ts"; import { exchangeSetup } from "../../js/net/src/connection/handshake.ts"; -import { NativeSession } from "../../js/net/src/ietf/adapter.ts"; import { Group } from "../../js/net/src/ietf/object.ts"; +import { NativeSession } from "../../js/net/src/ietf/session.ts"; import { Setup } from "../../js/net/src/ietf/setup.ts"; import { Subscribe, SubscribeOk } from "../../js/net/src/ietf/subscribe.ts"; import { Subscriber } from "../../js/net/src/ietf/subscriber.ts"; @@ -44,7 +44,7 @@ try { }), version, ); - await setup.parameters.encode(writer, version); + await setup.parameters.encode(writer); await writer.close(); console.log(JSON.stringify(bytes)); } else { diff --git a/test/interop/ietf-range-updates.ts b/test/interop/ietf-range-updates.ts index 5ab9f583a6b..70a45236a9e 100644 --- a/test/interop/ietf-range-updates.ts +++ b/test/interop/ietf-range-updates.ts @@ -1,8 +1,8 @@ /** Serve Rust-encoded range updates with the JS publisher and return its actual wire bytes. */ import assert from "node:assert/strict"; -import { NativeSession } from "../../js/net/src/ietf/adapter.ts"; import { Publisher } from "../../js/net/src/ietf/publisher.ts"; import { RequestOk } from "../../js/net/src/ietf/request.ts"; +import { NativeSession } from "../../js/net/src/ietf/session.ts"; import { Subscribe } from "../../js/net/src/ietf/subscribe.ts"; import type { IetfVersion } from "../../js/net/src/ietf/version.ts"; import { createMockTransportPair } from "../../js/net/src/mock.ts"; @@ -80,7 +80,7 @@ try { assert(narrowing); await client.writer.write(Uint8Array.from(narrowing)); await response(RequestOk.id); - // Legacy drafts must replay both groups skipped below the old floor, rather than silently + // Draft-18 must replay both groups skipped below the old floor, rather than silently // joining only the newest cached group. Draft-22 also verifies subsequent live delivery. if (input.draft === 22) for (let i = 5; i < 9; i++) publishGroup(); for (let i = 0; i < 2; i++) await stream(); diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 1743d2d6a2c..7d0d8a41de2 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -79,7 +79,7 @@ FINISH_ONLY=0 # except the clients the harness reads a grant from, which offer $AUTH_VERSION alone. Any other # entry pins every client in its pass to that version, and `ietf` expands to IETF_VERSIONS. VERSIONS="auto" -IETF_VERSIONS="moq-transport-14,moq-transport-16,moq-transport-18,moq-transport-22" +IETF_VERSIONS="moq-transport-18,moq-transport-22" # Cargo profile for the relay/cli/moq-c builds. Debug compiles faster, which is # what an interop test wants; the workload (320x240@30) is trivial either way. @@ -459,15 +459,7 @@ plan_pass() { record "not run" case="wire track status" publisher=rust subscriber="$runtime" record "not run" case=goaway publisher=rust subscriber="$runtime" [[ "$runtime" != js-native-bun ]] || record "not run" case=goaway publisher="$runtime" subscriber=rust - case "$VERSION" in - moq-transport-14 | moq-transport-16) - record "not run" case="request credit" publisher="$runtime" subscriber=rust - [[ "$runtime" != js-native-bun ]] || record "not run" case="request credit" publisher="$runtime" subscriber="$runtime" - ;; - *) - record "not run" case="track status" publisher="$runtime" subscriber=rust - ;; - esac + record "not run" case="track status" publisher="$runtime" subscriber=rust fi done [[ "$VERSION" == auto ]] || return 0 @@ -903,7 +895,7 @@ rust_connect() { # The pattern a cell's token grants its publisher, or its subscribers, on . The `auto` # pass mints patterns no prefix could carry, so AUTH must deliver them as minted. moq-transport's -# AUTH extension (draft-17 on) carries prefixes alone and refuses a narrower grant, closing the +# AUTH extension carries prefixes alone and refuses a narrower grant, closing the # session, so a pinned pass grants the broadcast's subtree. publish_grant() { if [[ "$VERSION" == auto ]]; then printf '%s' "$1"; else printf '%s/**' "$1"; fi @@ -2106,16 +2098,11 @@ run_control_case() { local command=("$TARGET_BASE/$PROFILE/examples/interop-control" "$kind" "$version" "${runtime##*-}") case "$kind" in status) cell=(case="wire track status" publisher=rust subscriber="$runtime" log="$LOGS/$name.log") ;; - credit) cell=(case="request credit" publisher="$runtime" subscriber=rust log="$LOGS/$name.log") ;; goaway) command=("$TARGET_BASE/$PROFILE/examples/interop-goaway" "$version" "${runtime##*-}") ;; reverse-goaway) command=("$TARGET_BASE/$PROFILE/examples/interop-goaway-client" "$version") cell=(case=goaway publisher="$runtime" subscriber=rust log="$LOGS/$name.log") ;; - js-credit) - command=(bun "$CLIENTS/js-native/server.ts" credit "$version") - cell=(case="request credit" publisher="$runtime" subscriber="$runtime" log="$LOGS/$name.log") - ;; esac if is_broken "$runtime"; then record fail "${cell[@]}" detail="client unavailable" @@ -2152,15 +2139,7 @@ run_tails() { run_control_case "$runtime" status run_control_case "$runtime" goaway [[ "$runtime" != js-native-bun ]] || run_control_case "$runtime" reverse-goaway - case "$VERSION" in - moq-transport-14 | moq-transport-16) - run_control_case "$runtime" credit - [[ "$runtime" != js-native-bun ]] || run_control_case "$runtime" js-credit - ;; - *) - run_tail_pair "$runtime" rust "track status" - ;; - esac + run_tail_pair "$runtime" rust "track status" fi fi done diff --git a/test/interop/interop.toml b/test/interop/interop.toml index 61a8c63e225..d696d7aff77 100644 --- a/test/interop/interop.toml +++ b/test/interop/interop.toml @@ -20,10 +20,6 @@ version = [ "moq-lite-01", "moq-transport-22", "moq-transport-18", - "moq-transport-17", - "moq-transport-16", - "moq-transport-15", - "moq-transport-14", ] [web.http] diff --git a/test/interop/relay-log.test.sh b/test/interop/relay-log.test.sh index 57a26f966aa..335a1190f3c 100755 --- a/test/interop/relay-log.test.sh +++ b/test/interop/relay-log.test.sh @@ -31,8 +31,8 @@ printf '%s\n' 'WARN conn{id=1 remote=localhost}: refused' 'INFO conn{id=4 remote # A pinned round fails on any connection that negotiated another version, and only counts its own. printf '%s\n' 'INFO conn{id=5 remote=localhost session="a"}: moq_relay::connection: negotiated version=moq-lite-06 transport=quic' \ - 'INFO conn{id=6 remote=localhost session="b"}: moq_relay::connection: negotiated version=moq-transport-14 transport=webtransport' >>"$HARNESS_RUN/relay.log" -check_versions pinned 13 moq-transport-14 -if check_versions pinned 12 moq-transport-14 >"$HARNESS_RUN/result"; then exit 1; fi -grep -x ' FAIL pinned round: relay connection 5 negotiated moq-lite-06, not moq-transport-14' "$HARNESS_RUN/result" + 'INFO conn{id=6 remote=localhost session="b"}: moq_relay::connection: negotiated version=moq-transport-18 transport=webtransport' >>"$HARNESS_RUN/relay.log" +check_versions pinned 13 moq-transport-18 +if check_versions pinned 12 moq-transport-18 >"$HARNESS_RUN/result"; then exit 1; fi +grep -x ' FAIL pinned round: relay connection 5 negotiated moq-lite-06, not moq-transport-18' "$HARNESS_RUN/result" echo 'relay log regression passed' diff --git a/test/interop/track-status.ts b/test/interop/track-status.ts index 65cbebf14b3..edf5c40dc5e 100644 --- a/test/interop/track-status.ts +++ b/test/interop/track-status.ts @@ -1,8 +1,8 @@ // Answer Rust-encoded TRACK_STATUS requests with the JS publisher, returning each response. import assert from "node:assert/strict"; import { Milli } from "@moq/clock"; -import { NativeSession } from "../../js/net/src/ietf/adapter.ts"; import { Publisher } from "../../js/net/src/ietf/publisher.ts"; +import { NativeSession } from "../../js/net/src/ietf/session.ts"; import { TrackStatusRequest } from "../../js/net/src/ietf/track.ts"; import type { IetfVersion } from "../../js/net/src/ietf/version.ts"; import { createMockTransportPair } from "../../js/net/src/mock.ts"; @@ -38,7 +38,7 @@ const responses: number[][] = []; for (const request of input.requests) { const written: number[] = []; const stream = new Stream({ - // The requester holds its side open until answered: a FIN cancels on drafts 14-18. + // The requester holds its side open until answered: a FIN cancels on draft 18. readable: new ReadableStream({ start(controller) { controller.enqueue(Uint8Array.from(request)); diff --git a/test/interop/varint.ts b/test/interop/varint.ts index b6027311848..4027a210752 100644 --- a/test/interop/varint.ts +++ b/test/interop/varint.ts @@ -1,13 +1,14 @@ /** Decode Rust's varint encodings into js/net's U64, then hand back js/net's own encodings. */ import assert from "node:assert/strict"; -import { type IetfVersion, Version } from "../../js/net/src/ietf/version.ts"; -import { Reader, Writer } from "../../js/net/src/stream.ts"; +import { Version as Ietf } from "../../js/net/src/ietf/version.ts"; +import { Version as Lite } from "../../js/net/src/lite/version.ts"; +import { Reader, type StreamVersion, Writer } from "../../js/net/src/stream.ts"; import { U64 } from "../../js/net/src/util/u64.ts"; // Each value is a decimal string, since JSON numbers round past 2^53. const input: { values: string[]; quic: number[][]; leadingOnes: number[][] } = JSON.parse(process.argv[2]); -async function encode(v: U64, version: IetfVersion): Promise { +async function encode(v: U64, version: StreamVersion): Promise { const bytes: number[] = []; const writer = new Writer(new WritableStream({ write: (chunk) => void bytes.push(...chunk) }), version); await writer.varint(v); @@ -16,7 +17,7 @@ async function encode(v: U64, version: IetfVersion): Promise { return bytes; } -async function decode(bytes: number[], version: IetfVersion): Promise { +async function decode(bytes: number[], version: StreamVersion): Promise { const reader = new Reader(undefined, new Uint8Array(bytes), version); const v = await reader.varint(); assert(await reader.done(), `trailing bytes after ${v}`); @@ -27,8 +28,8 @@ const output: { quic: number[][]; leadingOnes: number[][] } = { quic: [], leadin for (const [i, value] of input.values.entries()) { const expected = U64.fromBigInt(BigInt(value)); for (const [format, version] of [ - ["quic", Version.DRAFT_16], - ["leadingOnes", Version.DRAFT_17], + ["quic", Lite.DRAFT_06], + ["leadingOnes", Ietf.DRAFT_18], ] as const) { const decoded = await decode(input[format][i], version); assert(decoded.equals(expected), `${format}: decoded ${decoded}, expected ${value}`); diff --git a/test/max-age/README.md b/test/max-age/README.md index 995b9eebef8..d811ef86080 100644 --- a/test/max-age/README.md +++ b/test/max-age/README.md @@ -2,10 +2,9 @@ `just test max-age` runs real WebSocket/QMux sessions through a Rust origin relay. Rust-to-Rust, Rust-to-JavaScript, and JavaScript-to-Rust check omitted, zero, and -30-second publisher limits on lite-07, IETF draft 17, and IETF draft 22. The relay +30-second publisher limits on lite-07, IETF draft 18, and IETF draft 22. The relay has a one-second local cache ceiling; received metadata must remain unchanged. The JavaScript client uses Bun and the source checkout. Every listener binds an OS-assigned port. The interop workflow runs both tests; normal Rust CI runs the -Rust-only test. Codec regressions separately cover receive-only IETF drafts 14–16 -and the published lite layouts. +Rust-only test. Codec regressions separately cover the published lite layouts. diff --git a/test/smoke/README.md b/test/smoke/README.md index 88784e4749b..81fe8083e0a 100644 --- a/test/smoke/README.md +++ b/test/smoke/README.md @@ -13,7 +13,7 @@ This suite installs the published moq packages and checks that they exchange med | Packages | `just smoke`, `just full` | Published clients through a local `moq-relay`. `smoke` is Rust only. `full` is the cross-language matrix. | | Public relays | `just relays` | Rust publisher and subscriber through every relay in the [moq-interop-runner](https://github.com/englishm/moq-interop-runner) registry. CI publishes with Rust and the browser (`js-web`) and subscribes with Rust, `js-web`, and `js-bun`. On those relays `js-web` and `js-bun` run only on WebTransport (`@moq/web-transport` has no raw QUIC mode); a client that does not belong is omitted. WebSocket is its own column for the relays in `WEBSOCKET_KEYS` (`moq-dev-rs`): each of their `http(s)` endpoints also runs as `ws(s)`, and every other relay keeps that fallback off. A relay that refuses anonymous root broadcasts is dialed on the path `publish_path` names (`stitcher-moq` publishes under `/moq-test`). That run is what the [results page](https://moq-dev.github.io/moq/relays/) shows. Only `--required` relays fail the run; the default is moq-dev's `cdn.moq.dev`, including its WebSocket endpoint. | | Upstream Docker runner | `just runner-build`, `just runner UPSTREAM OUTPUT` | Every published registry client against this checkout's relay, and the moq-dev clients against published Docker relays. Uses unpinned upstream HEAD and its HTML report. Local-build-only adapters appear as not run. Any failed pair or setup step fails the run, after the report is written. CI publishes it to [interop-runner/](https://git.moq.dev/interop-runner/). | -| Cloudflare | `just cloudflare` | Relays built from the default branches of [cloudflare/moq-rs](https://github.com/cloudflare/moq-rs) and moq-dev/moq, driven by a Cloudflare client over WebTransport and raw QUIC. | +| Cloudflare | `just cloudflare` | Relays built from the heads of [cloudflare/moq-rs](https://github.com/cloudflare/moq-rs)'s `draft-18-dev` branch and moq-dev/moq's default branch, driven by a Cloudflare client over WebTransport and raw QUIC. | | moxygen | `just moxygen` | Meta's [`moxygen`](https://github.com/facebookexperimental/moxygen) interop client through the latest moq-dev relay. Needs Linux Docker. | | Tokens | `just token`, `just token-full` | `moq auth` and npm [`@moq/auth`](https://www.npmjs.com/package/@moq/auth) mint and verify each other's JWTs, and each verifier rejects a tampered token. | diff --git a/test/smoke/clients/cloudflare/Cargo.toml b/test/smoke/clients/cloudflare/Cargo.toml index 46c995a3219..f275b973a8b 100644 --- a/test/smoke/clients/cloudflare/Cargo.toml +++ b/test/smoke/clients/cloudflare/Cargo.toml @@ -12,8 +12,8 @@ publish = false anyhow = "1" bytes = "1" clap = { version = "4", features = ["derive"] } -moq-native-ietf = { git = "https://github.com/cloudflare/moq-rs" } -moq-transport = { git = "https://github.com/cloudflare/moq-rs" } +moq-native-ietf = { git = "https://github.com/cloudflare/moq-rs", branch = "draft-18-dev" } +moq-transport = { git = "https://github.com/cloudflare/moq-rs", branch = "draft-18-dev" } tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/test/smoke/cloudflare.sh b/test/smoke/cloudflare.sh index fefef637333..3d9e6ebdc0e 100755 --- a/test/smoke/cloudflare.sh +++ b/test/smoke/cloudflare.sh @@ -1,16 +1,19 @@ #!/usr/bin/env bash -# End-to-end smoke test using the current cloudflare/moq-rs default branch. +# End-to-end smoke test using the head of cloudflare/moq-rs's draft-18 branch. # # This is intentionally separate from smoke.sh's public-package media matrix: # cloudflare/moq-rs uses a different application catalog/track layout. We build # a purpose-built Cloudflare client and both projects' relays from their latest -# Git source. The client validates every byte of sustained subgroup payloads +# Git source. cloudflare/moq-rs's default branch speaks only moq-transport-16, +# which moq-dev/moq dropped, so both Cloudflare builds follow its draft-18 branch. +# The client validates every byte of sustained subgroup payloads # through both relays; Cloudflare's relay also exercises datagrams. set -euo pipefail SMOKE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CLOUDFLARE_CLIENT="$SMOKE_DIR/clients/cloudflare" CLOUDFLARE_MOQ_REPO="https://github.com/cloudflare/moq-rs" +CLOUDFLARE_MOQ_BRANCH="draft-18-dev" MOQ_REPO="https://github.com/moq-dev/moq" PORT="${CLOUDFLARE_SMOKE_PORT:-4443}" TIMEOUT="${CLOUDFLARE_SMOKE_TIMEOUT:-20}" @@ -26,13 +29,13 @@ CLIENT="${CLOUDFLARE_CLIENT_BIN:-}" have() { command -v "$1" >/dev/null 2>&1; } -# build_git

: build one package from a repo's default -# branch with its committed Cargo.lock, into /target/release. A plain +# build_git [branch]: build one package from the head of +# a repo's branch (its default branch when omitted) with its committed Cargo.lock, into /target/release. A plain # `cargo build` in a checkout rather than `cargo install --git`: a compile cache # (mbx in CI) reuses unchanged crates across builds, but not across installs. build_git() { - local repo="$1" package="$2" dir="$3" - git clone --quiet --depth 1 "$repo" "$dir" && + local repo="$1" package="$2" dir="$3" branch="${4:-}" + git clone --quiet --depth 1 ${branch:+--branch "$branch"} "$repo" "$dir" && cargo build --quiet --release --locked --manifest-path "$dir/Cargo.toml" -p "$package" \ --target-dir "$dir/target" } @@ -65,11 +68,11 @@ if [[ ${#missing[@]} -gt 0 ]]; then fi if [[ -z "$CLOUDFLARE_RELAY" ]]; then - echo "building moq-relay-ietf from $CLOUDFLARE_MOQ_REPO (latest default branch)..." + echo "building moq-relay-ietf from $CLOUDFLARE_MOQ_REPO (latest $CLOUDFLARE_MOQ_BRANCH)..." # Follow Git HEAD, but honor that checkout's committed Cargo.lock. Without # --locked Cargo currently selects a newer hyper-util that does not compile # with moq-rs's hyper-serve dependency. - if build_git "$CLOUDFLARE_MOQ_REPO" moq-relay-ietf "$TMP/cloudflare-moq" >"$TMP/relay-build.log" 2>&1; then + if build_git "$CLOUDFLARE_MOQ_REPO" moq-relay-ietf "$TMP/cloudflare-moq" "$CLOUDFLARE_MOQ_BRANCH" >"$TMP/relay-build.log" 2>&1; then CLOUDFLARE_RELAY="$TMP/cloudflare-moq/target/release/moq-relay-ietf" else echo "error: failed to build moq-relay-ietf" >&2 diff --git a/test/smoke/freshness.sh b/test/smoke/freshness.sh index 3c5e881d2a0..489fbc5605b 100755 --- a/test/smoke/freshness.sh +++ b/test/smoke/freshness.sh @@ -89,22 +89,26 @@ else note FAIL "smoke.sh no longer go-gets moq.dev/moq @latest" fail=1 fi -# Both relays and the local integrity client deliberately follow their -# repositories' default branches. A rev/tag/branch would silently turn this -# into a stale snapshot rather than a Git-head smoke test. +# Both relays and the local integrity client deliberately follow a branch head: +# moq-dev/moq's default branch, and cloudflare/moq-rs's draft-18 branch, since its +# default speaks only moq-transport-16. A rev or tag would silently turn this into +# a stale snapshot rather than a Git-head smoke test. # shellcheck disable=SC2016 # the greps match literal variable references builds_head() { # builds_head