From 53d038c73fa511f9e54ef33eb3c623052c6300d9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 01:56:24 -0800 Subject: [PATCH 1/6] chore(quest): claim quest/m2/ietf-scope/serving.md Quest-Claim: 44d57fd31b39abaa From 6cc40e075e509f8b394db48f242cdb2a92c3b3d8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 06:02:47 -0800 Subject: [PATCH 2/6] refactor(net): drop JS moq-transport FETCH serving and Rust track listing js/net answers every moq-transport FETCH with NOT_SUPPORTED, so a draft-18 Next Object subscription carries its current group whole instead of a joining FETCH head. moq-net no longer lists a moq-transport peer's tracks with SUBSCRIBE_TRACKS: such a listing fails as unsupported, and a received SUBSCRIBE_TRACKS is still refused. Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 12 +- doc/concept/stats.md | 3 +- js/net/src/group.test.ts | 71 - js/net/src/group.ts | 37 - js/net/src/ietf/connection.test.ts | 50 +- js/net/src/ietf/connection.ts | 24 +- js/net/src/ietf/fetch.test.ts | 1027 +------ js/net/src/ietf/malformed.test.ts | 22 +- js/net/src/ietf/publisher.test.ts | 29 +- js/net/src/ietf/publisher.ts | 542 +--- js/net/src/ietf/subscribe_tracks.ts | 40 - js/net/src/integration.test.ts | 43 +- js/net/src/internal.ts | 5 - quest/m2/ietf-scope/serving.md | 34 - quest/m3/audit-interop.md | 5 +- quest/m5/net-wrap/e2ee.md | 2 +- .../ietf/draft18-invalid-group-order-0 | Bin 13 -> 13 bytes .../ietf/draft18-invalid-group-order-255 | Bin 13 -> 13 bytes .../ietf/draft18-invalid-group-order-3 | Bin 13 -> 13 bytes .../draft18-namespace-field-trailing-slash | Bin 12 -> 12 bytes .../draft18-setup-envelope-truncated-unknown | Bin 12 -> 12 bytes .../ietf/draft18-setup-envelope-unknown-bytes | Bin 12 -> 12 bytes .../draft18-setup-envelope-unknown-varint | Bin 10 -> 10 bytes .../ietf/draft18-setup-truncated-unknown | Bin 8 -> 8 bytes .../ietf/draft18-setup-unknown-bytes | Bin 8 -> 8 bytes .../ietf/draft18-setup-unknown-varint | Bin 6 -> 6 bytes .../draft18-subscribe-empty-namespace-field | Bin 10 -> 10 bytes ...raft18-subscribe-oversized-full-track-name | Bin 4107 -> 4107 bytes .../draft18-subscribe-oversized-namespace | Bin 4108 -> 4108 bytes .../ietf/draft22-invalid-fill-group-order-0 | Bin 16 -> 16 bytes .../ietf/draft22-invalid-fill-group-order-255 | Bin 16 -> 16 bytes .../ietf/draft22-invalid-fill-group-order-3 | Bin 16 -> 16 bytes .../ietf/draft22-invalid-group-order-0 | Bin 13 -> 13 bytes .../ietf/draft22-invalid-group-order-255 | Bin 13 -> 13 bytes .../ietf/draft22-invalid-group-order-3 | Bin 13 -> 13 bytes .../draft22-setup-envelope-truncated-unknown | Bin 12 -> 12 bytes .../ietf/draft22-setup-envelope-unknown-bytes | Bin 12 -> 12 bytes .../draft22-setup-envelope-unknown-varint | Bin 10 -> 10 bytes .../ietf/draft22-setup-truncated-unknown | Bin 8 -> 8 bytes .../ietf/draft22-setup-unknown-bytes | Bin 8 -> 8 bytes .../ietf/draft22-setup-unknown-varint | Bin 6 -> 6 bytes .../draft22-subscribe-empty-namespace-field | Bin 10 -> 10 bytes ...raft22-subscribe-oversized-full-track-name | Bin 4107 -> 4107 bytes .../draft22-subscribe-oversized-namespace | Bin 4108 -> 4108 bytes rs/moq-net/src/auth.rs | 6 +- rs/moq-net/src/fuzz.rs | 34 +- rs/moq-net/src/fuzz/request.rs | 2 - rs/moq-net/src/ietf/error.rs | 3 - rs/moq-net/src/ietf/message.rs | 2 +- rs/moq-net/src/ietf/mod.rs | 2 - rs/moq-net/src/ietf/publish.rs | 19 - rs/moq-net/src/ietf/publisher.rs | 32 +- rs/moq-net/src/ietf/request_stream.rs | 45 - rs/moq-net/src/ietf/session.rs | 10 +- rs/moq-net/src/ietf/subscribe_namespace.rs | 4 + rs/moq-net/src/ietf/subscribe_tracks.rs | 226 -- rs/moq-net/src/ietf/subscriber.rs | 476 +-- rs/moq-net/src/ietf/subscriber/datagrams.rs | 6 +- rs/moq-net/src/ietf/subscriber/tracks.rs | 2621 ----------------- rs/moq-net/tests/integration/tracks_ietf.rs | 26 +- 60 files changed, 302 insertions(+), 5158 deletions(-) delete mode 100644 js/net/src/ietf/subscribe_tracks.ts delete mode 100644 quest/m2/ietf-scope/serving.md delete mode 100644 rs/moq-net/src/ietf/subscribe_tracks.rs delete mode 100644 rs/moq-net/src/ietf/subscriber/tracks.rs diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 6add841448c..f7d11724ed5 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -25,17 +25,16 @@ 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 peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`. -- **We list no tracks by `PUBLISH`.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. Our own peers list tracks over moq-lite instead. -- **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end fails the listing, and readers list again. Our own moq-transport publishers refuse it, so a Rust listing of one fails. -- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. An explicit absolute subscription start is served from that group on every supported draft, so a reader keeps its prefix even when its answer arrives late. A `SUBSCRIBE` without a Location Filter joins the live group rather than replaying history. A subscriber that wants the track from group 0 sends exactly that filterless `SUBSCRIBE`, the same on every draft, and fetches the groups below the answer's Largest Object with one range `FETCH`, which our publishers refuse once it spans more than one group. A live JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. +- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined; announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`. +- **No track listing.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. We never send one: listing a broadcast from a moq-transport peer fails as unsupported. Our own peers list tracks over moq-lite instead. +- **History is served one group at a time.** Rust answers a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. A JavaScript publisher refuses every `FETCH` with `NOT_SUPPORTED`, so on draft 18 its Next Object subscription carries the current group whole, from object 0; from draft 20 it answers the fill. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. An explicit absolute subscription start is served from that group on every supported draft, so a reader keeps its prefix even when its answer arrives late. A `SUBSCRIBE` without a Location Filter joins the live group rather than replaying history. A subscriber that wants the track from group 0 sends exactly that filterless `SUBSCRIBE`, the same on every draft, and fetches the groups below the answer's Largest Object with one range `FETCH`, which Rust refuses once it spans more than one group. A live JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. - **A requester's FIN.** On draft 18 a requester finishing its stream cancels its request, except after `TRACK_STATUS`: nothing can follow it, so the FIN only ends the requester's input and the request is still answered. From draft 20 a FIN never cancels; a reset or `STOP_SENDING` does. - **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. - **Timing.** A track whose `SUBSCRIBE_OK` or `PUBLISH` declares no `TIMESCALE` is untimed. A `SUBSCRIBE` or `FETCH` with `INCLUDE_PROPERTIES=0` gets no `TIMESCALE` in its `SUBSCRIBE_OK` or `FETCH_OK`, so its objects go out unstamped. A Rust reader that never subscribed takes the units from `TRACK_STATUS`, and still asks for the properties whenever it subscribes to or fetches a timed track. - **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 namespace field ending in `/` (it would map to a path with an empty segment), 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, each distinct request token is verified once while any open request on that session names it, and its grant authorizes only requests naming it, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update on a token-authorized request moves it to the token it carries; the old token ends with its last naming request. A request is admitted by the session or by a token for good, so an update naming a token on one the session admitted is refused with `NOT_SUPPORTED`. A token-less `PUBLISH_NAMESPACE` our limit does not cover is refused `UNAUTHORIZED`, and one the limit stops covering ends; the publisher re-announces once it widens. Expiry or revocation ends every request naming that token. The relay reports the lease end to its auth server, and a later request naming the token verifies it again. A refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE`; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. Rust honors a token on any supported request; discovery reveals only paths it grants, and a joining `FETCH` keeps following its subscription. A request repeating a token is refused with `MALFORMED_AUTH_TOKEN`. The negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. - **Pause delivery.** `FORWARD=0` on a `SUBSCRIBE` or its update drops the subscription, closing its data streams and holding nothing upstream, while the request and its slot stay. `FORWARD=1` subscribes again as a new `SUBSCRIBE` with the same filter would, so nothing carries across the pause: its `REQUEST_OK` names the live edge it joined at. A paused update may change only priority and max age. -- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `SUBSCRIBE_TRACKS`, multi-group `FETCH`, and joining requests reaching before the joined group get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` from draft 20 because the advertised filter limit is zero. From draft 20, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts. Rust also carries range changes, keeps bounded subscriptions updatable, renews request tokens, and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). From draft 20, a wider or historical fill fails on its separate fetch stream while its subscription stays open; a largest-group fill hands its head to the live subgroup tail. Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. A namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. +- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `SUBSCRIBE_TRACKS`, any `FETCH` to JavaScript, multi-group `FETCH`, and joining requests reaching before the joined group get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` from draft 20 because the advertised filter limit is zero. From draft 20, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts. Rust also carries range changes, keeps bounded subscriptions updatable, renews request tokens, and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). From draft 20, a wider or historical fill fails on its separate fetch stream while its subscription stays open; a largest-group fill hands its head to the live subgroup tail. Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. A namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. - **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Plain Rust readers and moq-lite peers without the extension see consecutive frames. moq-transport maps a [datagram track](/concept/moq-lite#datagrams) to `SUBSCRIBE` and `OBJECT_DATAGRAM`, with its sequence as the group ID and object ID 0. One upstream subscription serves both kinds of a name. Stream consumers receive only stream objects, and datagram consumers receive only object 0 datagrams, including through relays. An undefined Object Status or malformed datagram framing closes the session. Unreadable object properties drop only that datagram. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none, as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped between draft 18 and later drafts, or refused when immutable, because draft 20 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. A status object carrying properties is refused. A `FETCH` has no status objects, so its response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. - **Dynamic groups.** A track advertises `DYNAMIC_GROUPS` when its publisher sets a refresh ratio, and a track received with `DYNAMIC_GROUPS` gets a ratio of 0. A `NEW_GROUP_REQUEST` counts as one more subscriber joining, which a `SUBSCRIBE` already is: it asks for a new group only when the newest group's bytes after its first frame exceed the ratio times the first frame. Joins coalesce until the asked-for group arrives. A Rust relay sends `NEW_GROUP_REQUEST` upstream, on the `SUBSCRIBE` or in a `REQUEST_UPDATE`, once a join wants a group past the newest it holds; a relay holding nothing yet asks for none. On a track without a ratio, an update carrying one ends the subscription with `UPDATE_FAILED`. A moq-lite-07 hop (`moq-lite-07-wip`, opt-in) carries the same demand natively: the ratio in `TRACK_INFO` and the asked-for group in `SUBSCRIBE_UPDATE`. Earlier moq-lite versions carry neither, so a track received over one never refreshes. - **Priority.** Higher is served first. This stack's priorities run from -128 to 127 with 0 unset, and go out on the IETF wire, which ranks lower first, as `127 - priority`: an unset priority is IETF 127. An omitted subscriber or publisher priority reads as the drafts' default of IETF 128, one step below unset, so a relay forwards it unchanged. A relay's upstream subscription carries the highest priority its subscribers ask for and updates it as they come and go; its group order stays as the `SUBSCRIBE` asked, since no draft lets an update change it. @@ -114,8 +113,7 @@ choices. They follow the product model. termination code, so we close the session with it. - **Deduplicated subscriptions and discovery.** Shared subscriptions and overlapping - namespace prefixes are coalesced internally, so we never send `PREFIX_OVERLAP`. Only - a redundant draft-18 `PUBLISH` answering our own `SUBSCRIBE_TRACKS` gets + namespace prefixes are coalesced internally, so we never send `PREFIX_OVERLAP` or `DUPLICATE_SUBSCRIPTION`. - **Subscriber max age is negotiated.** The [Timestamp extension](/draft/moq-timestamp) diff --git a/doc/concept/stats.md b/doc/concept/stats.md index fd80480dfc7..1779fc7319e 100644 --- a/doc/concept/stats.md +++ b/doc/concept/stats.md @@ -226,8 +226,7 @@ it has lingered empty (five minutes by default), then unlisted and ended. A bucket that returns within the linger keeps its track and epoch, and its totals continue. The node's announcement never changes, so bucket churn costs no announce traffic across a cluster. Over a hop that cannot carry a listing -(moq-lite 06 and older, or moq-transport, whose `SUBSCRIBE_TRACKS` our relay -refuses) the listing is empty or refused. A bucket's track name carries its +(moq-lite 06 and older, or moq-transport) the listing is refused. A bucket's track name carries its run's epoch, so a reader finds it only through the listing; one that already knows the name can subscribe while it is listed, and is refused otherwise. diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 18dd84b764a..18a4c5e615c 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -886,77 +886,6 @@ test("a drained unfinished group fails with Old when its budget gives up", async expect(await consumer.closed).toBe(err as StreamError); }); -// Metadata waits use the same recovery and reader verdict as payload reads, without -// consuming the frames a later FETCH must still deliver. -test.each([undefined, 2].flatMap((until) => [false, true].map((stalled) => ({ until, stalled }))))( - "a group end wait recovers before selecting its end (cap $until, stalled $stalled)", - async ({ until, stalled }) => { - const source = new Producer(0n); - source.writeFrame(textFrame("zero")); - const reader = source.consume(); - const replacement = new Producer(0n); - replacement.writeFrame(textFrame("zero")); - replacement.writeFrame(textFrame("one")); - replacement.finish(); - const newer = new Signal(false); - hooks.recoverGroup(reader, { - changed: newer, - wants: (failed) => failed !== undefined || newer.peek(), - replace: async (index) => { - expect(index).toBe(0); - return { - group: replacement.consume(), - expiry: { expired: () => false, changed: [] }, - adopt: () => newer.set(false), - discard: () => {}, - }; - }, - }); - const end = hooks.groupEnd(reader, until); - if (stalled) newer.set(true); - else source.abort(new Error("route failed")); - expect(await end).toEqual({ end: 2, complete: true }); - expect(await reader.readString()).toBe("zero"); - expect(await reader.readString()).toBe("one"); - reader.close(); - source.finish(); - }, -); - -// The requested headers are already buffered: a copy failing afterwards reaches the payload -// reads, which drain them first, rather than refusing an answer that can still be served. -test("a bounded group end wait is met by buffered headers despite a failed copy", async () => { - const source = new Producer(0n); - source.writeFrame(textFrame("zero")); - const reader = source.consume(); - const cause = new Error("route failed"); - const end = hooks.groupEnd(reader, 2); - await yieldNow(); - // Both land before the parked wait looks again. - source.writeFrame(textFrame("one")); - source.abort(cause); - expect(await end).toEqual({ end: 2, complete: false }); - expect(await reader.readString()).toBe("zero"); - expect(await reader.readString()).toBe("one"); - await expect(reader.readFrame()).rejects.toBe(cause); -}); - -test.each(["eviction", "expiry"] as const)("a group end wait observes reader %s", async (failure) => { - const source = new Producer(0n); - source.writeFrame(textFrame("zero")); - const reader = source.consume(); - const expired = new Signal(false); - hooks.expireGroup(reader, { expired: () => expired.peek(), changed: [expired] }); - const end = hooks.groupEnd(reader); - if (failure === "eviction") hooks.evictGroup(reader, new StreamError(StreamCode.Evicted)); - else expired.set(true); - await expect(end).rejects.toMatchObject({ - code: failure === "eviction" ? StreamCode.Evicted : StreamCode.Old, - }); - source.finish(); - reader.close(); -}); - // Rust's `GroupState::shed` keeps an open group's newest committed frame even while a newer // frame is still being written, since that partial frame is not readable yet. test("a debt shed keeps the newest committed frame behind one being written", async () => { diff --git a/js/net/src/group.ts b/js/net/src/group.ts index dc71f859bba..86b0489e593 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -1000,7 +1000,6 @@ export class Consumer { hooks.groupIndex = (group) => group.#index(); hooks.groupStarted = (group) => group.#state.total.peek() > group.#state.offset || group.#state.closed.peek() !== undefined; - hooks.groupEnd = (group, until) => group.#end(until); hooks.groupFate = (group) => { const state = group.#state; return () => { @@ -1034,42 +1033,6 @@ export class Consumer { }; } - // Resolve a FETCH's end from headers without consuming or pinning payloads. This is a - // reader operation: its verdict and replacement copy, not a raw producer end, decide it. - async #end(until?: number): Promise<{ end: number; complete: boolean }> { - for (;;) { - const verdict = this.#verdict.peek(); - if (verdict) { - this.#abandon(); - throw verdict; - } - const closed = this.#state.closed.peek(); - const count = this.frameCount; - // The bound's headers are in hand: a failure after them reaches the payload reads, - // which drain what is buffered first, rather than refusing the answer. - if (until !== undefined && count >= until) { - this.#abandon(); - return { end: until, complete: closed === null && count === until }; - } - if (closed instanceof Error) { - if (await this.#resume(closed)) continue; - this.#abandon(); - throw closed; - } - if (closed !== undefined) { - this.#abandon(); - return { end: count, complete: true }; - } - if (this.#expire()) continue; - if (this.#recover?.wants(undefined)) { - // Buffered payloads do not satisfy an end wait, so a newer copy may win here. - await this.#resume(undefined, false); - continue; - } - await this.#changed(); - } - } - // Evaluate the drift budget for a read that has nothing buffered and is about to // wait. A group with frames in hand is always drained: the budget bounds a group // that has stalled while the live edge moved on, not a reader slower than the wire. diff --git a/js/net/src/ietf/connection.test.ts b/js/net/src/ietf/connection.test.ts index 45863aacb69..a4a6e7b3e0f 100644 --- a/js/net/src/ietf/connection.test.ts +++ b/js/net/src/ietf/connection.test.ts @@ -9,6 +9,7 @@ import * as Path from "../path.ts"; import { Stream, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; import { Connection } from "./connection.ts"; +import { Fetch, type FetchType } from "./fetch.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; import { Group } from "./object.ts"; @@ -120,13 +121,16 @@ for (const [version, alpn] of [ } } -/** We list no tracks by PUBLISH: each SUBSCRIBE_TRACKS gets NOT_SUPPORTED, and the session stays usable. */ +/** + * We serve neither FETCH (only a relay answers it, from its cache) nor SUBSCRIBE_TRACKS (we list + * no tracks by PUBLISH): each gets NOT_SUPPORTED on its own stream, and the session stays usable. + */ for (const [version, alpn] of [ [Version.DRAFT_18, ALPN.DRAFT_18], [Version.DRAFT_20, ALPN.DRAFT_20], [Version.DRAFT_22, ALPN.DRAFT_22], ] as const) { - test(`SUBSCRIBE_TRACKS is refused on its own stream on ${alpn}`, async () => { + test(`FETCH and SUBSCRIBE_TRACKS are refused on their own streams on ${alpn}`, async () => { const pair = createMockTransportPair(alpn); const control = await Stream.open(pair.server, { version }); const connection = new Connection({ @@ -137,16 +141,42 @@ for (const [version, alpn] of [ client: false, }); + const requests: ((writer: Writer, requestId: bigint) => Promise)[] = [ + async (writer, requestId) => { + await writer.u62(BigInt(SUBSCRIBE_TRACKS_ID)); + await Message.encode(writer, async (body) => { + await body.u62(requestId); + await Namespace.encode(body, Path.from("prefix")); + await body.u53(0); + }); + }, + async (writer, requestId) => { + await writer.u62(BigInt(Fetch.id)); + const type: FetchType = + version === Version.DRAFT_18 + ? { + kind: "standalone", + trackNamespace: Path.from("room"), + trackName: "video", + start: { group: 0n, object: 0n }, + end: { group: 1n, object: 0n }, + } + : { + kind: "filtered", + trackNamespace: Path.from("room"), + trackName: "video", + filter: { kind: "unfiltered" }, + }; + await new Fetch({ requestId, type }).encode(writer, version); + }, + ]; try { - // A second listing of the same prefix is refused the same way, each spending its Request ID. - for (let i = 0; i < 2; i++) { + // Each is refused again on a second request, spending its own Request ID. + let requestId = 0n; + for (const request of [...requests, ...requests]) { const stream = await Stream.open(pair.client, { version }); - await stream.writer.u62(BigInt(SUBSCRIBE_TRACKS_ID)); - await Message.encode(stream.writer, async (writer) => { - await writer.u62(BigInt(i * 2)); - await Namespace.encode(writer, Path.from("prefix")); - await writer.u53(0); - }); + await request(stream.writer, requestId); + requestId += 2n; expect(await stream.reader.u53()).toBe(RequestError.id); expect((await RequestError.decode(stream.reader, version)).errorCode).toBe(0x3); expect(await stream.reader.done()).toBe(true); diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index f75a5da0bf0..dc5a71a9965 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -26,8 +26,8 @@ import { malformed, refuse } from "./request_stream.ts"; import { NativeSession } from "./session.ts"; import { Subscribe, SubscribeUpdate } from "./subscribe.ts"; import { SUBSCRIBE_TRACKS_ID, SubscribeNamespace } from "./subscribe_namespace.ts"; -import { SubscribeTracks } from "./subscribe_tracks.ts"; import { Subscriber } from "./subscriber.ts"; +import * as Message from "./message.ts"; import { MalformedAuthToken } from "./token.ts"; import { TrackStatusRequest } from "./track.ts"; import { type IetfVersion, Version, versionName } from "./version.ts"; @@ -377,9 +377,17 @@ export class Connection implements Session { break; } case BigInt(SUBSCRIBE_TRACKS_ID): { - 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)); + // We list no tracks by PUBLISH, so each listing is refused on its own. Only its + // Request ID is read; the rest of the body goes unread. + const requestId = await Message.decode(stream.reader, async (body) => { + const id = await body.u62(); + await body.discard(); + return id; + }).catch((err: unknown) => { + throw malformed(err, "subscribe_tracks"); + }); + this.#spend(requestId); + await handed(this.#refuseUnsupported(stream, "subscribe_namespace", "SUBSCRIBE_TRACKS is not supported")); break; } case BigInt(TrackStatusRequest.id): { @@ -391,7 +399,8 @@ export class Connection implements Session { case BigInt(Fetch.id): { const decoded = Fetch.decode(stream.reader, this.#session.version); const msg = await this.#request(stream, "fetch", decoded); - if (msg) await handed(this.#publisher.runFetch(msg, stream)); + // Only a relay answers a FETCH, from its cache. + if (msg) await handed(this.#refuseUnsupported(stream, "fetch", "FETCH is not supported")); break; } @@ -466,6 +475,11 @@ export class Connection implements Session { this.#session.checkRequestId(requestId); } + /** Refuse a request we never serve NOT_SUPPORTED, ending its stream. */ + async #refuseUnsupported(stream: Stream, kind: RequestKind, reasonPhrase: string) { + await refuse(stream, this.#session.version, kind, { condition: "not_supported", reasonPhrase }); + } + /** Refuse a request MALFORMED_AUTH_TOKEN, ending its stream. Mirrors Rust's `token::reject`. */ async #refuseToken(stream: Stream, kind: RequestKind) { await refuse(stream, this.#session.version, kind, { diff --git a/js/net/src/ietf/fetch.test.ts b/js/net/src/ietf/fetch.test.ts index e8c01b56566..9815fe07f8f 100644 --- a/js/net/src/ietf/fetch.test.ts +++ b/js/net/src/ietf/fetch.test.ts @@ -1,107 +1,16 @@ import { expect, test } from "bun:test"; -import { Duration, Manual } from "@moq/clock"; -import { Signal } from "@moq/signals"; -import type { Grant } from "../auth.ts"; -import { DEFAULT_EXPIRY } from "../cache.ts"; -import { accept as acceptSession, connect as connectSession } from "../connection/index.ts"; -import { error } from "../error.ts"; -import { hooks } from "../internal.ts"; -import * as Lite from "../lite/index.ts"; -import { createMockTransportPair } from "../mock.ts"; -import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; -import { Reader, Stream, Writer } from "../stream.ts"; -import { Timescale, Timestamp } from "../time.ts"; -import type { Fill, Order, Producer as TrackProducer } from "../track.ts"; -import { SEGMENT_BYTES } from "../util/limits.ts"; -import { wireOf } from "../wire.ts"; -import { Fetch, FetchHeader, FetchOk, type FetchType, type Location } from "./fetch.ts"; +import { Reader, Writer } from "../stream.ts"; +import { Timescale } from "../time.ts"; +import { Fetch, FetchOk, type FetchType, type Location } from "./fetch.ts"; import type * as Filter from "./filter.ts"; -import { decodeFetchObject, Group as GroupMessage } from "./object.ts"; -import { Publisher } from "./publisher.ts"; -import { RequestError } from "./request.ts"; -import { NativeSession } from "./session.ts"; -import { Subscribe, SubscribeOk } from "./subscribe.ts"; -import { ALPN, type IetfVersion, Version } from "./version.ts"; +import { type IetfVersion, Version } from "./version.ts"; -const VERSIONS = [Version.DRAFT_18, Version.DRAFT_20, Version.DRAFT_22] as const; const D18 = Version.DRAFT_18; -const D20 = Version.DRAFT_20; const D22 = Version.DRAFT_22; -const ALPNS: Record = { [D18]: ALPN.DRAFT_18, [D20]: ALPN.DRAFT_20, [D22]: ALPN.DRAFT_22 }; -const SUBSCRIBE_ID = 7n; const FETCH_ID = 9n; -const TIMEOUT = 0x2; -const NOT_SUPPORTED = 0x3; -const UNAUTHORIZED = 0x1; -const DOES_NOT_EXIST = 0x10; -const INVALID_RANGE = 0x11; -const INVALID_JOINING_REQUEST_ID = 0x32; - -/** A publisher serving the `room` broadcast, with its `video` track and the client's uni streams. */ -// `maxAge` is the track's publisher max age: omitted is the authored zero, and a test that fills -// superseded history passes `null`, since the publisher fills only groups still fresh. -function serve(version: IetfVersion, grant?: Signal, priority?: number, maxAge?: Duration | null) { - const pair = createMockTransportPair(ALPNS[version]); - const origin = new OriginProducer(); - const pub = new Publisher({ - quic: pair.server, - session: new NativeSession(pair.server, version, false), - publish: origin.consume(), - requiresSolicitation: false, - grant, - }); - const publication = origin.route(Path.from("room")); - const track = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI, priority, maxAge }); - const uni = pair.client.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader< - ReadableStream - >; - return { - pair, - pub, - origin, - broadcast: publication.broadcast, - track, - uni, - version, - [Symbol.dispose]() { - uni.releaseLock(); - origin.close(); - }, - }; -} - -type Served = ReturnType; - -/** Write `payloads` into group `sequence`, finishing it unless told otherwise. */ -function writeGroup(track: TrackProducer, sequence: bigint, payloads: string[], finish = true) { - const group = track.createGroup(sequence); - for (const [object, payload] of payloads.entries()) { - group.writeFrame({ - payload: new TextEncoder().encode(payload), - timestamp: new Timestamp(object, Timescale.MILLI), - }); - } - if (finish) group.finish(); - return group; -} - -/** Groups `0..count`, each holding `g-0` and `g-1`, skipping `hole`. */ -function publishPairs(track: TrackProducer, count: number, hole?: number) { - for (let sequence = 0; sequence < count; sequence++) { - if (sequence !== hole) writeGroup(track, BigInt(sequence), [`${sequence}-0`, `${sequence}-1`]); - } -} - -/** The `[group, object, payload]` a run of two-object groups holds. */ -function pairs(groups: number[]): [number, number, string][] { - return groups.flatMap((group) => - [0, 1].map((object): [number, number, string] => [group, object, `${group}-${object}`]), - ); -} - /** * The range `[start, end)` as each draft spells it: a standalone FETCH through draft-18, End * Object 0 for the whole End Group, and an inclusive LOCATION_FILTER from draft-22. @@ -123,134 +32,10 @@ function filtered(filter: Filter.Filter, trackName = "video"): FetchType { return { kind: "filtered", trackNamespace: Path.from("room"), trackName, filter }; } -/** A FETCH_OK End Location naming `last` as the last object: one past it before draft-22. */ -function endAt(version: IetfVersion, group: number, last: number): Location { - return { group: BigInt(group), object: BigInt(version === D18 ? last + 1 : last) }; -} - function at(group: number, object: number): Location { return { group: BigInt(group), object: BigInt(object) }; } -/** A manual clock installed until disposed, so no wait can lapse on its own. */ -function manual(): Manual & Disposable { - const clock = new Manual(); - const installed = clock.install(); - return Object.assign(clock, { [Symbol.dispose]: () => installed.close() }); -} - -function ms(value: number): Duration { - return Duration.millis(value); -} - -/** Let every pending promise chain run. */ -async function flush() { - for (let i = 0; i < 20; i++) await new Promise((resolve) => setImmediate(resolve)); -} - -/** What a FETCH was answered with. */ -type Answer = - | { refused: number } - | { - ok: FetchOk; - /** The fetch stream's own bytes, after its type. */ - raw: Uint8Array; - requestId: bigint; - objects: [number, number, string][]; - }; - -/** A request stream the test writes the requester's side of and reads the answer from. */ -function requestStream(version: IetfVersion) { - let input!: ReadableStreamDefaultController; - const output = new TransformStream(); - const stream = new Stream({ - readable: new ReadableStream({ - start(c) { - input = c; - }, - }), - writable: output.writable, - version, - }); - return { - stream, - answer: new Reader(output.readable, undefined, version), - /** End the request as `version` cancels one: by FIN through draft 18, and by a reset after. */ - withdraw: () => (version === D18 ? input.close() : input.error(new Error("request cancelled"))), - }; -} - -/** Send `msg` and read the answer, along with the fetch stream that follows a FETCH_OK. */ -async function fetch(fx: Served, msg: Fetch): Promise { - const request = requestStream(fx.version); - const task = fx.pub.runFetch(msg, request.stream); - try { - return await answer(fx, request.answer, msg.groupOrder ?? "ascending"); - } finally { - await task; - } -} - -async function answer(fx: Served, wire: Reader, order: Order): Promise { - const id = await wire.u53(); - if (id === RequestError.id) { - const refused = (await RequestError.decode(wire, fx.version)).errorCode; - expect(await wire.done()).toBe(true); - return { refused }; - } - expect(id).toBe(FetchOk.id); - const ok = await FetchOk.decode(wire, fx.version); - expect(await wire.done()).toBe(true); - - const next = await fx.uni.read(); - if (next.done) throw new Error("no fetch stream"); - const bytes = new Uint8Array(await new Response(next.value).arrayBuffer()); - const reader = new Reader(undefined, bytes, fx.version); - expect(await reader.u53()).toBe(FetchHeader.type); - const requestId = (await FetchHeader.decode(reader, fx.version)).requestId; - return { ok, raw: bytes, requestId, objects: await readObjects(reader, order) }; -} - -/** - * Decode a fetch stream's objects as `[group, object, payload]`, its groups in `order`, and - * each End of Range as `[group, object, "end "]`. - */ -async function readObjects(reader: Reader, order: Order): Promise<[number, number, string][]> { - const objects: [number, number, string][] = []; - let group = 0; - let object = 0; - while (!(await reader.done())) { - const flags = await reader.u53(); - // Absolute ids, and nothing after them. - if (flags >= 0x80) { - group = Number(await reader.u62()); - object = Number(await reader.u62()); - objects.push([group, object, `end ${flags.toString(16)}`]); - continue; - } - if (flags & 0x08) { - const value = await reader.u53(); - group = objects.length === 0 ? value : order === "ascending" ? group + value + 1 : group - value - 1; - } - object = flags & 0x04 ? await reader.u53() : object + 1; - if (flags & 0x10) await reader.u8(); - if (flags & 0x20) await reader.read(await reader.u53()); - const payload = new TextDecoder().decode(await reader.read(await reader.u53())); - objects.push([group, object, payload]); - } - return objects; -} - -function served(answer: Answer) { - if ("refused" in answer) throw new Error(`refused with ${answer.refused}`); - return answer; -} - -function refused(answer: Answer): number { - if (!("refused" in answer)) throw new Error("answered with FETCH_OK"); - return answer.refused; -} - test("FETCH and FETCH_OK round trip on every draft", async () => { const encode = async (msg: Fetch | FetchOk, version: IetfVersion) => { const chunks: Uint8Array[] = []; @@ -289,807 +74,3 @@ test("FETCH and FETCH_OK round trip on every draft", async () => { await expect(encode(new Fetch({ requestId: 1n, type: range(D18, at(0, 0), at(0, 0)) }), D22)).rejects.toThrow(); await expect(encode(new Fetch({ requestId: 1n, type: filtered({ kind: "unfiltered" }) }), D18)).rejects.toThrow(); }); - -test("a FETCH of one whole group", async () => { - for (const version of VERSIONS) { - using fx = serve(version); - publishPairs(fx.track, 5); - const answer = served( - await fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, at(2, 0), at(2, 0)) })), - ); - // A whole-group answer before draft-22 keeps Object 0 rather than claiming the next group. - expect(answer.ok.endLocation).toEqual(version === D18 ? at(2, 0) : at(2, 1)); - expect(answer.ok.endOfTrack).toBe(false); - expect(answer.ok.properties.timescale).toBe(Timescale.MILLI); - expect(answer.requestId).toBe(FETCH_ID); - expect(answer.objects).toEqual(pairs([2])); - } -}); - -test("a FETCH stops at its end object", async () => { - for (const version of VERSIONS) { - using fx = serve(version); - publishPairs(fx.track, 5); - const answer = served( - await fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, at(2, 0), at(2, 1)) })), - ); - expect(answer.ok.endLocation).toEqual(endAt(version, 2, 0)); - expect(answer.objects).toEqual(pairs([2]).slice(0, 1)); - } -}); - -test("a FETCH spanning groups is refused before FETCH_OK", async () => { - for (const version of VERSIONS) { - for (const order of ["ascending", "descending"] as const) { - using fx = serve(version); - publishPairs(fx.track, 5, 2); - for (const end of [3, 9]) { - const type = range(version, at(0, 1), at(end, 0)); - expect(refused(await fetch(fx, new Fetch({ requestId: FETCH_ID, type, groupOrder: order })))).toBe( - NOT_SUPPORTED, - ); - } - } - } -}); - -/** The Publisher Priority each object on a fetch stream states, absent where it inherits. */ -async function priorities(raw: Uint8Array, version: IetfVersion): Promise<(number | undefined)[]> { - const reader = new Reader(undefined, raw, version); - expect(await reader.u53()).toBe(FetchHeader.type); - await FetchHeader.decode(reader, version); - const out: (number | undefined)[] = []; - while (!(await reader.done())) { - const flags = await reader.u53(); - if (flags & 0x08) await reader.u53(); - if (flags & 0x04) await reader.u53(); - out.push(flags & 0x10 ? await reader.u8() : undefined); - if (flags & 0x20) await reader.read(await reader.u53()); - await reader.read(await reader.u53()); - } - return out; -} - -// A fetch stream's first object states the track's Publisher Priority, as a subgroup header -// does, and the rest inherit it. Model priority 72 is wire 55, lower first. -test("a FETCH states the track's priority on its first object", async () => { - for (const version of VERSIONS) { - using fx = serve(version, undefined, 72); - publishPairs(fx.track, 2); - const type = range(version, at(1, 0), at(1, 0)); - const answer = served(await fetch(fx, new Fetch({ requestId: FETCH_ID, type }))); - expect(answer.objects).toEqual(pairs([1])); - expect(answer.ok.properties.priority).toBe(55); - expect(await priorities(answer.raw, version)).toEqual([55, undefined]); - } -}); - -// Only a finished track's final object ends it, wherever the range starts or ends. -test("a FETCH through the final object reports the end of track", async () => { - for (const version of VERSIONS) { - using fx = serve(version); - publishPairs(fx.track, 5); - fx.track.finish(); - - const last = served( - await fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, at(4, 1), at(4, 0)) })), - ); - expect(last.ok.endLocation).toEqual(version === D18 ? at(4, 0) : at(4, 1)); - expect(last.ok.endOfTrack).toBe(true); - expect(last.objects).toEqual(pairs([4]).slice(1)); - - // A shorter prefix must not claim the track ends there. - const prefix = served( - await fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, at(4, 0), at(4, 1)) })), - ); - expect(prefix.ok.endOfTrack).toBe(false); - } -}); - -// The Largest Object's group is read whole, so an answer reaching it waits for it to finish, -// and never claims a live track ends. -test("a FETCH waits for the Largest Object's open group", async () => { - for (const version of VERSIONS) { - using fx = serve(version); - publishPairs(fx.track, 4); - const live = writeGroup(fx.track, 4n, ["4-0"], false); - let answered = false; - const answering = fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, at(4, 0), at(4, 0)) })); - void answering.finally(() => { - answered = true; - }); - await flush(); - expect(answered).toBe(false); - live.writeFrame({ payload: new TextEncoder().encode("4-1"), timestamp: new Timestamp(1, Timescale.MILLI) }); - live.finish(); - const answer = served(await answering); - expect(answer.ok.endLocation).toEqual(version === D18 ? at(4, 0) : at(4, 1)); - expect(answer.ok.endOfTrack).toBe(false); - expect(answer.objects).toEqual(pairs([4])); - } -}); - -test("a FETCH the cache cannot answer is refused before FETCH_OK", async () => { - for (const version of VERSIONS) { - using fx = serve(version); - publishPairs(fx.track, 4, 2); - const ask = async (start: Location, end: Location) => - refused(await fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, start, end) }))); - const missing = new Fetch({ requestId: FETCH_ID, type: range(version, at(0, 0), at(0, 0), "missing") }); - expect(refused(await fetch(fx, missing))).toBe(DOES_NOT_EXIST); - // A group below the newest that does not exist, and one past every group. - expect(await ask(at(2, 0), at(2, 0))).toBe(DOES_NOT_EXIST); - expect(await ask(at(5, 0), at(5, 0))).toBe(DOES_NOT_EXIST); - // A start past Largest Object in a group the cache holds (draft-22 section 9.11). - expect(await ask(at(3, 2), at(3, 6))).toBe(INVALID_RANGE); - expect(await ask(at(3, 1), at(3, 1))).toBe(INVALID_RANGE); - // A newest group with no objects starts past Largest Object too. - writeGroup(fx.track, 4n, []); - expect(await ask(at(4, 0), at(4, 4))).toBe(INVALID_RANGE); - } -}); - -test("a FETCH fills never-cached and evicted history, then reuses the cache", async () => { - for (const version of VERSIONS) { - for (const evicted of [false, true]) { - using clock = manual(); - using fx = serve(version, undefined, undefined, null); - if (evicted) { - writeGroup(fx.track, 1n, ["1-0", "1-1", "1-2"]); - await clock.advance(DEFAULT_EXPIRY.mul(2)); - } - writeGroup(fx.track, 3n, ["live"]); - const cache = fx.track.subscribe({ ranges: [] }); - expect(hooks.holdsGroup(cache, 1n)).toBe(false); - cache.close(); - using requests = fx.track.requests(); - let fill: Fill | undefined; - const requested = requests.requestedFill().then((value) => { - fill = value; - }); - const msg = new Fetch({ - requestId: FETCH_ID, - type: range(version, at(1, 1), at(1, 3)), - subscriberPriority: 3, - groupOrder: "ascending", - }); - const fetching = fetch(fx, msg); - await flush(); - expect(fill).toBeDefined(); - if (!fill) throw new Error("no history fill"); - expect([fill.start, fill.end, fill.priority, fill.maxDelay]).toEqual([1n, 2n, 124, null]); - const group = fill.accept(1n); - for (let object = 0; object < 3; object++) - group.writeFrame({ - payload: new TextEncoder().encode(`1-${object}`), - timestamp: Timestamp.fromMillis(object), - }); - group.finish(); - fill.close(); - await requested; - const first = served(await fetching); - expect(first.objects).toEqual([ - [1, 1, "1-1"], - [1, 2, "1-2"], - ]); - expect(first.ok.endLocation).toEqual(endAt(version, 1, 2)); - expect(first.ok.endOfTrack).toBe(false); - // No handler remains: the same responder serves the filled group from cache. - requests.close(); - expect(served(await fetch(fx, msg)).objects).toEqual(first.objects); - } - } -}); - -test("a FETCH preserves relayed metadata and cached groups without upstream FETCH", async () => { - for (const version of VERSIONS) { - for (const warm of [false, true]) { - using fx = serve(version); - const upstream = new OriginProducer(); - const publication = upstream.route(Path.from("archive")); - const track = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI }); - writeGroup(track, 3n, ["3-0", "3-1"]); - // lite-04 has no FETCH; lite-05 can fetch, but refuses empty subscription ranges. - const pair = createMockTransportPair(warm ? Lite.ALPN_04 : Lite.ALPN_05); - const url = new URL("https://localhost:4443/test"); - const [client, server] = await Promise.all([ - connectSession({ url, transport: pair.client }), - acceptSession({ url, transport: pair.server, publish: upstream.consume() }), - ]); - try { - const source = wireOf(client).consume(Path.from("archive")); - const cached = warm ? source.track("video").subscribe({ delay: { max: null } }) : undefined; - if (cached) { - const group = await cached.recvGroup(); - expect(group?.sequence).toBe(3n); - while (await group?.readFrame()) {} - group?.close(); - } - const requests = fx.origin.claim(Path.from("relayed")); - const cursor = requests.requested(); - const requested = cursor.next(); - const type = { ...range(version, at(3, 0), at(3, 0)), trackNamespace: Path.from("relayed") }; - const fetching = fetch(fx, new Fetch({ requestId: FETCH_ID, type })); - const next = await requested; - if (next.done) throw new Error("no relayed broadcast request"); - next.value.serve(source); - await cursor.return?.(); - expect(served(await fetching).objects).toEqual(pairs([3])); - cached?.close(); - } finally { - client.abort(); - server.abort(); - upstream.close(); - } - } - } -}); - -test("a FETCH refused by its history responder sends no FETCH_OK", async () => { - for (const version of VERSIONS) { - using fx = serve(version, undefined, undefined, null); - writeGroup(fx.track, 3n, ["live"]); - using requests = fx.track.requests(); - const responding = (async () => { - using fill = await requests.requestedFill(); - expect(fill?.wanted()).toBe(1n); - })(); - const fetching = fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, at(1, 0), at(1, 0)) })); - expect(refused(await fetching)).toBe(DOES_NOT_EXIST); - requests.close(); - await responding; - } -}); - -test("a cancelled or unauthorized FETCH withdraws its unanswered history fill", async () => { - for (const version of VERSIONS) { - for (const revoke of [false, true]) { - const grant = new Signal(undefined); - using fx = serve(version, grant, undefined, null); - writeGroup(fx.track, 3n, ["live"]); - using requests = fx.track.requests(); - const requested = requests.requestedFill(); - const request = requestStream(version); - const fetching = fx.pub.runFetch( - new Fetch({ requestId: FETCH_ID, type: range(version, at(1, 0), at(1, 0)) }), - request.stream, - ); - using fill = await requested; - expect(fill?.wanted()).toBe(1n); - if (revoke) grant.set({ publish: new Path.Patterns([]), subscribe: new Path.Patterns([]) }); - else request.withdraw(); - if (revoke) expect(refused(await answer(fx, request.answer, "ascending"))).toBe(UNAUTHORIZED); - await fetching; - expect(await fill?.wantedChanged()).toBeUndefined(); - const read = await request.answer.done().catch(error); - expect(read === true || read instanceof Error).toBe(true); - } - } -}); - -// A filter other than a bounded absolute one resolves against Largest Object. -test("draft-22: a FETCH filter resolves against Largest Object", async () => { - const cases: [Filter.Filter, number[] | number][] = [ - [{ kind: "unfiltered" }, NOT_SUPPORTED], - [{ kind: "nextObject" }, INVALID_RANGE], - [{ kind: "relative", groups: 2n }, NOT_SUPPORTED], - [{ kind: "relative", groups: 1n }, [2]], - [{ kind: "absolute", startGroup: 2n, startObject: 0n }, [2]], - ]; - for (const [filter, expected] of cases) { - using fx = serve(D22); - publishPairs(fx.track, 3); - const answer = await fetch(fx, new Fetch({ requestId: FETCH_ID, type: filtered(filter) })); - if (typeof expected === "number") { - expect(refused(answer)).toBe(expected); - continue; - } - expect(served(answer).ok.endLocation).toEqual(at(2, 1)); - expect(served(answer).objects).toEqual(pairs(expected)); - } - - // An empty track has no Largest Object to resolve against. - using fx = serve(D22); - expect(refused(await fetch(fx, new Fetch({ requestId: FETCH_ID, type: filtered({ kind: "unfiltered" }) })))).toBe( - NOT_SUPPORTED, - ); -}); - -test("a FETCH outside the grant is refused", async () => { - const nothing = (): Grant => ({ publish: new Path.Patterns([]), subscribe: new Path.Patterns([]) }); - for (const version of VERSIONS) { - using fx = serve(version, new Signal(nothing())); - publishPairs(fx.track, 2); - const answer = await fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, at(0, 0), at(0, 0)) })); - expect(refused(answer)).toBe(UNAUTHORIZED); - } -}); - -// The requester leaving while the answer waits on an open group is owed nothing, budget or not. -test("a cancelled FETCH stops waiting", async () => { - for (const version of VERSIONS) { - for (const fillTimeout of [undefined, 10_000]) { - using _clock = manual(); - using fx = serve(version); - publishPairs(fx.track, 2); - writeGroup(fx.track, 2n, ["2-0"], false); - const request = requestStream(version); - const task = fx.pub.runFetch( - new Fetch({ requestId: FETCH_ID, type: range(version, at(2, 0), at(2, 0)), fillTimeout }), - request.stream, - ); - await flush(); - request.withdraw(); - await task; - const read = await request.answer.done().catch(error); - expect(read === true || read instanceof Error).toBe(true); - } - } -}); - -// FILL_TIMEOUT is accepted without truncating a one-group answer, including zero. -test("FILL_TIMEOUT is ignored while a group finishes", async () => { - for (const version of VERSIONS) { - for (const fillTimeout of [0, 100]) { - using clock = manual(); - using fx = serve(version); - const live = writeGroup(fx.track, 4n, ["4-0"], false); - let answered = false; - const answering = fetch( - fx, - new Fetch({ requestId: FETCH_ID, type: range(version, at(4, 0), at(4, 0)), fillTimeout }), - ); - void answering.then(() => { - answered = true; - }); - await clock.advance(ms(1000)); - expect(answered).toBe(false); - live.writeFrame({ payload: new TextEncoder().encode("4-1"), timestamp: new Timestamp(1, Timescale.MILLI) }); - live.finish(); - expect(served(await answering).objects).toEqual(pairs([4])); - } - } -}); - -/** Start a draft-18 Largest Object SUBSCRIBE of `room/video`, or as `fields` override, without reading its answer. */ -async function startSubscribe(fx: Served, fields: Partial[0]> = {}) { - const client = await Stream.open(fx.pair.client, { version: fx.version }); - const server = await Stream.accept(fx.pair.server, fx.version); - if (!server) throw new Error("publisher never accepted the subscribe stream"); - const msg = new Subscribe({ - requestId: SUBSCRIBE_ID, - trackNamespace: Path.from("room"), - trackName: "video", - subscriberPriority: 0, - filter: { kind: "nextObject" }, - ...fields, - }); - return { client, serving: fx.pub.runSubscribe(msg, server) }; -} - -/** Open a draft-18 SUBSCRIBE of `room/video` with `filter`, returning its SUBSCRIBE_OK. */ -async function subscribe(fx: Served, filter: Filter.Filter = { kind: "nextObject" }) { - const { client, serving } = await startSubscribe(fx, { filter }); - expect(await client.reader.u53()).toBe(SubscribeOk.id); - const ok = await SubscribeOk.decode(client.reader, fx.version); - return { client, ok, serving }; -} - -function joining(offset = 0n): Fetch { - return new Fetch({ requestId: FETCH_ID, type: { kind: "relativeJoining", subscription: SUBSCRIBE_ID, offset } }); -} - -// A Largest Object subscription starts right after the Largest Object SUBSCRIBE_OK names, -// and a joining FETCH answers the group's head up to it: together they cover the group with no -// gap or overlap, which is how the Rust subscriber joins mid-group on draft-18. -test("draft-18: a joining FETCH serves the head its subscription starts after", async () => { - using fx = serve(D18); - publishPairs(fx.track, 5); - const live = writeGroup(fx.track, 5n, ["5-0", "", "5-2"], false); - const { client, ok } = await subscribe(fx); - expect(ok.largest).toEqual({ groupId: 5n, objectId: 2n }); - - // Written after SUBSCRIBE_OK, so it belongs to the subscription, not the head. - live.writeFrame({ payload: new TextEncoder().encode("5-3"), timestamp: new Timestamp(3, Timescale.MILLI) }); - - const request = requestStream(D18); - const task = fx.pub.runFetch(joining(), request.stream); - // The subscription's stream for group 5 opened first, starting at the object after the head. - const next = await fx.uni.read(); - if (next.done) throw new Error("no subscription stream"); - const group = new Reader(next.value, undefined, D18); - const header = await GroupMessage.decode(group, D18); - expect(header.groupId).toBe(5n); - expect(header.flags.firstObject).toBe(false); - expect(await group.u53()).toBe(3); - - const head = served(await answer(fx, request.answer, "ascending")); - await task; - expect(head.ok).toEqual( - new FetchOk({ - endOfTrack: false, - endLocation: at(5, 3), - // The zero authored default max age, advertised as MAX_CACHE_DURATION. - properties: { timescale: Timescale.MILLI, priority: 127, groupOrder: 0x2, maxCacheDuration: 0n }, - }), - ); - expect(head.requestId).toBe(FETCH_ID); - expect(head.objects).toEqual([ - [5, 0, "5-0"], - [5, 1, ""], - [5, 2, "5-2"], - ]); - // The first object spells its group, object and priority, and the rest inherit them. - const reader = new Reader(undefined, head.raw, D18); - await reader.u53(); - await FetchHeader.decode(reader, D18); - expect(await reader.u53()).toBe(0x3c); - // The priority is the track's, as FETCH_OK and every subgroup header state it. - expect([await reader.u53(), await reader.u53(), await reader.u8()]).toEqual([5, 0, 127]); - await reader.read(await reader.u53()); - await reader.read(await reader.u53()); - expect(await reader.u53()).toBe(0x20); - client.close(); -}); - -// A subscription can end while its head is in flight, as when a track finishes right after -// SUBSCRIBE_OK, and the head is then all the subscriber gets of that group. -test("draft-18: a joining FETCH outlives a subscription that ends after FETCH_OK", async () => { - using fx = serve(D18); - const live = writeGroup(fx.track, 5n, ["5-0", "5-1"], false); - const { client, serving } = await subscribe(fx); - - const request = requestStream(D18); - const task = fx.pub.runFetch(joining(), request.stream); - expect(await request.answer.u53()).toBe(FetchOk.id); - client.close(); - await serving; - live.finish(); - - // The subscription's own stream for group 5 opened first, and its end reset it. - const subscription = await fx.uni.read(); - if (subscription.done) throw new Error("no subscription stream"); - const next = await fx.uni.read(); - if (next.done) throw new Error("no fetch stream"); - const reader = new Reader(next.value, undefined, D18); - expect(await reader.u53()).toBe(FetchHeader.type); - await FetchHeader.decode(reader, D18); - expect(await readObjects(reader, "ascending")).toEqual(pairs([5])); - await task; -}); - -test("draft-18: a joining FETCH its subscription cannot answer is refused", async () => { - { - using fx = serve(D18); - publishPairs(fx.track, 5); - // A subscription that never arrives, once the FETCH has waited as long as a request may. - const clock = new Manual(); - const installed = clock.install(); - try { - expect(refused(await clock.drive(fetch(fx, joining())))).toBe(INVALID_JOINING_REQUEST_ID); - } finally { - installed.close(); - } - - const { client, serving } = await subscribe(fx); - // Only the subscription's own group, never a run of groups before it. - expect(refused(await fetch(fx, joining(1n)))).toBe(NOT_SUPPORTED); - const absolute = new Fetch({ - requestId: FETCH_ID, - type: { kind: "absoluteJoining", subscription: SUBSCRIBE_ID, group: 5n }, - }); - expect(refused(await fetch(fx, absolute))).toBe(INVALID_RANGE); - - // A FETCH arriving after its subscription ended has no edge to end at. - client.close(); - await serving; - const later = new Manual(); - const reinstalled = later.install(); - try { - expect(refused(await later.drive(fetch(fx, joining())))).toBe(INVALID_JOINING_REQUEST_ID); - } finally { - reinstalled.close(); - } - } - { - // A subscription that does not start after Largest Object has no head to join. - using fx = serve(D18); - publishPairs(fx.track, 5); - const { client } = await subscribe(fx, { kind: "unfiltered" }); - expect(refused(await fetch(fx, joining()))).toBe(NOT_SUPPORTED); - client.close(); - } - { - // One that started on an empty track has no head at all. - using fx = serve(D18); - const { client } = await subscribe(fx); - expect(refused(await fetch(fx, joining()))).toBe(INVALID_RANGE); - client.close(); - } - { - // A head the cache lost is refused, rather than answered short of where the - // subscription starts. - using fx = serve(D18); - const live = writeGroup(fx.track, 5n, ["5-0", "5-1"], false); - const { client } = await subscribe(fx); - live.abort(new Error("evicted")); - expect(refused(await fetch(fx, joining()))).toBe(DOES_NOT_EXIST); - client.close(); - } - { - // An upstream can report the largest safe frame, making the head end one past it. No - // model group holds that many frames, so the head is unavailable rather than an error. - using fx = serve(D18); - writeGroup(fx.track, 5n, ["5-0", "5-1"], false); - hooks.reportedLargest(fx.track, { group: 5n, frame: Number.MAX_SAFE_INTEGER }); - const { client, ok } = await subscribe(fx); - expect(ok.largest).toEqual({ groupId: 5n, objectId: BigInt(Number.MAX_SAFE_INTEGER) }); - expect(refused(await fetch(fx, joining()))).toBe(DOES_NOT_EXIST); - client.close(); - } -}); - -// Request streams race, so a joining FETCH can be read before the SUBSCRIBE it names, as when -// the subscriber pipelines both: it waits for the subscription rather than being refused. -test("draft-18: a joining FETCH waits for the subscription it names", async () => { - using fx = serve(D18); - writeGroup(fx.track, 5n, ["5-0", "5-1"], false); - const request = requestStream(D18); - const task = fx.pub.runFetch(joining(), request.stream); - await flush(); - const { client } = await subscribe(fx); - - expect(await request.answer.u53()).toBe(FetchOk.id); - expect((await FetchOk.decode(request.answer, D18)).endLocation).toEqual(at(5, 2)); - // The subscription's own stream for group 5 may open on either side of the fetch stream. - for (;;) { - const next = await fx.uni.read(); - if (next.done) throw new Error("no fetch stream"); - const reader = new Reader(next.value, undefined, D18); - if ((await reader.u53()) !== FetchHeader.type) continue; - await FetchHeader.decode(reader, D18); - expect(await readObjects(reader, "ascending")).toEqual(pairs([5])); - break; - } - await task; - client.close(); -}); - -// A subscription still being set up when the FETCH's request budget runs out is refused TIMEOUT, -// rather than as one the publisher never saw. -test("draft-18: a joining FETCH times out on a subscription still being set up", async () => { - using fx = serve(D18); - // Serve tracks on demand and never answer the request, so the SUBSCRIBE never resolves. - const requested = wireOf(fx.broadcast).requested(); - const { client, serving } = await startSubscribe(fx, { trackName: "audio" }); - expect((await requested)?.name).toBe("audio"); - - const clock = new Manual(); - const installed = clock.install(); - try { - expect(refused(await clock.drive(fetch(fx, joining())))).toBe(TIMEOUT); - } finally { - installed.close(); - client.close(); - await serving; - } -}); - -// A subscription refused while a joining FETCH waits on it answers the FETCH at once. -test("draft-18: a joining FETCH whose subscription is refused is refused", async () => { - using fx = serve(D18); - publishPairs(fx.track, 5); - const request = requestStream(D18); - const task = fx.pub.runFetch(joining(), request.stream); - await flush(); - - const { client, serving } = await startSubscribe(fx, { rangeFilters: true }); - expect(await client.reader.u53()).toBe(RequestError.id); - await serving; - - expect(refused(await answer(fx, request.answer, "ascending"))).toBe(INVALID_JOINING_REQUEST_ID); - await task; - client.close(); -}); - -// Every selected header is known, but the last payload grows the slice past the group -// tail. Neither collecting the slice nor reading its last frame whole can deliver this. -test.each([ - { prefix: 3, size: SEGMENT_BYTES * 16 }, - { prefix: 0, size: SEGMENT_BYTES * 40 }, -])("a FETCH streams $prefix cached frames and a $size byte final frame past the tail", async ({ prefix, size }) => { - for (const version of VERSIONS) { - using fx = serve(version); - const group = fx.track.createGroup(0n); - const prefixSize = SEGMENT_BYTES * 8; - for (let index = 0; index < prefix; index++) { - const frame = group.createFrame({ size: prefixSize, timestamp: new Timestamp(index, Timescale.MILLI) }); - for (let offset = 0; offset < prefixSize; offset += SEGMENT_BYTES) - frame.write(new Uint8Array(SEGMENT_BYTES).fill(index)); - frame.finish(); - } - const frame = group.createFrame({ size, timestamp: new Timestamp(prefix, Timescale.MILLI) }); - const request = requestStream(version); - const task = fx.pub.runFetch( - new Fetch({ requestId: FETCH_ID, type: range(version, at(0, 0), at(0, prefix + 1)) }), - request.stream, - ); - try { - expect(await request.answer.u53()).toBe(FetchOk.id); - const ok = await FetchOk.decode(request.answer, version); - expect(ok.endLocation).toEqual(endAt(version, 0, prefix)); - const next = await fx.uni.read(); - if (!next.value) throw new Error("missing fetch stream"); - const reader = new Reader(next.value, undefined, version); - expect(await reader.u53()).toBe(FetchHeader.type); - expect((await FetchHeader.decode(reader, version)).requestId).toBe(FETCH_ID); - for (let index = 0; index <= prefix; index++) { - const length = index === prefix ? size : prefixSize; - const object = await reader.decode((cursor) => decodeFetchObject(cursor, Timescale.MILLI)); - expect(object).toMatchObject({ kind: "object", size: length, payload: undefined }); - if (object.kind !== "object") throw new Error("expected object"); - expect(object.timestamp?.value).toBe(index); - for (let offset = 0; offset < length; offset += SEGMENT_BYTES) { - const bytes = new Uint8Array(Math.min(SEGMENT_BYTES, length - offset)).fill(index); - // Forward each segment before the rest exists; no whole-frame read can do this. - if (index === prefix) frame.write(bytes); - expect(await reader.read(bytes.byteLength)).toEqual(bytes); - } - if (index === prefix) frame.finish(); - } - // The bound ends the response without waiting for this live group's FIN. - expect(await reader.done()).toBe(true); - await task; - } finally { - request.stream.abort(new Error("test ended")); - group.abort(new Error("test ended")); - await task; - } - } -}); - -// The group ending before a requested bound determines both the true end and its track flag. -test("a bounded FETCH waits for headers or the final group before promising its end", async () => { - for (const version of VERSIONS) { - using fx = serve(version); - const live = writeGroup(fx.track, 0n, ["0-0", "0-1", "0-2"], false); - const answering = fetch(fx, new Fetch({ requestId: FETCH_ID, type: range(version, at(0, 0), at(0, 10)) })); - await flush(); - live.finish(); - fx.track.finish(); - const answer = served(await answering); - expect(answer.ok.endOfTrack).toBe(true); - expect(answer.ok.endLocation).toEqual(endAt(version, 0, 2)); - expect(answer.objects).toEqual([ - [0, 0, "0-0"], - [0, 1, "0-1"], - [0, 2, "0-2"], - ]); - } -}); - -// Both streams must fail together if any part of an accepted answer becomes undeliverable. -test.each(["source", "request", "output"] as const)( - "a FETCH stalled inside a frame stops on %s failure", - async (failure) => { - for (const version of VERSIONS) { - using fx = serve(version); - const group = fx.track.createGroup(0n); - group.createFrame({ size: SEGMENT_BYTES * 2, timestamp: new Timestamp(0, Timescale.MILLI) }); - const request = requestStream(version); - const task = fx.pub.runFetch( - new Fetch({ requestId: FETCH_ID, type: range(version, at(0, 0), at(0, 1)) }), - request.stream, - ); - expect(await request.answer.u53()).toBe(FetchOk.id); - await FetchOk.decode(request.answer, version); - const next = await fx.uni.read(); - if (!next.value) throw new Error("missing fetch stream"); - const reader = new Reader(next.value, undefined, version); - expect(await reader.u53()).toBe(FetchHeader.type); - await FetchHeader.decode(reader, version); - expect(await reader.decode((cursor) => decodeFetchObject(cursor, Timescale.MILLI))).toMatchObject({ - kind: "object", - size: SEGMENT_BYTES * 2, - payload: undefined, - }); - const cause = new Error("test failure"); - if (failure === "source") group.abort(cause); - else if (failure === "request") request.withdraw(); - else reader.stop(cause); - await task; - if (failure !== "output") expect(await reader.readChunk(1).catch(error)).toBeInstanceOf(Error); - group.abort(cause); - } - }, -); - -// An empty frame is an object only once committed: its header alone cannot complete the answer, -// or a frame aborted after it would be served as if it existed. -test.each(["finish", "abort"] as const)("a FETCH of an unfinished empty frame waits for its %s", async (outcome) => { - for (const version of VERSIONS) { - using fx = serve(version); - const group = fx.track.createGroup(0n); - const frame = group.createFrame({ size: 0, timestamp: new Timestamp(0, Timescale.MILLI) }); - const request = requestStream(version); - let settled = false; - const task = fx.pub - .runFetch(new Fetch({ requestId: FETCH_ID, type: range(version, at(0, 0), at(0, 1)) }), request.stream) - .finally(() => { - settled = true; - }); - try { - expect(await request.answer.u53()).toBe(FetchOk.id); - await FetchOk.decode(request.answer, version); - const next = await fx.uni.read(); - if (!next.value) throw new Error("missing fetch stream"); - const reader = new Reader(next.value, undefined, version); - expect(await reader.u53()).toBe(FetchHeader.type); - await FetchHeader.decode(reader, version); - expect(await reader.decode((cursor) => decodeFetchObject(cursor, Timescale.MILLI))).toMatchObject({ - kind: "object", - size: 0, - }); - await flush(); - expect(settled).toBe(false); - - if (outcome === "finish") frame.finish(); - else group.abort(new Error("source failed")); - await task; - if (outcome === "finish") expect(await reader.done()).toBe(true); - else expect(await reader.done().catch(error)).toBeInstanceOf(Error); - } finally { - request.stream.abort(new Error("test ended")); - group.abort(new Error("test ended")); - await task; - } - } -}); - -// A write parked on flow control never reads the group, so the group failing has to reach it -// another way, or the answer holds its reader until the request ends. -test("a FETCH parked on flow control stops when its group fails", async () => { - for (const version of VERSIONS) { - using fx = serve(version); - const group = fx.track.createGroup(0n); - const frames = 2048; - for (let index = 0; index < frames; index++) - group.writeFrame({ - payload: new Uint8Array(64).fill(index), - timestamp: new Timestamp(index, Timescale.MILLI), - }); - const request = requestStream(version); - let settled = false; - const task = fx.pub - .runFetch(new Fetch({ requestId: FETCH_ID, type: range(version, at(0, 0), at(0, frames)) }), request.stream) - .finally(() => { - settled = true; - }); - try { - expect(await request.answer.u53()).toBe(FetchOk.id); - await FetchOk.decode(request.answer, version); - const next = await fx.uni.read(); - if (!next.value) throw new Error("missing fetch stream"); - // Nothing reads the fetch stream, so it fills its window and the publisher parks on a write. - await flush(); - expect(settled).toBe(false); - - group.abort(new Error("source failed")); - await flush(); - expect(settled).toBe(true); - // Both streams are reset rather than ending as if the answer were complete. - expect(await request.answer.u53().catch(error)).toBeInstanceOf(Error); - const data = next.value.getReader(); - let failure: unknown; - try { - while (!(await data.read()).done); - } catch (err: unknown) { - failure = err; - } - expect(failure).toBeInstanceOf(Error); - } finally { - request.stream.abort(new Error("test ended")); - await task; - } - } -}); diff --git a/js/net/src/ietf/malformed.test.ts b/js/net/src/ietf/malformed.test.ts index de11d7d619e..338052db693 100644 --- a/js/net/src/ietf/malformed.test.ts +++ b/js/net/src/ietf/malformed.test.ts @@ -14,7 +14,7 @@ import { RequestOk } from "./request.ts"; import { Setup } from "./setup.ts"; import { SetupOption, SetupOptions } from "./setup_options.ts"; import { SubscribeOk } from "./subscribe.ts"; -import { SUBSCRIBE_TRACKS_ID, SubscribeNamespace } from "./subscribe_namespace.ts"; +import { SubscribeNamespace } from "./subscribe_namespace.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; // Malformed control input the draft says ends the session must end it, with the draft's code. @@ -112,26 +112,6 @@ for (const [version, alpn] of DRAFTS) { ["a namespace over 4096 bytes", subscribe([field(4097)], 1), SessionCode.ProtocolViolation], ["a full track name over 4096 bytes", subscribe([field(4000)], 97), SessionCode.ProtocolViolation], ["a trailing byte", frame(0x03, [0, 1, ...field(4), ...field(5), 0, 0]), SessionCode.ProtocolViolation], - [ - "an invalid SUBSCRIBE_TRACKS FORWARD", - frame(SUBSCRIBE_TRACKS_ID, [0, 0, 1, 0x10, 2]), - SessionCode.ProtocolViolation, - ], - [ - "a duplicate SUBSCRIBE_TRACKS FORWARD", - frame(SUBSCRIBE_TRACKS_ID, [0, 0, 2, 0x10, 0, 0, 1]), - SessionCode.ProtocolViolation, - ], - [ - "a SUBSCRIBE_TRACKS prefix over 32 fields", - frame(SUBSCRIBE_TRACKS_ID, [0, 33, ...new Array(33).fill(field(1)).flat(), 0]), - SessionCode.ProtocolViolation, - ], - [ - "a SUBSCRIBE_TRACKS prefix over 4096 bytes", - frame(SUBSCRIBE_TRACKS_ID, [0, 1, ...field(4097), 0]), - SessionCode.ProtocolViolation, - ], ] as const) { test(`a request with ${name} closes ${alpn}`, async () => { using clock = manual(); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 9a0dd2f252f..cef63163f19 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -28,7 +28,7 @@ import { wireOf } from "../wire.ts"; import type * as Cluster from "./cluster.ts"; import { ObjectDatagram } from "./datagram.ts"; import { SubscribeRefused, toRequestCode } from "./error.ts"; -import { Fetch, FetchHeader } from "./fetch.ts"; +import { FetchHeader } from "./fetch.ts"; import * as Filter from "./filter.ts"; import { Frame, Group as GroupMessage } from "./object.ts"; import { Parameters } from "./parameters.ts"; @@ -683,21 +683,18 @@ test("Range Filters over MAX_FILTER_RANGES zero get INVALID_FILTER", async () => for (const version of [Version.DRAFT_22]) { const pair = createMockTransportPair(ALPNS[version]); const { pub, origin } = publisher(pair.server, { session: new NativeSession(pair.server, version, true) }); - for (const fetch of [false, true]) { - const request = requestStream(version); - try { - const name = [1, 4, ...new TextEncoder().encode("test"), 5, ...new TextEncoder().encode("video")]; - const fields = [7, ...name, 1, 0x25, 0]; - const encoded = new Reader(undefined, Uint8Array.from([0, fields.length, ...fields]), version); - if (fetch) await pub.runFetch(await Fetch.decode(encoded, version), request.stream); - else await pub.runSubscribe(await Subscribe.decode(encoded, version), request.stream); - await request.stream.writer.closed; - const wire = request.wire(); - expect(await wire.u53()).toBe(RequestError.id); - expect((await RequestError.decode(wire, version)).errorCode).toBe(0x36); - } finally { - request.cancel(); - } + const request = requestStream(version); + try { + const name = [1, 4, ...new TextEncoder().encode("test"), 5, ...new TextEncoder().encode("video")]; + const fields = [7, ...name, 1, 0x25, 0]; + const encoded = new Reader(undefined, Uint8Array.from([0, fields.length, ...fields]), version); + await pub.runSubscribe(await Subscribe.decode(encoded, version), request.stream); + await request.stream.writer.closed; + const wire = request.wire(); + expect(await wire.u53()).toBe(RequestError.id); + expect((await RequestError.decode(wire, version)).errorCode).toBe(0x36); + } finally { + request.cancel(); } origin.close(); } diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 98b26232631..0f8a6440b94 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -24,7 +24,6 @@ import type * as group from "../group.ts"; import type { Hop, WireRoute } from "../hop.ts"; import { datagramTrackInfo, - finalSequence, firstEligible, hiddenBelow, hooks, @@ -51,14 +50,14 @@ import { toNumber } from "../util/bigint.ts"; import { nextChange } from "../util/change.ts"; import { Deadline, REQUEST_TIMEOUT_MS } from "../util/deadline.ts"; import { GivenUp } from "../util/given_up.ts"; -import { max, min, saturatingSub } from "../util/sequence.ts"; +import { max, saturatingSub } from "../util/sequence.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 * as Cluster from "./cluster.ts"; import { encodeDatagram } from "./datagram.ts"; import { type RequestCondition, requestReason, SubscribeRefused } from "./error.ts"; -import { type Fetch, FetchHeader, FetchOk, type FetchType } from "./fetch.ts"; +import { FetchHeader } from "./fetch.ts"; import * as Filter from "./filter.ts"; import { MAX_AGE_PARAMETER } from "./max_age.ts"; import * as Message from "./message.ts"; @@ -86,7 +85,6 @@ import { SubscribeNamespaceEntry, SubscribeNamespaceEntryDone, } from "./subscribe_namespace.ts"; -import type { SubscribeTracks } from "./subscribe_tracks.ts"; import { MalformedAuthToken } from "./token.ts"; import { TrackStatusOk, type TrackStatusRequest } from "./track.ts"; import type { IetfVersion } from "./version.ts"; @@ -155,9 +153,6 @@ function sameHops(a: Hop[], b: Hop[]): boolean { const ADVERTISE_TIMEOUT_MS = REQUEST_TIMEOUT_MS; const ADVERTISE_TIMEOUT = Duration.millis(ADVERTISE_TIMEOUT_MS); -/** How long a joining FETCH waits for the subscription it names to arrive and resolve. */ -const JOIN_TIMEOUT = Duration.millis(REQUEST_TIMEOUT_MS); - /** Sleep `delay`, jittered, so a relay's namespaces don't all retry on the same tick. */ function retryAfter(delay: number): Promise { return sleep(Duration.millis(delay * (0.5 + Math.random() / 2))); @@ -370,12 +365,6 @@ export class Publisher { #datagramWriter?: ReturnType; #session: Session; #requiresSolicitation: boolean; - // What a joining FETCH needs from each draft-18 SUBSCRIBE, by its request ID, while it lasts: - // pending from its arrival until it resolves. Request streams race, so a joining FETCH can - // arrive before the subscription it names has. - #joins = new Map(); - // Bumped on every change to #joins, which a waiting joining FETCH rechecks. - #joinsChanged = new Signal(0); #hidden: boolean; // Whether a SUBSCRIBE_NAMESPACE's REQUEST_OK counts the NAMESPACEs that follow it (MoQ Active Count). @@ -483,19 +472,11 @@ export class Publisher { } async #runSubscribe(msg: Subscribe, request: RequestStream) { - // Draft-22 replaced joining FETCH with fills. - const joinable = !Filter.isDraft20(this.#session.version); - if (joinable) this.#setJoin(msg.requestId, "pending"); - try { - if (Namespace.reserved(msg.trackNamespace, this.#session.version)) { - await refuseReserved(request); - return; - } - await this.#serveSubscribe(msg, request); - } finally { - // A joining FETCH arriving after the subscription ended has no edge to end at. - if (joinable) this.#setJoin(msg.requestId, undefined); + if (Namespace.reserved(msg.trackNamespace, this.#session.version)) { + await refuseReserved(request); + return; } + await this.#serveSubscribe(msg, request); } async #serveSubscribe(msg: Subscribe, request: RequestStream) { @@ -543,7 +524,7 @@ export class Publisher { const subscribe = (source: broadcast.Consumer) => { // A range that resolves without the live edge has a fixed start, which is no join: // bound the subscription before subscribing so the refresh ratio never judges it. - const fixed = filterRange(msg.filter, {}); + const fixed = filterRange(msg.filter, {}, this.#session.version); // Parked past a bounded window's end: the peer reads this stream's end as the track's. return hooks.parked( source.track(msg.trackName).subscribe({ @@ -810,7 +791,7 @@ export class Publisher { msg.fill && msg.forward && Filter.isDraft20(version) ? fillRange(msg.fill, msg.filter, edge.largest) : undefined; - const requested = filterRange(msg.filter, edge); + const requested = filterRange(msg.filter, edge, version); // A copy, since an update moves the cursor in place and must not move the bounds too. const range = { ...afterFill(requested, fill, edge) }; // What the subscriber asked for, which only a narrowing changes: the fill's split @@ -846,26 +827,6 @@ export class Publisher { const ranked = new Priority(track, info.priority); ranking = ranked; const properties = trackProperties(info, msg.propertiesWanted); - // Draft-22 replaced joining FETCH with fills. Older drafts save the Largest Object the - // subscription starts after, again on every resume, so a joining FETCH ends exactly - // where the subscription begins. Saved before the answer names it. - if (!Filter.isDraft20(version)) { - this.#setJoin( - msg.requestId, - msg.filter.kind === "nextObject" - ? { - kind: "join", - track, - largest: edge.largest, - namespace: name, - properties, - timescale, - publisherPriority, - ranking: ranked, - } - : { kind: "unsupported" }, - ); - } if (answer) { // The resuming update learns where the subscription now joins. @@ -1010,7 +971,7 @@ export class Publisher { ? bounds.peek() : msg.filter.kind === "unfiltered" ? { start: { group: 0n, object: 0n } } - : filterRange(msg.filter, snapshot); + : filterRange(msg.filter, snapshot, version); const tail = afterFill(requested, selected, snapshot); range.start = tail.start; range.end = tail.end; @@ -2333,19 +2294,6 @@ export class Publisher { } } - /** Refuse a request to discover tracks under a namespace prefix: we list none by PUBLISH. @internal */ - runSubscribeTracks(msg: SubscribeTracks, stream: Stream): Promise { - const request = new RequestStream(stream, this.#session, this.#quic); - return request.serve( - Namespace.reserved(msg.prefix, this.#session.version) - ? refuseReserved(request) - : request.refuse("subscribe_namespace", { - condition: "not_supported", - reasonPhrase: "SUBSCRIBE_TRACKS is not supported", - }), - ); - } - async #subscriptionUpdate(framed: Framed): Promise<{ params: Parameters; requestId: bigint }> { const version = this.#session.version; const update = await Message.decode(framed.reader, async (body) => { @@ -2392,388 +2340,6 @@ export class Publisher { } } - /** - * Handles an incoming FETCH on a bidi stream: a range of the named track, or a joining - * FETCH's head of its subscription's group. A named range missing from the cache asks - * the producer's request handler; a joining head must already be cached. - * - * @internal - */ - runFetch(msg: Fetch, stream: Stream): Promise { - const request = new RequestStream(stream, this.#session, this.#quic); - return request.serve(this.#runFetch(msg, request)); - } - - async #runFetch(msg: Fetch, request: RequestStream) { - // FILL_TIMEOUT does not truncate a group or time out its application fill. Both remain - // wanted until answered, refused, or cancelled by the requester or its grant. - const version = this.#session.version; - const type = msg.type; - const named = type.kind === "standalone" || type.kind === "filtered" ? type : undefined; - const joining = type.kind === "relativeJoining" || type.kind === "absoluteJoining" ? type : undefined; - if (named && Namespace.reserved(named.trackNamespace, version)) { - await refuseReserved(request); - return; - } - // Serving a Range Filter unfiltered would deliver objects the requester excluded. - if (msg.rangeFilters) { - await request.refuse("fetch", { - condition: "invalid_filter", - reasonPhrase: "Range Filters exceed MAX_FILTER_RANGES=0", - }); - return; - } - // A joining FETCH was authorized with its subscription; a standalone one is checked - // here, so a denied request never reaches the origin. - const denied: Refusal = { condition: "unauthorized", reasonPhrase: "not granted" }; - if (named && !this.#gate.allows(named.trackNamespace)) { - await request.refuse("fetch", denied); - return; - } - - // Everything that ends the answer early: the requester leaving, our grant narrowing, - // or the subscription a joining FETCH named ending before it resolved. - const stop = new AbortController(); - const cancel = new StreamError(StreamCode.Cancel, { message: "cancel" }); - void request.ended.then(() => stop.abort(cancel)); - // Nothing may follow the request. - void request.next().then( - (framed) => framed && request.abort(new ProtocolViolation("unexpected message on a FETCH")), - // The request ended, which the stop already heard. - () => undefined, - ); - - let join: Joinable | undefined; - if (joining) { - let found: Joinable | Refusal; - try { - found = await this.#joinOf(joining.subscription, stop.signal); - } catch (err: unknown) { - if (stop.signal.reason !== cancel) throw err; - request.abort(cancel); - return; - } - if ("condition" in found) { - await request.refuse("fetch", found); - return; - } - join = found; - } - const namespace = named?.trackNamespace ?? (join?.kind === "join" ? join.namespace : undefined); - // A resolved answer owns its group reader independently of the subscription. Ending - // that subscription alone does not cancel the head; source failure or eviction still can. - let resolved = false; - const lapsed = new Error("subscription ended"); - const lapse = - joining && - this.#joinsChanged.subscribe(() => { - if (!resolved && !this.#joins.has(joining.subscription)) stop.abort(lapsed); - }); - const watch = namespace && this.#gate.watch(namespace, () => stop.abort(unauthorized(namespace))); - if (namespace && watch?.revoked()) stop.abort(unauthorized(namespace)); - - let answer: FetchAnswer | Refusal | undefined; - try { - try { - if (named) answer = await this.#fetchRange(msg, named, stop.signal); - else if (joining && join) answer = this.#fetchJoin(joining, join); - else throw new Error("unknown FETCH type"); - } catch (err: unknown) { - if (stop.signal.reason === cancel) { - request.abort(cancel); - return; - } - answer = - stop.signal.reason === lapsed - ? { condition: "invalid_joining_request_id", reasonPhrase: "subscription ended" } - : stop.signal.aborted && namespace && !this.#gate.allows(namespace) - ? denied - : this.#failed(err); - } - if ("condition" in answer) { - await request.refuse("fetch", answer); - return; - } - resolved = true; - await this.#answerFetch(msg, request, answer, stop.signal); - } finally { - lapse?.(); - watch?.dispose(); - stop.abort(); - if (answer && "owned" in answer && answer.owned) answer.cache.close(); - } - } - - /** - * Resolve a standalone or draft-22 FETCH to its group reader and end metadata, filling - * uncached history through the producer's request handler, or the refusal to answer with. - * - * A whole-group request waits for the group to finish, so a downstream cache never - * mistakes a prefix for a complete group. - */ - async #fetchRange( - msg: Fetch, - named: Extract, - signal: AbortSignal, - ): Promise { - const version = this.#session.version; - // Every filter but a bounded absolute one ends at the Largest Object, which resolves - // once the track does. - let relative: Filter.Filter | undefined; - let requested: FetchRange; - if (named.kind === "standalone") { - requested = { start: named.start, end: named.end }; - } else if (named.filter.kind === "absolute" && named.filter.endGroup !== undefined) { - // The filter's End Object is inclusive, where a standalone one is the last object - // plus one. Without one, both include the whole End Group. - const { startGroup, startObject, endGroup, endObject } = named.filter; - requested = { - start: { group: startGroup, object: startObject }, - end: { group: endGroup, object: endObject === undefined ? 0n : endObject + 1n }, - }; - } else { - relative = named.filter; - // Placeholders until the filter resolves against the Largest Object. - requested = { start: { group: 0n, object: 0n }, end: { group: 0n, object: 0n } }; - } - // End Object 0 includes the entire End Group. - const { start, end } = requested; - if ( - !relative && - (start.group > end.group || (start.group === end.group && end.object !== 0n && start.object >= end.object)) - ) { - return { condition: "invalid_range", reasonPhrase: "empty range" }; - } - - if (!relative && start.group !== end.group) - return { condition: "not_supported", reasonPhrase: "FETCH spans more than one group" }; - - const broadcast = await this.#resolve(named.trackNamespace, signal); - const priority = fromWire(msg.subscriberPriority); - const track = broadcast.track(named.trackName); - const cache = track.subscribe({ priority, delay: { max: Duration.millis(Varint.MAX_U53), min: null } }); - let answer: FetchAnswer | undefined; - try { - const info = await untilAborted(cache.info(), signal); - const position = hooks.objectLargest(cache); - const largest = position && { group: position.group, object: BigInt(position.frame) }; - const range = { ...requested }; - if (relative) { - if (!largest) { - return { - condition: "not_supported", - reasonPhrase: "FETCH relative to Largest Object needs a known publisher edge", - }; - } - range.start = fetchStart(relative, largest); - range.end = { group: largest.group, object: 0n }; - } - if (range.start.group !== range.end.group) - return { condition: "not_supported", reasonPhrase: "FETCH spans more than one group" }; - - if (largest) { - // A start past Largest Object in a group the cache holds is refused INVALID_RANGE - // (draft-22 section 9.11), rather than waiting on the live group. A group past - // every one held does not exist yet. - if (after(range.start, largest)) { - return hooks.holdsGroup(cache, range.start.group) - ? { condition: "invalid_range", reasonPhrase: "start past Largest Object" } - : { condition: "does_not_exist", reasonPhrase: `group ${range.start.group}` }; - } - } - - const selected = range.start.group; - // A relayed track may hold the group even when its upstream cannot FETCH. Only a - // cache miss asks the producer's handler or fetches the group from its serving route. - const group = - hooks.cachedGroup(cache, selected) ?? (await track.fetchGroup(selected, { priority, signal })); - const slice = fetchSlice(range, selected); - // Wait for the selected headers or the group's end, through this reader's recovery - // and verdict. The reader holds only the normal tail, not a copied slice. - try { - const last = await untilAborted(hooks.groupEnd(group, slice.until), signal); - - // Only a finished track's final object ends it, never a live track's Largest Object. - const final = hooks.subscriberEnd(cache); - const endOfTrack = last.complete && final !== undefined && finalSequence(final) === group.sequence + 1n; - const sequence = group.sequence; - const groupEnd = BigInt(Math.max(slice.skip, last.end)); - const until = slice.until; - let endLocation: Location; - if (Filter.isDraft20(version)) { - // Draft-22's End Location is inclusive. An empty answer still covers its start. - const object = - until === undefined - ? max(saturatingSub(groupEnd, 1n), BigInt(slice.skip)) - : endOfTrack - ? min(BigInt(until) - 1n, saturatingSub(groupEnd, 1n)) - : BigInt(until) - 1n; - endLocation = { group: sequence, object }; - } else if (endOfTrack && range.end.object > 0n) { - // A bounded response cannot promise objects past the track's end. - endLocation = { group: sequence, object: groupEnd }; - } else { - endLocation = range.end; - } - - answer = { - cache, - owned: true, - range, - group, - slice, - ok: new FetchOk({ - endOfTrack, - endLocation, - properties: trackProperties(info, msg.propertiesWanted), - }), - // A Timestamp goes out only when FETCH_OK actually carries TIMESCALE. - timescale: msg.propertiesWanted ? (info.timescale ?? undefined) : undefined, - publisherPriority: toWire(info.priority), - // Its own rank, so it competes with group streams by its priority alone. - sendOrder: sendOrder({ priority }), - }; - return answer; - } finally { - if (!answer) group.close(); - } - } finally { - if (!answer) cache.close(); - } - } - - /** Record what a joining FETCH finds for subscription `id`, or remove it when absent. */ - #setJoin(id: bigint, join: Joinable | "pending" | undefined) { - if (join) this.#joins.set(id, join); - else this.#joins.delete(id); - this.#joinsChanged.update((n) => n + 1); - } - - /** - * Wait for the subscription a joining FETCH names to resolve, or the refusal to answer - * with. One not yet arrived or still resolving is waited on, bounded like any request, - * since the FETCH's stream can beat its SUBSCRIBE's. - */ - async #joinOf(id: bigint, signal: AbortSignal): Promise { - const missing: Refusal = { condition: "invalid_joining_request_id", reasonPhrase: "no such subscription" }; - const done = new AbortController(); - const expired = sleep(JOIN_TIMEOUT, { signal: done.signal }).then( - () => "expired" as const, - () => "expired" as const, - ); - let pending = false; - try { - for (;;) { - const join = this.#joins.get(id); - if (join === "pending") pending = true; - else if (join) return join; - // Seen pending and now gone: it ended, or was refused, before it resolved. - else if (pending) return missing; - if ((await untilAborted(race([Signal.race(this.#joinsChanged), expired]), signal)) === "expired") - return pending ? { condition: "timeout", reasonPhrase: "subscription not ready" } : missing; - } - } finally { - // Settles the deadline, which releases the change listener a cancelled wait left. - done.abort(); - } - } - - /** - * Resolve the head of a joining FETCH through its subscription's saved Largest Object, - * or the refusal to answer with. - */ - #fetchJoin( - type: Extract, - join: Joinable, - ): FetchAnswer | Refusal { - if (join.kind === "unsupported") - return { condition: "not_supported", reasonPhrase: "joining filter not supported" }; - const largest = join.largest; - if (!largest) return { condition: "invalid_range", reasonPhrase: "no objects at subscription start" }; - if (type.kind === "relativeJoining" && type.offset !== 0n) - return { condition: "not_supported", reasonPhrase: "joining FETCH reaches before the joined group" }; - const start = type.kind === "relativeJoining" ? largest.group : type.group; - if (start > largest.group) - return { condition: "invalid_range", reasonPhrase: "joining group past the subscription" }; - // The groups before the subscription's are a standalone FETCH's to ask for. - if (start !== largest.group) - return { condition: "not_supported", reasonPhrase: "joining FETCH spans more than one group" }; - - const end = { group: largest.group, object: largest.object + 1n }; - const sequence = end.group; - const unavailable: Refusal = { condition: "does_not_exist", reasonPhrase: "joining prefix unavailable" }; - const group = hooks.cachedGroup(join.track, sequence); - if (!group) return unavailable; - // The saved Largest Object proves this prefix's headers exist. Do not read their - // payloads ahead: eviction after FETCH_OK fails the stream, as for a live subgroup. - if (hooks.groupFate(group)() || hooks.groupIndex(group) !== 0 || group.frameCount < frameBound(end.object)) { - group.close(); - return unavailable; - } - return { - cache: join.track, - owned: false, - range: { start: { group: end.group, object: 0n }, end }, - group, - slice: { skip: 0, until: frameBound(end.object) }, - // Where the subscription starts, never the end of the track. - ok: new FetchOk({ endOfTrack: false, endLocation: end, properties: join.properties }), - timescale: join.timescale, - publisherPriority: join.publisherPriority, - // Ranked as the group it heads, ahead of that group's live rest. - sendOrder: join.ranking.rank(sequence, 0), - ranking: join.ranking, - }; - } - - /** - * Write FETCH_OK, then the group on a fetch stream named by the FETCH's own request ID. Nothing past FETCH_OK can be refused, so a failure - * resets both streams instead. - */ - async #answerFetch(msg: Fetch, request: RequestStream, answer: FetchAnswer, cancel: AbortSignal) { - const version = this.#session.version; - const { range, group, slice, timescale, publisherPriority, ranking } = answer; - - // The group failing for good ends the answer too, even while a write is parked on flow control. - const given = untilGivenUp(group, cancel); - const stop = given.signal; - let stream: Writer | undefined; - try { - // FETCH_OK on every draft, never REQUEST_OK: section 5.2 allows exactly one FETCH_OK or - // REQUEST_ERROR in answer to a FETCH. - await untilAborted(Message.write(request.writer, FetchOk.id, answer.ok, version), stop); - const stopped = new Promise((resolve) => { - if (stop.aborted) resolve(undefined); - stop.addEventListener("abort", () => resolve(undefined), { once: true }); - }); - stream = await Writer.tryOpen(this.#quic, { version, sendOrder: answer.sendOrder, cancel: stopped }); - if (!stream) throw stop.reason ?? new Error("fetch stream failed to open"); - ranking?.add(stream, range.start.group, frameBound(range.start.object)); - await untilAborted(new FetchHeader({ requestId: msg.requestId }).encode(stream, version), stop); - - const out = new FetchWriter({ stream, version, timescale, priority: publisherPriority }); - await this.#writeFetchGroup({ out, group, slice, cancelled: stop }); - stream.close(); - // The stream holds every object it owes, so the group failing later no longer resets it. - given.release(); - // The request stream ends once the answer is delivered. - await untilAborted(stream.closed, stop); - } catch (err: unknown) { - const e = stop.aborted ? error(stop.reason) : error(err); - console.debug(`fetch failed: request=${msg.requestId} error=${reason(e)}`); - stream?.reset(e); - request.abort(e); - } finally { - given.release(); - group.close(); - // Ranked until it settles, like a group stream. - if (stream && ranking) { - const opened = stream; - void opened.closed.catch(() => {}).then(() => ranking.remove(opened)); - } - } - } - /** * What a request that failed to resolve is refused with. A missing publisher a rendezvous * waited for is a timeout, which, like Rust's `rendezvous_error`, invites no retry. @@ -2851,57 +2417,6 @@ interface ServeRange { end?: EndLocation; } -/** What a draft-18 joining FETCH inherits from the SUBSCRIBE it names. */ -type Joinable = - /** The subscription's filter starts nowhere a joining FETCH could end. */ - | { kind: "unsupported" } - | { - kind: "join"; - /** The subscription's view of the track, whose cache holds the head. */ - track: TrackSubscriber; - /** The Largest Object the subscription starts right after, absent when there was none. */ - largest?: Location; - /** The subscription's namespace, which the fetch is held to the grant on. */ - namespace: Path.Valid; - /** The Track Properties the subscription was answered with. */ - properties: Properties.Properties; - /** The units the subscription stamps objects in, if any; see {@link RunGroup.timescale}. */ - timescale?: Timescale; - /** The track's Publisher Priority on the wire, which the fetch's first object states. */ - publisherPriority: number; - /** The subscription's ranking, which the fetch stream joins as the group it heads. */ - ranking: Priority; - }; - -/** The span a FETCH answers, as a standalone FETCH spells it. */ -interface FetchRange { - /** The first Location. */ - start: Location; - /** The exclusive end: End Object 0 includes the whole End Group. */ - end: Location; -} - -/** A FETCH resolved against its track, ready to answer. */ -interface FetchAnswer { - /** The track whose cache holds the answer's groups. */ - cache: TrackSubscriber; - /** Whether the answer subscribed `cache` itself, and so closes it. */ - owned: boolean; - range: FetchRange; - /** The reader held until the answer finishes; its normal tail bounds retained payloads. */ - group: group.Consumer; - slice: GroupSlice; - ok: FetchOk; - /** The units objects are stamped in, if any; see {@link RunGroup.timescale}. */ - timescale?: Timescale; - /** The track's Publisher Priority on the wire, which the stream's first object states. */ - publisherPriority: number; - /** The fetch stream's send order. */ - sendOrder: number; - /** The subscription ranking the fetch stream joins, if any. */ - ranking?: Priority; -} - // A filter keeps its peer-chosen Object IDs as bigint. Its derived frame selection // bound can stop at the model's count ceiling: no object exists at or above it, // so clamping either a skip or an exclusive end selects exactly the same frames. @@ -2909,33 +2424,6 @@ function frameBound(object: bigint): number { return object > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(object); } -/** Whether `a` is past `b`. */ -function after(a: Location, b: Location): boolean { - return a.group > b.group || (a.group === b.group && a.object > b.object); -} - -/** Where a draft-22 FETCH's filter starts, resolved against Largest Object. */ -function fetchStart(filter: Filter.Filter, largest: Location): Location { - switch (filter.kind) { - case "unfiltered": - return { group: 0n, object: 0n }; - case "nextObject": - return { group: largest.group, object: largest.object + 1n }; - case "relative": - return { group: saturatingSub(largest.group + 1n, filter.groups), object: 0n }; - case "absolute": - return { group: filter.startGroup, object: filter.startObject }; - } -} - -/** The slice of one group a {@link FetchRange} selects. */ -function fetchSlice(range: FetchRange, sequence: bigint): GroupSlice { - return { - skip: range.start.group === sequence ? frameBound(range.start.object) : 0, - until: range.end.group === sequence && range.end.object > 0n ? frameBound(range.end.object) : undefined, - }; -} - /** Write one group's fetch objects, inheriting the first object's priority. */ class FetchWriter { readonly stream: Writer; @@ -3106,7 +2594,7 @@ function liveEdge(track: TrackSubscriber): LiveEdge { } /** The Locations a single Location Filter selects, resolved against the live edge. */ -function filterRange(filter: Filter.Filter, edge: LiveEdge): ServeRange { +function filterRange(filter: Filter.Filter, edge: LiveEdge, version: IetfVersion): ServeRange { switch (filter.kind) { // No restriction. moq-lite starts at the beginning of the latest group, which is the // join point it is built around; a subscription passes objects as they are published, @@ -3116,10 +2604,14 @@ function filterRange(filter: Filter.Filter, edge: LiveEdge): ServeRange { return {}; // `{Largest.Group, Largest.Object + 1}`. Everything below it, including the already // published head of the current group, is outside the requested range, so the join is - // mid-group by construction. The draft pairs this with a fill, or a joining FETCH - // before draft-22, when the subscriber wants the head; see {@link Publisher.runFetch}. + // mid-group by construction. From draft-20 the draft pairs this with a fill when the + // subscriber wants the head. Before it, the head comes from a joining FETCH, which we + // refuse, so the subscription carries the current group whole instead: a subscriber + // starting at object 0 takes it as the whole group, and the head still reaches one that + // sends no joining FETCH. case "nextObject": - return { start: edge.next }; + if (!edge.next || Filter.isDraft20(version)) return { start: edge.next }; + return { start: { group: edge.next.group, object: 0n } }; // `{Largest.Group + 1 - groups, 0}`: 0 is the next group and 1 is the current one. // Counted from `Largest.Group`, which sits below the newest group while that group has // no objects yet; only with no largest at all does the newest group stand in for it. diff --git a/js/net/src/ietf/subscribe_tracks.ts b/js/net/src/ietf/subscribe_tracks.ts deleted file mode 100644 index 17a8346d285..00000000000 --- a/js/net/src/ietf/subscribe_tracks.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type * as Path from "../path.ts"; -import type { Reader, Writer } from "../stream.ts"; -import * as Filter 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"; - -/** A request to list every track under a namespace prefix, which we refuse. @internal */ -export class SubscribeTracks { - static readonly id = 0x51; - readonly requestId: bigint; - readonly prefix: Path.Valid; - readonly parameters: Parameters; - constructor(requestId: bigint, prefix: Path.Valid, parameters = new Parameters()) { - this.requestId = requestId; - this.prefix = prefix; - this.parameters = parameters; - } - - async encode(writer: Writer, version: IetfVersion): Promise { - if (version < Version.DRAFT_18) throw new Error("SUBSCRIBE_TRACKS before draft-18"); - await Message.encode(writer, async (body) => { - await body.u62(this.requestId); - await Namespace.encode(body, this.prefix); - await this.parameters.encode(body, version); - }); - } - - static async decode(reader: Reader, version: IetfVersion): Promise { - if (version < Version.DRAFT_18) throw new Error("SUBSCRIBE_TRACKS before draft-18"); - return Message.decode(reader, async (body) => { - const requestId = await body.u62(); - const prefix = await Namespace.decode(body); - const parameters = await Parameters.decode(body, version, "subscribe-tracks"); - if (parameters.fillParameters) Filter.decodeFill(parameters.fillParameters, version); - return new SubscribeTracks(requestId, prefix, parameters); - }); - } -} diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 5d91d2e0d9b..c68fb995cf3 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -15,7 +15,6 @@ import * as Ietf from "./ietf/index.ts"; import { Publisher as IetfPublisher } from "./ietf/publisher.ts"; import { RequestError } from "./ietf/request.ts"; import { refuse as refuseRequest } from "./ietf/request_stream.ts"; -import { SubscribeTracks } from "./ietf/subscribe_tracks.ts"; import * as Instance from "./instance.ts"; import { hooks } from "./internal.ts"; import * as Lite from "./lite/index.ts"; @@ -1293,43 +1292,6 @@ test("integration: lite draft-07 fetch uses the republished track's timescale", server.abort(); }); -test("integration: ietf fetches a past group of a finished track", async () => { - for (const protocol of [Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_20, Ietf.ALPN.DRAFT_22]) { - const pair = createMockTransportPair(protocol); - const origin = new OriginProducer(); - const publication = origin.route(Path.from("test")); - // No publisher limit, so the superseded group stays fresh to fetch, and no MAX_CACHE_DURATION. - const published = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); - for (let sequence = 0; sequence < 3; sequence++) { - const group = published.appendGroup(); - group.writeFrame({ - payload: new Uint8Array([sequence]), - timestamp: new Timestamp(sequence * 1000, Timescale.MILLI), - }); - group.finish(); - } - published.finish(); - const [client, server] = await Promise.all([ - connect(url, { transport: pair.client }), - accept(pair.server, url, { publish: origin.consume() }), - ]); - const remote = wireOf(client).consume(Path.from("test")); - const group = await remote.track("video").fetchGroup(1n); - expect(group.sequence).toBe(1n); - const frame = await group.readFrame(); - expect(frame?.payload).toEqual(new Uint8Array([1])); - expect(frame?.timestamp).toEqual(new Timestamp(1000, Timescale.MILLI)); - expect(await group.readFrame()).toBeUndefined(); - group.close(); - // The FETCH-only copy keeps the absent MAX_CACHE_DURATION as no limit, not the authored zero. - expect((await remote.track("video").info()).maxAge).toBeNull(); - remote.close(); - client.abort(); - server.abort(); - origin.close(); - } -}); - // We list no tracks by PUBLISH, so a SUBSCRIBE_TRACKS is refused NOT_SUPPORTED on its own stream, and // the subscription already on the session keeps going. test("integration: ietf SUBSCRIBE_TRACKS is refused per request", async () => { @@ -1364,8 +1326,9 @@ test("integration: ietf SUBSCRIBE_TRACKS is refused per request", async () => { const bidi = await pair.client.createBidirectionalStream(); const listing = new Stream({ ...bidi, version }); - await listing.writer.u53(SubscribeTracks.id); - await new SubscribeTracks(62n, Path.from("room")).encode(listing.writer, version); + // SUBSCRIBE_TRACKS (0x51): Request ID 62, the prefix "room", and no parameters. + await listing.writer.u53(0x51); + await listing.writer.write(Uint8Array.from([0, 8, 62, 1, 4, ...new TextEncoder().encode("room"), 0])); expect(await listing.reader.u53()).toBe(RequestError.id); expect((await RequestError.decode(listing.reader, version)).errorCode).toBe(0x3); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 336dccf698b..43a174a6743 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -825,8 +825,6 @@ export const hooks: { /** The next absolute frame this reader can serve. Assigned by `group.ts`. */ groupIndex: (group: GroupConsumer) => number; groupStarted: (group: GroupConsumer) => boolean; - /** Wait for the selected headers or this reader's group end, without retaining payloads. */ - groupEnd: (group: GroupConsumer, until?: number) => Promise<{ end: number; complete: boolean }>; /** Watch the error a group's copy closes with, ignoring readers' verdicts, without holding the consumer. */ groupFate: (group: GroupConsumer) => () => Error | undefined; /** Record when a track's mirror of a group joined that track at this hop. */ @@ -1165,9 +1163,6 @@ export const hooks: { groupStarted: () => { throw new Error("group.ts not loaded"); }, - groupEnd: () => { - throw new Error("group.ts not loaded"); - }, groupFate: () => { throw new Error("group.ts not loaded"); }, diff --git a/quest/m2/ietf-scope/serving.md b/quest/m2/ietf-scope/serving.md deleted file mode 100644 index 07691ef2cc9..00000000000 --- a/quest/m2/ietf-scope/serving.md +++ /dev/null @@ -1,34 +0,0 @@ -# [M] moq-transport FETCH serving in JS and track listing are gone - -## Goal - -js/net no longer answers moq-transport FETCH as a publisher, and moq-net no -longer lists a moq-transport peer's tracks with SUBSCRIBE_TRACKS -(`ietf/subscribe_tracks.rs`, `ietf/subscriber/tracks.rs`, and the JS -`subscribe_tracks.ts`). A received SUBSCRIBE_TRACKS is refused, as it is -today. - -## Plan - -Part of the [IETF scope freeze](/quest/m2/ietf-scope/README.md), decided -2026-11-09. JS FETCH serving and its pacing landed in -[moq-dev/moq#6945](https://github.com/moq-dev/moq/pull/6945), -[moq-dev/moq#7793](https://github.com/moq-dev/moq/pull/7793), -[moq-dev/moq#7794](https://github.com/moq-dev/moq/pull/7794), -[moq-dev/moq#8117](https://github.com/moq-dev/moq/pull/8117), and -[moq-dev/moq#8231](https://github.com/moq-dev/moq/pull/8231); a browser -publisher serving a third-party FETCH has no user. Relays keep answering -FETCH from their cache, and a subscriber keeps its joining FETCH. - -Before deleting track listing, confirm no relay or moq.pro path needs it to -bridge a moq-transport publisher into moq-lite announcements; if one does, -keep that part, record why here, and delete the rest. A JS publisher that -receives a moq-transport FETCH answers it with the error moq-transport -defines for an unsupported request. - -[Subscribe ranges](/quest/m2/subscribe-ranges/README.md) fills relay misses -over moq-transport; this quest leaves relay FETCH alone. - -Public API: breaking only if js/net exposes a FETCH-serving option or Rust a -track-listing call publicly; name each in the PR's Impact section. Wire: -none; both are optional moq-transport behaviors. diff --git a/quest/m3/audit-interop.md b/quest/m3/audit-interop.md index e5d188a98c8..07ff3dfc051 100644 --- a/quest/m3/audit-interop.md +++ b/quest/m3/audit-interop.md @@ -99,8 +99,9 @@ Still red from `main`, ours: - moq-dev-js `data-subgroup-basic` against libquicr never sees the track end, while moq-dev-rs passes. Not yet traced. - Every `js-web` publisher cell behind a joining FETCH (imquic, MOQtail - draft-22, aiomoqt, red5) was refused its FETCH; JS now serves cached and - draft-18 joining FETCH, so rerun them. + draft-22, aiomoqt, red5) was refused its FETCH; JS still refuses every + FETCH, but now serves a draft-18 Next Object subscription's current group + whole, so rerun them. Theirs, reported or waiting on others: diff --git a/quest/m5/net-wrap/e2ee.md b/quest/m5/net-wrap/e2ee.md index c5e0ac4b18c..1e4ef8cedc6 100644 --- a/quest/m5/net-wrap/e2ee.md +++ b/quest/m5/net-wrap/e2ee.md @@ -72,7 +72,7 @@ the reverse (decided 2026-10-20, maintainer): buffers chunks from `write` and seals at `finish`, and its frame consumer yields chunks only after the frame authenticates. - Listing (maintainer, 2026-10-24): the wire listing (moq-lite's Tracks - stream, IETF SUBSCRIBE_TRACKS) carries physical names unchanged, so + stream) carries physical names unchanged, so relays list a protected broadcast like any other. The consumer's `tracks` opens each listed physical name and reports its semantic name. A name that does not open (relay-forged, another epoch's, malformed) is skipped after one open 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 b87d0aa307b28bbdc0343bc248ebdbbc6173cf52..ea58acb716b180f6a05f4571fb797e27134f653a 100644 GIT binary patch literal 13 UcmZSJV&G(8WK3jCVpL)P00jsD{Qv*} literal 13 UcmZSJX5eICWK3jCVpL)P00k5P{r~^~ 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 7e9a314a706e135ebd01d37e8fd0369c74e69558..0855c8c1a3011b269415274aed4fa81a804a1d27 100644 GIT binary patch literal 13 UcmZSJV&G(8WK3jCVpRGM00svE`~Uy| literal 13 UcmZSJX5eICWK3jCVpRGM00t8Q{Qv*} 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 264b409f72123dfe84076a98810ea9fde82b7174..a7f0074eb4f8959e25bebdf58aab4d8723e8c39f 100644 GIT binary patch literal 13 UcmZSJV&G(8WK3jCVpL)V00j#H00000 literal 13 UcmZSJX5eICWK3jCVpL)V00kET0RR91 diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-namespace-field-trailing-slash b/rs/moq-net/fuzz/regressions/ietf/draft18-namespace-field-trailing-slash index 8bd10803afa1eda42899bb34053e7649b4ab6d19..d1a3a86c3e89e29e4e987b09795f91986371fa2d 100644 GIT binary patch literal 12 TcmZQzWMWCn&(~*8N-P2Z408gr literal 12 TcmZROVPZ+k&(~*8N-P2Z4V(hb 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 915c20d41c3fce6ea8075c269a54ceb63e3c24d8..052c0a61a4a9ed57d576dbd0005070e9d797880d 100644 GIT binary patch literal 12 TcmZROSkJ(~rpTDcz?1|43dsSr literal 12 TcmZROTF=11rpTDcz?1|43f}>@ 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 96f776f5eeeac506c78a7c6fe3fe20073f988a20..944f8942603cb0db15115d15e559a87c52f20364 100644 GIT binary patch literal 12 TcmZROSkJ(~rpTDcz?cL83daGo literal 12 TcmZROTF=11rpTDcz?cL83f%#= 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 8e998087af50b9cac6ace45bf6af02bfea51622e..db77a0b38387f9688c1ec9d4412bcb2f18247f66 100644 GIT binary patch literal 10 RcmZROSkJ(~qQJ<&1ONu}0Pg?* literal 10 RcmZROTF=11qQJ<&1ONvG0Pz3- 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 69f2fc116406a28ab695ef72f40d291fd2ac1bc1..380cdbb77af32df28f9c51983978dd14dac348a3 100644 GIT binary patch literal 8 PcmZP&R%A?MU`hf21401% literal 8 PcmZP&QDjVHU`hf214#h< 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 faf6680ce75a5f899dfd5d6ba94a7a62447968c6..723f884ef5b8fdecc8290dc08ede0e7110881298 100644 GIT binary patch literal 8 PcmZP&R%A?MU`zr413&=! literal 8 PcmZP&QDjVHU`zr414jV+ 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 8e228f345e273272097ab6860d00ec2ebfe4363e..e41275462bb5db71b93b87d9282be9bce6d39e2f 100644 GIT binary patch literal 6 NcmZP&R$ydc0ssK&05<>t literal 6 NcmZP&QD9_X0ssK-05|{u 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 639915ac8e4dbc41e5456eb8cfc3cdc56009a178..95c18085bd8229c32b904f788212f09a1137bfd3 100644 GIT binary patch literal 10 RcmZSJVqjxrWMHgd0004u0E_?t literal 10 RcmZSJW?*AvWMHgd0004%0F3|u 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 85cc10f525ecc669943859fadf5d1fff82976308..b3b5fb4d5a64b2d027047aa22e44851a802b96fd 100644 GIT binary patch delta 24 fcmeBH=vLrh;1XbGWSqdTQSdweL0FD3v 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 29b653c967758de2dc4391f4827809f392f39f45..da7bad02d2bf5254e84c4684624c9a8c404a3096 100644 GIT binary patch delta 24 fcmeBH=vLrh grant.subscribe.matches(path), Self::Discover => !grant.subscribe.is_empty(), - Self::Publish => grant.publish.matches(path), Self::PublishNamespace => { crate::Pattern::subtree(path).is_ok_and(|subtree| grant.publish.contains(&subtree)) } @@ -2486,8 +2483,7 @@ impl Gate { } /// The kind of request this gate admits, for its token's grant to cover. A gate on what - /// the peer publishes to us verifies only a PUBLISH_NAMESPACE's token; a PUBLISH's goes - /// straight to [`Link::verify_request`]. + /// the peer publishes to us verifies only a PUBLISH_NAMESPACE's token. fn kind(&self) -> RequestKind { match (self.discovery, self.direction) { (true, _) => RequestKind::Discover, diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 4cbcf800809..f62baa04bee 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -272,7 +272,7 @@ const IETF_VERSIONS: &[ietf::Version] = &[ietf::Version::Draft18, ietf::Version: const LITE_KINDS: u8 = 34; /// How many types [`ietf_wire`] dispatches over. -const IETF_KINDS: u8 = 27; +const IETF_KINDS: u8 = 25; /// Split the two selector bytes off the input: a version and a type. fn select(data: &[u8], versions: usize) -> Option<(usize, u8, &[u8])> { @@ -1071,23 +1071,21 @@ pub fn ietf_wire(data: &[u8]) -> bool { 7 => roundtrip::, _>(rest, &mut Vec::new(), version), 8 => roundtrip::(rest, &mut Vec::new(), version), 9 => roundtrip::, _>(rest, &mut Vec::new(), version), - 10 => roundtrip::, _>(rest, &mut Vec::new(), version), - 11 => roundtrip::, _>(rest, &mut Vec::new(), version), - 12 => roundtrip::(rest, &mut Vec::new(), version), - 13 => roundtrip::(rest, &mut Vec::new(), version), - 14 => roundtrip::(rest, &mut Vec::new(), version), - 15 => roundtrip::(rest, &mut Vec::new(), version), - 16 => roundtrip::(rest, &mut Vec::new(), version), - 17 => roundtrip::(rest, &mut Vec::new(), version), - 18 => roundtrip::, _>(rest, &mut Vec::new(), version), - 19 => roundtrip::(rest, &mut Vec::new(), version), - 20 => roundtrip::(rest, &mut Vec::new(), version), - 21 => roundtrip::(rest, &mut Vec::new(), version), - 22 => roundtrip::(rest, &mut Vec::new(), version), - 23 => roundtrip::(rest, &mut Vec::new(), version), - 24 => roundtrip::(rest, &mut Vec::new(), version), - 25 => roundtrip::(rest, &mut Vec::new(), version), - 26 => setup::(rest, &mut Vec::new(), version, |setup| &setup.parameters), + 10 => roundtrip::, _>(rest, &mut Vec::new(), version), + 11 => roundtrip::(rest, &mut Vec::new(), version), + 12 => roundtrip::(rest, &mut Vec::new(), version), + 13 => roundtrip::(rest, &mut Vec::new(), version), + 14 => roundtrip::(rest, &mut Vec::new(), version), + 15 => roundtrip::(rest, &mut Vec::new(), version), + 16 => roundtrip::(rest, &mut Vec::new(), version), + 17 => roundtrip::, _>(rest, &mut Vec::new(), version), + 18 => roundtrip::(rest, &mut Vec::new(), version), + 19 => roundtrip::(rest, &mut Vec::new(), version), + 20 => roundtrip::(rest, &mut Vec::new(), version), + 21 => roundtrip::(rest, &mut Vec::new(), version), + 22 => roundtrip::(rest, &mut Vec::new(), version), + 23 => roundtrip::(rest, &mut Vec::new(), version), + 24 => setup::(rest, &mut Vec::new(), version, |setup| &setup.parameters), _ => unreachable!("kind is taken modulo IETF_KINDS"), } } diff --git a/rs/moq-net/src/fuzz/request.rs b/rs/moq-net/src/fuzz/request.rs index b28087945ea..e8721c29f8d 100644 --- a/rs/moq-net/src/fuzz/request.rs +++ b/rs/moq-net/src/fuzz/request.rs @@ -153,8 +153,6 @@ fn body(framed: &ietf::Framed, version: ietf::Version) { ietf::SubscribeNamespace, ietf::Namespace, ietf::NamespaceDone, - ietf::SubscribeTracks, - ietf::PublishSkipped<'_>, ietf::RequestOk, ietf::RequestError<'_>, ); diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index 6b23f7942d4..4379892abe3 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -147,9 +147,6 @@ 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. - pub(crate) const DUPLICATE_SUBSCRIPTION: u64 = 0x19; - /// The endpoint is overloaded and cannot process the request. pub(crate) const EXCESSIVE_LOAD: u64 = 0x9; diff --git a/rs/moq-net/src/ietf/message.rs b/rs/moq-net/src/ietf/message.rs index f0e16ec4f3a..1bf55769d47 100644 --- a/rs/moq-net/src/ietf/message.rs +++ b/rs/moq-net/src/ietf/message.rs @@ -7,7 +7,7 @@ pub(super) fn control(id: u64) -> crate::stats::Message { use crate::stats::Message as Class; match id { PublishNamespace::ID | SubscribeNamespace::ID | Namespace::ID | NamespaceDone::ID => Class::Announce, - TrackStatus::ID | SubscribeTracks::ID | PublishSkipped::ID => Class::TrackInfo, + TrackStatus::ID | SUBSCRIBE_TRACKS_ID => Class::TrackInfo, 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, diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index bfc349d728a..33b0b60356d 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -49,7 +49,6 @@ mod session; pub mod solicit; mod subscribe; mod subscribe_namespace; -mod subscribe_tracks; #[cfg(feature = "async")] mod subscriber; pub(crate) mod token; @@ -76,7 +75,6 @@ pub use request::*; pub use session::*; pub use subscribe::*; pub use subscribe_namespace::*; -pub use subscribe_tracks::*; #[cfg(feature = "async")] use subscriber::*; // The fuzz harness drives a subgroup stream's ingest directly. diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index e1e0ad6dc63..78938ef712e 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -228,25 +228,6 @@ pub struct Publish<'a> { pub filter: Option, } -impl Publish<'_> { - /// Copy the borrowed track name, so the request outlives the bytes it came from. - pub fn into_owned(self) -> Publish<'static> { - Publish { - request_id: self.request_id, - authorization_token: self.authorization_token, - track_namespace: self.track_namespace, - track_name: Cow::Owned(self.track_name.into_owned()), - track_alias: self.track_alias, - largest_location: self.largest_location, - forward: self.forward, - properties: self.properties, - subscriber_priority: self.subscriber_priority, - group_order: self.group_order, - filter: self.filter, - } - } -} - impl<'a> Message<'a> for Publish<'a> { const ID: u64 = 0x1D; diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 234f37f9229..cfb7e6ef67e 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2985,30 +2985,16 @@ impl Publisher { Poll::Ready(()) }) } - 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. - if !data.is_empty() { - return Err(Error::WrongSize); - } - let kind = request::Kind::SubscribeNamespace; - return Ok(this.reject_token(stream, kind, err, serve)); - } - msg => msg?, - }; - if !data.is_empty() { - return Err(Error::WrongSize); - } - tracing::debug!(message = ?msg, "received subscribe_tracks"); + ietf::SUBSCRIBE_TRACKS_ID => { // We list no tracks by PUBLISH, so each listing is refused on its own and the - // session stays up. Decoded first, so a refusal still admits its Request ID. - let error = - super::namespace::refusal(&msg.prefix, this.version).unwrap_or_else(|| ietf::RequestError { - error_code: request::NOT_SUPPORTED, - reason_phrase: "SUBSCRIBE_TRACKS is not supported".into(), - retry_interval: 0, - }); + // session stays up. The dispatcher already admitted its Request ID, and the rest + // of the body is never read. + tracing::debug!("refusing subscribe_tracks"); + let error = ietf::RequestError { + error_code: request::NOT_SUPPORTED, + reason_phrase: "SUBSCRIBE_TRACKS is not supported".into(), + retry_interval: 0, + }; let mut stream = stream; let buffered = stream.writer.buffer_message(ietf::RequestError::ID, &error); deliver_refusal(stream, buffered, serve, "subscribe_tracks refusal failed") diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index 83f3083edd0..d7bb221998a 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -238,51 +238,6 @@ impl Reject { } } -/// Abandon a request we sent: STOP_SENDING, then our FIN and its acknowledgement. -/// -/// There is no message for it, 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. Finishing alone would leave the writer's Drop free to RESET_STREAM, discarding what -/// was already written, so the writer is closed instead; a request the peer's send window -/// held back resets. -pub(super) struct Cancel { - /// Held until the close settles, as the request stream it belongs to. - _reader: crate::coding::Reader, - closing: Option>, -} - -impl Cancel { - pub(super) fn new(stream: Stream) -> Self { - let Stream { writer, mut reader } = stream; - // STOP_SENDING needs no acknowledgement, so it goes first and the wait covers only - // what we still have to deliver. - reader.abort(&Error::Cancel); - let closing = match writer.closing() { - Ok(closing) => Some(closing), - Err(err) => { - tracing::debug!(%err, "failed to close the subscribe stream"); - None - } - }; - Self { - _reader: reader, - closing, - } - } - - /// Ready once the peer acknowledges our FIN, or the stream dies. - pub(super) fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - if let Some(closing) = &mut self.closing { - if let Err(err) = ready!(closing.poll(waiter)) { - tracing::debug!(%err, "failed to close the subscribe stream"); - } - self.closing = None; - } - Poll::Ready(()) - } -} - /// Deliver the last message buffered on a response: flush it, then finish the stream and /// wait for the peer's acknowledgement. /// diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 1ca7020ef8a..a4b24b321ab 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1673,7 +1673,7 @@ impl BidiServe { ietf::Subscribe::ID | ietf::Fetch::ID | ietf::SubscribeNamespace::ID - | ietf::SubscribeTracks::ID + | ietf::SUBSCRIBE_TRACKS_ID | ietf::TrackStatus::ID | ietf::Publish::ID | ietf::PublishNamespace::ID @@ -1688,11 +1688,11 @@ impl BidiServe { } } Ok(match id { - // Publisher handles: Subscribe, Fetch, SubscribeNamespace, SubscribeTracks, TrackStatus + // Publisher handles: Subscribe, Fetch, SubscribeNamespace, SUBSCRIBE_TRACKS, TrackStatus ietf::Subscribe::ID | ietf::Fetch::ID | ietf::SubscribeNamespace::ID - | ietf::SubscribeTracks::ID + | ietf::SUBSCRIBE_TRACKS_ID | ietf::TrackStatus::ID => BidiStep::Serve(publisher.handle_stream(id, data, stream)?), // Subscriber handles: Publish, PublishNamespace ietf::Publish::ID | ietf::PublishNamespace::ID => { @@ -4191,9 +4191,9 @@ mod tests { "a 4097 byte full track name", ), ( - frame(version, ietf::SubscribeTracks::ID, &oversized_prefix), + frame(version, ietf::SubscribeNamespace::ID, &oversized_prefix), SessionError::ProtocolViolation, - "a 4097 byte SUBSCRIBE_TRACKS prefix", + "a 4097 byte SUBSCRIBE_NAMESPACE prefix", ), ( subscribe(&[&valid[..], &[0]].concat()), diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 8ab6249cf9f..158e18673af 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -9,6 +9,10 @@ use super::publish_namespace::{AdvertParams, Negotiated}; use super::Version; +/// SUBSCRIBE_TRACKS (0x51, draft-18+), which lists every track under a prefix. We send +/// none and refuse each one received, so only its type is known. +pub(crate) const SUBSCRIBE_TRACKS_ID: u64 = 0x51; + /// SUBSCRIBE_NAMESPACE message (type 0x50). #[derive(Clone, Debug)] pub struct SubscribeNamespace { diff --git a/rs/moq-net/src/ietf/subscribe_tracks.rs b/rs/moq-net/src/ietf/subscribe_tracks.rs deleted file mode 100644 index d65b305c942..00000000000 --- a/rs/moq-net/src/ietf/subscribe_tracks.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! IETF moq-transport SUBSCRIBE_TRACKS (draft-18+) and the PUBLISH_SKIPPED answering it. - -use std::borrow::Cow; - -use crate::{ - Path, - coding::*, - ietf::{Filter, GroupOrder, Opaque, RequestId}, -}; - -use super::Message; -use super::Version; -use super::namespace::{decode_namespace, encode_namespace}; -use super::subscribe::has_range_filters; -use super::token::RequestToken; - -/// SUBSCRIBE_TRACKS (0x51, draft-18+): list every track under a namespace prefix, each -/// answered with a PUBLISH (or a [`PublishSkipped`]) as it appears. -/// -/// We send it to list an IETF peer's tracks, and refuse one we receive, so only what our -/// request carries is kept; the rest is read to validate it and dropped. -#[derive(Clone, Debug)] -pub struct SubscribeTracks { - pub request_id: RequestId, - pub prefix: Path, - /// FORWARD: absent means 1, which asks each PUBLISH to push its objects at once. - pub forward: bool, - /// The default group order of each PUBLISH subscription. - pub group_order: Option, - /// INCLUDE_PROPERTIES (draft-22): whether each PUBLISH carries Track Properties. - pub properties_wanted: bool, - /// The `AUTHORIZATION TOKEN` (0x03) this request presents. - pub authorization_token: Option, -} - -impl<'a> Message<'a> for SubscribeTracks { - const ID: u64 = 0x51; - - fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - self.request_id.encode(w, version)?; - encode_namespace(w, &self.prefix)?; - // Absent means 1, so only a paused listing writes it. - let include_properties = (!self.properties_wanted && Filter::is_draft20(version)).then_some(false); - // Draft-18 allows only AUTHORIZATION_TOKEN and FORWARD here. Draft-20 and draft-22 - // (section 9.18) also list GROUP_ORDER. - let defaults = version != Version::Draft18; - encode_params!(w, version, - 0x03 => self.authorization_token.clone(), - 0x10 => (!self.forward).then_some(false), - 0x22 => self.group_order.filter(|_| defaults), - 0x35 => include_properties, - ); - Ok(()) - } - - fn decode_msg(r: &mut Decoder<'a>, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - let prefix = decode_namespace(r)?; - // From draft-20 any parameter a SUBSCRIBE takes is legal here (section 3.6.2 of draft-22); - // draft-18 allows only AUTHORIZATION_TOKEN and FORWARD. Those that only make sense - // on one subscription, such as RENDEZVOUS_TIMEOUT or a delivery timeout, are read - // and dropped. - let defaults = version != Version::Draft18; - decode_params!(r, version, - 0x02 => _object_delivery_timeout: Option where defaults, - 0x03 => authorization_token: Vec, - 0x04 => _rendezvous_timeout: Option where defaults, - 0x06 => _subgroup_delivery_timeout: Option where defaults, - 0x10 => forward: Option, - 0x20 => _subscriber_priority: Option where defaults, - 0x21 => _filter: Option where defaults, - 0x22 => group_order: Option where defaults, - // Each PUBLISH starts paused, where a fill opens nothing, even once resumed - // (draft-22 section 3.4.1), so FILL_PARAMETERS is read and dropped. - 0x23 => _fill: Option where Filter::is_draft20(version), - 0x25 => _subgroup_filter: Vec where has_range_filters(version), - 0x26 => _object_id_filter: Vec where has_range_filters(version), - 0x27 => _priority_filter: Vec where has_range_filters(version), - 0x28 => _object_property_filter: Vec where has_range_filters(version), - 0x29 => _track_property_filter: Vec where has_range_filters(version), - 0x32 => _new_group: Option where defaults, - 0x35 => include_properties: Option where Filter::is_draft20(version), - ); - // Last, so a malformed field closes the session before a token refusal. - let authorization_token = super::token::single(authorization_token)?; - - Ok(Self { - request_id, - prefix, - forward: forward.unwrap_or(true), - group_order, - properties_wanted: include_properties.unwrap_or(true), - authorization_token, - }) - } -} - -/// 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. -/// -/// 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> { - /// The track's namespace below the request's prefix. - pub suffix: Path, - pub track_name: Cow<'a, str>, -} - -impl<'a> Message<'a> for PublishSkipped<'a> { - const ID: u64 = 0x0F; - - 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<'a>, _: Version) -> Result { - let suffix = decode_namespace(r)?; - let track_name = Cow::Borrowed(r.str()?); - Ok(Self { suffix, track_name }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn encode<'m, M: Message<'m>>(msg: &M, version: Version) -> Vec { - let mut buf = Vec::new(); - msg.encode_msg(&mut Encoder::new(&mut buf, version.into()), version) - .unwrap(); - buf - } - - fn decode<'m, M: Message<'m>>(bytes: &'m [u8], version: Version) -> Result { - let mut buf = bytes; - crate::coding::decode_from(&mut buf, version, |r, v| M::decode_msg(r, v)) - } - - fn paused() -> SubscribeTracks { - SubscribeTracks { - request_id: RequestId(4), - prefix: Path::from_static("room"), - forward: false, - group_order: None, - properties_wanted: true, - authorization_token: None, - } - } - - #[test] - fn a_paused_listing_round_trips() { - for version in [Version::Draft18, Version::Draft20, Version::Draft22] { - let decoded: SubscribeTracks = decode(&encode(&paused(), version), version).unwrap(); - assert_eq!(decoded.request_id, RequestId(4), "{version}"); - assert_eq!(decoded.prefix.as_str(), "room", "{version}"); - assert!(!decoded.forward, "{version}"); - } - } - - /// FORWARD defaults to 1, so a listing that omits it asks for every track's objects. - #[test] - fn an_omitted_forward_means_one() { - // Request ID 4, prefix "room", no parameters. - let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x00]; - let decoded: SubscribeTracks = decode(&bytes, Version::Draft22).unwrap(); - assert!(decoded.forward); - } - - /// TRACK_PROPERTY_FILTER (draft-22) is legal, so it is read. - #[test] - fn a_track_property_filter_is_read() { - // Request ID 4, prefix "room", one parameter: 0x29 with a two-byte opaque value. - let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x01, 0x29, 0x02, 0xaa, 0xbb]; - assert!(decode::(&bytes, Version::Draft22).is_ok()); - // Draft-18 has no such parameter, and an unknown even type is malformed there. - assert!(decode::(&bytes, Version::Draft18).is_err()); - } - - /// Draft-18 carries no subscription parameters on SUBSCRIBE_TRACKS: one is not sent, - /// and one received is malformed. - #[test] - fn draft_18_carries_no_subscription_parameters() { - let listing = SubscribeTracks { - group_order: Some(GroupOrder::Ascending), - ..paused() - }; - assert_eq!(encode(&listing, Version::Draft18), encode(&paused(), Version::Draft18)); - // Request ID 4, prefix "room", one parameter: SUBSCRIBER_PRIORITY 7. - let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x01, 0x20, 0x07]; - assert!(decode::(&bytes, Version::Draft18).is_err()); - } - - /// From draft-20 GROUP_ORDER is sent, and any subscription parameter received is read. - #[test] - fn later_drafts_send_their_group_order() { - let ordered = SubscribeTracks { - group_order: Some(GroupOrder::Ascending), - ..paused() - }; - for version in [Version::Draft20, Version::Draft22] { - assert_ne!(encode(&ordered, version), encode(&paused(), version), "{version}"); - let decoded: SubscribeTracks = decode(&encode(&ordered, version), version).unwrap(); - assert_eq!(decoded.group_order, Some(GroupOrder::Ascending), "{version}"); - // Request ID 4, prefix "room", one parameter: SUBSCRIBER_PRIORITY 7. - let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x01, 0x20, 0x07]; - assert!(decode::(&bytes, version).is_ok(), "{version}"); - } - } - - #[test] - fn publish_skipped_round_trips() { - for version in [Version::Draft18, Version::Draft20, Version::Draft22] { - let msg = PublishSkipped { - suffix: Path::from_static("cam"), - track_name: "video".into(), - }; - let wire = encode(&msg, version); - let decoded: PublishSkipped = decode(&wire, version).unwrap(); - assert_eq!(decoded.suffix.as_str(), "cam", "{version}"); - assert_eq!(decoded.track_name, "video", "{version}"); - } - } -} diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4623fa4c084..6c380b350d6 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -24,11 +24,25 @@ use kio::Lock; mod namespaces; pub(super) use namespaces::Namespaces; mod datagrams; -mod tracks; use datagrams::{Requested, Shared}; const TRACK_ALIAS_TIMEOUT: Duration = Duration::from_secs(1); +/// A track, by its namespace and name. +type TrackKey = (Path, std::sync::Arc); + +/// How a PUBLISH_DONE ends the track: only TRACK_ENDED ends it cleanly. Any other status, +/// INTERNAL_ERROR included, fails the copy rather than declare an end the track may run past. +fn done_end(done: &ietf::PublishDone, version: Version) -> Result<(), Error> { + match done.status_code == ietf::PublishDoneStatus::TrackEnded.code() { + true => Ok(()), + false => Err(done + .end(version) + .err() + .unwrap_or(Error::Remote(u32::try_from(done.status_code).unwrap_or(u32::MAX)))), + } +} + /// How many cancelled aliases to remember. Objects keep arriving for about a round trip /// after we cancel, so a handful covers the window, while the cap keeps a long session with /// heavy subscription churn from accumulating tombstones for its whole lifetime. @@ -68,21 +82,13 @@ enum Alias { /// promptly is what bounds the exposure, since it caps the arrival window at a round /// trip rather than leaving it open for the life of the session. Retired, - - /// A paused subscription a listing holds: the alias stays its own, but a group carrying - /// it is refused rather than buffered, since nothing reads the track. - Paused(RequestId), - - /// A paused subscription resuming, whose REQUEST_UPDATE is not answered yet. Its groups - /// can overtake the answer, so they wait on it as they would on a SUBSCRIBE_OK. - Resuming(RequestId), } impl Alias { /// The subscription holding the alias, if any. fn holder(self) -> Option { match self { - Self::Active(id) | Self::Paused(id) | Self::Resuming(id) => Some(id), + Self::Active(id) => Some(id), Self::Retired => None, } } @@ -120,10 +126,6 @@ fn insert_track_alias(aliases: &TrackAliases, alias: u64, request_id: RequestId) table.retired.retain(|&retired| retired != alias); Ok(()) } - Entry::Occupied(mut entry) if entry.get().holder() == Some(request_id) => { - entry.insert(Alias::Active(request_id)); - Ok(()) - } Entry::Occupied(_) => Err(Error::Duplicate), Entry::Vacant(entry) => { entry.insert(Alias::Active(request_id)); @@ -149,19 +151,6 @@ pub(super) fn is_protocol_violation(err: &Error) -> bool { ) } -/// Move `alias` to `to` (paused, resuming, or active again), unless another subscription -/// than `request_id` holds it. -fn rebind_track_alias(aliases: &TrackAliases, alias: u64, request_id: RequestId, to: Alias) { - let Ok(mut aliases) = aliases.write() else { - return; - }; - if let Some(held) = aliases.map.get_mut(&alias) - && held.holder() == Some(request_id) - { - *held = to; - } -} - /// Retire an alias, so groups still in flight for it are dropped promptly rather than /// reported as unknown. /// @@ -194,7 +183,7 @@ fn retire_track_alias(aliases: &TrackAliases, alias: u64, request_id: RequestId) struct State { /// Clean ends keep their request streams until the driver drains datagrams received with them. datagram_ends: Option>, - datagram_requests: HashMap, crate::datagram::Request)>, + datagram_requests: HashMap, crate::datagram::Request)>, // Each active subscription subscribes: HashMap, @@ -714,7 +703,7 @@ pub(super) async fn refusing_upstream( (subscriber, task_set) } -/// A publisher's answer to a SUBSCRIBE, or to the REQUEST_UPDATE resuming one. +/// A publisher's answer to a SUBSCRIBE. enum Answer { Accepted(Accepted), /// A REQUEST_ERROR: the error its code reads as, and the Retry Interval it asked for. @@ -1105,11 +1094,8 @@ pub(super) struct Subscriber { going_away: crate::goaway::GoingAway, // Our grant (MoQ Auth): a subscription it stops covering is cancelled. auth: crate::auth::Link, - // Namespaces the peer may have announced at once (`session::Limits::announces`), and - // tracks it lists to us. + // Namespaces the peer may have announced at once (`session::Limits::announces`). pub(super) announces: crate::session::Slots, - // What SUBSCRIBE_TRACKS lists of the peer's tracks. - listings: Lock, } /// One SUBSCRIBE_NAMESPACE on its stream. @@ -1154,10 +1140,8 @@ impl ResolveAlias { // our STOP_SENDING. Discard the group now rather than waiting out the timeout // for a binding that is never coming. Some(Alias::Retired) => Poll::Ready(Err(Error::Cancel)), - // Nothing reads a paused subscription, so its groups are refused. - Some(Alias::Paused(_)) => Poll::Ready(Err(Error::Cancel)), - Some(Alias::Resuming(_)) | None if aliases.ended => Poll::Ready(Err(Error::Cancel)), - Some(Alias::Resuming(_)) | None => Poll::Pending, + None if aliases.ended => Poll::Ready(Err(Error::Cancel)), + None => Poll::Pending, }); if let Poll::Ready(result) = resolved { return Poll::Ready(result.unwrap_or(Err(Error::Cancel))); @@ -1215,7 +1199,6 @@ impl Subscriber { going_away, auth: crate::auth::Link::new(false), announces: Default::default(), - listings: Default::default(), validity: Default::default(), } } @@ -1405,14 +1388,6 @@ impl Subscriber { Ok(()) } - /// Move the alias the subscription with `request_id` is bound to into `to`. - fn rebind_alias(&self, request_id: RequestId, to: Alias) { - let state = self.state.lock(); - if let Some(alias) = state.subscribes.get(&request_id).and_then(|track| track.alias) { - rebind_track_alias(&state.aliases, alias, request_id, to); - } - } - /// Whether the subscription already holding `alias` is for the same full track name as /// `request_id`, making the repeat legal sharing rather than a collision. fn alias_names_same_track(&self, state: &State, alias: u64, request_id: RequestId) -> bool { @@ -1919,11 +1894,9 @@ impl Subscriber { return Err(Error::WrongSize); } tracing::debug!(message = ?msg, "received publish"); - // Owned, since the task outlives the body it was read from. - let msg = msg.into_owned(); - let mut serve = tracks::ServePublish::new(this, stream, msg); + let mut reject = this.decline_publish(stream, &msg); Box::new(move |waiter: &kio::Waiter| { - if let Err(err) = ready!(serve.poll(waiter)) { + if let Err(err) = ready!(reject.poll(waiter)) { tracing::debug!(%err, "publish stream error"); } Poll::Ready(()) @@ -1987,6 +1960,75 @@ impl Subscriber { Ok(task) } + /// Decline a PUBLISH. + /// + /// PUBLISH offers a single track, so honoring it means routing per + /// (namespace, track). Our model routes per namespace: a source attaches at a + /// path and serves every track under it, resolved on demand via SUBSCRIBE. + /// Accepting a PUBLISH would mean inventing a namespace-level source out of a + /// track-level offer, and that fiction then contradicts any real + /// PUBLISH_NAMESPACE for the same path. + /// + /// Declining the request rather than failing the session, since a peer using + /// a feature we don't implement is not a protocol violation. The rejection is the + /// whole exchange, but it still has to arrive: [`super::request_stream::Reject`] delivers it. + fn decline_publish( + &self, + mut stream: Stream, + msg: &ietf::Publish<'_>, + ) -> super::request_stream::Reject { + if let Some(error) = super::namespace::refusal(&msg.track_namespace, self.version) { + return super::request_stream::Reject::new(stream, error); + } + tracing::debug!(broadcast = %msg.track_namespace, track = %msg.track_name, "rejecting publish"); + + // We decline the method itself rather than this particular track, which would be + // UNINTERESTED. + // + // The alias the message carries is deliberately not recorded. Nothing will ever bind + // 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. + let (kind, err) = (request::Kind::Publish, &Error::Unsupported); + let buffered = super::request_stream::buffer_error( + &mut stream.writer, + kind, + err, + "PUBLISH is not supported", + self.version, + ); + super::request_stream::Reject::buffered(stream, buffered) + } + + /// Read the answer to a SUBSCRIBE: its SUBSCRIBE_OK, or the REQUEST_ERROR refusing it. + fn poll_read_answer( + &self, + waiter: &kio::Waiter, + stream: &mut Stream, + messages: &mut super::request_stream::Messages, + ) -> Poll> { + let super::request_stream::Framed(id, body) = ready!(messages.poll_message(&mut stream.reader, waiter))?; + let mut data = body.decoder(self.version); + Poll::Ready(match id { + ietf::SubscribeOk::ID => { + let msg = ietf::SubscribeOk::decode_msg(&mut data, self.version)?; + tracing::debug!(message = ?msg, "received subscribe ok"); + Ok(Answer::Accepted(Accepted { + alias: msg.track_alias, + properties: msg.properties, + largest: msg.largest, + })) + } + ietf::RequestError::ID => { + let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; + tracing::warn!(message = ?msg, "request error"); + let err = request::from_code(msg.error_code, request::Kind::Subscribe, self.version); + Ok(Answer::Refused(err, Duration::from_millis(msg.retry_interval))) + } + _ => Err(Error::UnexpectedMessage), + }) + } + /// Refuse a request whose AUTHORIZATION TOKEN repeats (section 8.9), which unlike any /// other decode error leaves the session open. fn reject_token( @@ -3089,38 +3131,6 @@ impl Subscriber { kio::wait(|waiter| kio::Task::poll(&mut run, waiter)).await } - /// Answer the source's listings while anyone lists it, from a SUBSCRIBE_TRACKS covering - /// it; a failed listing ends its readers, which ask again. - fn poll_listing( - &self, - path: &Path, - lister: &std::sync::Arc, - listening: &mut Option, - waiter: &kio::Waiter, - ) { - loop { - match listening { - None => { - if !matches!(lister.poll_wanted(waiter), Poll::Ready(Ok(()))) { - return; - } - *listening = Some(self.listen(path.to_owned(), lister.clone())); - } - Some(_) if lister.poll_unwanted(waiter).is_ready() => { - *listening = None; - lister.clear(); - } - Some(current) => match current.poll_failed(waiter) { - Poll::Ready(err) => { - *listening = None; - lister.abort(err); - } - Poll::Pending => return, - }, - } - } - } - /// Serve one track request; see [`RunSubscribe`]. #[cfg(test)] pub(super) async fn run_subscribe( @@ -3311,9 +3321,8 @@ struct RunBroadcast { broadcast: broadcast::Requests, route: Option>, subscribes: TaskSet, - /// Answers the source's listings with the peer's tracks, while anyone lists it. - lister: std::sync::Arc, - listening: Option, + /// Refuses the source's listings: we never list a moq-transport peer's tracks. + lister: broadcast::Lister, /// The broadcast closed: no new tracks, but those in flight run to their own end. draining: bool, } @@ -3326,11 +3335,9 @@ impl RunBroadcast { broadcast: broadcast::Requests, route: Option>, ) -> Self { - let lister = std::sync::Arc::new( - broadcast - .lister() - .expect("only the source answers its broadcast's listings"), - ); + let lister = broadcast + .lister() + .expect("only the source answers its broadcast's listings"); Self { this, path, @@ -3340,7 +3347,6 @@ impl RunBroadcast { route, subscribes: TaskSet::owned(), lister, - listening: None, draining: false, } } @@ -3350,8 +3356,10 @@ impl RunBroadcast { if self.this.session.poll_closed(waiter).is_ready() { return Poll::Ready(None); } - self.this - .poll_listing(&self.path, &self.lister, &mut self.listening, waiter); + // An empty answer would claim the peer lists nothing. + while let Poll::Ready(Ok(())) = self.lister.poll_wanted(waiter) { + self.lister.abort(Error::Unsupported); + } loop { while let Poll::Ready(Ok(request)) = self.datagrams.poll_requested(waiter) { if self.this.session.max_datagram_size() == 0 { @@ -3419,7 +3427,6 @@ impl kio::Task for RunBroadcast { } Some(Err(err)) => { tracing::debug!(%err, "broadcast closed"); - drop(self.listening.take()); self.draining = true; } // Session gone. @@ -3608,12 +3615,6 @@ impl kio::Task for RunSubscribe { } } -/// How a listed track's handoff ended without a subscription to adopt. -enum Abandon { - Failed(Error), - Unused, -} - /// What ended the wait for a SUBSCRIBE's answer. A publisher can be serving before its /// SUBSCRIBE_OK reaches us, since the data streams are independent of the request stream. /// Waiting for the response alone would miss the local side going away in that window and @@ -3647,12 +3648,10 @@ enum ServeEnd { struct SubscribeOnce { source: Option, shared: Option, - key: tracks::TrackKey, - /// This request's own view of the track's gate, which a resumed subscription's token - /// binds to. + key: TrackKey, + /// This request's own view of the track's gate. gate: crate::auth::Gate, target: Option, - token_grant: Option, _working: crate::session::Working, validity: crate::model::broadcast::RequestValidity, resolved: ResolvedRanges, @@ -3667,11 +3666,6 @@ struct SubscribeOnce { /// copy's fills fetch an earlier start's groups. An absolute start keeps its requested /// prefix even if the answer arrives late. live: bool, - /// A listed track's paused subscription, which this one resumes rather than open a - /// second one. - handoff: Option>, - /// The resumed subscription's alias, properties, and owed answers, when resuming one. - held: Option, /// Set once the request has its stream. request_id: RequestId, stream: Option>, @@ -3701,11 +3695,9 @@ struct SubscribeOnce { enum OnceStep { /// Resolving what to ask for. Start, - /// Waiting for a listed track's paused subscription to be handed over. - Adopt, /// Opening a fresh request stream. Open(crate::transport::open::Open), - /// Writing the SUBSCRIBE, or the update resuming a held subscription. + /// Writing the SUBSCRIBE. Write, /// Waiting for the answer, flushing the request meanwhile. Answer, @@ -3737,7 +3729,6 @@ enum OnceStep { /// An Established subscription, from its answer to its end. struct Established { - alias: u64, properties: ietf::Properties, /// The answer's Largest Location. largest: Option, @@ -3772,7 +3763,7 @@ struct Established { unanswered: u64, /// Our grant stopped covering the track: it is aborted rather than kept lingering. failed: bool, - /// How the upstream subscription ended, which ends a listing entry it held. + /// How the upstream subscription ended, which ends its datagram copy too. ended: Option>, /// The publisher ended the track cleanly: the copy lingers for its history's fills. finished: bool, @@ -3796,7 +3787,6 @@ impl SubscribeOnce { key: (broadcast_path.clone(), track_name.into()), gate: gate.unnamed(), target: Some(target), - token_grant: None, _working: this.session.work().start(), validity: this.validity.start(), resolved: ResolvedRanges::default(), @@ -3805,8 +3795,6 @@ impl SubscribeOnce { start: None, join: Join::default(), live: false, - handoff: None, - held: None, request_id: RequestId(0), stream: None, messages: None, @@ -3874,87 +3862,7 @@ impl SubscribeOnce { self.subscription = subscription; self.target = Some(target); - // A listed track's paused subscription is already this track's upstream - // copy, so resume it rather than open a second one. - self.handoff = this.want(&self.key); - self.step = match self.handoff { - Some(_) => OnceStep::Adopt, - None => OnceStep::Open(Default::default()), - }; - } - OnceStep::Adopt => { - let handoff = self.handoff.as_ref().expect("adopting a handoff"); - // The handoff waits for the held stream to flush, which a peer withholding - // credit can stall, so nobody wanting the track any more ends the wait too. - // Every wait here drives the fetches a lingering copy started. - let _ = group_fetches.poll(waiter); - let adopt = if self.gate.poll_denied(waiter).is_ready() { - Err(Abandon::Failed(Error::Unauthorized)) - } else if let Err(err) = self.validity.check(waiter) { - Err(Abandon::Failed(err)) - } else if let Poll::Ready(adopted) = Subscriber::poll_handoff(handoff, waiter) { - Ok(adopted) - } else { - let target = self.target.as_mut().expect("the target is held until it is parked"); - ready!(match target { - Target::Request(request) => crate::util::poll_unwanted(&mut request.demand(), waiter), - Target::Resume(idle) => crate::util::poll_unwanted(&mut idle.demand, waiter), - }); - Err(Abandon::Unused) - }; - match adopt { - Ok(Some(upstream)) => { - this.adopted(&self.key, upstream.request_id); - let tracks::Upstream { - token_grant: token, - stream, - messages, - request_id, - alias, - properties, - owed, - // This subscription forwards into the copy itself from here. - copy: _, - } = upstream; - if let Some(token) = &token { - self.gate.bind(token.clone()); - } - self.token_grant = token; - self.held = Some(tracks::Resumed { - alias, - properties, - owed, - ended: None, - }); - self.request_id = request_id; - self.stream = Some(stream); - self.messages = Some(messages); - self.register(this, broadcast_path, track_name); - } - Ok(None) => self.step = OnceStep::Open(Default::default()), - Err(Abandon::Failed(err)) => { - this.unwant(self.key.clone(), handoff); - self.target - .take() - .expect("the target is held until it is parked") - .fail(err); - return Poll::Ready(None); - } - // Nobody wants the track: a resumed copy lingers again, a request is refused. - Err(Abandon::Unused) => { - match self.target.take().expect("the target is held until it is parked") { - Target::Resume(idle) => { - this.unwant(self.key.clone(), handoff); - return Poll::Ready(Some(idle)); - } - Target::Request(request) if request.reject_unwanted(Error::Cancel) => { - this.unwant(self.key.clone(), handoff); - return Poll::Ready(None); - } - target @ Target::Request(_) => self.target = Some(target), - } - } - } + self.step = OnceStep::Open(Default::default()); } OnceStep::Open(open) => { let _ = group_fetches.poll(waiter); @@ -3995,7 +3903,7 @@ impl SubscribeOnce { let stream = self.stream.as_mut().expect("the request stream is open"); let messages = self.messages.as_mut().expect("the request stream is open"); let response = match stream.writer.poll_flush(waiter) { - Poll::Ready(Ok(())) => this.poll_read_answer(waiter, stream, messages, self.held.as_mut()), + Poll::Ready(Ok(())) => this.poll_read_answer(waiter, stream, messages), Poll::Ready(Err(err)) => Poll::Ready(Err(err)), Poll::Pending => Poll::Pending, }; @@ -4170,35 +4078,20 @@ impl SubscribeOnce { true => Fill::Requested, false => Fill::Done, }); - { - let mut state = this.state.lock(); - // A resumed subscription keeps the alias it is bound to and the data streams it - // counted while it forwarded before. - let (alias, tail) = match self.held.is_some() { - true => state - .subscribes - .remove(&self.request_id) - .map(|held| (held.alias, held.tail)) - .unwrap_or_default(), - false => Default::default(), - }; - state.subscribes.insert( - self.request_id, - TrackState { - // A resumed copy is the session's to abort from here on. - producer: self.target().producer().cloned(), - alias, - tail, - ..TrackState::pending(track_name.to_owned(), broadcast_path.clone(), fill.clone()) - }, - ); - } + let producer = self.target().producer().cloned(); + this.state.lock().subscribes.insert( + self.request_id, + TrackState { + // A resumed copy is the session's to abort from here on. + producer, + ..TrackState::pending(track_name.to_owned(), broadcast_path.clone(), fill.clone()) + }, + ); self.fill = Some(fill); self.step = OnceStep::Write; } - /// Write the SUBSCRIBE, or the update resuming a held subscription, and park the origin - /// request where a session abort can reject it. `Some` once that failed. + /// Write the SUBSCRIBE, and park the origin request where a session abort can reject it. `Some` once that failed. fn write(&mut self, this: &Subscriber, broadcast_path: &Path, track_name: &str) -> Option>> { // The aggregate is read now: a subscriber can join while the request ID and stream // were awaited. Updates follow its priority from here, but no draft lets one change @@ -4220,46 +4113,28 @@ impl SubscribeOnce { let stream = self.stream.as_mut().expect("the request stream is open"); // Buffered only: the wait for the answer flushes it, so the local terminals still end // a request the peer's send window holds back. - let written = match &self.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. - Some(held) => { - this.rebind_alias(request_id, Alias::Resuming(request_id)); - let forward = tracks::Forward { - forward: true, - filter: Some(self.join.filter), - fill: self.join.fill, - priority: Some(self.priority), - max_age: self.max_age, - // Only a track declaring DYNAMIC_GROUPS takes one on an update. - new_group: self.new_group.filter(|_| held.properties.dynamic_groups), - }; - this.buffer_forward(&mut stream.writer, &forward) - } - None => stream.writer.buffer_message( - ietf::Subscribe::ID, - &ietf::Subscribe { - max_age: self.max_age, - new_group: self.new_group, - request_id, - track_namespace: broadcast_path.clone(), - track_name: track_name.into(), - subscriber_priority: super::priority::to_wire(self.priority), - group_order: order.into(), - filter: self.join.filter, - fill: self.join.fill, - properties_wanted: self.properties_wanted, - forward: true, - rendezvous: false, - range_filters: false, - authorization_token: None, - }, - ), - }; + let written = stream.writer.buffer_message( + ietf::Subscribe::ID, + &ietf::Subscribe { + max_age: self.max_age, + new_group: self.new_group, + request_id, + track_namespace: broadcast_path.clone(), + track_name: track_name.into(), + subscriber_priority: super::priority::to_wire(self.priority), + group_order: order.into(), + filter: self.join.filter, + fill: self.join.fill, + properties_wanted: self.properties_wanted, + forward: true, + rendezvous: false, + range_filters: false, + authorization_token: None, + }, + ); if let Err(err) = written { tracing::debug!(%err, "failed to encode subscribe"); this.remove_subscribe(request_id); - this.entry_ended(&self.key, request_id, Err(err.clone())); self.target .take() .expect("the target is held until it is parked") @@ -4294,24 +4169,6 @@ impl SubscribeOnce { track = %track_name, "subscribe abandoned before it was accepted" ); - // A held subscription is still the track's entry: pause it again, owing the - // answer to the resume as well. - if let Some(held) = self.held.take() - && this.state.lock().subscribes.contains_key(&self.request_id) - { - let upstream = tracks::Upstream { - token_grant: self.token_grant.clone(), - stream: self.stream(), - messages: self.messages.take().expect("the request stream is open"), - request_id: self.request_id, - alias: held.alias, - properties: held.properties, - owed: held.owed + 1, - copy: self.resumed.as_ref().map(|idle| idle.track.clone()), - }; - this.pause(self.key.clone(), upstream); - return Some(Poll::Ready(self.resumed.take())); - } // 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 = this.remove_subscribe(self.request_id).is_none(); @@ -4351,7 +4208,6 @@ impl SubscribeOnce { pending.reject(err.clone()); } this.remove_subscribe(request_id); - this.entry_ended(&self.key, request_id, Err(err.clone())); if let Some(idle) = self.resumed.take() { let _ = idle.track.abort(err); } @@ -4368,25 +4224,13 @@ impl SubscribeOnce { (None, _) => {} } this.remove_subscribe(request_id); - // A held subscription that the publisher ended before answering ends its - // entry as the PUBLISH_DONE says; one whose resume failed fails it, and is - // cancelled since its PUBLISH_DONE has yet to come. - let ended = self.held.as_mut().and_then(|held| held.ended.take()); - let cancel = self.unanswered || (self.held.is_some() && ended.is_none()); - let finished = matches!(ended, Some(Ok(()))); - this.entry_ended(&self.key, request_id, ended.unwrap_or_else(|| Err(err.clone()))); - // A resumed copy whose track finished before the resume was answered cannot - // place the end, so it lingers again and its reader's SUBSCRIBE learns it. - if finished && self.resumed.is_some() { - return Some(Poll::Ready(self.resumed.take())); - } if let Some(idle) = self.resumed.take() { let _ = match retry { Some(retry) => idle.track.refuse(err, retry), None => idle.track.abort(err), }; } - if cancel { + if self.unanswered { self.step = OnceStep::Refuse(Subscriber::cancelling(self.stream())); return None; } @@ -4400,9 +4244,9 @@ impl SubscribeOnce { } = accepted; // A resumed SUBSCRIBE opts out of the properties from draft-22, so its answer carries // none and the copy keeps the ones it learned. Before that the answer declares them - // again. A pause holds these too, so a later resume still knows DYNAMIC_GROUPS. + // again. let properties = match &self.resumed { - Some(idle) if self.held.is_none() && !self.properties_wanted && ietf::Filter::is_draft20(this.version) => { + Some(idle) if !self.properties_wanted && ietf::Filter::is_draft20(this.version) => { idle.properties.clone() } _ => properties, @@ -4498,11 +4342,7 @@ impl SubscribeOnce { } } } - this.entry_info(&self.key, request_id, track.info()); - // A resumed subscription stays bound to the alias it was held with. - if self.held.is_some() { - this.rebind_alias(request_id, Alias::Active(request_id)); - } else if let Err(err) = this.register_alias(request_id, alias) { + if let Err(err) = this.register_alias(request_id, alias) { if matches!(err, Error::Duplicate) { tracing::warn!(track_alias = %alias, "publisher reused a live track alias for another track"); this.session.close( @@ -4540,7 +4380,6 @@ impl SubscribeOnce { None => {} } self.established = Some(Established { - alias, properties, largest, timescale, @@ -4890,10 +4729,10 @@ impl SubscribeOnce { Some(true) } ServeEnd::Done(res) => { - // Only TRACK_ENDED ends a listing entry quietly; a clean end of another - // kind, such as SUBSCRIPTION_ENDED, leaves the track's fate unknown. + // Only TRACK_ENDED ends the track; a clean end of another kind, such as + // SUBSCRIPTION_ENDED, leaves the track's fate unknown. let end = match &res { - Ok(done) => tracks::listing_end(done, this.version), + Ok(done) => done_end(done, this.version), Err(err) => Err(err.clone()), }; live.ended = Some(end.clone()); @@ -4948,33 +4787,6 @@ impl SubscribeOnce { // Release an unallocated join now, or let the session cancel its committed request. live.fetching = None; - if let Some(end) = live.ended.take() { - this.entry_ended(&self.key, request_id, end); - } else if idle && this.is_entry(&self.key, request_id) && this.state.lock().subscribes.contains_key(&request_id) - { - // A listed track's subscription pauses rather than ends, held again as its entry. - let mut live = self.established.take().expect("established"); - live.track.set_idle(); - let upstream = tracks::Upstream { - token_grant: self.token_grant.clone(), - stream: self.stream(), - messages: self.messages.take().expect("the request stream is open"), - request_id, - alias: live.alias, - properties: live.properties.clone(), - owed: live.unanswered, - copy: Some(live.track.clone()), - }; - this.pause(self.key.clone(), upstream); - return Some(Poll::Ready(Some(Idle { - demand: live.track.demand(), - track: live.track, - dynamic: live.dynamic, - properties: live.properties, - finished: false, - }))); - } - // Clean up let aborted = this.remove_subscribe(request_id).is_none(); @@ -12198,7 +12010,7 @@ mod tests { }; // Errors are surfaced to the peer on the stream, not raised as a session error. - let mut decline = subscriber.decline_publish(stream, msg); + let mut decline = subscriber.decline_publish(stream, &msg); kio::wait(|waiter| decline.poll(waiter)).await.unwrap(); kio::clock::sleep(Duration::from_millis(1)).await; diff --git a/rs/moq-net/src/ietf/subscriber/datagrams.rs b/rs/moq-net/src/ietf/subscriber/datagrams.rs index e0c9e41ba66..386da5b90a2 100644 --- a/rs/moq-net/src/ietf/subscriber/datagrams.rs +++ b/rs/moq-net/src/ietf/subscriber/datagrams.rs @@ -5,7 +5,7 @@ use crate::datagram; /// Keeps the initial stream interest until its answer also accepts the datagram request. pub(super) struct Requested { this: Subscriber, - key: tracks::TrackKey, + key: TrackKey, id: std::sync::Arc<()>, subscribing: track::Subscribing, priority: i8, @@ -14,7 +14,7 @@ pub(super) struct Requested { impl Requested { pub(super) fn new( this: Subscriber, - key: tracks::TrackKey, + key: TrackKey, track: track::Consumer, request: datagram::Request, ) -> Self { @@ -99,7 +99,7 @@ impl Shared { pub(super) fn new( this: &Subscriber, source: &broadcast::Producer, - key: tracks::TrackKey, + key: TrackKey, track: &track::Producer, ) -> Option { let info = datagram::Info::default() diff --git a/rs/moq-net/src/ietf/subscriber/tracks.rs b/rs/moq-net/src/ietf/subscriber/tracks.rs deleted file mode 100644 index fc10bd33974..00000000000 --- a/rs/moq-net/src/ietf/subscriber/tracks.rs +++ /dev/null @@ -1,2621 +0,0 @@ -//! Lists a moq-transport peer's tracks through the TRACKS hook (draft-18+). -//! -//! A listed broadcast sends SUBSCRIBE_TRACKS with FORWARD=0, and each track it lists is -//! one paused subscription held for the session: the PUBLISH the peer answers with, -//! accepted paused, or for a track the peer skips, a SUBSCRIBE we open paused. That -//! subscription is both the listing entry, removed only by its PUBLISH_DONE with TRACK_ENDED, -//! and the track's upstream copy: a reader resumes it with REQUEST_UPDATE and pauses it -//! again once the last one leaves, rather than opening a second subscription. -//! -//! One SUBSCRIBE_TRACKS covers each nesting chain of listed broadcasts, since the peer -//! refuses an overlapping prefix: a nested listing reuses the broader request and filters -//! locally, and a broader one replaces the nested requests once the peer has closed them, -//! keeping the subscriptions they made. Any end of a held subscription other than -//! TRACK_ENDED fails every listing its request serves, which then asks again, rather -//! than hide a track that may still be live. -//! -//! Nothing goes out until a broadcast is listed: many peers do not implement -//! SUBSCRIBE_TRACKS yet, and one that refuses, resets, or ignores it fails or empties only -//! that listing, leaving the session and its SUBSCRIBEs alone. - -use crate::driver::Transport; -use std::sync::Arc; - -use super::*; -use crate::ietf::request_stream::{self, Framed, Messages}; - -/// A listed track, by its namespace and name. -pub(super) type TrackKey = (Path, Arc); - -/// How a PUBLISH_DONE leaves the listing: only TRACK_ENDED removes the track. Any other -/// 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() { - true => Ok(()), - false => Err(done - .end(version) - .err() - .unwrap_or(Error::Remote(u32::try_from(done.status_code).unwrap_or(u32::MAX)))), - } -} - -/// A paused upstream subscription and the request stream that controls it. -pub(super) struct Upstream { - pub(super) token_grant: Option, - pub(super) stream: Stream, - pub(super) messages: Messages, - pub(super) request_id: RequestId, - pub(super) alias: u64, - /// The Track Properties the PUBLISH or SUBSCRIBE_OK declared. - pub(super) properties: ietf::Properties, - /// REQUEST_UPDATE answers still owed, read before anything else. - pub(super) owed: u64, - /// The copy a reader left lingering, which a failed upstream aborts. - pub(super) copy: Option, -} - -/// Hands a paused subscription from the task watching it to a subscription resuming it. -#[derive(Default)] -pub(super) struct Handoff { - /// A subscription asked for it. - wanted: bool, - /// Handed over by the watcher. - upstream: Option, - /// The entry was let go, so the watcher cancels the subscription. - gone: bool, -} - -/// One listed track and the upstream subscription that holds it. -struct Entry { - /// Its TRACK_INFO, once known: a paused SUBSCRIBE learns it from its SUBSCRIBE_OK. - info: Option, - /// The subscription holding it, once known. - request_id: Option, - /// Set while it is paused, or still opening, and a watcher owns it. `None` while a - /// subscription forwarding it owns it. - handoff: Option>, - /// Discovery state the peer makes us hold, as for a lite listed track. - _slot: crate::session::Slot, -} - -impl Drop for Entry { - fn drop(&mut self) { - if let Some(handoff) = &self.handoff { - handoff.lock().gone = true; - } - } -} - -/// A broadcast listed through this session. -struct Listener { - path: Path, - lister: Arc, - /// Set to the error that fails the listing. - failed: kio::Producer>, -} - -/// One SUBSCRIBE_TRACKS, from when it is wanted until the peer has closed it. -struct Request { - prefix: Path, - /// Dropped to cancel it; `None` once cancelled, while it leaves the wire. - live: Option>, - /// Closes once it is off the wire, so an overlapping one may go out. - gone: kio::Consumer<()>, -} - -/// The session's listings of the peer's tracks. -#[derive(Default)] -pub(super) struct Listings { - listeners: HashMap, - requests: HashMap, - entries: HashMap, - next: u64, -} - -/// A SUBSCRIBE_TRACKS for its task to send. -struct Opening { - key: u64, - prefix: Path, - /// The overlapping requests that must leave the wire first. - blockers: Vec>, - live: kio::Consumer<()>, - gone: kio::Producer<()>, -} - -impl Listings { - fn key(&mut self) -> u64 { - self.next += 1; - self.next - } - - /// The standing request that covers `namespace`; at most one does. - fn covering(&self, namespace: &Path) -> Option { - self.requests - .iter() - .find(|(_, request)| request.live.is_some() && namespace.has_prefix(&request.prefix)) - .map(|(key, _)| *key) - } - - fn listers<'a>(&'a self, namespace: &'a Path) -> impl Iterator + 'a { - self.listeners - .values() - .filter(move |listener| &listener.path == namespace) - .map(|listener| &*listener.lister) - } - - fn listen( - &mut self, - path: Path, - lister: Arc, - ) -> (u64, kio::Consumer>, Option) { - // What a broader listing already holds is listed at once. - for ((namespace, name), entry) in &self.entries { - if namespace == &path - && let Some(info) = &entry.info - { - let _ = lister.add(name.clone(), info.clone()); - } - } - // This hop never waits on its upstream: later tracks follow as live adds. - lister.synced(); - let failed = kio::Producer::new(None); - let consumer = failed.consume(); - let key = self.key(); - self.listeners.insert( - key, - Listener { - path: path.clone(), - lister, - failed, - }, - ); - if self.covering(&path).is_some() { - return (key, consumer, None); - } - // Nested requests give way to this broader one, which keeps what they hold. - for request in self.requests.values_mut() { - if request.prefix.has_prefix(&path) { - request.live = None; - } - } - let blockers = self - .requests - .values() - .filter(|request| request.prefix.has_prefix(&path) || path.has_prefix(&request.prefix)) - .map(|request| request.gone.clone()) - .collect(); - let live = kio::Producer::new(()); - let gone = kio::Producer::new(()); - let request = self.key(); - self.requests.insert( - request, - Request { - prefix: path.clone(), - gone: gone.consume(), - live: Some(live.clone()), - }, - ); - let opening = Opening { - key: request, - prefix: path, - blockers, - live: live.consume(), - gone, - }; - (key, consumer, Some(opening)) - } - - fn unlisten(&mut self, key: u64) { - self.listeners.remove(&key); - self.release(); - } - - /// Cancel each standing request nobody lists under, and let go of each held track no - /// standing request covers. - fn release(&mut self) { - for request in self.requests.values_mut() { - if request.live.is_some() - && !self - .listeners - .values() - .any(|listener| listener.path.has_prefix(&request.prefix)) - { - request.live = None; - } - } - let standing: Vec = self - .requests - .values() - .filter(|request| request.live.is_some()) - .map(|request| request.prefix.clone()) - .collect(); - self.entries - .retain(|(namespace, _), _| standing.iter().any(|prefix| namespace.has_prefix(prefix))); - } - - /// Fail every listing the request covering `namespace` serves with `err`, and cancel it: - /// they ask again rather than miss a track that may still be live. - fn fail(&mut self, namespace: &Path, err: Error) { - let Some(key) = self.covering(namespace) else { - return; - }; - let request = self.requests.get_mut(&key).expect("covering"); - request.live = None; - let prefix = request.prefix.clone(); - tracing::debug!(%prefix, %err, "track listing failed"); - for listener in self.listeners.values() { - if listener.path.has_prefix(&prefix) - && let Ok(mut failed) = listener.failed.write() - { - *failed = Some(err.clone()); - } - } - self.release(); - } - - fn insert(&mut self, key: TrackKey, entry: Entry) { - if let Some(info) = &entry.info { - for lister in self.listers(&key.0) { - let _ = lister.add(key.1.clone(), info.clone()); - } - } - self.entries.insert(key, entry); - } - - /// Unlist the entry for `key`, as a track that `finished` or left unfinished. - fn remove(&mut self, key: &TrackKey, finished: bool) { - let Some(entry) = self.entries.remove(key) else { - return; - }; - if entry.info.is_some() { - for lister in self.listers(&key.0) { - let _ = match finished { - true => lister.finish(&key.1), - false => lister.remove(&key.1), - }; - } - } - } - - /// The entry held by the subscription with `request_id`, if this is it. - fn held_by(&mut self, key: &TrackKey, request_id: RequestId) -> Option<&mut Entry> { - self.entries - .get_mut(key) - .filter(|entry| entry.request_id == Some(request_id)) - } - - /// The entry `handoff` belongs to, if it still stands. - fn watched_by(&mut self, key: &TrackKey, handoff: &kio::Shared) -> Option<&mut Entry> { - self.entries - .get_mut(key) - .filter(|entry| entry.handoff.as_ref().is_some_and(|held| held.same_channel(handoff))) - } - - /// End the entry held by the subscription with `request_id`: TRACK_ENDED unlists it as - /// finished, and anything else fails its listings. - fn ended(&mut self, key: &TrackKey, request_id: RequestId, end: Result<(), Error>) { - if self.held_by(key, request_id).is_none() { - return; - } - match end { - Ok(()) => self.remove(key, true), - Err(err) => { - self.remove(key, false); - self.fail(&key.0, err); - } - } - } -} - -/// A broadcast's listing through this session, until dropped. -pub(super) struct Listening { - listings: kio::Lock, - key: u64, - failed: kio::Consumer>, -} - -impl Listening { - /// Ready with the error that failed the listing. - pub(super) fn poll_failed(&self, waiter: &kio::Waiter) -> Poll { - match self.failed.poll(waiter, |failed| match &**failed { - Some(err) => Poll::Ready(err.clone()), - None => Poll::Pending, - }) { - Poll::Ready(Ok(err)) => Poll::Ready(err), - Poll::Ready(Err(_)) => Poll::Ready(Error::Cancel), - Poll::Pending => Poll::Pending, - } - } -} - -impl Drop for Listening { - fn drop(&mut self) { - self.listings.lock().unlisten(self.key); - } -} - -/// What to do with a PUBLISH. -enum Offer { - /// No listing asked for it. - Unsolicited, - /// Refused with this code and reason. - Refuse(u64, &'static str), - /// Accepted paused and held as the entry `handoff` watches. - Hold(kio::Shared), -} - -impl Subscriber { - /// List `path`'s tracks into `lister` until the returned guard drops, through a - /// SUBSCRIBE_TRACKS covering it. - pub(super) fn listen(&self, path: Path, lister: Arc) -> Listening { - let (key, failed, opening) = self.listings.lock().listen(path, lister); - if let Some(opening) = opening { - let mut task = SubscribeTracks::new(self.clone(), opening); - self.tasks.push(move |waiter: &kio::Waiter| task.poll(waiter)); - } - Listening { - listings: self.listings.clone(), - key, - failed, - } - } - - /// A fresh subscription of ours to `name` in `namespace`, and its info once accepted. - fn subscribed(&self, namespace: &Path, name: &str) -> Option<(RequestId, Option)> { - let state = self.state.lock(); - let aliases = state.aliases.read(); - // A paused one is on its way out with the entry it held. - let paused = |id: &RequestId, track: &TrackState| { - track - .alias - .is_some_and(|alias| aliases.map.get(&alias) == Some(&Alias::Paused(*id))) - }; - state - .subscribes - .iter() - .find(|(id, track)| track.broadcast == *namespace && track.name == name && !paused(id, track)) - .map(|(id, track)| (*id, track.producer.as_ref().map(|producer| producer.info().clone()))) - } - - /// The peer skipped a listed track: list it through a subscription of ours, opening - /// one paused if we have none. - fn skipped(&self, namespace: Path, name: Arc) { - let key = (namespace, name); - let mut listings = self.listings.lock(); - if listings.entries.contains_key(&key) || listings.covering(&key.0).is_none() { - return; - } - let slot = match self.announces.acquire() { - Ok(slot) => slot, - Err(err) => return listings.fail(&key.0, err), - }; - if let Some((request_id, info)) = self.subscribed(&key.0, &key.1) { - let entry = Entry { - info, - request_id: Some(request_id), - handoff: None, - _slot: slot, - }; - return listings.insert(key, entry); - } - let handoff = kio::Shared::::default(); - let entry = Entry { - info: None, - request_id: None, - handoff: Some(handoff.clone()), - _slot: slot, - }; - listings.insert(key.clone(), entry); - drop(listings); - let mut task = Skipped::Opening { - this: self.clone(), - key, - handoff, - open: OpenPaused::new(), - }; - self.tasks.push(move |waiter: &kio::Waiter| task.poll(waiter)); - } - - /// Bind the alias a held subscription's PUBLISH or SUBSCRIBE_OK chose, paused, closing - /// the session if it names another track. - fn bind_alias(&self, request_id: RequestId, alias: u64) -> Result<(), Error> { - let err = match self.register_alias(request_id, alias) { - Ok(()) => { - self.rebind_alias(request_id, Alias::Paused(request_id)); - return Ok(()); - } - Err(err) => err, - }; - if matches!(err, Error::Duplicate) { - tracing::warn!(track_alias = %alias, "publisher reused a live track alias for another track"); - self.session.clone().close( - super::super::error::to_session_code(&SessionError::from(&err)), - err.to_string().as_ref(), - ); - } - Err(err) - } - - /// What to do with a PUBLISH whose token, if it decides, granted it. - fn offer( - &self, - stream: Stream, - msg: ietf::Publish<'static>, - token_grant: Option, - flate: bool, - ) -> ServePublishState { - let key: TrackKey = (msg.track_namespace.clone(), msg.track_name.as_ref().into()); - let info = accepted_info(&msg.properties, self.version); - let offer = { - let mut listings = self.listings.lock(); - // Draft-18 allows one subscription per track (section 5.1); later drafts allow - // more, but the one we hold already lists the track and carries its data. - let duplicate = match self.version { - Version::Draft18 => request::DUPLICATE_SUBSCRIPTION, - _ => request::to_code(&Error::Unroutable, request::Kind::Publish, self.version), - }; - if listings.covering(&key.0).is_none() { - match listings - .requests - .values() - .any(|request| key.0.has_prefix(&request.prefix)) - { - true => Offer::Refuse( - request::to_code(&Error::Unroutable, request::Kind::Publish, self.version), - "listing cancelled", - ), - false => Offer::Unsolicited, - } - } else if listings.entries.contains_key(&key) { - Offer::Refuse(duplicate, "already subscribed") - } else if msg.forward && !matches!(self.version, Version::Draft18) { - // From draft-22 the PUBLISH sets its own Forward State, and PUBLISH_OK has no - // FORWARD to pause it: its objects would be refused unseen behind a healthy - // listing. - listings.fail(&key.0, Error::Unsupported); - Offer::Refuse( - request::to_code(&Error::Unsupported, request::Kind::Publish, self.version), - "PUBLISH forwards for a paused listing", - ) - } else if let Some(err) = refused(&msg.properties, self.version, flate) { - // The peer sends nothing else for the track, so a listing left standing would - // silently miss it, as with a refused paused SUBSCRIBE. - let code = request::to_code(&err, request::Kind::Publish, self.version); - listings.fail(&key.0, err); - Offer::Refuse(code, "track properties not supported") - } else { - match self.announces.acquire() { - Err(err) => { - listings.fail(&key.0, err); - Offer::Refuse(request::EXCESSIVE_LOAD, "too many listed tracks") - } - Ok(slot) => match self.subscribed(&key.0, &key.1) { - // Our own SUBSCRIBE holds the track, so it is the entry. - Some((request_id, _)) => { - let entry = Entry { - info: Some(info), - request_id: Some(request_id), - handoff: None, - _slot: slot, - }; - listings.insert(key.clone(), entry); - Offer::Refuse(duplicate, "already subscribed") - } - None => { - let handoff = kio::Shared::::default(); - let entry = Entry { - info: Some(info), - request_id: Some(msg.request_id), - handoff: Some(handoff.clone()), - _slot: slot, - }; - listings.insert(key.clone(), entry); - Offer::Hold(handoff) - } - }, - } - } - }; - let reject = match offer { - Offer::Unsolicited => self.decline_publish(stream, msg), - Offer::Refuse(code, reason) => { - tracing::debug!(broadcast = %key.0, track = %key.1, reason, "refusing publish"); - let error = ietf::RequestError { - error_code: code, - reason_phrase: reason.into(), - // The rule judges the refusal as the peer reads it. - retry_interval: request::retry_interval( - &request::from_code(code, request::Kind::Publish, self.version), - code, - ), - }; - request_stream::Reject::new(stream, error) - } - Offer::Hold(handoff) => { - self.state.lock().subscribes.insert( - msg.request_id, - TrackState { - ..TrackState::pending(key.1.to_string(), key.0.clone(), kio::Producer::new(Fill::Done)) - }, - ); - return ServePublishState::Accepting { - stream, - msg, - token_grant, - key, - handoff, - answered: false, - }; - } - }; - ServePublishState::Rejecting { - reject, - _credit: None, - _grant: token_grant, - } - } - - /// Decline a PUBLISH no listing asked for. - /// - /// PUBLISH offers a single track, so honoring it means routing per - /// (namespace, track). Our model routes per namespace: a source attaches at a - /// path and serves every track under it, resolved on demand via SUBSCRIBE. - /// Accepting a PUBLISH would mean inventing a namespace-level source out of a - /// track-level offer, and that fiction then contradicts any real - /// PUBLISH_NAMESPACE for the same path. - /// - /// Declining the request rather than failing the session, since a peer using - /// a feature we don't implement is not a protocol violation. The rejection is the - /// whole exchange, but it still has to arrive: [`request_stream::Reject`] delivers it. - pub(super) fn decline_publish( - &self, - mut stream: Stream, - msg: ietf::Publish<'static>, - ) -> request_stream::Reject { - if let Some(error) = super::super::namespace::refusal(&msg.track_namespace, self.version) { - return request_stream::Reject::new(stream, error); - } - tracing::debug!(broadcast = %msg.track_namespace, track = %msg.track_name, "rejecting publish"); - - // We decline the method itself rather than this particular track, which would be - // UNINTERESTED. - // - // The alias the message carries is deliberately not recorded. Nothing will ever bind - // 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. - let (kind, err) = (request::Kind::Publish, &Error::Unsupported); - let buffered = - request_stream::buffer_error(&mut stream.writer, kind, err, "PUBLISH is not supported", self.version); - request_stream::Reject::buffered(stream, buffered) - } - - /// Ask for the paused subscription a listing holds for this track, to resume it. - pub(super) fn want(&self, key: &TrackKey) -> Option> { - let listings = self.listings.lock(); - let handoff = listings.entries.get(key)?.handoff.clone()?; - handoff.lock().wanted = true; - Some(handoff) - } - - /// Ready with the subscription `handoff` hands over, or `None` once it ended instead. - pub(super) fn poll_handoff(handoff: &kio::Shared, waiter: &kio::Waiter) -> Poll> { - let mut handoff = ready!( - handoff.poll(waiter, |handoff| match handoff.gone || handoff.upstream.is_some() { - true => Poll::Ready(()), - false => Poll::Pending, - }) - ); - Poll::Ready(handoff.upstream.take()) - } - - /// Give up wanting `handoff`, holding again what it already handed over. - pub(super) fn unwant(&self, key: TrackKey, handoff: &kio::Shared) { - let upstream = { - let mut held = handoff.lock(); - held.wanted = false; - held.upstream.take() - }; - if let Some(upstream) = upstream { - self.hold(key, upstream); - } - } - - /// The subscription with `request_id` took over the entry it resumes. - pub(super) fn adopted(&self, key: &TrackKey, request_id: RequestId) { - if let Some(entry) = self.listings.lock().held_by(key, request_id) { - entry.handoff = None; - } - } - - /// Whether the subscription with `request_id` holds this track's listing entry, so - /// it pauses rather than ends once nobody reads it. - pub(super) fn is_entry(&self, key: &TrackKey, request_id: RequestId) -> bool { - self.listings - .lock() - .held_by(key, request_id) - .is_some_and(|entry| entry.handoff.is_none()) - } - - /// The subscription with `request_id` learned the info of the track it holds, which - /// lists it if a skip made it the entry before its SUBSCRIBE_OK. - pub(super) fn entry_info(&self, key: &TrackKey, request_id: RequestId, info: &track::Info) { - let mut listings = self.listings.lock(); - let Some(entry) = listings.held_by(key, request_id) else { - return; - }; - if entry.info.is_some() { - return; - } - entry.info = Some(info.clone()); - for lister in listings.listers(&key.0) { - let _ = lister.add(key.1.clone(), info.clone()); - } - } - - /// The subscription with `request_id` ended: so does the entry it held. - pub(super) fn entry_ended(&self, key: &TrackKey, request_id: RequestId, end: Result<(), Error>) { - self.listings.lock().ended(key, request_id, end); - } - - /// Hold a paused subscription again as its track's entry, watched until a reader - /// resumes it, or cancel it if the entry is gone. - pub(super) fn hold(&self, key: TrackKey, upstream: Upstream) { - let handoff = kio::Shared::::default(); - let held = match self.listings.lock().held_by(&key, upstream.request_id) { - Some(entry) => { - entry.handoff = Some(handoff.clone()); - true - } - None => false, - }; - let mut task = Watch::new(self.clone(), key, upstream, handoff, held); - self.tasks.push(move |waiter: &kio::Waiter| task.poll(waiter)); - } - - /// Pause a subscription nobody reads that is its track's entry, and hold it again. The - /// held watch flushes the pause, so the entry going still ends it under send pressure. - pub(super) fn pause(&self, key: TrackKey, mut upstream: Upstream) { - self.park_subscribe(upstream.request_id); - let pause = Forward { - forward: false, - ..Default::default() - }; - match self.buffer_forward(&mut upstream.stream.writer, &pause) { - Ok(()) => { - upstream.owed += 1; - tracing::debug!(broadcast = %key.0, track = %key.1, "subscription paused"); - self.hold(key, upstream); - } - Err(err) => { - tracing::debug!(%err, "failed to pause the subscription"); - self.remove_subscribe(upstream.request_id); - self.entry_ended(&key, upstream.request_id, Err(err)); - } - } - } - - /// Reset a subscription's state to paused, keeping the alias it is bound to: objects - /// still arriving for it are refused rather than buffered. - fn park_subscribe(&self, request_id: RequestId) { - let mut state = self.state.lock(); - let Some(held) = state.subscribes.get_mut(&request_id) else { - return; - }; - if let Ok(mut fill) = held.fill.write() { - fill.cancel(); - } - let parked = TrackState { - alias: held.alias, - tail: std::mem::take(&mut held.tail), - ..TrackState::pending( - std::mem::take(&mut held.name), - held.broadcast.clone(), - kio::Producer::new(Fill::Done), - ) - }; - let fetch_id = std::mem::replace(held, parked).fetch_id; - if let Some(fetch_id) = fetch_id { - state.fetches.remove(&fetch_id); - } - if let Some(alias) = state.subscribes.get(&request_id).and_then(|track| track.alias) { - rebind_track_alias(&state.aliases, alias, request_id, Alias::Paused(request_id)); - } - } - - /// Buffer the REQUEST_UPDATE setting a held subscription's Forward State, for the - /// caller to flush. - pub(super) fn buffer_forward( - &self, - writer: &mut Writer, - forward: &Forward, - ) -> Result<(), Error> { - let request_id = self.control.next_request_id(); - writer.buffer_message( - ietf::SubscribeUpdate::ID, - &ietf::SubscribeUpdate { - max_age: forward.max_age, - request_id, - subscriber_priority: forward.priority.map(super::super::priority::to_wire), - forward: Some(forward.forward), - filter: forward.filter, - authorization_token: None, - new_group: forward.new_group, - fill: forward.fill, - }, - ) - } - - /// Check the answer to a REQUEST_UPDATE a held subscription still owes. It carries - /// nothing to act on: a refused one is followed by the PUBLISH_DONE that ends it. - fn owed_answer(&self, id: u64, body: &ietf::Body) -> Result<(), Error> { - match id { - ietf::RequestOk::ID => body.message::(self.version).map(|_| ())?, - _ => body.message::(self.version).map(|_| ())?, - } - Ok(()) - } - - /// Read the answer to a SUBSCRIBE, or to the REQUEST_UPDATE resuming `held`. - pub(super) fn poll_read_answer( - &self, - waiter: &kio::Waiter, - stream: &mut Stream, - messages: &mut Messages, - held: Option<&mut Resumed>, - ) -> Poll> { - let Some(held) = held else { - let Framed(id, body) = ready!(messages.poll_message(&mut stream.reader, waiter))?; - return Poll::Ready(self.subscribe_response(id, &body)); - }; - loop { - let Framed(id, body) = ready!(messages.poll_message(&mut stream.reader, waiter))?; - match id { - // The answer to a pause that went out before this resume. - ietf::RequestOk::ID | ietf::RequestError::ID if held.owed > 0 => { - self.owed_answer(id, &body)?; - held.owed -= 1; - } - ietf::RequestOk::ID => { - let ok = body.message::(self.version)?; - tracing::debug!(message = ?ok, "subscription resumed"); - return Poll::Ready(Ok(Answer::Accepted(Accepted { - alias: held.alias, - properties: held.properties.clone(), - largest: ok.largest, - }))); - } - ietf::RequestError::ID => { - let refused = body.message::(self.version)?; - tracing::debug!(message = ?refused, "resume refused"); - return Poll::Ready(Ok(self.refused(&refused))); - } - ietf::PublishDone::ID => { - let done = body.message::(self.version)?; - let end = listing_end(&done, self.version); - held.ended = Some(end.clone()); - return Poll::Ready(Err(end.err().unwrap_or(Error::NotFound))); - } - _ => return Poll::Ready(Err(Error::UnexpectedMessage)), - } - } - } - - /// The answer to a SUBSCRIBE: its SUBSCRIBE_OK, or the REQUEST_ERROR refusing it. - fn subscribe_response(&self, id: u64, body: &ietf::Body) -> Result { - let mut data = body.decoder(self.version); - match id { - ietf::SubscribeOk::ID => { - let msg = ietf::SubscribeOk::decode_msg(&mut data, self.version)?; - tracing::debug!(message = ?msg, "received subscribe ok"); - Ok(Answer::Accepted(Accepted { - alias: msg.track_alias, - properties: msg.properties, - largest: msg.largest, - })) - } - ietf::RequestError::ID => { - let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; - tracing::warn!(message = ?msg, "request error"); - Ok(self.refused(&msg)) - } - _ => Err(Error::UnexpectedMessage), - } - } - - /// The REQUEST_ERROR refusing a SUBSCRIBE or its resume, as an [`Answer`]. - fn refused(&self, msg: &ietf::RequestError<'_>) -> Answer { - let err = request::from_code(msg.error_code, request::Kind::Subscribe, self.version); - Answer::Refused(err, Duration::from_millis(msg.retry_interval)) - } -} - -/// One SUBSCRIBE_TRACKS, sent once the overlapping requests leave the wire, then read until -/// it is cancelled and the peer has closed it, or it fails. -struct SubscribeTracks { - this: Subscriber, - key: u64, - prefix: Path, - /// The overlapping requests that must leave the wire first. - blockers: Vec>, - live: kio::Consumer<()>, - /// Dropped once it is off the wire, so an overlapping one may go out. - gone: Option>, - state: SubscribeTracksState, -} - -enum SubscribeTracksState { - /// Waiting for the overlapping requests to leave the wire. - Blocked, - /// Waiting for a stream to send it on. - Opening(crate::transport::open::Open), - /// Reading its answers until it is cancelled. - Listing { - stream: Stream, - messages: Messages, - /// Its REQUEST_OK arrived. - established: bool, - /// A peer streaming skips must not hold the session's other tasks. - budget: kio::coop::Budget, - }, - /// Cancelled: reading until the peer closes its own half. A draft-18 FIN leaves the - /// writer to reset only once that is done. - Draining { - reader: crate::coding::Reader, - _writer: Option>, - messages: Messages, - budget: kio::coop::Budget, - }, - Done, -} - -impl SubscribeTracks { - fn new(this: Subscriber, opening: Opening) -> Self { - let Opening { - key, - prefix, - blockers, - live, - gone, - } = opening; - Self { - this, - key, - prefix, - blockers, - live, - gone: Some(gone), - state: SubscribeTracksState::Blocked, - } - } - - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - let result = ready!(self.poll_request(waiter)); - self.state = SubscribeTracksState::Done; - let mut listings = self.this.listings.lock(); - if let Err(err) = result - && listings - .requests - .get(&self.key) - .is_some_and(|request| request.live.is_some()) - { - listings.fail(&self.prefix, err); - } - listings.requests.remove(&self.key); - listings.release(); - drop(listings); - // Off the wire: an overlapping request may go out. - self.gone = None; - Poll::Ready(()) - } - - fn poll_request(&mut self, waiter: &kio::Waiter) -> Poll> { - let this = &self.this; - let prefix = &self.prefix; - loop { - match &mut self.state { - SubscribeTracksState::Blocked => { - if self.live.poll_closed(waiter).is_ready() { - return Poll::Ready(Ok(())); - } - // The peer refuses a prefix overlapping one it still holds, so wait for each - // overlapping request to leave the wire. - for blocker in &self.blockers { - if blocker.poll_closed(waiter).is_pending() { - return Poll::Pending; - } - } - self.state = SubscribeTracksState::Opening(Default::default()); - } - SubscribeTracksState::Opening(open) => { - if self.live.poll_closed(waiter).is_ready() { - return Poll::Ready(Ok(())); - } - let stream = ready!(Stream::poll_open(&this.session, open, this.version, waiter))?; - let mut stream = stream.with_stats(this.origin.stats()); - let request_id = this.control.next_request_id(); - stream.writer.buffer_message( - ietf::SubscribeTracks::ID, - &ietf::SubscribeTracks { - request_id, - prefix: prefix.clone(), - forward: false, - group_order: None, - properties_wanted: true, - authorization_token: None, - }, - )?; - tracing::debug!(prefix = %crate::path::Rooted(this.origin.root(), prefix), id = %request_id, "subscribe_tracks started"); - self.state = SubscribeTracksState::Listing { - stream, - messages: Messages::new(this.session.clone(), this.control.client()), - established: false, - budget: kio::coop::Budget::new(32), - }; - } - SubscribeTracksState::Listing { - stream, - messages, - established, - budget, - } => { - loop { - ready!(budget.poll_yield(waiter)); - if this.session.poll_closed(waiter).is_ready() { - return Poll::Ready(Ok(())); - } - if self.live.poll_closed(waiter).is_ready() { - break; - } - ready!(stream.writer.poll_flush(waiter))?; - let Framed(id, body) = match ready!(messages.poll_next(&mut stream.reader, waiter))? { - // The peer ended the listing, which only a cancel of ours should do. - None if *established => return Poll::Ready(Err(Error::Closed)), - None => return Poll::Ready(Err(messages.failed(Error::ProtocolViolation))), - Some(framed) => framed, - }; - match id { - ietf::RequestOk::ID if !*established => { - body.message::(this.version) - .map_err(|err| messages.failed(err.into()))?; - *established = true; - } - ietf::RequestError::ID if !*established => { - let refused = body - .message::(this.version) - .map_err(|err| messages.failed(err.into()))?; - tracing::debug!(prefix = %prefix, code = refused.error_code, reason = %refused.reason_phrase, "subscribe_tracks refused"); - return Poll::Ready(Err(request::from_code( - refused.error_code, - request::Kind::SubscribeNamespace, - this.version, - ))); - } - ietf::PublishSkipped::ID if *established => { - let skipped = body - .message::(this.version) - .map_err(|err| messages.failed(err.into()))?; - let namespace = prefix - .join(&skipped.suffix) - .map_err(|err| messages.failed(err.into()))?; - this.skipped(namespace, skipped.track_name.as_ref().into()); - } - _ => return Poll::Ready(Err(messages.failed(Error::UnexpectedMessage))), - } - } - - // Cancelled: close our half (FIN on draft-18, RESET_STREAM from draft-22, where - // a FIN is not a cancel), then read until the peer closes its own. The - // PUBLISHes it made stay ours. - tracing::debug!(prefix = %crate::path::Rooted(this.origin.root(), prefix), "subscribe_tracks cancelled"); - let SubscribeTracksState::Listing { - stream: Stream { writer, mut reader }, - messages, - budget, - .. - } = std::mem::replace(&mut self.state, SubscribeTracksState::Done) - else { - unreachable!() - }; - // The request never reached the peer whole, so it holds nothing to wait on. - if !writer.is_flushed() { - reader.abort(&Error::Cancel); - writer.abort(&Error::Cancel); - return Poll::Ready(Ok(())); - } - let writer = match request_stream::fin_cancels(this.version) { - true => { - let mut writer = writer; - let _ = writer.finish(); - Some(writer) - } - false => { - writer.abort(&Error::Cancel); - None - } - }; - self.state = SubscribeTracksState::Draining { - reader, - _writer: writer, - messages, - budget, - }; - } - SubscribeTracksState::Draining { - reader, - messages, - budget, - .. - } => { - if this.session.poll_closed(waiter).is_ready() { - return Poll::Ready(Ok(())); - } - loop { - ready!(budget.poll_yield(waiter)); - match ready!(messages.poll_next(reader, waiter)) { - Ok(Some(_)) => continue, - Ok(None) | Err(_) => return Poll::Ready(Ok(())), - } - } - } - SubscribeTracksState::Done => unreachable!("polled after it ended"), - } - } - } -} - -/// A skipped track's paused SUBSCRIBE: opened, then watched as the track's entry. -#[expect( - clippy::large_enum_variant, - reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk" -)] -enum Skipped { - Opening { - this: Subscriber, - key: TrackKey, - handoff: kio::Shared, - open: OpenPaused, - }, - Watching(Watch), - Done, -} - -impl Skipped { - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - loop { - match self { - Self::Opening { - this, - key, - handoff, - open, - } => { - let result = ready!(open.poll(waiter, this, key, handoff)); - let Self::Opening { this, key, handoff, .. } = std::mem::replace(self, Self::Done) else { - unreachable!() - }; - match result { - Ok(Some(upstream)) => *self = Self::Watching(Watch::new(this, key, upstream, handoff, true)), - Ok(None) => return Poll::Ready(()), - Err(err) => { - tracing::debug!(broadcast = %key.0, track = %key.1, %err, "paused subscribe failed"); - let mut listings = this.listings.lock(); - if listings.watched_by(&key, &handoff).is_some() { - listings.remove(&key, false); - listings.fail(&key.0, err); - } - return Poll::Ready(()); - } - } - } - Self::Watching(watch) => return watch.poll(waiter), - Self::Done => return Poll::Ready(()), - } - } - } -} - -/// Opens a paused SUBSCRIBE for a skipped track and lists it once its SUBSCRIBE_OK -/// arrives. `None` once its entry was let go. -struct OpenPaused { - state: OpenPausedState, -} - -#[expect( - clippy::large_enum_variant, - reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk" -)] -enum OpenPausedState { - /// Waiting for a stream to send it on. - Opening(crate::transport::open::Open), - /// Flushed while waiting for the answer, so the entry going still ends a request the - /// peer's send window holds back. - Answering { - stream: Stream, - messages: Messages, - request_id: RequestId, - }, - /// Accepted, waiting for the peer's SETUP to judge its properties. - Accepted { - stream: Stream, - messages: Messages, - request_id: RequestId, - accepted: Accepted, - }, - /// Abandoned: cancelling it before resolving with `result`. - Cancelling { - cancel: request_stream::Cancel, - result: Option, Error>>, - }, - Done, -} - -impl OpenPausedState { - /// Remove and cancel the subscription, then resolve with `result`. - fn cancel( - this: &Subscriber, - request_id: RequestId, - stream: Stream, - result: Result, Error>, - ) -> Self { - this.remove_subscribe(request_id); - Self::Cancelling { - cancel: request_stream::Cancel::new(stream), - result: Some(result), - } - } -} - -impl OpenPaused { - fn new() -> Self { - Self { - state: OpenPausedState::Opening(Default::default()), - } - } - - fn poll( - &mut self, - waiter: &kio::Waiter, - this: &Subscriber, - key: &TrackKey, - handoff: &kio::Shared, - ) -> Poll, Error>> { - let gone = |waiter: &kio::Waiter| { - handoff - .poll(waiter, |handoff| match handoff.gone { - true => Poll::Ready(()), - false => Poll::Pending, - }) - .is_ready() - }; - loop { - match &mut self.state { - OpenPausedState::Opening(open) => { - if gone(waiter) { - return Poll::Ready(Ok(None)); - } - let stream = ready!(Stream::poll_open(&this.session, open, this.version, waiter))?; - let mut stream = stream.with_stats(this.origin.stats()); - let request_id = this.control.next_request_id(); - this.state.lock().subscribes.insert( - request_id, - TrackState { - ..TrackState::pending(key.1.to_string(), key.0.clone(), kio::Producer::new(Fill::Done)) - }, - ); - let messages = Messages::new(this.session.clone(), this.control.client()); - let buffered = stream.writer.buffer_message( - ietf::Subscribe::ID, - &ietf::Subscribe { - max_age: None, - new_group: None, - request_id, - track_namespace: key.0.clone(), - track_name: key.1.as_ref().into(), - subscriber_priority: super::super::priority::DEFAULT_WIRE, - // No update can change it, so it takes the order a reader gets by default. - group_order: track::Order::default().into(), - filter: Filter::NextObject, - fill: None, - properties_wanted: true, - forward: false, - rendezvous: false, - range_filters: false, - authorization_token: None, - }, - ); - if let Err(err) = buffered { - this.remove_subscribe(request_id); - return Poll::Ready(Err(err)); - } - self.state = OpenPausedState::Answering { - stream, - messages, - request_id, - }; - } - OpenPausedState::Answering { - stream, - messages, - request_id, - } => { - let request_id = *request_id; - if gone(waiter) { - let OpenPausedState::Answering { stream, .. } = - std::mem::replace(&mut self.state, OpenPausedState::Done) - else { - unreachable!() - }; - self.state = OpenPausedState::cancel(this, request_id, stream, Ok(None)); - continue; - } - let answered = match stream.writer.poll_flush(waiter) { - Poll::Ready(Ok(())) => this.poll_read_answer(waiter, stream, messages, None), - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - Poll::Pending => Poll::Pending, - }; - let accepted = match ready!(answered) { - Ok(Answer::Accepted(accepted)) => accepted, - Ok(Answer::Refused(err, _)) | Err(err) => { - this.remove_subscribe(request_id); - return Poll::Ready(Err(err)); - } - }; - let OpenPausedState::Answering { stream, messages, .. } = - std::mem::replace(&mut self.state, OpenPausedState::Done) - else { - unreachable!() - }; - self.state = OpenPausedState::Accepted { - stream, - messages, - request_id, - accepted, - }; - } - OpenPausedState::Accepted { .. } => { - let flate = ready!(this.peer_setup.poll_get(waiter)).flate; - let OpenPausedState::Accepted { - stream, - messages, - request_id, - accepted, - } = std::mem::replace(&mut self.state, OpenPausedState::Done) - else { - unreachable!() - }; - if let Some(err) = refused(&accepted.properties, this.version, flate) { - self.state = OpenPausedState::cancel(this, request_id, stream, Err(err)); - continue; - } - if let Err(err) = this.bind_alias(request_id, accepted.alias) { - self.state = OpenPausedState::cancel(this, request_id, stream, Err(err)); - continue; - } - let info = accepted_info(&accepted.properties, this.version); - let listed = { - let mut listings = this.listings.lock(); - match listings.watched_by(key, handoff) { - Some(entry) => { - entry.request_id = Some(request_id); - entry.info = Some(info.clone()); - for lister in listings.listers(&key.0) { - let _ = lister.add(key.1.clone(), info.clone()); - } - true - } - None => false, - } - }; - if !listed { - self.state = OpenPausedState::cancel(this, request_id, stream, Ok(None)); - continue; - } - return Poll::Ready(Ok(Some(Upstream { - token_grant: None, - stream, - messages, - request_id, - alias: accepted.alias, - properties: accepted.properties, - owed: 0, - copy: None, - }))); - } - OpenPausedState::Cancelling { cancel, result } => { - ready!(cancel.poll(waiter)); - let result = result.take().expect("resolved once"); - self.state = OpenPausedState::Done; - return Poll::Ready(result); - } - OpenPausedState::Done => unreachable!("polled after it resolved"), - } - } - } -} - -/// Watches a paused subscription until it ends, its entry is let go, or a subscription -/// takes it to resume. -struct Watch { - this: Subscriber, - key: TrackKey, - /// Taken once it is handed over, cancelled, or ended. - upstream: Option, - handoff: kio::Shared, - state: WatchState, -} - -#[expect( - clippy::large_enum_variant, - reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk" -)] -enum WatchState { - /// Its entry was already gone when it was held, so it is cancelled at once. - Unheld, - Watching, - /// Cancelling it. The rest of the subscription, its grant and lingering copy included, - /// is released only after. - Cancelling { - cancel: request_stream::Cancel, - _rest: (Option, Messages, Option), - }, - Done, -} - -impl Watch { - /// Watch `upstream` as the entry `handoff` belongs to, or cancel it unless `held`. - fn new(this: Subscriber, key: TrackKey, upstream: Upstream, handoff: kio::Shared, held: bool) -> Self { - Self { - this, - key, - upstream: Some(upstream), - handoff, - state: match held { - true => WatchState::Watching, - false => WatchState::Unheld, - }, - } - } - - /// Remove and cancel the subscription. - fn cancel(&mut self) { - let Upstream { - token_grant, - stream, - messages, - request_id, - copy, - .. - } = self.upstream.take().expect("cancelled once"); - self.this.remove_subscribe(request_id); - self.state = WatchState::Cancelling { - cancel: request_stream::Cancel::new(stream), - _rest: (token_grant, messages, copy), - }; - } - - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - enum Step { - Closed, - Gone, - Wanted, - Message(Result, Error>), - } - let end = loop { - match &mut self.state { - WatchState::Unheld => { - self.cancel(); - continue; - } - WatchState::Cancelling { cancel, .. } => { - ready!(cancel.poll(waiter)); - self.state = WatchState::Done; - return Poll::Ready(()); - } - WatchState::Done => return Poll::Ready(()), - WatchState::Watching => {} - } - let upstream = self.upstream.as_mut().expect("watched"); - let step = 'step: { - if self.this.session.poll_closed(waiter).is_ready() { - break 'step Step::Closed; - } - // A held subscription is handed over only once flushed, so resumes and pauses - // can't pile up behind a peer withholding credit. - let flushed = match upstream.stream.writer.poll_flush(waiter) { - Poll::Ready(Err(err)) => break 'step Step::Message(Err(err)), - Poll::Ready(Ok(())) => true, - Poll::Pending => false, - }; - if let Poll::Ready(handoff) = - self.handoff - .poll(waiter, |handoff| match handoff.gone || (handoff.wanted && flushed) { - true => Poll::Ready(()), - false => Poll::Pending, - }) { - break 'step match handoff.gone { - true => Step::Gone, - false => Step::Wanted, - }; - } - if let Some(grant) = &upstream.token_grant - && let Poll::Ready(refusal) = grant.poll_ended(waiter) - { - break 'step Step::Message(Err(refusal.error)); - } - Step::Message(ready!(upstream.messages.poll_next(&mut upstream.stream.reader, waiter))) - }; - match step { - Step::Closed => { - self.upstream = None; - self.state = WatchState::Done; - return Poll::Ready(()); - } - Step::Wanted => { - let mut held = self.handoff.lock(); - match (held.gone, held.wanted) { - (true, _) => {} - // The subscription gave up before it was handed over. - (false, false) => continue, - (false, true) => { - held.upstream = self.upstream.take(); - drop(held); - self.state = WatchState::Done; - return Poll::Ready(()); - } - } - drop(held); - self.cancel(); - } - Step::Gone => self.cancel(), - Step::Message(Ok(Some(Framed(id, body)))) => match id { - // The answer to a pause, or to a resume that paused again before it landed. - ietf::RequestOk::ID | ietf::RequestError::ID if upstream.owed > 0 => { - if let Err(err) = self.this.owed_answer(id, &body) { - break Err(upstream.messages.failed(err)); - } - upstream.owed -= 1; - } - ietf::PublishDone::ID => { - break body - .message::(self.this.version) - .map_err(|err| upstream.messages.failed(err.into())) - .and_then(|done| listing_end(&done, self.this.version)); - } - _ => break Err(upstream.messages.failed(Error::UnexpectedMessage)), - }, - // PUBLISH_DONE must come before the FIN (draft-22 section 6.4.2.2). - Step::Message(Ok(None)) => break Err(Error::ProtocolViolation), - Step::Message(Err(err)) => break Err(err), - } - }; - - self.state = WatchState::Done; - let key = &self.key; - let mut upstream = self.upstream.take().expect("watched"); - tracing::debug!(broadcast = %key.0, track = %key.1, ?end, "held subscription ended"); - self.this.remove_subscribe(upstream.request_id); - // A pause still held back would go out truncated behind a FIN. - match upstream.stream.writer.is_flushed() { - true => { - let _ = upstream.stream.writer.finish(); - } - false => upstream.stream.writer.abort(&Error::Cancel), - } - // A failed upstream fails the copy, as it would while forwarding. TRACK_ENDED leaves it - // idle: the end is past groups it never saw, so a returning reader's SUBSCRIBE learns - // where, as for a copy whose subscription was cancelled. - if let (Err(err), Some(copy)) = (&end, upstream.copy.take()) { - let _ = copy.abort(err.clone()); - } - let mut listings = self.this.listings.lock(); - if listings.watched_by(key, &self.handoff).is_some() { - match end { - Ok(()) => listings.remove(key, true), - Err(err) => { - listings.remove(key, false); - listings.fail(&key.0, err); - } - } - } - Poll::Ready(()) - } -} - -/// Serve a PUBLISH: hold one answering our SUBSCRIBE_TRACKS paused as its track's entry, -/// refuse a redundant one, and decline one no listing asked for. -pub(super) struct ServePublish { - this: Subscriber, - state: ServePublishState, -} - -#[expect( - clippy::large_enum_variant, - reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk" -)] -enum ServePublishState { - /// Waiting for the acceptor to judge the PUBLISH's token. - Verifying { - stream: Stream, - msg: ietf::Publish<'static>, - verdict: crate::auth::RequestVerdict, - /// What the acceptor holds while it decides: its credit lasts as long as this. - credit: kio::Producer<()>, - }, - /// The requester withdrew before the verdict: waiting for the acceptor to let go. - Withdrawn { - credit: kio::Producer<()>, - _stream: Stream, - }, - /// Waiting for the peer's SETUP to judge the track's properties. - Setup { - stream: Stream, - msg: ietf::Publish<'static>, - token_grant: Option, - }, - /// Refusing or declining it, holding what the request held until it is delivered. - Rejecting { - reject: request_stream::Reject, - _credit: Option>, - _grant: Option, - }, - /// Answering PUBLISH_OK, under the token's grant. - Accepting { - stream: Stream, - msg: ietf::Publish<'static>, - token_grant: Option, - key: TrackKey, - handoff: kio::Shared, - /// The alias is bound and PUBLISH_OK buffered. - answered: bool, - }, - /// Held as its track's entry. - Watching(Watch), - Done, -} - -impl ServePublish { - /// Start serving `msg`, the PUBLISH that opened `stream`. - pub(super) fn new(this: Subscriber, stream: Stream, msg: ietf::Publish<'static>) -> Self { - let state = match msg - .authorization_token - .clone() - .filter(|_| this.auth.token_decides(crate::auth::Direction::Subscribe)) - { - Some(token) => { - let credit = kio::Producer::<()>::default(); - let hold: crate::auth::Hold = std::sync::Arc::new(credit.consume()); - let verdict = this.auth.verify_request(token.0, Some(hold)); - ServePublishState::Verifying { - stream, - msg, - verdict, - credit, - } - } - None => ServePublishState::Setup { - stream, - msg, - token_grant: None, - }, - }; - Self { this, state } - } - - /// Ready once the PUBLISH is refused, declined, or its held subscription ends. - pub(super) fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { - let this = &self.this; - loop { - match &mut self.state { - ServePublishState::Verifying { stream, verdict, .. } => { - let withdrawn = this.session.poll_closed(waiter).is_ready() - || request_stream::poll_withdrawn(stream, request_stream::fin_cancels(this.version), waiter) - .is_ready(); - if withdrawn { - let ServePublishState::Verifying { stream, credit, .. } = - std::mem::replace(&mut self.state, ServePublishState::Done) - else { - unreachable!() - }; - self.state = ServePublishState::Withdrawn { - credit, - _stream: stream, - }; - continue; - } - let verified = ready!(verdict.poll_grant(waiter)); - let ServePublishState::Verifying { - mut stream, - msg, - verdict, - credit, - } = std::mem::replace(&mut self.state, ServePublishState::Done) - else { - unreachable!() - }; - let granted = crate::auth::RequestGrant::new( - &this.runtime, - verdict, - verified, - msg.track_namespace.clone(), - crate::auth::RequestKind::Publish, - None, - ); - self.state = match granted { - Ok(grant) => ServePublishState::Setup { - stream, - msg, - token_grant: Some(grant), - }, - Err(refusal) => { - let (kind, err) = (request::Kind::Publish, &refusal.error); - let buffered = request_stream::buffer_error( - &mut stream.writer, - kind, - err, - &refusal.reason, - this.version, - ); - // Dropping an unfinished writer resets it, which could discard the refusal. - ServePublishState::Rejecting { - reject: request_stream::Reject::buffered(stream, buffered), - _credit: Some(credit), - _grant: None, - } - } - }; - } - ServePublishState::Withdrawn { credit, .. } => { - if this.session.poll_closed(waiter).is_ready() - || credit.poll_wanted_changed(waiter, true).is_ready() - { - self.state = ServePublishState::Done; - return Poll::Ready(Ok(())); - } - return Poll::Pending; - } - ServePublishState::Setup { .. } => { - let flate = ready!(this.peer_setup.poll_get(waiter)).flate; - let ServePublishState::Setup { - stream, - msg, - token_grant, - } = std::mem::replace(&mut self.state, ServePublishState::Done) - else { - unreachable!() - }; - self.state = this.offer(stream, msg, token_grant, flate); - } - ServePublishState::Rejecting { reject, .. } => { - let result = ready!(reject.poll(waiter)); - self.state = ServePublishState::Done; - return Poll::Ready(result); - } - ServePublishState::Accepting { - stream, - msg, - token_grant, - answered, - .. - } => { - // The grant ending at any point ends the request there, and is checked first, - // so a request never acts on authority that already ended. - let ok = match token_grant.as_ref().map(|grant| grant.poll_ended(waiter)) { - Some(Poll::Ready(refusal)) => Err(refusal.error), - _ => { - let mut answer = || { - if !std::mem::replace(answered, true) { - this.bind_alias(msg.request_id, msg.track_alias)?; - // PUBLISH_OK is a REQUEST_OK on draft-22. FORWARD=0 holds it paused on - // draft-18; on draft-22 the PUBLISH our FORWARD=0 asked for already is. - let id = match this.version { - Version::Draft18 => ietf::PublishOk::ID, - _ => ietf::RequestOk::ID, - }; - stream.writer.buffer_message( - id, - &ietf::PublishOk { - forward: false, - subscriber_priority: None, - group_order: None, - filter: None, - new_group: None, - }, - )?; - } - Ok(()) - }; - match answer() { - Ok(()) => ready!(stream.writer.poll_flush(waiter)), - Err(err) => Err(err), - } - } - }; - let ServePublishState::Accepting { - stream, - msg, - token_grant, - key, - handoff, - .. - } = std::mem::replace(&mut self.state, ServePublishState::Done) - else { - unreachable!() - }; - let request_id = msg.request_id; - if let Err(err) = ok { - tracing::debug!(broadcast = %key.0, track = %key.1, %err, "failed to hold publish"); - this.remove_subscribe(request_id); - let mut listings = this.listings.lock(); - if listings.watched_by(&key, &handoff).is_some() { - listings.remove(&key, false); - listings.fail(&key.0, err); - } - return Poll::Ready(Ok(())); - } - tracing::debug!(broadcast = %crate::path::Rooted(this.origin.root(), &key.0), track = %key.1, id = %request_id, "holding publish"); - let upstream = Upstream { - token_grant, - stream, - messages: Messages::new(this.session.clone(), this.control.client()), - request_id, - alias: msg.track_alias, - properties: msg.properties, - owed: 0, - copy: None, - }; - self.state = ServePublishState::Watching(Watch::new(this.clone(), key, upstream, handoff, true)); - } - ServePublishState::Watching(watch) => { - ready!(watch.poll(waiter)); - self.state = ServePublishState::Done; - return Poll::Ready(Ok(())); - } - ServePublishState::Done => unreachable!("polled after it resolved"), - } - } - } -} - -/// A REQUEST_UPDATE setting a held subscription's Forward State. -#[derive(Default)] -pub(super) struct Forward { - pub(super) forward: bool, - /// The Location Filter it resumes with. - pub(super) filter: Option, - /// FILL_PARAMETERS (draft-22), the backfill it resumes with. - pub(super) fill: Option, - /// The aggregate priority, in the model's higher-first terms. - pub(super) priority: Option, - pub(super) max_age: Option, - pub(super) new_group: Option, -} - -/// What a subscription resuming a held one keeps of it. -pub(super) struct Resumed { - pub(super) alias: u64, - pub(super) properties: ietf::Properties, - /// REQUEST_UPDATE answers still owed. - pub(super) owed: u64, - /// How a PUBLISH_DONE ended it before the resume was answered. - pub(super) ended: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::ProduceTest as _; - use crate::transport::memory; - - const TIMEOUT: Duration = Duration::from_secs(1); - - type PeerStream = Stream; - - /// A subscriber listing `broadcast` over a raw peer that plays the publisher. - struct Harness { - subscriber: Subscriber, - peer: memory::Session, - tracks: crate::broadcast::Tracks, - listening: Listening, - version: Version, - _broadcast: crate::broadcast::Producer, - _driver: kio::clock::sim::JoinHandle<()>, - } - - async fn settle() { - kio::clock::sleep(Duration::from_millis(1)).await; - } - - fn harness(version: Version) -> Harness { - harness_with(version, memory::Config::default()) - } - - fn harness_with(version: Version, config: memory::Config) -> Harness { - let (session, peer) = memory::pair(config); - let (tasks, mut task_set) = TaskSet::new(); - let driver = kio::clock::sim::spawn(async move { kio::wait(|waiter| task_set.poll(waiter)).await }); - let subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - crate::origin::Config::new(std::num::NonZeroU64::new(1).unwrap()).produce(), - Control::new(false), - None, - peer::PeerSetup::declared(peer::Peer::default()), - crate::Hop::new(1).unwrap(), - None, - version, - tasks, - Default::default(), - ); - let broadcast = crate::broadcast::Info::default().produce(); - let lister = Arc::new(broadcast.requests().lister().unwrap()); - let tracks = broadcast.consume().tracks().unwrap(); - let listening = subscriber.listen(crate::Path::from_static("bcast"), lister); - Harness { - subscriber, - peer, - tracks, - listening, - version, - _broadcast: broadcast, - _driver: driver, - } - } - - async fn read(stream: &mut PeerStream) -> (u64, ietf::Body) { - let read = async { - let id = stream.reader.varint().await.unwrap(); - (id, stream.reader.decode::().await.unwrap()) - }; - kio::clock::timeout(TIMEOUT, read) - .await - .expect("the subscriber sent nothing") - } - - /// One message in one write, so a peer holding one message per stream still delivers it. - async fn write<'m, M: ietf::Message<'m> + std::fmt::Debug>(stream: &mut PeerStream, id: u64, msg: &M) { - stream.writer.buffer_message(id, msg).unwrap(); - kio::wait(|waiter| stream.writer.poll_flush(waiter)).await.unwrap(); - settle().await; - } - - /// The next listing event as `+name`, `-name`, or `synced`; `None` while pending. - fn event(tracks: &mut crate::broadcast::Tracks) -> Option { - match tracks.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(crate::broadcast::TracksEvent::Added { name, .. }))) => Some(format!("+{name}")), - Poll::Ready(Ok(Some(crate::broadcast::TracksEvent::Removed { name }))) => Some(format!("-{name}")), - Poll::Ready(Ok(Some(crate::broadcast::TracksEvent::Finished { name }))) => Some(format!("~{name}")), - Poll::Ready(Ok(Some(crate::broadcast::TracksEvent::Synced))) => Some("synced".into()), - Poll::Ready(other) => Some(format!("{other:?}")), - Poll::Pending => None, - } - } - - impl Harness { - /// Accept the SUBSCRIBE_TRACKS and establish it. - async fn establish(&mut self) -> PeerStream { - let mut stream = kio::clock::timeout( - TIMEOUT, - Stream::accept(&mut crate::driver::Transport::new(self.peer.clone()), self.version), - ) - .await - .expect("no SUBSCRIBE_TRACKS") - .unwrap(); - let (id, body) = read(&mut stream).await; - assert_eq!(id, ietf::SubscribeTracks::ID); - let msg: ietf::SubscribeTracks = body.message(self.version).unwrap(); - assert_eq!(msg.prefix.as_str(), "bcast"); - assert!(!msg.forward, "the listing asks for every track's objects"); - let ok = ietf::RequestOk { - active: None, - largest: None, - }; - write(&mut stream, ietf::RequestOk::ID, &ok).await; - assert_eq!(event(&mut self.tracks).as_deref(), Some("synced")); - stream - } - - /// PUBLISH `name` paused, returning the stream once the subscriber holds it. - async fn publish(&mut self, name: &str, request_id: u64) -> PeerStream { - let (stream, id, _) = self.offer(offered("bcast", name, request_id)).await; - assert!( - [ietf::RequestOk::ID, ietf::PublishOk::ID].contains(&id), - "PUBLISH refused: {id:#x}" - ); - stream - } - - /// A reader of `name`, served by the subscriber as a broadcast's request would be. - async fn read_track( - &mut self, - name: &str, - ) -> ( - crate::track::Consumer, - kio::Pending, - kio::clock::sim::JoinHandle<()>, - ) { - let mut dynamic = self._broadcast.requests(); - let track = self._broadcast.consume().track(name).unwrap(); - let reader = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let mut subscriber = self.subscriber.clone(); - let serving = kio::clock::sim::spawn(async move { - subscriber - .run_subscribe(Path::from_static("bcast"), dynamic, None, request) - .await; - }); - (track, reader, serving) - } - - /// Resume the held `video` for a reader, returning it once accepted. - async fn resume( - &mut self, - video: &mut PeerStream, - ) -> ( - crate::track::Consumer, - crate::track::Subscriber, - kio::clock::sim::JoinHandle<()>, - ) { - let (track, reader, serving) = self.read_track("video").await; - let (id, body) = read(video).await; - assert_eq!(id, ietf::SubscribeUpdate::ID, "not resumed"); - let resume: ietf::SubscribeUpdate = body.message(self.version).unwrap(); - assert_eq!(resume.forward, Some(true)); - let ok = ietf::RequestOk { - active: None, - largest: None, - }; - write(video, ietf::RequestOk::ID, &ok).await; - let reader = kio::clock::timeout(TIMEOUT, reader) - .await - .expect("never accepted") - .unwrap(); - (track, reader, serving) - } - - /// Resume the held `video` for a reader, then let it go so the subscription pauses. - async fn resume_and_pause( - &mut self, - video: &mut PeerStream, - ) -> (crate::track::Consumer, kio::clock::sim::JoinHandle<()>) { - let (track, reader, serving) = self.resume(video).await; - drop(reader); - let (id, body) = read(video).await; - assert_eq!(id, ietf::SubscribeUpdate::ID, "not paused"); - let pause: ietf::SubscribeUpdate = body.message(self.version).unwrap(); - assert_eq!(pause.forward, Some(false)); - settle().await; - (track, serving) - } - - /// Send `publish`, returning the answer. - async fn offer(&mut self, publish: ietf::Publish<'_>) -> (PeerStream, u64, ietf::Body) { - let mut stream = Stream::open(&crate::driver::Transport::new(self.peer.clone()), self.version) - .await - .unwrap(); - stream.writer.encode_message(&publish).await.unwrap(); - - let mut ours = Stream::accept(&mut self.subscriber.session.clone(), self.version) - .await - .unwrap(); - let id = ours.reader.varint().await.unwrap(); - assert_eq!(id, ietf::Publish::ID); - let body: ietf::Body = ours.reader.decode().await.unwrap(); - let subscriber = self.subscriber.clone(); - let version = self.version; - kio::clock::sim::spawn(async move { - let msg = body.message::(version).unwrap().into_owned(); - let mut serve = ServePublish::new(subscriber, ours, msg); - let _ = kio::wait(|waiter| serve.poll(waiter)).await; - }); - let (id, body) = read(&mut stream).await; - (stream, id, body) - } - } - - /// Readers coming and going while the peer withholds credit on a held track never pile - /// updates up behind it: the held subscription is handed over only once its pause is out, - /// and a reader leaving meanwhile stops waiting for it. - #[kio::test] - async fn churn_under_withheld_credit_buffers_one_resume_and_pause() { - for version in [Version::Draft18, Version::Draft20, Version::Draft22] { - // One message in flight per stream, so a peer that stops reading withholds credit. - let config = memory::Config { - stream_buffer: 0, - ..Default::default() - }; - let mut h = harness_with(version, config); - let _listing = h.establish().await; - let mut video = h.publish("video", 1).await; - - for _ in 0..3 { - let (track, reader, serving) = h.read_track("video").await; - settle().await; - drop(reader); - drop(track); - kio::clock::timeout(TIMEOUT, serving) - .await - .unwrap_or_else(|_| panic!("{version}: the abandoned resume stayed alive")) - .unwrap(); - } - - let mut updates = Vec::new(); - while let Ok(Ok(id)) = kio::clock::timeout(TIMEOUT, video.reader.varint()).await { - let body: ietf::Body = video.reader.decode().await.unwrap(); - assert_eq!(id, ietf::SubscribeUpdate::ID, "{version}"); - updates.push(body.message::(version).unwrap().forward); - } - assert_eq!(updates, [Some(true), Some(false)], "{version}"); - } - } - - /// A paused PUBLISH of `name` in `namespace`, aliased by its request ID. - fn offered<'a>(namespace: &'a str, name: &'a str, request_id: u64) -> ietf::Publish<'a> { - ietf::Publish { - authorization_token: None, - request_id: RequestId(request_id), - track_namespace: Path::new(namespace).unwrap(), - track_name: name.into(), - track_alias: request_id, - largest_location: None, - forward: false, - properties: ietf::Properties::default(), - subscriber_priority: None, - group_order: None, - filter: None, - } - } - - /// One SUBSCRIBE_TRACKS covers each nesting chain, since the peer refuses an overlapping - /// prefix: a nested listing reuses the broader request, and a broader one cancels the - /// nested requests and goes out only once they have left the wire. - #[test] - fn overlapping_listings_share_one_request() { - let broadcasts: Vec<_> = (0..5).map(|_| crate::broadcast::Info::default().produce()).collect(); - let lister = |i: usize| Arc::new(broadcasts[i].requests().lister().unwrap()); - let live = |listings: &Listings| listings.requests.values().filter(|r| r.live.is_some()).count(); - - let mut listings = Listings::default(); - let (_, _, outer) = listings.listen(Path::from_static("a"), lister(0)); - assert!( - outer.is_some_and(|outer| outer.blockers.is_empty()), - "the first listing waits" - ); - let (_, _, nested) = listings.listen(Path::from_static("a/b"), lister(1)); - assert!(nested.is_none(), "a nested listing asked again"); - assert_eq!(live(&listings), 1); - - let mut listings = Listings::default(); - for (i, path) in ["a/b", "a/c"].into_iter().enumerate() { - let (_, _, nested) = listings.listen(Path::new(path).unwrap(), lister(2 + i)); - assert!(nested.is_some_and(|nested| nested.blockers.is_empty()), "{path}"); - } - let (_, _, outer) = listings.listen(Path::from_static("a"), lister(4)); - assert_eq!(outer.expect("the broader listing asks").blockers.len(), 2); - assert_eq!(live(&listings), 1, "the nested requests stand beside it"); - } - - /// The error code a refused PUBLISH answered with. - fn refusal(id: u64, body: &ietf::Body, version: Version) -> u64 { - assert_eq!(id, ietf::RequestError::ID, "accepted"); - body.message::(version).unwrap().error_code - } - - /// A PUBLISH no listing asked for stays refused, and one for a track already held is - /// redundant: DUPLICATE_SUBSCRIPTION on draft-18, which allows one subscription per - /// track, and UNINTERESTED after. - #[kio::test] - async fn an_unasked_or_redundant_publish_is_refused() { - for (version, duplicate) in [(Version::Draft18, 0x19), (Version::Draft22, 0x20)] { - let mut h = harness(version); - let _listing = h.establish().await; - let (_, id, body) = h.offer(offered("other", "video", 1)).await; - assert_eq!(refusal(id, &body, version), 0x3, "{version}: unsolicited"); - let _video = h.publish("video", 3).await; - let (_, id, body) = h.offer(offered("bcast", "video", 5)).await; - assert_eq!(refusal(id, &body, version), duplicate, "{version}: redundant"); - } - } - - /// A PUBLISH over the listed-track limit is refused as EXCESSIVE_LOAD, which may pass once - /// the peer unlists a track, so it invites a retry (draft-18 section 10.6.2). - #[kio::test] - async fn an_over_limit_publish_invites_a_retry() { - for version in [Version::Draft18, Version::Draft20, Version::Draft22] { - let mut h = harness(version); - h.subscriber.announces = crate::session::Slots::new(0); - let _listing = h.establish().await; - let (_, id, body) = h.offer(offered("bcast", "video", 1)).await; - assert_eq!(id, ietf::RequestError::ID, "{version}: accepted"); - let refused: ietf::RequestError = body.message(version).unwrap(); - assert_eq!(refused.error_code, request::EXCESSIVE_LOAD, "{version}"); - assert_eq!( - refused.retry_interval, - crate::auth::RETRY_INITIAL.as_millis() as u64, - "{version}" - ); - } - } - - /// A PUBLISH_DONE with TRACK_ENDED unlists the track as finished, and the listing stands. - #[kio::test] - async fn a_track_ended_publish_finishes_the_track() { - let version = Version::Draft22; - let mut h = harness(version); - let _listing = h.establish().await; - let mut video = h.publish("video", 1).await; - assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); - - let done = ietf::PublishDone { - status_code: ietf::PublishDoneStatus::TrackEnded.code(), - stream_count: 0, - reason_phrase: "".into(), - }; - write(&mut video, ietf::PublishDone::ID, &done).await; - assert_eq!(event(&mut h.tracks).as_deref(), Some("~video")); - assert!(h.listening.poll_failed(&kio::Waiter::noop()).is_pending()); - } - - /// Any other PUBLISH_DONE, INTERNAL_ERROR included, fails the listing rather than hide a - /// track that may be live. - #[kio::test] - async fn another_publish_done_fails_the_listing() { - let version = Version::Draft22; - for (status, expected) in [ - (ietf::PublishDoneStatus::Unauthorized, Error::Unauthorized), - (ietf::PublishDoneStatus::InternalError, Error::Remote(0)), - ] { - let mut h = harness(version); - let _listing = h.establish().await; - let mut video = h.publish("video", 1).await; - assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); - - let done = ietf::PublishDone { - status_code: status.code(), - stream_count: 0, - reason_phrase: "".into(), - }; - write(&mut video, ietf::PublishDone::ID, &done).await; - match h.listening.poll_failed(&kio::Waiter::noop()) { - Poll::Ready(err) => assert_eq!(err.to_string(), expected.to_string(), "{status:?}"), - Poll::Pending => panic!("{status:?}: the listing stood"), - } - } - } - - /// A resumed listed track ending any way but TRACK_ENDED fails the listing, even a - /// clean SUBSCRIPTION_ENDED (draft 18), which says nothing of the track itself. - #[kio::test] - async fn a_resumed_track_ending_otherwise_fails_the_listing() { - let version = Version::Draft18; - let mut h = harness(version); - let _listing = h.establish().await; - let mut video = h.publish("video", 1).await; - assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); - - let (_track, _reader, _serving) = h.resume(&mut video).await; - assert!(h.listening.poll_failed(&kio::Waiter::noop()).is_pending()); - - // SUBSCRIPTION_ENDED, which draft 18 registers. - let done = ietf::PublishDone { - status_code: 0x3, - stream_count: 0, - reason_phrase: "".into(), - }; - write(&mut video, ietf::PublishDone::ID, &done).await; - // The subscription waits out its data streams before it ends. - kio::clock::sleep(Duration::from_secs(5)).await; - assert!(h.listening.poll_failed(&kio::Waiter::noop()).is_ready()); - } - - /// A skipped track is listed through a paused SUBSCRIBE of ours, once it answers. - #[kio::test] - async fn a_skipped_track_lists_once_its_paused_subscribe_answers() { - for version in [Version::Draft18, Version::Draft20, Version::Draft22] { - let mut h = harness(version); - let mut listing = h.establish().await; - let skipped = ietf::PublishSkipped { - suffix: Path::from_static(""), - track_name: "audio".into(), - }; - write(&mut listing, ietf::PublishSkipped::ID, &skipped).await; - assert_eq!(event(&mut h.tracks), None, "{version}: listed before it answered"); - - let mut stream = kio::clock::timeout( - TIMEOUT, - Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), - ) - .await - .expect("no paused SUBSCRIBE") - .unwrap(); - let (id, body) = read(&mut stream).await; - assert_eq!(id, ietf::Subscribe::ID, "{version}"); - let msg: ietf::Subscribe = body.message(version).unwrap(); - assert_eq!(msg.track_name, "audio", "{version}"); - assert_eq!( - msg.group_order, - crate::track::Order::default().into(), - "{version}: the order a reader gets by default" - ); - assert!(!msg.forward, "{version}: the SUBSCRIBE forwards"); - - let ok = ietf::SubscribeOk { - track_alias: 3, - largest: None, - properties: ietf::Properties { - timescale: Some(crate::Timescale::MILLI), - ..Default::default() - }, - }; - write(&mut stream, ietf::SubscribeOk::ID, &ok).await; - assert_eq!(event(&mut h.tracks).as_deref(), Some("+audio"), "{version}"); - } - } - - /// A refused paused SUBSCRIBE fails the listing. - #[kio::test] - async fn a_refused_paused_subscribe_fails_the_listing() { - let version = Version::Draft22; - let mut h = harness(version); - let mut listing = h.establish().await; - let skipped = ietf::PublishSkipped { - suffix: Path::from_static(""), - track_name: "audio".into(), - }; - write(&mut listing, ietf::PublishSkipped::ID, &skipped).await; - let mut stream = kio::clock::timeout( - TIMEOUT, - Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), - ) - .await - .expect("no paused SUBSCRIBE") - .unwrap(); - let _ = read(&mut stream).await; - let refused = ietf::RequestError { - error_code: 0x3, - reason_phrase: "".into(), - retry_interval: 0, - }; - write(&mut stream, ietf::RequestError::ID, &refused).await; - assert!(matches!( - h.listening.poll_failed(&kio::Waiter::noop()), - Poll::Ready(Error::Unsupported) - )); - } - - /// A listed PUBLISH whose properties we cannot take fails the listing: the peer sends - /// nothing else for that track, so a listing left standing would silently miss it. - #[kio::test] - async fn an_unsupported_publish_fails_the_listing() { - let version = Version::Draft22; - let mut h = harness(version); - let _listing = h.establish().await; - let compressed = ietf::Publish { - properties: ietf::Properties { - compress: true, - ..Default::default() - }, - ..offered("bcast", "video", 1) - }; - let (_, id, _) = h.offer(compressed).await; - assert_eq!(id, ietf::RequestError::ID, "accepted"); - assert!(matches!( - h.listening.poll_failed(&kio::Waiter::noop()), - Poll::Ready(Error::UnsupportedExtension) - )); - } - - /// On draft-22 a PUBLISH that forwards contradicts the paused listing it answers, and - /// nothing we send can pause it: it is refused and the listing fails, rather than have its - /// objects refused unseen. On draft-18 our PUBLISH_OK pauses it. - #[kio::test] - async fn a_forwarding_publish_fails_the_listing_on_draft_22() { - for version in [Version::Draft18, Version::Draft20, Version::Draft22] { - let mut h = harness(version); - let _listing = h.establish().await; - let forwarding = ietf::Publish { - forward: true, - ..offered("bcast", "video", 1) - }; - let (_, id, _) = h.offer(forwarding).await; - let failed = h.listening.poll_failed(&kio::Waiter::noop()); - match version { - Version::Draft18 => { - assert_ne!(id, ietf::RequestError::ID, "{version}: refused"); - assert!(failed.is_pending(), "{version}: {failed:?}"); - } - _ => { - assert_eq!(id, ietf::RequestError::ID, "{version}: accepted"); - assert!( - matches!(failed, Poll::Ready(Error::Unsupported)), - "{version}: {failed:?}" - ); - } - } - } - } - - /// A copy that learned DYNAMIC_GROUPS keeps it through a draft-22 resume that opts out of - /// the properties, so once that subscription pauses as the listing's entry, the - /// REQUEST_UPDATE resuming it still carries the group a join asked for. - #[kio::test] - async fn a_paused_resume_that_opted_out_keeps_dynamic_groups() { - let version = Version::Draft22; - let mut h = harness(version); - let mut listing = h.establish().await; - let accept = |peer: memory::Session| async move { - let mut stream = kio::clock::timeout(TIMEOUT, Stream::accept(&mut Transport::new(peer), version)) - .await - .expect("no SUBSCRIBE") - .unwrap(); - let (id, body) = read(&mut stream).await; - assert_eq!(id, ietf::Subscribe::ID); - let msg: ietf::Subscribe = body.message(version).unwrap(); - (stream, msg.properties_wanted, msg.new_group) - }; - let subscribe_ok = |dynamic_groups| ietf::SubscribeOk { - track_alias: 3, - largest: Some(ietf::Location { group: 5, object: 0 }), - properties: ietf::Properties { - dynamic_groups, - ..Default::default() - }, - }; - let answer = ietf::RequestOk { - active: None, - largest: None, - }; - - // The first answer declares DYNAMIC_GROUPS, and group 5 lands with a delta. - let (track, first, _serving) = h.read_track("video").await; - let (mut stream, wanted, _) = accept(h.peer.clone()).await; - assert!(wanted); - write(&mut stream, ietf::SubscribeOk::ID, &subscribe_ok(true)).await; - let first = kio::clock::timeout(TIMEOUT, first) - .await - .expect("never accepted") - .unwrap(); - let copy = h - .subscriber - .state - .lock() - .subscribes - .values() - .next() - .unwrap() - .producer - .clone() - .unwrap(); - let mut group = copy.create_group(crate::model::group::Info { sequence: 5 }).unwrap(); - group.write_frame(None, b"key".as_slice()).unwrap(); - group.write_frame(None, b"delta".as_slice()).unwrap(); - - // A join past the ratio asks for group 6, which stays outstanding. - let join = track.subscribe(None).await.unwrap(); - settle().await; - let (id, body) = read(&mut stream).await; - assert_eq!(id, ietf::SubscribeUpdate::ID); - assert_eq!( - body.message::(version).unwrap().new_group, - Some(6) - ); - write(&mut stream, ietf::RequestOk::ID, &answer).await; - - // Everyone leaves, and the copy lingers. - drop((first, join)); - settle().await; - - // A returning reader resumes it, opting out of the properties: the answer has none. - let reader = track.subscribe(None); - let (mut stream, wanted, new_group) = accept(h.peer.clone()).await; - assert!(!wanted, "the resume opts out"); - assert_eq!(new_group, Some(6)); - write(&mut stream, ietf::SubscribeOk::ID, &subscribe_ok(false)).await; - let reader = kio::clock::timeout(TIMEOUT, reader) - .await - .expect("never accepted") - .unwrap(); - - // Skipped in the listing, so this subscription becomes the entry and pauses. - let skipped = ietf::PublishSkipped { - suffix: Path::from_static(""), - track_name: "video".into(), - }; - write(&mut listing, ietf::PublishSkipped::ID, &skipped).await; - assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); - drop(reader); - let (id, body) = read(&mut stream).await; - assert_eq!(id, ietf::SubscribeUpdate::ID, "not paused"); - assert_eq!( - body.message::(version).unwrap().forward, - Some(false) - ); - write(&mut stream, ietf::RequestOk::ID, &answer).await; - - // The resume still asks for group 6, since the track declared DYNAMIC_GROUPS. - let _reader = track.subscribe(None); - let (id, body) = read(&mut stream).await; - assert_eq!(id, ietf::SubscribeUpdate::ID, "not resumed"); - let resume: ietf::SubscribeUpdate = body.message(version).unwrap(); - assert_eq!(resume.forward, Some(true)); - assert_eq!(resume.new_group, Some(6), "the pause forgot DYNAMIC_GROUPS"); - } - - /// A listed track that finishes before its resume is answered goes back to lingering, and - /// its waiting reader SUBSCRIBEs: the idle copy cannot place the end, which the SUBSCRIBE - /// learns, rather than abort a clean finish. - #[kio::test] - async fn a_track_ending_before_its_resume_resubscribes() { - let version = Version::Draft22; - let mut h = harness(version); - let _listing = h.establish().await; - let mut video = h.publish("video", 1).await; - let (track, _serving) = h.resume_and_pause(&mut video).await; - - let _reader = track.subscribe(None); - let (id, body) = read(&mut video).await; - assert_eq!(id, ietf::SubscribeUpdate::ID, "not resumed"); - assert_eq!( - body.message::(version).unwrap().forward, - Some(true) - ); - let done = ietf::PublishDone { - status_code: ietf::PublishDoneStatus::TrackEnded.code(), - stream_count: 0, - reason_phrase: "".into(), - }; - write(&mut video, ietf::PublishDone::ID, &done).await; - assert!( - h.listening.poll_failed(&kio::Waiter::noop()).is_pending(), - "TRACK_ENDED failed the listing" - ); - - let mut stream = kio::clock::timeout( - TIMEOUT, - Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), - ) - .await - .expect("the copy did not SUBSCRIBE") - .unwrap(); - let (id, _) = read(&mut stream).await; - assert_eq!(id, ietf::Subscribe::ID); - } - - /// A paused track's upstream failing aborts the copy a reader left lingering, rather - /// than leave it open with nothing upstream. - #[kio::test] - async fn a_paused_track_failing_aborts_its_lingering_copy() { - let version = Version::Draft22; - let mut h = harness(version); - let _listing = h.establish().await; - let mut video = h.publish("video", 1).await; - let (track, serving) = h.resume_and_pause(&mut video).await; - assert!(!serving.is_finished(), "the copy did not linger"); - - let done = ietf::PublishDone { - status_code: ietf::PublishDoneStatus::InternalError.code(), - stream_count: 0, - reason_phrase: "".into(), - }; - write(&mut video, ietf::PublishDone::ID, &done).await; - assert!(serving.is_finished(), "the copy outlived its upstream"); - drop(track); - } - - /// The answer a pause owes is decoded like any other, so a malformed one closes the - /// session rather than count as an answer. - #[kio::test] - async fn a_malformed_pause_answer_closes_the_session() { - let version = Version::Draft22; - let mut h = harness(version); - let _listing = h.establish().await; - let mut video = h.publish("video", 1).await; - let (_track, _serving) = h.resume_and_pause(&mut video).await; - - // A REQUEST_OK with no parameters, then a trailing byte. - video.writer.varint(ietf::RequestOk::ID).await.unwrap(); - video - .writer - .write_all(&mut &[0x00, 0x02, 0x00, 0xff][..]) - .await - .unwrap(); - let peer = h.peer.clone(); - let closed = kio::wait(move |waiter| crate::transport::poll::Session::poll_closed(&peer, waiter)); - kio::clock::timeout(TIMEOUT, closed) - .await - .expect("the session stayed up"); - } - - /// A peer that refuses SUBSCRIBE_TRACKS, resets it, or never answers it fails at most - /// that listing: a plain SUBSCRIBE still goes out and is accepted on the session. - #[kio::test] - async fn a_peer_not_answering_the_listing_still_serves_subscribe() { - let version = Version::Draft22; - for case in ["refused", "reset", "silent"] { - let mut h = harness(version); - let mut listing = kio::clock::timeout( - TIMEOUT, - Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), - ) - .await - .expect("no SUBSCRIBE_TRACKS") - .unwrap(); - let (id, _) = read(&mut listing).await; - assert_eq!(id, ietf::SubscribeTracks::ID); - let _held = match case { - "refused" => { - let refused = ietf::RequestError { - error_code: 0x3, - reason_phrase: "".into(), - retry_interval: 0, - }; - write(&mut listing, ietf::RequestError::ID, &refused).await; - None - } - "reset" => { - let Stream { writer, reader } = listing; - writer.abort(&Error::Cancel); - settle().await; - Some(reader) - } - _ => { - settle().await; - Some(listing.reader) - } - }; - let failed = h.listening.poll_failed(&kio::Waiter::noop()); - match case { - "refused" => assert!(matches!(failed, Poll::Ready(Error::Unsupported)), "{case}: {failed:?}"), - "reset" => assert!(failed.is_ready(), "{case}: the listing stood"), - _ => assert!(failed.is_pending(), "{case}: the listing failed"), - } - - let (_track, reader, _serving) = h.read_track("video").await; - let mut stream = kio::clock::timeout( - TIMEOUT, - Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), - ) - .await - .expect("no SUBSCRIBE") - .unwrap(); - let (id, body) = read(&mut stream).await; - assert_eq!(id, ietf::Subscribe::ID, "{case}"); - let msg: ietf::Subscribe = body.message(version).unwrap(); - assert!(msg.forward, "{case}: a paused SUBSCRIBE"); - let ok = ietf::SubscribeOk { - track_alias: 7, - largest: None, - properties: ietf::Properties::default(), - }; - write(&mut stream, ietf::SubscribeOk::ID, &ok).await; - kio::clock::timeout(TIMEOUT, reader) - .await - .expect("never accepted") - .unwrap_or_else(|err| panic!("{case}: {err}")); - } - } - - /// Dropping the listing cancels its SUBSCRIBE_TRACKS: FIN on draft-18, RESET_STREAM on - /// draft-22, where a FIN is no cancel. The held PUBLISHes are let go with it. - #[kio::test] - async fn dropping_the_listing_cancels_it() { - let version = Version::Draft18; - let mut h = harness(version); - let mut listing = h.establish().await; - let mut video = h.publish("video", 1).await; - drop(h.listening); - settle().await; - let ended = kio::clock::timeout(TIMEOUT, listing.reader.varint_maybe()) - .await - .expect("the listing stayed open"); - match version { - Version::Draft18 => assert!(matches!(ended, Ok(None)), "{version}: {ended:?}"), - _ => assert!(ended.is_err(), "{version}: {ended:?}"), - } - let ended = kio::clock::timeout(TIMEOUT, video.reader.varint_maybe()) - .await - .expect("the PUBLISH stayed held"); - assert!(!matches!(ended, Ok(Some(_))), "{version}: {ended:?}"); - } -} diff --git a/rs/moq-net/tests/integration/tracks_ietf.rs b/rs/moq-net/tests/integration/tracks_ietf.rs index a8c2040c506..ee9f8e17bd3 100644 --- a/rs/moq-net/tests/integration/tracks_ietf.rs +++ b/rs/moq-net/tests/integration/tracks_ietf.rs @@ -1,9 +1,7 @@ #![cfg(feature = "async")] -//! A TRACKS listing over a moq-transport session (draft-18+) sends SUBSCRIBE_TRACKS with -//! FORWARD=0, only on demand. Our own publisher refuses it, so a listing of our peer fails -//! alone: the session and its SUBSCRIBEs carry on. The listing's handling of a peer that -//! answers is covered against a scripted peer in `ietf::subscriber::tracks`. +//! We never list a moq-transport peer's tracks: a TRACKS listing over that session fails +//! as unsupported without sending SUBSCRIBE_TRACKS, and the session and its SUBSCRIBEs carry on. //! //! Deterministic: paused time and a single-threaded runtime over the mock transport. @@ -17,7 +15,7 @@ use support::harness::{MockConnectOptions, MockPair, connect_mock}; const TIMEOUT: Duration = Duration::from_secs(10); -/// Every draft that has SUBSCRIBE_TRACKS. +/// Every supported moq-transport draft. const DRAFTS: [&str; 3] = ["moq-transport-18", "moq-transport-20", "moq-transport-22"]; /// SUBSCRIBE (0x03), as the first byte of a request stream. @@ -93,11 +91,10 @@ async fn read(sub: &mut moq_net::track::Subscriber) -> Option<(u64, Vec)> { Some((group.sequence, frame.payload.to_vec())) } -/// SUBSCRIBE_TRACKS goes out only for a listing, since many peers do not answer it: -/// connecting, an announce, and a reader's SUBSCRIBE send none. Our own publisher refuses -/// it, which fails only that listing, and the SUBSCRIBE keeps delivering. +/// A listing fails as unsupported rather than claim the peer lists nothing, sends no +/// SUBSCRIBE_TRACKS, and leaves the SUBSCRIBE delivering. #[kio::test] -async fn a_listing_of_our_own_peer_fails_alone() { +async fn a_listing_fails_alone() { for version in DRAFTS { let publisher = produce_origin(1); let broadcast = publisher @@ -120,13 +117,14 @@ async fn a_listing_of_our_own_peer_fails_alone() { group.write_frame(None, &b"before"[..]).unwrap(); group.finish().unwrap(); assert_eq!(read(&mut sub).await, Some((0, b"before".to_vec())), "{version}"); - assert_eq!(opened(&pair, SUBSCRIBE_TRACKS), 0, "{version}: listed unasked"); let mut tracks = remote.tracks().unwrap(); - assert_eq!(next(&mut tracks).await, "synced", "{version}"); - let failed = next(&mut tracks).await; - assert!(failed.starts_with("error:"), "{version}: {failed}"); - assert_eq!(opened(&pair, SUBSCRIBE_TRACKS), 1, "{version}"); + assert_eq!( + next(&mut tracks).await, + format!("error: {}", moq_net::Error::Unsupported), + "{version}" + ); + assert_eq!(opened(&pair, SUBSCRIBE_TRACKS), 0, "{version}"); let mut group = video.append_group().unwrap(); group.write_frame(None, &b"after"[..]).unwrap(); From 681787f69d4065f46e32b36ba8bc83f99edec97d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 07:44:45 -0800 Subject: [PATCH 3/6] chore: format, and drop the JS FETCH serving benchmark Co-Authored-By: Claude Opus 5.5 --- js/net/bench/fetch.ts | 123 -------------------- js/net/src/ietf/connection.ts | 6 +- js/net/src/ietf/publisher.ts | 9 +- rs/moq-net/src/ietf/subscriber.rs | 4 +- rs/moq-net/src/ietf/subscriber/datagrams.rs | 7 +- 5 files changed, 7 insertions(+), 142 deletions(-) delete mode 100644 js/net/bench/fetch.ts diff --git a/js/net/bench/fetch.ts b/js/net/bench/fetch.ts deleted file mode 100644 index e1b8800e8fa..00000000000 --- a/js/net/bench/fetch.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** Sweep cached groups, independent FETCH peers, and small or chunked frame payloads. */ -import { Duration } from "@moq/clock"; -import { Fetch, FetchHeader, FetchOk } from "../src/ietf/fetch.ts"; -import { decodeFetchObject } from "../src/ietf/object.ts"; -import { Publisher } from "../src/ietf/publisher.ts"; -import { NativeSession } from "../src/ietf/session.ts"; -import { ALPN, Version } from "../src/ietf/version.ts"; -import { createMockTransportPair } from "../src/mock.ts"; -import { Producer } from "../src/origin.ts"; -import * as Path from "../src/path.ts"; -import { Reader, Stream } from "../src/stream.ts"; -import { SEGMENT_BYTES } from "../src/util/limits.ts"; - -console.debug = () => {}; -const version = Version.DRAFT_22; -const frames = 4; -console.log("groups,subscribers,frame_bytes,fetch_us"); -for (const groups of [2, 128]) { - for (const subscribers of [1, 8]) { - for (const size of [16, 1000, SEGMENT_BYTES * 2]) { - const origin = new Producer(); - const route = origin.route(Path.from("fetch")); - const track = route.broadcast.createTrack("video", { maxAge: Duration.millis(60_000) }); - for (let sequence = 0; sequence < groups; sequence++) { - const group = track.appendGroup(); - // Only the selected group needs payloads; the rest expose cache-table scans. - if (sequence === groups - 1) { - for (let index = 0; index < frames; index++) { - if (size <= SEGMENT_BYTES) group.writeFrame({ payload: new Uint8Array(size).fill(index) }); - else { - const frame = group.createFrame({ size }); - for (let offset = 0; offset < size; offset += SEGMENT_BYTES) - frame.write(new Uint8Array(Math.min(SEGMENT_BYTES, size - offset)).fill(index)); - frame.finish(); - } - } - } - group.finish(); - } - const peers = Array.from({ length: subscribers }, () => { - const pair = createMockTransportPair(ALPN.DRAFT_22); - const publisher = new Publisher({ - quic: pair.server, - session: new NativeSession(pair.server, version, false), - publish: origin.consume(), - requiresSolicitation: true, - }); - return { pair, publisher, uni: pair.client.incomingUnidirectionalStreams.getReader() }; - }); - const samples: number[] = []; - try { - for (let round = 0; round < 7; round++) { - const start = performance.now(); - await Promise.all( - peers.map(async ({ pair, publisher, uni }) => { - const client = await Stream.open(pair.client, { version }); - const server = await Stream.accept(pair.server, version); - if (!server) throw new Error("missing FETCH request stream"); - const requestId = BigInt(round); - const groupId = BigInt(groups - 1); - const running = publisher.runFetch( - new Fetch({ - requestId, - type: { - kind: "filtered", - trackNamespace: Path.from("fetch"), - trackName: "video", - filter: { - kind: "absolute", - startGroup: groupId, - startObject: 0n, - endGroup: groupId, - endObject: BigInt(frames - 1), - }, - }, - }), - server, - ); - try { - if ((await client.reader.u53()) !== FetchOk.id) throw new Error("missing FETCH_OK"); - await FetchOk.decode(client.reader, version); - const stream = (await uni.read()).value; - if (!stream) throw new Error("missing FETCH data stream"); - const reader = new Reader(stream, undefined, version); - if ((await reader.u53()) !== FetchHeader.type) throw new Error("wrong data stream"); - if ((await FetchHeader.decode(reader, version)).requestId !== requestId) - throw new Error("wrong FETCH ID"); - for (let index = 0; index < frames; index++) { - const object = await reader.decode((cursor) => - decodeFetchObject(cursor, undefined), - ); - if (object.kind !== "object" || object.size !== size) - throw new Error("wrong FETCH object"); - if (object.payload) { - if (object.payload.some((byte) => byte !== index)) - throw new Error("wrong FETCH payload"); - } else { - for (let left = size; left > 0; ) { - const chunk = await reader.readChunk(Math.min(left, SEGMENT_BYTES)); - if (chunk.some((byte) => byte !== index)) - throw new Error("wrong FETCH chunk"); - left -= chunk.byteLength; - } - } - } - if (!(await reader.done())) throw new Error("extra FETCH objects"); - } finally { - client.close(); - await running; - } - }), - ); - if (round > 1) samples.push((performance.now() - start) * 1000); - } - samples.sort((a, b) => a - b); - console.log(`${groups},${subscribers},${size},${samples[2].toFixed(2)}`); - } finally { - for (const peer of peers) peer.uni.releaseLock(); - origin.close(); - } - } - } -} diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index dc5a71a9965..9e724e3781f 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -18,6 +18,7 @@ import * as Cluster from "./cluster.ts"; import type { RequestKind } from "./error.ts"; import { Fetch, FetchHeader } from "./fetch.ts"; import { GoAway } from "./goaway.ts"; +import * as Message from "./message.ts"; import { Group } from "./object.ts"; import { Publish } from "./publish.ts"; import { PublishNamespace } from "./publish_namespace.ts"; @@ -27,7 +28,6 @@ import { NativeSession } from "./session.ts"; import { Subscribe, SubscribeUpdate } from "./subscribe.ts"; import { SUBSCRIBE_TRACKS_ID, SubscribeNamespace } from "./subscribe_namespace.ts"; import { Subscriber } from "./subscriber.ts"; -import * as Message from "./message.ts"; import { MalformedAuthToken } from "./token.ts"; import { TrackStatusRequest } from "./track.ts"; import { type IetfVersion, Version, versionName } from "./version.ts"; @@ -387,7 +387,9 @@ export class Connection implements Session { throw malformed(err, "subscribe_tracks"); }); this.#spend(requestId); - await handed(this.#refuseUnsupported(stream, "subscribe_namespace", "SUBSCRIBE_TRACKS is not supported")); + await handed( + this.#refuseUnsupported(stream, "subscribe_namespace", "SUBSCRIBE_TRACKS is not supported"), + ); break; } case BigInt(TrackStatusRequest.id): { diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 0f8a6440b94..5dcf9f529f7 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -22,14 +22,7 @@ import { } from "../error.ts"; import type * as group from "../group.ts"; import type { Hop, WireRoute } from "../hop.ts"; -import { - datagramTrackInfo, - firstEligible, - hiddenBelow, - hooks, - presented, - type ReadGroupFrame, -} from "../internal.ts"; +import { datagramTrackInfo, firstEligible, hiddenBelow, hooks, presented, type ReadGroupFrame } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { sendPayload } from "../payload.ts"; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 6c380b350d6..22b0baa47cc 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -4246,9 +4246,7 @@ impl SubscribeOnce { // none and the copy keeps the ones it learned. Before that the answer declares them // again. let properties = match &self.resumed { - Some(idle) if !self.properties_wanted && ietf::Filter::is_draft20(this.version) => { - idle.properties.clone() - } + Some(idle) if !self.properties_wanted && ietf::Filter::is_draft20(this.version) => idle.properties.clone(), _ => properties, }; let timescale = properties.timescale; diff --git a/rs/moq-net/src/ietf/subscriber/datagrams.rs b/rs/moq-net/src/ietf/subscriber/datagrams.rs index 386da5b90a2..ce9f585b93e 100644 --- a/rs/moq-net/src/ietf/subscriber/datagrams.rs +++ b/rs/moq-net/src/ietf/subscriber/datagrams.rs @@ -12,12 +12,7 @@ pub(super) struct Requested { } impl Requested { - pub(super) fn new( - this: Subscriber, - key: TrackKey, - track: track::Consumer, - request: datagram::Request, - ) -> Self { + pub(super) fn new(this: Subscriber, key: TrackKey, track: track::Consumer, request: datagram::Request) -> Self { let priority = request.priority().unwrap_or(0); let id = std::sync::Arc::new(()); this.state From fa33c64e7ce9d4bcde711a278e47d95be1c80f6d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 08:05:47 -0800 Subject: [PATCH 4/6] fix: retarget the fuzz seeds and nightly bench list Co-Authored-By: Claude Opus 5.5 --- js/justfile | 2 +- rs/moq-net/src/fuzz.rs | 6 +++--- sh/gh/nightly.sh | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/js/justfile b/js/justfile index 5afce5bccf4..0e1cd508cdc 100644 --- a/js/justfile +++ b/js/justfile @@ -65,7 +65,7 @@ test-changed list: coverage package="": python3 ../sh/js/coverage.py '{{ package }}' -# Run one net benchmark: broadcasts, forward, requests, fills, fetch, reader, frames, track, resume, observed, drift, ledger, bandwidth, replay, varint, lite-varint, priority, subscribe-drop, refresh, compression, or datagrams. +# Run one net benchmark: broadcasts, forward, requests, fills, reader, frames, track, resume, observed, drift, ledger, bandwidth, replay, varint, lite-varint, priority, subscribe-drop, refresh, compression, or datagrams. bench name: uv run --no-project python ../sh/js/install.py frozen bun net/bench/{{ name }}.ts diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index f62baa04bee..042d436a751 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -1681,7 +1681,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 = vec![(26, crate::setup::Setup { parameters }.encode_bytes(outer).unwrap())]; + let messages = vec![(24, crate::setup::Setup { parameters }.encode_bytes(outer).unwrap())]; for (kind, message) in messages { let mut data = vec![index as u8, kind]; data.extend(message); @@ -1773,11 +1773,11 @@ pub fn seeds() -> Vec { continue; }; - let mut data = vec![index as u8, 12]; + let mut data = vec![index as u8, 11]; data.extend_from_slice(&encoded); seeds.push(Seed { target: "ietf", - kind: 12, + kind: 11, data, }); } diff --git a/sh/gh/nightly.sh b/sh/gh/nightly.sh index b79053c4e52..bab47e615c9 100755 --- a/sh/gh/nightly.sh +++ b/sh/gh/nightly.sh @@ -160,7 +160,7 @@ case "$1" in done ;; js) - for benchmark in broadcasts requests fills fetch forward reader frames drift ledger bandwidth replay varint lite-varint track resume priority subscribe-drop refresh observed compression datagrams socket; do + for benchmark in broadcasts requests fills forward reader frames drift ledger bandwidth replay varint lite-varint track resume priority subscribe-drop refresh observed compression datagrams socket; do javascript "$benchmark" just js bench "$benchmark" done javascript catalog just js catalog-bench From 453b165cf8f03111513e6a2df766ef11c467bfd8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 09:43:59 -0800 Subject: [PATCH 5/6] fix: skip the moq-transport FETCH cells with a JS publisher, and drop stale FETCH claims A relay fills a downstream miss with an upstream FETCH, which a JS publisher now refuses on moq-transport, so the interop CLI FETCH and history cells with a native JS publisher are skipped there with a named reason. The JS net docs, two comments, and the m3 JS joining FETCH quest no longer describe IETF FETCH serving. Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 2 +- js/net/src/ietf/publisher.test.ts | 4 ++-- js/net/src/ietf/publisher.ts | 4 ++-- quest/m3/README.md | 1 - quest/m3/audit-ci.md | 3 ++- quest/m3/ietf-js-joining-fetch.md | 31 ------------------------------- test/interop/README.md | 4 +++- test/interop/interop.sh | 7 +++++++ 8 files changed, 17 insertions(+), 39 deletions(-) delete mode 100644 quest/m3/ietf-js-joining-fetch.md diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index cffb5956f93..7610a17b7ad 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -50,7 +50,7 @@ route.announce(); - **One receive estimate per connection.** `connection.viewers` divides it among watched broadcasts, max-min fair by each viewer's ceiling, so several players on one pooled connection don't each select against the whole downlink. Holding a viewer requests PROBE, and the viewers' ceilings, summed, are the PROBE target a relay advertising `Increase` probes the downlink up to. `setTarget(0)` opts out. `watch`, `update`, and `setTarget` throw a `RangeError` when the ceilings or the target would pass `Viewers.MAX_TARGET` (`Number.MAX_SAFE_INTEGER`), the most a PROBE target carries. - **List a broadcast's tracks.** `for await (const event of broadcast.tracks())` reports each track the broadcast lists (`added`, with its `Track.Info`), one `synced`, then `added` and `removed` as tracks come and go, until the broadcast ends; leaving the loop stops listing. Over a session it needs `moq-lite-07-wip`, and syncs when the peer does, so a track added after `synced` is new at the source. A track served only on request is never listed. See [discovery](/concept/moq-lite#discovery). - **One track per name.** Concurrent subscriptions share one request and producer. `createTrack` on a name with a request still queued answers it. The application owns sequence numbers: the broadcast keeps nothing of an ended track, so a track that replaces it, created or requested, starts at 0. A name always means the same content, so the replacement continues past its predecessor's groups by writing its first group with an explicit sequence. Sequences are `bigint`, so every ID the wire carries fits, past 2^53 included: `track.createGroup(5n)`, `{ absolute: 10n }`. Frame indices and relative anchors stay `number`. -- **Serve history on request.** `track.requests()` opens a handle that serves the groups readers miss: ones their ranges name below the newest group written that the cache no longer holds, or never did. Each run of misses arrives as a `Track.Fill` from `await requests.requestedFill()`, shared by every reader missing it. `accept(sequence)` it into the cache and write the group's frames as first published, `dropGroups(start, end)` what the source lacks (sequences are `bigint`), and serve `wanted()` until it is `undefined`: a reader that leaves or narrows stops wanting its groups. `close()` the fill when done; whatever is still wanted is named dropped. A `fetchGroup` of an uncached group asks the handle at once, even for a group not written yet, which a live write then answers; with no handle open it fails with `NotFound`, as in Rust, and a reader's miss is given up. A reader only asks for what its `delay.max` still wants, so a reader at the live edge never asks. This serves lite FETCH, single-group IETF FETCH, and lite-07 ranged subscriptions from the same track, as Rust's `requests()` does. +- **Serve history on request.** `track.requests()` opens a handle that serves the groups readers miss: ones their ranges name below the newest group written that the cache no longer holds, or never did. Each run of misses arrives as a `Track.Fill` from `await requests.requestedFill()`, shared by every reader missing it. `accept(sequence)` it into the cache and write the group's frames as first published, `dropGroups(start, end)` what the source lacks (sequences are `bigint`), and serve `wanted()` until it is `undefined`: a reader that leaves or narrows stops wanting its groups. `close()` the fill when done; whatever is still wanted is named dropped. A `fetchGroup` of an uncached group asks the handle at once, even for a group not written yet, which a live write then answers; with no handle open it fails with `NotFound`, as in Rust, and a reader's miss is given up. A reader only asks for what its `delay.max` still wants, so a reader at the live edge never asks. This serves lite FETCH and lite-07 ranged subscriptions from the same track, as Rust's `requests()` does. A moq-transport FETCH is refused, so a moq-transport reader never reaches it. - **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` producers. - **Durations and instants are `Clock` types.** Every duration (`maxAge`, `delay`, timeouts, RTT) is a `Clock.Duration` and every monotonic time a `Clock.Instant`, from [`@moq/clock`](./clock), which `@moq/net` re-exports as `Clock`: `maxAge: Moq.Clock.Duration.secs(30)`. Neither converts to a number implicitly; read one with an explicit unit, such as `asMillis()`. The wire still carries whole milliseconds, rounded up. Frame timestamps stay `Time.Timestamp`, a tick count at the track's timescale. - **A subscription's `delay` is a `Track.Delay { min, max }`, as in Rust.** Staleness is `delay.max`: it counts from a group's successor, by media time and by wall clock, whichever reaches it first. `delay.min` is the target latency, carried on moq-lite 07; a peer that cannot carry it leaves it `null` (not stated). Passing the old `maxAge` or `maxDelay` key throws a `TypeError` naming `delay.max`. The publisher's own staleness bound is the separate `Track.Info.maxAge`, which caps `delay.max` and fetches. diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index cef63163f19..c156d5b6124 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -2849,8 +2849,8 @@ async function subscribeOkLargest(version: IetfVersion): Promise { for (const version of IETF_VERSIONS) { diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 5dcf9f529f7..b9f7b6b28da 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -831,8 +831,8 @@ export class Publisher { const ok = new SubscribeOk({ trackAlias: msg.requestId, // Required once the track has content. A Next Object subscription starts right - // after it, and recovers the group's head with a FILL on draft-22 or a joining - // FETCH before it, both of which end here. + // after it and recovers the group's head with a FILL from draft 20, which ends + // here. On draft 18 it carries the group whole from object 0 instead. largest: edge.largest && { groupId: edge.largest.group, objectId: edge.largest.object }, properties, }); diff --git a/quest/m3/README.md b/quest/m3/README.md index 7d5e3b806a6..514c958acd4 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -121,7 +121,6 @@ federation) land. - [Publisher timeliness at relay ingest](/quest/m3/qos-publisher-timeliness.md) - follow-on capability after the release contract is stable - [The relay refuses a cluster peer it cannot dial](/quest/m3/relay-undialable-peers.md) - moq-tokio reports the schemes it can dial and names the missing feature when one can't, and the relay refuses an undialable peer at startup instead of retrying forever - [moq-transport finish interop](/quest/m3/ietf-finish.md) - the interop `finish` case runs on every even moq-transport draft instead of being skipped -- [JS joining FETCH](/quest/m3/ietf-js-joining-fetch.md) - the JS publisher answers a joining FETCH that arrives after a quick finish, mirroring the Rust bound - [An Absolute join never races an older-history FETCH](/quest/m3/ietf-absolute-join-race.md) - reproduce #7680's suspected collision on main, then fix it or keep the test and delete - [A passing interop run stops publishers cleanly](/quest/m3/interop-shutdown-warnings.md) - no lingering publisher or unfinished-producer warning in a green interop run - [Dependency and alternatives audit](/quest/m3/audit-deps.md) - every dependency earns its weight, preferring fewer, and the rest are current, deduplicated, and the best maintained option diff --git a/quest/m3/audit-ci.md b/quest/m3/audit-ci.md index 8a5fe235335..ba0e22cc64b 100644 --- a/quest/m3/audit-ci.md +++ b/quest/m3/audit-ci.md @@ -90,7 +90,8 @@ verdicts: - The earlier finite-track failures before draft 22 (`Error: old`, a JS `RESET_STREAM`) retain their split ownership: [moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306) fixes the Rust publisher, including the removed WASM harness's quick-finish - case; [JS joining FETCH](/quest/m3/ietf-js-joining-fetch.md) owns the JS half. + case. The JS half is gone: the JS publisher refuses every FETCH, and a draft-18 + subscription carries its first group whole instead. - Visibility gaps retained with existing owners: Rust benchmark tests and shipping sizes are measured but not on Pages yet; [moq-dev/moq#7510](https://github.com/moq-dev/moq/pull/7510) publishes both. diff --git a/quest/m3/ietf-js-joining-fetch.md b/quest/m3/ietf-js-joining-fetch.md deleted file mode 100644 index ca44c9eef4d..00000000000 --- a/quest/m3/ietf-js-joining-fetch.md +++ /dev/null @@ -1,31 +0,0 @@ -# [S] The JS publisher answers a joining FETCH after a quick finish - -## Goal - -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 -SUBSCRIBE_OK. - -## Plan - -Decided 2026-10-25 (maintainer): honoring Largest Object and serving the -head moved into JavaScript FETCH in m1, which landed; only this part stays -in later work (m3). A FETCH that resolves before PUBLISH_DONE already finishes its head. - -In the tail test the track is finished before SUBSCRIBE_OK goes out, so -PUBLISH_DONE follows at once and the FETCH arrives to no subscription. Keep a -finished subscription's join answerable under the bound Rust settled in -[moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306), and mirror it on the joinable-SUBSCRIBE registry js-fetch adds: -only a track that ended cleanly keeps its join, until the subscriber closes the -SUBSCRIBE's request stream, the session closes, or the request token's grant -or the session grant stops covering the track. - -Tests: a unit test for a FETCH that arrives after PUBLISH_DONE, the bound's -refusals mirrored from Rust, and the tail cells -`just test interop --tail --publishers js-native-node,js-native-bun --subscribers rust --versions ietf` -plus the browser publisher. - -Public API: none. Wire: none; a late joining FETCH is answered where it was -refused. diff --git a/test/interop/README.md b/test/interop/README.md index c002e6f015c..d55e36f01ac 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -76,7 +76,9 @@ publisher and subscriber. Each reader fetches group 1 of a finished track, requi its three objects, each with its own bytes, and closes under the relay idle-out check. Each native JS runtime also serves that FETCH to all three subscribers from history (`CLI FETCH history`): its publisher writes only group 3 live and answers groups 0 through 2, which it never cached, from an in-memory responder on `track.requests()`. -This runs on moq-lite and every supported IETF draft. +Both lanes run on moq-lite and every supported IETF draft, except that a native JS +publisher's cells are skipped on moq-transport: the relay fills its miss with an upstream +FETCH, which a JS publisher refuses there by design. A late-join lane runs Rust-to-Rust and Rust/Node/Bun in both directions. The publisher writes the first two objects of group 0 before any subscriber exists and leaves the group diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 5f56bbe24e3..026e0a79be3 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -2155,6 +2155,13 @@ run_fetch_round() { local label="CLI FETCH${3:+ $3}" local started=$SECONDS why="" local cell=(case="$label" publisher="$pub" subscriber="$sub" log="$LOGS/$name-sub.log") + # The relay fills its miss with an upstream FETCH, which a JS publisher refuses on + # moq-transport by design: it serves FETCH only over moq-lite. + if [[ "$VERSION" == moq-transport-* && "$pub" == js-native-* ]]; then + echo " SKIP $label $pub -> $sub (a JS publisher refuses moq-transport FETCH)" + record skip "${cell[@]}" detail="a JS publisher refuses moq-transport FETCH" + return + fi if is_broken "$pub" || is_broken "$sub"; then echo " FAIL $label $pub -> $sub (client unavailable)" record fail "${cell[@]}" detail="client unavailable" From b87eb2473dd489f500086f80f860caac40011f8f Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:33:13 -0800 Subject: [PATCH 6/6] revert(net): keep Rust SUBSCRIBE_TRACKS listing of a moq-transport peer Cloudflare's relay answers SUBSCRIBE_TRACKS, so restore moq-net's listing and the PUBLISH adoption, paused/resuming states, and DUPLICATE_SUBSCRIPTION it needs, as on main. Docs and the ietf-scope epic record why. Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 8 +- doc/concept/stats.md | 3 +- quest/m2/ietf-scope/README.md | 5 + quest/m5/net-wrap/e2ee.md | 2 +- .../ietf/draft18-invalid-group-order-0 | Bin 13 -> 13 bytes .../ietf/draft18-invalid-group-order-255 | Bin 13 -> 13 bytes .../ietf/draft18-invalid-group-order-3 | Bin 13 -> 13 bytes .../draft18-namespace-field-trailing-slash | Bin 12 -> 12 bytes .../draft18-setup-envelope-truncated-unknown | Bin 12 -> 12 bytes .../ietf/draft18-setup-envelope-unknown-bytes | Bin 12 -> 12 bytes .../draft18-setup-envelope-unknown-varint | Bin 10 -> 10 bytes .../ietf/draft18-setup-truncated-unknown | Bin 8 -> 8 bytes .../ietf/draft18-setup-unknown-bytes | Bin 8 -> 8 bytes .../ietf/draft18-setup-unknown-varint | Bin 6 -> 6 bytes .../draft18-subscribe-empty-namespace-field | Bin 10 -> 10 bytes ...raft18-subscribe-oversized-full-track-name | Bin 4107 -> 4107 bytes .../draft18-subscribe-oversized-namespace | Bin 4108 -> 4108 bytes .../ietf/draft22-invalid-fill-group-order-0 | Bin 16 -> 16 bytes .../ietf/draft22-invalid-fill-group-order-255 | Bin 16 -> 16 bytes .../ietf/draft22-invalid-fill-group-order-3 | Bin 16 -> 16 bytes .../ietf/draft22-invalid-group-order-0 | Bin 13 -> 13 bytes .../ietf/draft22-invalid-group-order-255 | Bin 13 -> 13 bytes .../ietf/draft22-invalid-group-order-3 | Bin 13 -> 13 bytes .../draft22-setup-envelope-truncated-unknown | Bin 12 -> 12 bytes .../ietf/draft22-setup-envelope-unknown-bytes | Bin 12 -> 12 bytes .../draft22-setup-envelope-unknown-varint | Bin 10 -> 10 bytes .../ietf/draft22-setup-truncated-unknown | Bin 8 -> 8 bytes .../ietf/draft22-setup-unknown-bytes | Bin 8 -> 8 bytes .../ietf/draft22-setup-unknown-varint | Bin 6 -> 6 bytes .../draft22-subscribe-empty-namespace-field | Bin 10 -> 10 bytes ...raft22-subscribe-oversized-full-track-name | Bin 4107 -> 4107 bytes .../draft22-subscribe-oversized-namespace | Bin 4108 -> 4108 bytes rs/moq-net/src/auth.rs | 6 +- rs/moq-net/src/fuzz.rs | 40 +- rs/moq-net/src/fuzz/request.rs | 2 + rs/moq-net/src/ietf/error.rs | 3 + rs/moq-net/src/ietf/message.rs | 2 +- rs/moq-net/src/ietf/mod.rs | 2 + rs/moq-net/src/ietf/publish.rs | 19 + rs/moq-net/src/ietf/publisher.rs | 32 +- rs/moq-net/src/ietf/request_stream.rs | 45 + rs/moq-net/src/ietf/session.rs | 10 +- rs/moq-net/src/ietf/subscribe_namespace.rs | 4 - rs/moq-net/src/ietf/subscribe_tracks.rs | 226 ++ rs/moq-net/src/ietf/subscriber.rs | 524 +++- rs/moq-net/src/ietf/subscriber/datagrams.rs | 11 +- rs/moq-net/src/ietf/subscriber/tracks.rs | 2621 +++++++++++++++++ rs/moq-net/tests/integration/tracks_ietf.rs | 26 +- 48 files changed, 3382 insertions(+), 209 deletions(-) create mode 100644 rs/moq-net/src/ietf/subscribe_tracks.rs create mode 100644 rs/moq-net/src/ietf/subscriber/tracks.rs diff --git a/doc/concept/standard.md b/doc/concept/standard.md index f7d11724ed5..47ad7be3edb 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -25,8 +25,9 @@ maps everything else to "not supported" or a harmless equivalent. The [moq-lite page](/concept/moq-lite#what-moq-lite-leaves-out) lists what the subset drops. What a peer actually observes against this implementation: -- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined; announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`. -- **No track listing.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. We never send one: listing a broadcast from a moq-transport peer fails as unsupported. Our own peers list tracks over moq-lite instead. +- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`. +- **We list no tracks by `PUBLISH`.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. Our own peers list tracks over moq-lite instead. +- **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end fails the listing, and readers list again. Our own moq-transport publishers refuse it, so a Rust listing of one fails. - **History is served one group at a time.** Rust answers a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. A JavaScript publisher refuses every `FETCH` with `NOT_SUPPORTED`, so on draft 18 its Next Object subscription carries the current group whole, from object 0; from draft 20 it answers the fill. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. An explicit absolute subscription start is served from that group on every supported draft, so a reader keeps its prefix even when its answer arrives late. A `SUBSCRIBE` without a Location Filter joins the live group rather than replaying history. A subscriber that wants the track from group 0 sends exactly that filterless `SUBSCRIBE`, the same on every draft, and fetches the groups below the answer's Largest Object with one range `FETCH`, which Rust refuses once it spans more than one group. A live JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. - **A requester's FIN.** On draft 18 a requester finishing its stream cancels its request, except after `TRACK_STATUS`: nothing can follow it, so the FIN only ends the requester's input and the request is still answered. From draft 20 a FIN never cancels; a reset or `STOP_SENDING` does. - **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. @@ -113,7 +114,8 @@ choices. They follow the product model. termination code, so we close the session with it. - **Deduplicated subscriptions and discovery.** Shared subscriptions and overlapping - namespace prefixes are coalesced internally, so we never send `PREFIX_OVERLAP` or + namespace prefixes are coalesced internally, so we never send `PREFIX_OVERLAP`. Only + a redundant draft-18 `PUBLISH` answering our own `SUBSCRIBE_TRACKS` gets `DUPLICATE_SUBSCRIPTION`. - **Subscriber max age is negotiated.** The [Timestamp extension](/draft/moq-timestamp) diff --git a/doc/concept/stats.md b/doc/concept/stats.md index 1779fc7319e..fd80480dfc7 100644 --- a/doc/concept/stats.md +++ b/doc/concept/stats.md @@ -226,7 +226,8 @@ it has lingered empty (five minutes by default), then unlisted and ended. A bucket that returns within the linger keeps its track and epoch, and its totals continue. The node's announcement never changes, so bucket churn costs no announce traffic across a cluster. Over a hop that cannot carry a listing -(moq-lite 06 and older, or moq-transport) the listing is refused. A bucket's track name carries its +(moq-lite 06 and older, or moq-transport, whose `SUBSCRIBE_TRACKS` our relay +refuses) the listing is empty or refused. A bucket's track name carries its run's epoch, so a reader finds it only through the listing; one that already knows the name can subscribe while it is listed, and is refused otherwise. diff --git a/quest/m2/ietf-scope/README.md b/quest/m2/ietf-scope/README.md index 0fe228c3de0..61eed32cce3 100644 --- a/quest/m2/ietf-scope/README.md +++ b/quest/m2/ietf-scope/README.md @@ -29,6 +29,11 @@ Settled with this epic: [moq-dev/moq#7190](https://github.com/moq-dev/moq/pull/7190)), and the relay paths moq.pro's fleet smoke runs: cross-relay moq-transport subscribes and a draft-18 session to a third-party relay. +- Kept (decided 2026-11-10, maintainer, in + [moq-dev/moq#8445](https://github.com/moq-dev/moq/pull/8445)): moq-net's + listing of a moq-transport peer's tracks with SUBSCRIBE_TRACKS, since + Cloudflare's relay (cloudflare/moq-rs) answers it; without it we could not + list tracks on that relay. Receiving SUBSCRIBE_TRACKS stays refused. - Kept, as rejected in the same interview: lite-07's `ietf` object extension and moq-lite 01 to 04. - Not here: `ietf/flate.rs`; compression removal has its own work. diff --git a/quest/m5/net-wrap/e2ee.md b/quest/m5/net-wrap/e2ee.md index 1e4ef8cedc6..c5e0ac4b18c 100644 --- a/quest/m5/net-wrap/e2ee.md +++ b/quest/m5/net-wrap/e2ee.md @@ -72,7 +72,7 @@ the reverse (decided 2026-10-20, maintainer): buffers chunks from `write` and seals at `finish`, and its frame consumer yields chunks only after the frame authenticates. - Listing (maintainer, 2026-10-24): the wire listing (moq-lite's Tracks - stream) carries physical names unchanged, so + stream, IETF SUBSCRIBE_TRACKS) carries physical names unchanged, so relays list a protected broadcast like any other. The consumer's `tracks` opens each listed physical name and reports its semantic name. A name that does not open (relay-forged, another epoch's, malformed) is skipped after one open 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 ea58acb716b180f6a05f4571fb797e27134f653a..b87d0aa307b28bbdc0343bc248ebdbbc6173cf52 100644 GIT binary patch literal 13 UcmZSJX5eICWK3jCVpL)P00k5P{r~^~ literal 13 UcmZSJV&G(8WK3jCVpL)P00jsD{Qv*} 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 0855c8c1a3011b269415274aed4fa81a804a1d27..7e9a314a706e135ebd01d37e8fd0369c74e69558 100644 GIT binary patch literal 13 UcmZSJX5eICWK3jCVpRGM00t8Q{Qv*} literal 13 UcmZSJV&G(8WK3jCVpRGM00svE`~Uy| 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 a7f0074eb4f8959e25bebdf58aab4d8723e8c39f..264b409f72123dfe84076a98810ea9fde82b7174 100644 GIT binary patch literal 13 UcmZSJX5eICWK3jCVpL)V00kET0RR91 literal 13 UcmZSJV&G(8WK3jCVpL)V00j#H00000 diff --git a/rs/moq-net/fuzz/regressions/ietf/draft18-namespace-field-trailing-slash b/rs/moq-net/fuzz/regressions/ietf/draft18-namespace-field-trailing-slash index d1a3a86c3e89e29e4e987b09795f91986371fa2d..8bd10803afa1eda42899bb34053e7649b4ab6d19 100644 GIT binary patch literal 12 TcmZROVPZ+k&(~*8N-P2Z4V(hb literal 12 TcmZQzWMWCn&(~*8N-P2Z408gr 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 052c0a61a4a9ed57d576dbd0005070e9d797880d..915c20d41c3fce6ea8075c269a54ceb63e3c24d8 100644 GIT binary patch literal 12 TcmZROTF=11rpTDcz?1|43f}>@ literal 12 TcmZROSkJ(~rpTDcz?1|43dsSr 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 944f8942603cb0db15115d15e559a87c52f20364..96f776f5eeeac506c78a7c6fe3fe20073f988a20 100644 GIT binary patch literal 12 TcmZROTF=11rpTDcz?cL83f%#= literal 12 TcmZROSkJ(~rpTDcz?cL83daGo 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 db77a0b38387f9688c1ec9d4412bcb2f18247f66..8e998087af50b9cac6ace45bf6af02bfea51622e 100644 GIT binary patch literal 10 RcmZROTF=11qQJ<&1ONvG0Pz3- literal 10 RcmZROSkJ(~qQJ<&1ONu}0Pg?* 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 380cdbb77af32df28f9c51983978dd14dac348a3..69f2fc116406a28ab695ef72f40d291fd2ac1bc1 100644 GIT binary patch literal 8 PcmZP&QDjVHU`hf214#h< literal 8 PcmZP&R%A?MU`hf21401% 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 723f884ef5b8fdecc8290dc08ede0e7110881298..faf6680ce75a5f899dfd5d6ba94a7a62447968c6 100644 GIT binary patch literal 8 PcmZP&QDjVHU`zr414jV+ literal 8 PcmZP&R%A?MU`zr413&=! 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 e41275462bb5db71b93b87d9282be9bce6d39e2f..8e228f345e273272097ab6860d00ec2ebfe4363e 100644 GIT binary patch literal 6 NcmZP&QD9_X0ssK-05|{u literal 6 NcmZP&R$ydc0ssK&05<>t 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 95c18085bd8229c32b904f788212f09a1137bfd3..639915ac8e4dbc41e5456eb8cfc3cdc56009a178 100644 GIT binary patch literal 10 RcmZSJW?*AvWMHgd0004%0F3|u literal 10 RcmZSJVqjxrWMHgd0004u0E_?t 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 b3b5fb4d5a64b2d027047aa22e44851a802b96fd..85cc10f525ecc669943859fadf5d1fff82976308 100644 GIT binary patch delta 24 fcmeBH=vLrh;1*zKWSqdTQSdweL0FD3v literal 10 RcmZSNVqjxrWMHgd0004&0F3|u 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 da7bad02d2bf5254e84c4684624c9a8c404a3096..29b653c967758de2dc4391f4827809f392f39f45 100644 GIT binary patch delta 24 fcmeBH=vLrh grant.subscribe.matches(path), Self::Discover => !grant.subscribe.is_empty(), + Self::Publish => grant.publish.matches(path), Self::PublishNamespace => { crate::Pattern::subtree(path).is_ok_and(|subtree| grant.publish.contains(&subtree)) } @@ -2553,7 +2556,8 @@ impl Gate { } /// The kind of request this gate admits, for its token's grant to cover. A gate on what - /// the peer publishes to us verifies only a PUBLISH_NAMESPACE's token. + /// the peer publishes to us verifies only a PUBLISH_NAMESPACE's token; a PUBLISH's goes + /// straight to [`Link::verify_request`]. fn kind(&self) -> RequestKind { match (self.discovery, self.direction) { (true, _) => RequestKind::Discover, diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 042d436a751..4cbcf800809 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -272,7 +272,7 @@ const IETF_VERSIONS: &[ietf::Version] = &[ietf::Version::Draft18, ietf::Version: const LITE_KINDS: u8 = 34; /// How many types [`ietf_wire`] dispatches over. -const IETF_KINDS: u8 = 25; +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])> { @@ -1071,21 +1071,23 @@ pub fn ietf_wire(data: &[u8]) -> bool { 7 => roundtrip::, _>(rest, &mut Vec::new(), version), 8 => roundtrip::(rest, &mut Vec::new(), version), 9 => roundtrip::, _>(rest, &mut Vec::new(), version), - 10 => roundtrip::, _>(rest, &mut Vec::new(), version), - 11 => roundtrip::(rest, &mut Vec::new(), version), - 12 => roundtrip::(rest, &mut Vec::new(), version), - 13 => roundtrip::(rest, &mut Vec::new(), version), - 14 => roundtrip::(rest, &mut Vec::new(), version), - 15 => roundtrip::(rest, &mut Vec::new(), version), - 16 => roundtrip::(rest, &mut Vec::new(), version), - 17 => roundtrip::, _>(rest, &mut Vec::new(), version), - 18 => roundtrip::(rest, &mut Vec::new(), version), - 19 => roundtrip::(rest, &mut Vec::new(), version), - 20 => roundtrip::(rest, &mut Vec::new(), version), - 21 => roundtrip::(rest, &mut Vec::new(), version), - 22 => roundtrip::(rest, &mut Vec::new(), version), - 23 => roundtrip::(rest, &mut Vec::new(), version), - 24 => setup::(rest, &mut Vec::new(), version, |setup| &setup.parameters), + 10 => roundtrip::, _>(rest, &mut Vec::new(), version), + 11 => roundtrip::, _>(rest, &mut Vec::new(), version), + 12 => roundtrip::(rest, &mut Vec::new(), version), + 13 => roundtrip::(rest, &mut Vec::new(), version), + 14 => roundtrip::(rest, &mut Vec::new(), version), + 15 => roundtrip::(rest, &mut Vec::new(), version), + 16 => roundtrip::(rest, &mut Vec::new(), version), + 17 => roundtrip::(rest, &mut Vec::new(), version), + 18 => roundtrip::, _>(rest, &mut Vec::new(), version), + 19 => roundtrip::(rest, &mut Vec::new(), version), + 20 => roundtrip::(rest, &mut Vec::new(), version), + 21 => roundtrip::(rest, &mut Vec::new(), version), + 22 => roundtrip::(rest, &mut Vec::new(), version), + 23 => roundtrip::(rest, &mut Vec::new(), version), + 24 => roundtrip::(rest, &mut Vec::new(), version), + 25 => roundtrip::(rest, &mut Vec::new(), version), + 26 => setup::(rest, &mut Vec::new(), version, |setup| &setup.parameters), _ => unreachable!("kind is taken modulo IETF_KINDS"), } } @@ -1681,7 +1683,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 = vec![(24, 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); @@ -1773,11 +1775,11 @@ pub fn seeds() -> Vec { continue; }; - let mut data = vec![index as u8, 11]; + let mut data = vec![index as u8, 12]; data.extend_from_slice(&encoded); seeds.push(Seed { target: "ietf", - kind: 11, + kind: 12, data, }); } diff --git a/rs/moq-net/src/fuzz/request.rs b/rs/moq-net/src/fuzz/request.rs index e8721c29f8d..b28087945ea 100644 --- a/rs/moq-net/src/fuzz/request.rs +++ b/rs/moq-net/src/fuzz/request.rs @@ -153,6 +153,8 @@ fn body(framed: &ietf::Framed, version: ietf::Version) { ietf::SubscribeNamespace, ietf::Namespace, ietf::NamespaceDone, + ietf::SubscribeTracks, + ietf::PublishSkipped<'_>, ietf::RequestOk, ietf::RequestError<'_>, ); diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index 4379892abe3..6b23f7942d4 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -147,6 +147,9 @@ 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. + pub(crate) const DUPLICATE_SUBSCRIPTION: u64 = 0x19; + /// The endpoint is overloaded and cannot process the request. pub(crate) const EXCESSIVE_LOAD: u64 = 0x9; diff --git a/rs/moq-net/src/ietf/message.rs b/rs/moq-net/src/ietf/message.rs index 1bf55769d47..f0e16ec4f3a 100644 --- a/rs/moq-net/src/ietf/message.rs +++ b/rs/moq-net/src/ietf/message.rs @@ -7,7 +7,7 @@ pub(super) fn control(id: u64) -> crate::stats::Message { use crate::stats::Message as Class; match id { PublishNamespace::ID | SubscribeNamespace::ID | Namespace::ID | NamespaceDone::ID => Class::Announce, - TrackStatus::ID | SUBSCRIBE_TRACKS_ID => Class::TrackInfo, + TrackStatus::ID | SubscribeTracks::ID | PublishSkipped::ID => Class::TrackInfo, 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, diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 33b0b60356d..bfc349d728a 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -49,6 +49,7 @@ mod session; pub mod solicit; mod subscribe; mod subscribe_namespace; +mod subscribe_tracks; #[cfg(feature = "async")] mod subscriber; pub(crate) mod token; @@ -75,6 +76,7 @@ pub use request::*; pub use session::*; pub use subscribe::*; pub use subscribe_namespace::*; +pub use subscribe_tracks::*; #[cfg(feature = "async")] use subscriber::*; // The fuzz harness drives a subgroup stream's ingest directly. diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index 78938ef712e..e1e0ad6dc63 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -228,6 +228,25 @@ pub struct Publish<'a> { pub filter: Option, } +impl Publish<'_> { + /// Copy the borrowed track name, so the request outlives the bytes it came from. + pub fn into_owned(self) -> Publish<'static> { + Publish { + request_id: self.request_id, + authorization_token: self.authorization_token, + track_namespace: self.track_namespace, + track_name: Cow::Owned(self.track_name.into_owned()), + track_alias: self.track_alias, + largest_location: self.largest_location, + forward: self.forward, + properties: self.properties, + subscriber_priority: self.subscriber_priority, + group_order: self.group_order, + filter: self.filter, + } + } +} + impl<'a> Message<'a> for Publish<'a> { const ID: u64 = 0x1D; diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 3ad30046924..429a9bb40d1 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2985,16 +2985,30 @@ impl Publisher { Poll::Ready(()) }) } - ietf::SUBSCRIBE_TRACKS_ID => { - // We list no tracks by PUBLISH, so each listing is refused on its own and the - // session stays up. The dispatcher already admitted its Request ID, and the rest - // of the body is never read. - tracing::debug!("refusing subscribe_tracks"); - let error = ietf::RequestError { - error_code: request::NOT_SUPPORTED, - reason_phrase: "SUBSCRIBE_TRACKS is not supported".into(), - retry_interval: 0, + 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. + if !data.is_empty() { + return Err(Error::WrongSize); + } + let kind = request::Kind::SubscribeNamespace; + return Ok(this.reject_token(stream, kind, err, serve)); + } + msg => msg?, }; + if !data.is_empty() { + return Err(Error::WrongSize); + } + tracing::debug!(message = ?msg, "received subscribe_tracks"); + // We list no tracks by PUBLISH, so each listing is refused on its own and the + // session stays up. Decoded first, so a refusal still admits its Request ID. + let error = + super::namespace::refusal(&msg.prefix, this.version).unwrap_or_else(|| ietf::RequestError { + error_code: request::NOT_SUPPORTED, + reason_phrase: "SUBSCRIBE_TRACKS is not supported".into(), + retry_interval: 0, + }); let mut stream = stream; let buffered = stream.writer.buffer_message(ietf::RequestError::ID, &error); deliver_refusal(stream, buffered, serve, "subscribe_tracks refusal failed") diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index d7bb221998a..83f3083edd0 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -238,6 +238,51 @@ impl Reject { } } +/// Abandon a request we sent: STOP_SENDING, then our FIN and its acknowledgement. +/// +/// There is no message for it, 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. Finishing alone would leave the writer's Drop free to RESET_STREAM, discarding what +/// was already written, so the writer is closed instead; a request the peer's send window +/// held back resets. +pub(super) struct Cancel { + /// Held until the close settles, as the request stream it belongs to. + _reader: crate::coding::Reader, + closing: Option>, +} + +impl Cancel { + pub(super) fn new(stream: Stream) -> Self { + let Stream { writer, mut reader } = stream; + // STOP_SENDING needs no acknowledgement, so it goes first and the wait covers only + // what we still have to deliver. + reader.abort(&Error::Cancel); + let closing = match writer.closing() { + Ok(closing) => Some(closing), + Err(err) => { + tracing::debug!(%err, "failed to close the subscribe stream"); + None + } + }; + Self { + _reader: reader, + closing, + } + } + + /// Ready once the peer acknowledges our FIN, or the stream dies. + pub(super) fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + if let Some(closing) = &mut self.closing { + if let Err(err) = ready!(closing.poll(waiter)) { + tracing::debug!(%err, "failed to close the subscribe stream"); + } + self.closing = None; + } + Poll::Ready(()) + } +} + /// Deliver the last message buffered on a response: flush it, then finish the stream and /// wait for the peer's acknowledgement. /// diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index a4b24b321ab..1ca7020ef8a 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1673,7 +1673,7 @@ impl BidiServe { ietf::Subscribe::ID | ietf::Fetch::ID | ietf::SubscribeNamespace::ID - | ietf::SUBSCRIBE_TRACKS_ID + | ietf::SubscribeTracks::ID | ietf::TrackStatus::ID | ietf::Publish::ID | ietf::PublishNamespace::ID @@ -1688,11 +1688,11 @@ impl BidiServe { } } Ok(match id { - // Publisher handles: Subscribe, Fetch, SubscribeNamespace, SUBSCRIBE_TRACKS, TrackStatus + // Publisher handles: Subscribe, Fetch, SubscribeNamespace, SubscribeTracks, TrackStatus ietf::Subscribe::ID | ietf::Fetch::ID | ietf::SubscribeNamespace::ID - | ietf::SUBSCRIBE_TRACKS_ID + | ietf::SubscribeTracks::ID | ietf::TrackStatus::ID => BidiStep::Serve(publisher.handle_stream(id, data, stream)?), // Subscriber handles: Publish, PublishNamespace ietf::Publish::ID | ietf::PublishNamespace::ID => { @@ -4191,9 +4191,9 @@ mod tests { "a 4097 byte full track name", ), ( - frame(version, ietf::SubscribeNamespace::ID, &oversized_prefix), + frame(version, ietf::SubscribeTracks::ID, &oversized_prefix), SessionError::ProtocolViolation, - "a 4097 byte SUBSCRIBE_NAMESPACE prefix", + "a 4097 byte SUBSCRIBE_TRACKS prefix", ), ( subscribe(&[&valid[..], &[0]].concat()), diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 158e18673af..8ab6249cf9f 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -9,10 +9,6 @@ use super::publish_namespace::{AdvertParams, Negotiated}; use super::Version; -/// SUBSCRIBE_TRACKS (0x51, draft-18+), which lists every track under a prefix. We send -/// none and refuse each one received, so only its type is known. -pub(crate) const SUBSCRIBE_TRACKS_ID: u64 = 0x51; - /// SUBSCRIBE_NAMESPACE message (type 0x50). #[derive(Clone, Debug)] pub struct SubscribeNamespace { diff --git a/rs/moq-net/src/ietf/subscribe_tracks.rs b/rs/moq-net/src/ietf/subscribe_tracks.rs new file mode 100644 index 00000000000..d65b305c942 --- /dev/null +++ b/rs/moq-net/src/ietf/subscribe_tracks.rs @@ -0,0 +1,226 @@ +//! IETF moq-transport SUBSCRIBE_TRACKS (draft-18+) and the PUBLISH_SKIPPED answering it. + +use std::borrow::Cow; + +use crate::{ + Path, + coding::*, + ietf::{Filter, GroupOrder, Opaque, RequestId}, +}; + +use super::Message; +use super::Version; +use super::namespace::{decode_namespace, encode_namespace}; +use super::subscribe::has_range_filters; +use super::token::RequestToken; + +/// SUBSCRIBE_TRACKS (0x51, draft-18+): list every track under a namespace prefix, each +/// answered with a PUBLISH (or a [`PublishSkipped`]) as it appears. +/// +/// We send it to list an IETF peer's tracks, and refuse one we receive, so only what our +/// request carries is kept; the rest is read to validate it and dropped. +#[derive(Clone, Debug)] +pub struct SubscribeTracks { + pub request_id: RequestId, + pub prefix: Path, + /// FORWARD: absent means 1, which asks each PUBLISH to push its objects at once. + pub forward: bool, + /// The default group order of each PUBLISH subscription. + pub group_order: Option, + /// INCLUDE_PROPERTIES (draft-22): whether each PUBLISH carries Track Properties. + pub properties_wanted: bool, + /// The `AUTHORIZATION TOKEN` (0x03) this request presents. + pub authorization_token: Option, +} + +impl<'a> Message<'a> for SubscribeTracks { + const ID: u64 = 0x51; + + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + self.request_id.encode(w, version)?; + encode_namespace(w, &self.prefix)?; + // Absent means 1, so only a paused listing writes it. + let include_properties = (!self.properties_wanted && Filter::is_draft20(version)).then_some(false); + // Draft-18 allows only AUTHORIZATION_TOKEN and FORWARD here. Draft-20 and draft-22 + // (section 9.18) also list GROUP_ORDER. + let defaults = version != Version::Draft18; + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + 0x10 => (!self.forward).then_some(false), + 0x22 => self.group_order.filter(|_| defaults), + 0x35 => include_properties, + ); + Ok(()) + } + + fn decode_msg(r: &mut Decoder<'a>, version: Version) -> Result { + let request_id = RequestId::decode(r, version)?; + let prefix = decode_namespace(r)?; + // From draft-20 any parameter a SUBSCRIBE takes is legal here (section 3.6.2 of draft-22); + // draft-18 allows only AUTHORIZATION_TOKEN and FORWARD. Those that only make sense + // on one subscription, such as RENDEZVOUS_TIMEOUT or a delivery timeout, are read + // and dropped. + let defaults = version != Version::Draft18; + decode_params!(r, version, + 0x02 => _object_delivery_timeout: Option where defaults, + 0x03 => authorization_token: Vec, + 0x04 => _rendezvous_timeout: Option where defaults, + 0x06 => _subgroup_delivery_timeout: Option where defaults, + 0x10 => forward: Option, + 0x20 => _subscriber_priority: Option where defaults, + 0x21 => _filter: Option where defaults, + 0x22 => group_order: Option where defaults, + // Each PUBLISH starts paused, where a fill opens nothing, even once resumed + // (draft-22 section 3.4.1), so FILL_PARAMETERS is read and dropped. + 0x23 => _fill: Option where Filter::is_draft20(version), + 0x25 => _subgroup_filter: Vec where has_range_filters(version), + 0x26 => _object_id_filter: Vec where has_range_filters(version), + 0x27 => _priority_filter: Vec where has_range_filters(version), + 0x28 => _object_property_filter: Vec where has_range_filters(version), + 0x29 => _track_property_filter: Vec where has_range_filters(version), + 0x32 => _new_group: Option where defaults, + 0x35 => include_properties: Option where Filter::is_draft20(version), + ); + // Last, so a malformed field closes the session before a token refusal. + let authorization_token = super::token::single(authorization_token)?; + + Ok(Self { + request_id, + prefix, + forward: forward.unwrap_or(true), + group_order, + properties_wanted: include_properties.unwrap_or(true), + authorization_token, + }) + } +} + +/// 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. +/// +/// 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> { + /// The track's namespace below the request's prefix. + pub suffix: Path, + pub track_name: Cow<'a, str>, +} + +impl<'a> Message<'a> for PublishSkipped<'a> { + const ID: u64 = 0x0F; + + 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<'a>, _: Version) -> Result { + let suffix = decode_namespace(r)?; + let track_name = Cow::Borrowed(r.str()?); + Ok(Self { suffix, track_name }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encode<'m, M: Message<'m>>(msg: &M, version: Version) -> Vec { + let mut buf = Vec::new(); + msg.encode_msg(&mut Encoder::new(&mut buf, version.into()), version) + .unwrap(); + buf + } + + fn decode<'m, M: Message<'m>>(bytes: &'m [u8], version: Version) -> Result { + let mut buf = bytes; + crate::coding::decode_from(&mut buf, version, |r, v| M::decode_msg(r, v)) + } + + fn paused() -> SubscribeTracks { + SubscribeTracks { + request_id: RequestId(4), + prefix: Path::from_static("room"), + forward: false, + group_order: None, + properties_wanted: true, + authorization_token: None, + } + } + + #[test] + fn a_paused_listing_round_trips() { + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + let decoded: SubscribeTracks = decode(&encode(&paused(), version), version).unwrap(); + assert_eq!(decoded.request_id, RequestId(4), "{version}"); + assert_eq!(decoded.prefix.as_str(), "room", "{version}"); + assert!(!decoded.forward, "{version}"); + } + } + + /// FORWARD defaults to 1, so a listing that omits it asks for every track's objects. + #[test] + fn an_omitted_forward_means_one() { + // Request ID 4, prefix "room", no parameters. + let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x00]; + let decoded: SubscribeTracks = decode(&bytes, Version::Draft22).unwrap(); + assert!(decoded.forward); + } + + /// TRACK_PROPERTY_FILTER (draft-22) is legal, so it is read. + #[test] + fn a_track_property_filter_is_read() { + // Request ID 4, prefix "room", one parameter: 0x29 with a two-byte opaque value. + let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x01, 0x29, 0x02, 0xaa, 0xbb]; + assert!(decode::(&bytes, Version::Draft22).is_ok()); + // Draft-18 has no such parameter, and an unknown even type is malformed there. + assert!(decode::(&bytes, Version::Draft18).is_err()); + } + + /// Draft-18 carries no subscription parameters on SUBSCRIBE_TRACKS: one is not sent, + /// and one received is malformed. + #[test] + fn draft_18_carries_no_subscription_parameters() { + let listing = SubscribeTracks { + group_order: Some(GroupOrder::Ascending), + ..paused() + }; + assert_eq!(encode(&listing, Version::Draft18), encode(&paused(), Version::Draft18)); + // Request ID 4, prefix "room", one parameter: SUBSCRIBER_PRIORITY 7. + let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x01, 0x20, 0x07]; + assert!(decode::(&bytes, Version::Draft18).is_err()); + } + + /// From draft-20 GROUP_ORDER is sent, and any subscription parameter received is read. + #[test] + fn later_drafts_send_their_group_order() { + let ordered = SubscribeTracks { + group_order: Some(GroupOrder::Ascending), + ..paused() + }; + for version in [Version::Draft20, Version::Draft22] { + assert_ne!(encode(&ordered, version), encode(&paused(), version), "{version}"); + let decoded: SubscribeTracks = decode(&encode(&ordered, version), version).unwrap(); + assert_eq!(decoded.group_order, Some(GroupOrder::Ascending), "{version}"); + // Request ID 4, prefix "room", one parameter: SUBSCRIBER_PRIORITY 7. + let bytes = [0x04, 0x01, 0x04, b'r', b'o', b'o', b'm', 0x01, 0x20, 0x07]; + assert!(decode::(&bytes, version).is_ok(), "{version}"); + } + } + + #[test] + fn publish_skipped_round_trips() { + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + let msg = PublishSkipped { + suffix: Path::from_static("cam"), + track_name: "video".into(), + }; + let wire = encode(&msg, version); + let decoded: PublishSkipped = decode(&wire, version).unwrap(); + assert_eq!(decoded.suffix.as_str(), "cam", "{version}"); + assert_eq!(decoded.track_name, "video", "{version}"); + } + } +} diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 5f9c18ef2ac..cbfd7eb31f8 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -24,25 +24,11 @@ use kio::Lock; mod namespaces; pub(super) use namespaces::Namespaces; mod datagrams; +mod tracks; use datagrams::{Requested, Shared}; const TRACK_ALIAS_TIMEOUT: Duration = Duration::from_secs(1); -/// A track, by its namespace and name. -type TrackKey = (Path, std::sync::Arc); - -/// How a PUBLISH_DONE ends the track: only TRACK_ENDED ends it cleanly. Any other status, -/// INTERNAL_ERROR included, fails the copy rather than declare an end the track may run past. -fn done_end(done: &ietf::PublishDone, version: Version) -> Result<(), Error> { - match done.status_code == ietf::PublishDoneStatus::TrackEnded.code() { - true => Ok(()), - false => Err(done - .end(version) - .err() - .unwrap_or(Error::Remote(u32::try_from(done.status_code).unwrap_or(u32::MAX)))), - } -} - /// How many cancelled aliases to remember. Objects keep arriving for about a round trip /// after we cancel, so a handful covers the window, while the cap keeps a long session with /// heavy subscription churn from accumulating tombstones for its whole lifetime. @@ -82,13 +68,21 @@ enum Alias { /// promptly is what bounds the exposure, since it caps the arrival window at a round /// trip rather than leaving it open for the life of the session. Retired, + + /// A paused subscription a listing holds: the alias stays its own, but a group carrying + /// it is refused rather than buffered, since nothing reads the track. + Paused(RequestId), + + /// A paused subscription resuming, whose REQUEST_UPDATE is not answered yet. Its groups + /// can overtake the answer, so they wait on it as they would on a SUBSCRIBE_OK. + Resuming(RequestId), } impl Alias { /// The subscription holding the alias, if any. fn holder(self) -> Option { match self { - Self::Active(id) => Some(id), + Self::Active(id) | Self::Paused(id) | Self::Resuming(id) => Some(id), Self::Retired => None, } } @@ -126,6 +120,10 @@ fn insert_track_alias(aliases: &TrackAliases, alias: u64, request_id: RequestId) table.retired.retain(|&retired| retired != alias); Ok(()) } + Entry::Occupied(mut entry) if entry.get().holder() == Some(request_id) => { + entry.insert(Alias::Active(request_id)); + Ok(()) + } Entry::Occupied(_) => Err(Error::Duplicate), Entry::Vacant(entry) => { entry.insert(Alias::Active(request_id)); @@ -151,6 +149,19 @@ pub(super) fn is_protocol_violation(err: &Error) -> bool { ) } +/// Move `alias` to `to` (paused, resuming, or active again), unless another subscription +/// than `request_id` holds it. +fn rebind_track_alias(aliases: &TrackAliases, alias: u64, request_id: RequestId, to: Alias) { + let Ok(mut aliases) = aliases.write() else { + return; + }; + if let Some(held) = aliases.map.get_mut(&alias) + && held.holder() == Some(request_id) + { + *held = to; + } +} + /// Retire an alias, so groups still in flight for it are dropped promptly rather than /// reported as unknown. /// @@ -183,7 +194,7 @@ fn retire_track_alias(aliases: &TrackAliases, alias: u64, request_id: RequestId) struct State { /// Clean ends keep their request streams until the driver drains datagrams received with them. datagram_ends: Option>, - datagram_requests: HashMap, crate::datagram::Request)>, + datagram_requests: HashMap, crate::datagram::Request)>, // Each active subscription subscribes: HashMap, @@ -703,7 +714,7 @@ pub(super) async fn refusing_upstream( (subscriber, task_set) } -/// A publisher's answer to a SUBSCRIBE. +/// A publisher's answer to a SUBSCRIBE, or to the REQUEST_UPDATE resuming one. enum Answer { Accepted(Accepted), /// A REQUEST_ERROR: the error its code reads as, and the Retry Interval it asked for. @@ -1094,8 +1105,11 @@ pub(super) struct Subscriber { going_away: crate::goaway::GoingAway, // Our grant (MoQ Auth): a subscription it stops covering is cancelled. auth: crate::auth::Link, - // Namespaces the peer may have announced at once (`session::Limits::announces`). + // Namespaces the peer may have announced at once (`session::Limits::announces`), and + // tracks it lists to us. pub(super) announces: crate::session::Slots, + // What SUBSCRIBE_TRACKS lists of the peer's tracks. + listings: Lock, } /// One SUBSCRIBE_NAMESPACE on its stream. @@ -1140,8 +1154,10 @@ impl ResolveAlias { // our STOP_SENDING. Discard the group now rather than waiting out the timeout // for a binding that is never coming. Some(Alias::Retired) => Poll::Ready(Err(Error::Cancel)), - None if aliases.ended => Poll::Ready(Err(Error::Cancel)), - None => Poll::Pending, + // Nothing reads a paused subscription, so its groups are refused. + Some(Alias::Paused(_)) => Poll::Ready(Err(Error::Cancel)), + Some(Alias::Resuming(_)) | None if aliases.ended => Poll::Ready(Err(Error::Cancel)), + Some(Alias::Resuming(_)) | None => Poll::Pending, }); if let Poll::Ready(result) = resolved { return Poll::Ready(result.unwrap_or(Err(Error::Cancel))); @@ -1199,6 +1215,7 @@ impl Subscriber { going_away, auth: crate::auth::Link::new(false), announces: Default::default(), + listings: Default::default(), validity: Default::default(), } } @@ -1388,6 +1405,14 @@ impl Subscriber { Ok(()) } + /// Move the alias the subscription with `request_id` is bound to into `to`. + fn rebind_alias(&self, request_id: RequestId, to: Alias) { + let state = self.state.lock(); + if let Some(alias) = state.subscribes.get(&request_id).and_then(|track| track.alias) { + rebind_track_alias(&state.aliases, alias, request_id, to); + } + } + /// Whether the subscription already holding `alias` is for the same full track name as /// `request_id`, making the repeat legal sharing rather than a collision. fn alias_names_same_track(&self, state: &State, alias: u64, request_id: RequestId) -> bool { @@ -1894,9 +1919,11 @@ impl Subscriber { return Err(Error::WrongSize); } tracing::debug!(message = ?msg, "received publish"); - let mut reject = this.decline_publish(stream, &msg); + // Owned, since the task outlives the body it was read from. + let msg = msg.into_owned(); + let mut serve = tracks::ServePublish::new(this, stream, msg); Box::new(move |waiter: &kio::Waiter| { - if let Err(err) = ready!(reject.poll(waiter)) { + if let Err(err) = ready!(serve.poll(waiter)) { tracing::debug!(%err, "publish stream error"); } Poll::Ready(()) @@ -1960,75 +1987,6 @@ impl Subscriber { Ok(task) } - /// Decline a PUBLISH. - /// - /// PUBLISH offers a single track, so honoring it means routing per - /// (namespace, track). Our model routes per namespace: a source attaches at a - /// path and serves every track under it, resolved on demand via SUBSCRIBE. - /// Accepting a PUBLISH would mean inventing a namespace-level source out of a - /// track-level offer, and that fiction then contradicts any real - /// PUBLISH_NAMESPACE for the same path. - /// - /// Declining the request rather than failing the session, since a peer using - /// a feature we don't implement is not a protocol violation. The rejection is the - /// whole exchange, but it still has to arrive: [`super::request_stream::Reject`] delivers it. - fn decline_publish( - &self, - mut stream: Stream, - msg: &ietf::Publish<'_>, - ) -> super::request_stream::Reject { - if let Some(error) = super::namespace::refusal(&msg.track_namespace, self.version) { - return super::request_stream::Reject::new(stream, error); - } - tracing::debug!(broadcast = %msg.track_namespace, track = %msg.track_name, "rejecting publish"); - - // We decline the method itself rather than this particular track, which would be - // UNINTERESTED. - // - // The alias the message carries is deliberately not recorded. Nothing will ever bind - // 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. - let (kind, err) = (request::Kind::Publish, &Error::Unsupported); - let buffered = super::request_stream::buffer_error( - &mut stream.writer, - kind, - err, - "PUBLISH is not supported", - self.version, - ); - super::request_stream::Reject::buffered(stream, buffered) - } - - /// Read the answer to a SUBSCRIBE: its SUBSCRIBE_OK, or the REQUEST_ERROR refusing it. - fn poll_read_answer( - &self, - waiter: &kio::Waiter, - stream: &mut Stream, - messages: &mut super::request_stream::Messages, - ) -> Poll> { - let super::request_stream::Framed(id, body) = ready!(messages.poll_message(&mut stream.reader, waiter))?; - let mut data = body.decoder(self.version); - Poll::Ready(match id { - ietf::SubscribeOk::ID => { - let msg = ietf::SubscribeOk::decode_msg(&mut data, self.version)?; - tracing::debug!(message = ?msg, "received subscribe ok"); - Ok(Answer::Accepted(Accepted { - alias: msg.track_alias, - properties: msg.properties, - largest: msg.largest, - })) - } - ietf::RequestError::ID => { - let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; - tracing::warn!(message = ?msg, "request error"); - let err = request::from_code(msg.error_code, request::Kind::Subscribe, self.version); - Ok(Answer::Refused(err, Duration::from_millis(msg.retry_interval))) - } - _ => Err(Error::UnexpectedMessage), - }) - } - /// Refuse a request whose AUTHORIZATION TOKEN repeats (section 8.9), which unlike any /// other decode error leaves the session open. fn reject_token( @@ -3131,6 +3089,38 @@ impl Subscriber { kio::wait(|waiter| kio::Task::poll(&mut run, waiter)).await } + /// Answer the source's listings while anyone lists it, from a SUBSCRIBE_TRACKS covering + /// it; a failed listing ends its readers, which ask again. + fn poll_listing( + &self, + path: &Path, + lister: &std::sync::Arc, + listening: &mut Option, + waiter: &kio::Waiter, + ) { + loop { + match listening { + None => { + if !matches!(lister.poll_wanted(waiter), Poll::Ready(Ok(()))) { + return; + } + *listening = Some(self.listen(path.to_owned(), lister.clone())); + } + Some(_) if lister.poll_unwanted(waiter).is_ready() => { + *listening = None; + lister.clear(); + } + Some(current) => match current.poll_failed(waiter) { + Poll::Ready(err) => { + *listening = None; + lister.abort(err); + } + Poll::Pending => return, + }, + } + } + } + /// Serve one track request; see [`RunSubscribe`]. #[cfg(test)] pub(super) async fn run_subscribe( @@ -3321,8 +3311,9 @@ struct RunBroadcast { broadcast: broadcast::Requests, route: Option>, subscribes: TaskSet, - /// Refuses the source's listings: we never list a moq-transport peer's tracks. - lister: broadcast::Lister, + /// Answers the source's listings with the peer's tracks, while anyone lists it. + lister: std::sync::Arc, + listening: Option, /// The broadcast closed: no new tracks, but those in flight run to their own end. draining: bool, } @@ -3335,9 +3326,11 @@ impl RunBroadcast { broadcast: broadcast::Requests, route: Option>, ) -> Self { - let lister = broadcast - .lister() - .expect("only the source answers its broadcast's listings"); + let lister = std::sync::Arc::new( + broadcast + .lister() + .expect("only the source answers its broadcast's listings"), + ); Self { this, path, @@ -3347,6 +3340,7 @@ impl RunBroadcast { route, subscribes: TaskSet::owned(), lister, + listening: None, draining: false, } } @@ -3356,10 +3350,8 @@ impl RunBroadcast { if self.this.session.poll_closed(waiter).is_ready() { return Poll::Ready(None); } - // An empty answer would claim the peer lists nothing. - while let Poll::Ready(Ok(())) = self.lister.poll_wanted(waiter) { - self.lister.abort(Error::Unsupported); - } + self.this + .poll_listing(&self.path, &self.lister, &mut self.listening, waiter); loop { while let Poll::Ready(Ok(request)) = self.datagrams.poll_requested(waiter) { if self.this.session.max_datagram_size() == 0 { @@ -3427,6 +3419,7 @@ impl kio::Task for RunBroadcast { } Some(Err(err)) => { tracing::debug!(%err, "broadcast closed"); + drop(self.listening.take()); self.draining = true; } // Session gone. @@ -3621,6 +3614,12 @@ impl kio::Task for RunSubscribe { } } +/// How a listed track's handoff ended without a subscription to adopt. +enum Abandon { + Failed(Error), + Unused, +} + /// What ended the wait for a SUBSCRIBE's answer. A publisher can be serving before its /// SUBSCRIBE_OK reaches us, since the data streams are independent of the request stream. /// Waiting for the response alone would miss the local side going away in that window and @@ -3654,8 +3653,9 @@ enum ServeEnd { /// it failed. struct SubscribeOnce { source: Option, - key: TrackKey, - /// This request's own view of the track's gate. + key: tracks::TrackKey, + /// This request's own view of the track's gate, which a resumed subscription's token + /// binds to. gate: crate::auth::Gate, _working: crate::session::Working, validity: crate::model::broadcast::RequestValidity, @@ -3721,6 +3721,8 @@ struct OnceRequest { messages: super::request_stream::Messages, /// The fill registered for the join's head. fill: kio::Producer, + /// What an adopted subscription's token earned, handed back with it when it pauses. + token_grant: Option, } /// What the request went out with. @@ -3737,6 +3739,8 @@ struct Sent { /// A request waiting for its answer. struct Answering { req: OnceRequest, + /// The resumed subscription's alias, properties, and owed answers, when resuming one. + held: Option, /// The lingering copy being subscribed again. A request's target is parked in the /// session's state instead, where a session abort can reject it. resumed: Option, @@ -3754,13 +3758,23 @@ struct Serving { reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk; an allow, since the size gap varies by target" )] enum OnceStep { + /// Waiting for a listed track's paused subscription to be handed over. + Adopt { + target: Target, + handoff: kio::Shared, + }, /// Opening a fresh request stream. Open { target: Target, open: crate::transport::open::Open, }, - /// Writing the SUBSCRIBE once the peer's SETUP says what it takes. - Write { target: Target, req: OnceRequest }, + /// Writing the SUBSCRIBE, or the update resuming a held subscription, once the peer's + /// SETUP says what it takes. + Write { + target: Target, + req: OnceRequest, + held: Option, + }, /// Waiting for the answer, flushing the request meanwhile. Answer(Answering), /// Abandoned before the answer: cancelling the request while the fetches it drives go @@ -3830,7 +3844,8 @@ enum Served { /// How an Established subscription ended, which decides what becomes of its request and /// its copy. enum Ending { - /// Nobody wants it any more: the subscription is cancelled, and the copy lingers. + /// Nobody wants it any more: a listed track's subscription pauses, any other is + /// cancelled, and the copy lingers. Idle, /// It failed here with `err`: the request is cancelled, and the copy lingers unless our /// grant stopped covering it. @@ -3842,6 +3857,7 @@ enum Ending { /// An Established subscription, from its answer to its end. struct Established { + alias: u64, properties: ietf::Properties, /// The answer's Largest Location. largest: Option, @@ -3901,18 +3917,25 @@ impl SubscribeOnce { return None; } }; + let key = (broadcast_path.clone(), track_name.into()); + // A listed track's paused subscription is already this track's upstream copy, so + // resume it rather than open a second one. + let step = match this.want(&key) { + Some(handoff) => OnceStep::Adopt { target, handoff }, + None => OnceStep::Open { + target, + open: Default::default(), + }, + }; Some(Self { source: None, - key: (broadcast_path.clone(), track_name.into()), + key, gate: gate.unnamed(), _working: this.session.work().start(), validity: this.validity.start(), resolved, plan, - step: OnceStep::Open { - target, - open: Default::default(), - }, + step, }) } @@ -3926,12 +3949,15 @@ impl SubscribeOnce { ) -> Poll> { loop { let flow = match std::mem::replace(&mut self.step, OnceStep::Done) { + OnceStep::Adopt { target, handoff } => { + self.adopt(waiter, this, broadcast_path, track_name, group_fetches, target, handoff) + } OnceStep::Open { target, open } => { self.open(waiter, this, broadcast_path, track_name, group_fetches, target, open) } - OnceStep::Write { target, req } => match this.peer_setup.poll_get(waiter) { - Poll::Ready(peer) => self.write(this, broadcast_path, track_name, target, req, peer), - Poll::Pending => Flow::Wait(OnceStep::Write { target, req }), + OnceStep::Write { target, req, held } => match this.peer_setup.poll_get(waiter) { + Poll::Ready(peer) => self.write(this, broadcast_path, track_name, target, req, held, peer), + Poll::Pending => Flow::Wait(OnceStep::Write { target, req, held }), }, OnceStep::Answer(answering) => { self.answer(waiter, this, broadcast_path, track_name, group_fetches, answering) @@ -4044,6 +4070,97 @@ impl SubscribeOnce { } } + /// Wait for a listed track's paused subscription to be handed over, and resume it. + #[allow(clippy::too_many_arguments, reason = "one step's state, split from the machine")] + fn adopt( + &mut self, + waiter: &kio::Waiter, + this: &Subscriber, + broadcast_path: &Path, + track_name: &str, + group_fetches: &mut TaskSet, + mut target: Target, + handoff: kio::Shared, + ) -> Flow { + // The handoff waits for the held stream to flush, which a peer withholding credit can + // stall, so nobody wanting the track any more ends the wait too. Every wait here drives + // the fetches a lingering copy started. + let _ = group_fetches.poll(waiter); + let adopt = if self.gate.poll_denied(waiter).is_ready() { + Err(Abandon::Failed(Error::Unauthorized)) + } else if let Err(err) = self.validity.check(waiter) { + Err(Abandon::Failed(err)) + } else if let Poll::Ready(adopted) = Subscriber::poll_handoff(&handoff, waiter) { + Ok(adopted) + } else { + let unwanted = match &mut target { + Target::Request(request) => crate::util::poll_unwanted(&mut request.demand(), waiter), + Target::Resume(idle) => crate::util::poll_unwanted(&mut idle.demand, waiter), + }; + if unwanted.is_pending() { + return Flow::Wait(OnceStep::Adopt { target, handoff }); + } + Err(Abandon::Unused) + }; + match adopt { + Ok(Some(upstream)) => { + this.adopted(&self.key, upstream.request_id); + let tracks::Upstream { + token_grant, + stream, + messages, + request_id, + alias, + properties, + owed, + // This subscription forwards into the copy itself from here. + copy: _, + } = upstream; + if let Some(token) = &token_grant { + self.gate.bind(token.clone()); + } + let fill = self.register(this, broadcast_path, track_name, &target, request_id, true); + Flow::Next(OnceStep::Write { + target, + req: OnceRequest { + id: request_id, + stream, + messages, + fill, + token_grant, + }, + held: Some(tracks::Resumed { + alias, + properties, + owed, + ended: None, + }), + }) + } + Ok(None) => Flow::Next(OnceStep::Open { + target, + open: Default::default(), + }), + Err(Abandon::Failed(err)) => { + this.unwant(self.key.clone(), &handoff); + target.fail(err); + Flow::Ready(None) + } + // Nobody wants the track: a resumed copy lingers again, a request is refused. + Err(Abandon::Unused) => match target { + Target::Resume(idle) => { + this.unwant(self.key.clone(), &handoff); + Flow::Ready(Some(idle)) + } + Target::Request(request) if request.reject_unwanted(Error::Cancel) => { + this.unwant(self.key.clone(), &handoff); + Flow::Ready(None) + } + target => Flow::Next(OnceStep::Adopt { target, handoff }), + }, + } + } + /// Open a fresh request stream and register the request under its ID. #[allow(clippy::too_many_arguments, reason = "one step's state, split from the machine")] fn open( @@ -4076,7 +4193,7 @@ impl SubscribeOnce { }; let id = this.control.next_request_id(); let messages = super::request_stream::Messages::new(this.session.clone(), this.control.client()); - let fill = self.register(this, broadcast_path, track_name, &target, id); + let fill = self.register(this, broadcast_path, track_name, &target, id, false); Flow::Next(OnceStep::Write { target, req: OnceRequest { @@ -4084,13 +4201,15 @@ impl SubscribeOnce { stream, messages, fill, + token_grant: None, }, + held: None, }) } /// Register the request before writing SUBSCRIBE so SUBSCRIBE_OK can bind its alias, /// and so a fill fetch stream that overtakes it finds the subscription it answers. - /// Returns the fill registered for it. + /// `held` when it resumes a held subscription. Returns the fill registered for it. fn register( &self, this: &Subscriber, @@ -4098,23 +4217,38 @@ impl SubscribeOnce { track_name: &str, target: &Target, request_id: RequestId, + held: bool, ) -> kio::Producer { let fill = kio::Producer::new(match self.plan.join.fill.is_some() || self.plan.join.fetch { true => Fill::Requested, false => Fill::Done, }); - this.state.lock().subscribes.insert( + let mut state = this.state.lock(); + // A resumed subscription keeps the alias it is bound to and the data streams it + // counted while it forwarded before. + let (alias, tail) = match held { + true => state + .subscribes + .remove(&request_id) + .map(|held| (held.alias, held.tail)) + .unwrap_or_default(), + false => Default::default(), + }; + state.subscribes.insert( request_id, TrackState { // A resumed copy is the session's to abort from here on. producer: target.producer().cloned(), + alias, + tail, ..TrackState::pending(track_name.to_owned(), broadcast_path.clone(), fill.clone()) }, ); fill } - /// Write the SUBSCRIBE, and park the origin request where a session abort can reject it. + /// Write the SUBSCRIBE, or the update resuming a held subscription, and park the origin + /// request where a session abort can reject it. #[allow(clippy::too_many_arguments, reason = "one step's state, split from the machine")] fn write( &mut self, @@ -4123,6 +4257,7 @@ impl SubscribeOnce { track_name: &str, target: Target, mut req: OnceRequest, + held: Option, peer: peer::Peer, ) -> Flow { // The aggregate is read now: a subscriber can join while the request ID and stream @@ -4149,28 +4284,46 @@ impl SubscribeOnce { let join = &self.plan.join; // Buffered only: the wait for the answer flushes it, so the local terminals still end // a request the peer's send window holds back. - let written = req.stream.writer.buffer_message( - ietf::Subscribe::ID, - &ietf::Subscribe { - max_age: sent.max_age, - new_group: sent.new_group, - request_id, - track_namespace: broadcast_path.clone(), - track_name: track_name.into(), - subscriber_priority: super::priority::to_wire(sent.priority), - group_order: order.into(), - filter: join.filter, - fill: join.fill, - properties_wanted: sent.properties_wanted, - forward: true, - rendezvous: false, - range_filters: false, - authorization_token: None, - }, - ); + 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. + Some(held) => { + this.rebind_alias(request_id, Alias::Resuming(request_id)); + let forward = tracks::Forward { + forward: true, + filter: Some(join.filter), + fill: join.fill, + priority: Some(sent.priority), + max_age: sent.max_age, + // Only a track declaring DYNAMIC_GROUPS takes one on an update. + new_group: sent.new_group.filter(|_| held.properties.dynamic_groups), + }; + this.buffer_forward(&mut req.stream.writer, &forward) + } + None => req.stream.writer.buffer_message( + ietf::Subscribe::ID, + &ietf::Subscribe { + max_age: sent.max_age, + new_group: sent.new_group, + request_id, + track_namespace: broadcast_path.clone(), + track_name: track_name.into(), + subscriber_priority: super::priority::to_wire(sent.priority), + group_order: order.into(), + filter: join.filter, + fill: join.fill, + properties_wanted: sent.properties_wanted, + forward: true, + rendezvous: false, + range_filters: false, + authorization_token: None, + }, + ), + }; if let Err(err) = written { tracing::debug!(%err, "failed to encode subscribe"); this.remove_subscribe(request_id); + this.entry_ended(&self.key, request_id, Err(err.clone())); target.fail(err); return Flow::Ready(None); } @@ -4192,7 +4345,12 @@ impl SubscribeOnce { } Target::Resume(idle) => Some(idle), }; - Flow::Next(OnceStep::Answer(Answering { req, resumed, sent })) + Flow::Next(OnceStep::Answer(Answering { + req, + held, + resumed, + sent, + })) } /// Wait for the answer, or for a local terminal to abandon the request first. @@ -4206,9 +4364,9 @@ impl SubscribeOnce { mut answering: Answering, ) -> Flow { let _ = group_fetches.poll(waiter); - let Answering { req, resumed, .. } = &mut answering; + let Answering { req, held, resumed, .. } = &mut answering; let response = match req.stream.writer.poll_flush(waiter) { - Poll::Ready(Ok(())) => this.poll_read_answer(waiter, &mut req.stream, &mut req.messages), + Poll::Ready(Ok(())) => this.poll_read_answer(waiter, &mut req.stream, &mut req.messages, held.as_mut()), Poll::Ready(Err(err)) => Poll::Ready(Err(err)), Poll::Pending => Poll::Pending, }; @@ -4288,7 +4446,25 @@ impl SubscribeOnce { track = %track_name, "subscribe abandoned before it was accepted" ); - let Answering { req, resumed, .. } = answering; + let Answering { req, held, resumed, .. } = answering; + // A held subscription is still the track's entry: pause it again, owing the + // answer to the resume as well. + if let Some(held) = held + && this.state.lock().subscribes.contains_key(&req.id) + { + let upstream = tracks::Upstream { + token_grant: req.token_grant, + stream: req.stream, + messages: req.messages, + request_id: req.id, + alias: held.alias, + properties: held.properties, + owed: held.owed + 1, + copy: resumed.as_ref().map(|idle| idle.track.clone()), + }; + this.pause(self.key.clone(), upstream); + return Flow::Ready(resumed); + } // 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, // and it took the resumed copy too. @@ -4311,7 +4487,12 @@ impl SubscribeOnce { unanswered: bool, response: Result, ) -> Flow { - let Answering { req, mut resumed, sent } = answering; + let Answering { + req, + mut held, + mut resumed, + sent, + } = answering; let request_id = req.id; // Only a refusal carries a Retry Interval, which a relay forwards to its own readers. let response = match response { @@ -4331,6 +4512,7 @@ impl SubscribeOnce { pending.reject(err.clone()); } this.remove_subscribe(request_id); + this.entry_ended(&self.key, request_id, Err(err.clone())); if let Some(idle) = resumed { let _ = idle.track.abort(err); } @@ -4347,13 +4529,25 @@ impl SubscribeOnce { (None, _) => {} } this.remove_subscribe(request_id); + // A held subscription that the publisher ended before answering ends its + // entry as the PUBLISH_DONE says; one whose resume failed fails it, and is + // cancelled since its PUBLISH_DONE has yet to come. + let ended = held.as_mut().and_then(|held| held.ended.take()); + let cancel = unanswered || (held.is_some() && ended.is_none()); + let finished = matches!(ended, Some(Ok(()))); + this.entry_ended(&self.key, request_id, ended.unwrap_or_else(|| Err(err.clone()))); + // A resumed copy whose track finished before the resume was answered cannot + // place the end, so it lingers again and its reader's SUBSCRIBE learns it. + if finished && resumed.is_some() { + return Flow::Ready(resumed); + } if let Some(idle) = resumed { let _ = match retry { Some(retry) => idle.track.refuse(err, retry), None => idle.track.abort(err), }; } - return match unanswered { + return match cancel { true => Flow::Next(OnceStep::Refuse(Subscriber::cancelling(req.stream))), false => Flow::Ready(None), }; @@ -4366,9 +4560,11 @@ impl SubscribeOnce { } = accepted; // A resumed SUBSCRIBE opts out of the properties from draft-22, so its answer carries // none and the copy keeps the ones it learned. Before that the answer declares them - // again. + // again. A pause holds these too, so a later resume still knows DYNAMIC_GROUPS. let properties = match &resumed { - Some(idle) if !sent.properties_wanted && ietf::Filter::is_draft20(this.version) => idle.properties.clone(), + Some(idle) if held.is_none() && !sent.properties_wanted && ietf::Filter::is_draft20(this.version) => { + idle.properties.clone() + } _ => properties, }; let timescale = properties.timescale; @@ -4462,7 +4658,11 @@ impl SubscribeOnce { } } } - if let Err(err) = this.register_alias(request_id, alias) { + this.entry_info(&self.key, request_id, track.info()); + // A resumed subscription stays bound to the alias it was held with. + if held.is_some() { + this.rebind_alias(request_id, Alias::Active(request_id)); + } else if let Err(err) = this.register_alias(request_id, alias) { if matches!(err, Error::Duplicate) { tracing::warn!(track_alias = %alias, "publisher reused a live track alias for another track"); this.session.close( @@ -4500,6 +4700,7 @@ impl SubscribeOnce { None => {} } let live = Established { + alias, properties, largest, timescale, @@ -4845,10 +5046,10 @@ impl SubscribeOnce { Served::Ended(Ending::Idle) } ServeEnd::Done(res) => { - // Only TRACK_ENDED ends the track quietly; a clean end of another kind, such - // as SUBSCRIPTION_ENDED, leaves the track's fate unknown. + // Only TRACK_ENDED ends a listing entry quietly; a clean end of another + // kind, such as SUBSCRIPTION_ENDED, leaves the track's fate unknown. let end = match &res { - Ok(done) => done_end(done, this.version), + Ok(done) => tracks::listing_end(done, this.version), Err(err) => Err(err.clone()), }; match res.map(|done| done.stream_count) { @@ -4930,6 +5131,29 @@ impl SubscribeOnce { // Release an unallocated join now, or let the session cancel its committed request. live.fetching = None; + match &ending { + Ending::Idle => { + if this.is_entry(&self.key, request_id) && this.state.lock().subscribes.contains_key(&request_id) { + // A listed track's subscription pauses rather than ends, held again as its entry. + live.track.set_idle(); + let upstream = tracks::Upstream { + token_grant: req.token_grant, + stream: req.stream, + messages: req.messages, + request_id, + alias: live.alias, + properties: live.properties.clone(), + owed: live.unanswered, + copy: Some(live.track.clone()), + }; + this.pause(self.key.clone(), upstream); + return Flow::Ready(Some(live.idle(false))); + } + } + Ending::Cancelled { err, .. } => this.entry_ended(&self.key, request_id, Err(err.clone())), + Ending::Closed(end) => this.entry_ended(&self.key, request_id, end.clone()), + } + // Clean up let aborted = this.remove_subscribe(request_id).is_none(); @@ -12158,7 +12382,7 @@ mod tests { }; // Errors are surfaced to the peer on the stream, not raised as a session error. - let mut decline = subscriber.decline_publish(stream, &msg); + let mut decline = subscriber.decline_publish(stream, msg); kio::wait(|waiter| decline.poll(waiter)).await.unwrap(); kio::clock::sleep(Duration::from_millis(1)).await; diff --git a/rs/moq-net/src/ietf/subscriber/datagrams.rs b/rs/moq-net/src/ietf/subscriber/datagrams.rs index ce9f585b93e..e0c9e41ba66 100644 --- a/rs/moq-net/src/ietf/subscriber/datagrams.rs +++ b/rs/moq-net/src/ietf/subscriber/datagrams.rs @@ -5,14 +5,19 @@ use crate::datagram; /// Keeps the initial stream interest until its answer also accepts the datagram request. pub(super) struct Requested { this: Subscriber, - key: TrackKey, + key: tracks::TrackKey, id: std::sync::Arc<()>, subscribing: track::Subscribing, priority: i8, } impl Requested { - pub(super) fn new(this: Subscriber, key: TrackKey, track: track::Consumer, request: datagram::Request) -> Self { + pub(super) fn new( + this: Subscriber, + key: tracks::TrackKey, + track: track::Consumer, + request: datagram::Request, + ) -> Self { let priority = request.priority().unwrap_or(0); let id = std::sync::Arc::new(()); this.state @@ -94,7 +99,7 @@ impl Shared { pub(super) fn new( this: &Subscriber, source: &broadcast::Producer, - key: TrackKey, + key: tracks::TrackKey, track: &track::Producer, ) -> Option { let info = datagram::Info::default() diff --git a/rs/moq-net/src/ietf/subscriber/tracks.rs b/rs/moq-net/src/ietf/subscriber/tracks.rs new file mode 100644 index 00000000000..fc10bd33974 --- /dev/null +++ b/rs/moq-net/src/ietf/subscriber/tracks.rs @@ -0,0 +1,2621 @@ +//! Lists a moq-transport peer's tracks through the TRACKS hook (draft-18+). +//! +//! A listed broadcast sends SUBSCRIBE_TRACKS with FORWARD=0, and each track it lists is +//! one paused subscription held for the session: the PUBLISH the peer answers with, +//! accepted paused, or for a track the peer skips, a SUBSCRIBE we open paused. That +//! subscription is both the listing entry, removed only by its PUBLISH_DONE with TRACK_ENDED, +//! and the track's upstream copy: a reader resumes it with REQUEST_UPDATE and pauses it +//! again once the last one leaves, rather than opening a second subscription. +//! +//! One SUBSCRIBE_TRACKS covers each nesting chain of listed broadcasts, since the peer +//! refuses an overlapping prefix: a nested listing reuses the broader request and filters +//! locally, and a broader one replaces the nested requests once the peer has closed them, +//! keeping the subscriptions they made. Any end of a held subscription other than +//! TRACK_ENDED fails every listing its request serves, which then asks again, rather +//! than hide a track that may still be live. +//! +//! Nothing goes out until a broadcast is listed: many peers do not implement +//! SUBSCRIBE_TRACKS yet, and one that refuses, resets, or ignores it fails or empties only +//! that listing, leaving the session and its SUBSCRIBEs alone. + +use crate::driver::Transport; +use std::sync::Arc; + +use super::*; +use crate::ietf::request_stream::{self, Framed, Messages}; + +/// A listed track, by its namespace and name. +pub(super) type TrackKey = (Path, Arc); + +/// How a PUBLISH_DONE leaves the listing: only TRACK_ENDED removes the track. Any other +/// 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() { + true => Ok(()), + false => Err(done + .end(version) + .err() + .unwrap_or(Error::Remote(u32::try_from(done.status_code).unwrap_or(u32::MAX)))), + } +} + +/// A paused upstream subscription and the request stream that controls it. +pub(super) struct Upstream { + pub(super) token_grant: Option, + pub(super) stream: Stream, + pub(super) messages: Messages, + pub(super) request_id: RequestId, + pub(super) alias: u64, + /// The Track Properties the PUBLISH or SUBSCRIBE_OK declared. + pub(super) properties: ietf::Properties, + /// REQUEST_UPDATE answers still owed, read before anything else. + pub(super) owed: u64, + /// The copy a reader left lingering, which a failed upstream aborts. + pub(super) copy: Option, +} + +/// Hands a paused subscription from the task watching it to a subscription resuming it. +#[derive(Default)] +pub(super) struct Handoff { + /// A subscription asked for it. + wanted: bool, + /// Handed over by the watcher. + upstream: Option, + /// The entry was let go, so the watcher cancels the subscription. + gone: bool, +} + +/// One listed track and the upstream subscription that holds it. +struct Entry { + /// Its TRACK_INFO, once known: a paused SUBSCRIBE learns it from its SUBSCRIBE_OK. + info: Option, + /// The subscription holding it, once known. + request_id: Option, + /// Set while it is paused, or still opening, and a watcher owns it. `None` while a + /// subscription forwarding it owns it. + handoff: Option>, + /// Discovery state the peer makes us hold, as for a lite listed track. + _slot: crate::session::Slot, +} + +impl Drop for Entry { + fn drop(&mut self) { + if let Some(handoff) = &self.handoff { + handoff.lock().gone = true; + } + } +} + +/// A broadcast listed through this session. +struct Listener { + path: Path, + lister: Arc, + /// Set to the error that fails the listing. + failed: kio::Producer>, +} + +/// One SUBSCRIBE_TRACKS, from when it is wanted until the peer has closed it. +struct Request { + prefix: Path, + /// Dropped to cancel it; `None` once cancelled, while it leaves the wire. + live: Option>, + /// Closes once it is off the wire, so an overlapping one may go out. + gone: kio::Consumer<()>, +} + +/// The session's listings of the peer's tracks. +#[derive(Default)] +pub(super) struct Listings { + listeners: HashMap, + requests: HashMap, + entries: HashMap, + next: u64, +} + +/// A SUBSCRIBE_TRACKS for its task to send. +struct Opening { + key: u64, + prefix: Path, + /// The overlapping requests that must leave the wire first. + blockers: Vec>, + live: kio::Consumer<()>, + gone: kio::Producer<()>, +} + +impl Listings { + fn key(&mut self) -> u64 { + self.next += 1; + self.next + } + + /// The standing request that covers `namespace`; at most one does. + fn covering(&self, namespace: &Path) -> Option { + self.requests + .iter() + .find(|(_, request)| request.live.is_some() && namespace.has_prefix(&request.prefix)) + .map(|(key, _)| *key) + } + + fn listers<'a>(&'a self, namespace: &'a Path) -> impl Iterator + 'a { + self.listeners + .values() + .filter(move |listener| &listener.path == namespace) + .map(|listener| &*listener.lister) + } + + fn listen( + &mut self, + path: Path, + lister: Arc, + ) -> (u64, kio::Consumer>, Option) { + // What a broader listing already holds is listed at once. + for ((namespace, name), entry) in &self.entries { + if namespace == &path + && let Some(info) = &entry.info + { + let _ = lister.add(name.clone(), info.clone()); + } + } + // This hop never waits on its upstream: later tracks follow as live adds. + lister.synced(); + let failed = kio::Producer::new(None); + let consumer = failed.consume(); + let key = self.key(); + self.listeners.insert( + key, + Listener { + path: path.clone(), + lister, + failed, + }, + ); + if self.covering(&path).is_some() { + return (key, consumer, None); + } + // Nested requests give way to this broader one, which keeps what they hold. + for request in self.requests.values_mut() { + if request.prefix.has_prefix(&path) { + request.live = None; + } + } + let blockers = self + .requests + .values() + .filter(|request| request.prefix.has_prefix(&path) || path.has_prefix(&request.prefix)) + .map(|request| request.gone.clone()) + .collect(); + let live = kio::Producer::new(()); + let gone = kio::Producer::new(()); + let request = self.key(); + self.requests.insert( + request, + Request { + prefix: path.clone(), + gone: gone.consume(), + live: Some(live.clone()), + }, + ); + let opening = Opening { + key: request, + prefix: path, + blockers, + live: live.consume(), + gone, + }; + (key, consumer, Some(opening)) + } + + fn unlisten(&mut self, key: u64) { + self.listeners.remove(&key); + self.release(); + } + + /// Cancel each standing request nobody lists under, and let go of each held track no + /// standing request covers. + fn release(&mut self) { + for request in self.requests.values_mut() { + if request.live.is_some() + && !self + .listeners + .values() + .any(|listener| listener.path.has_prefix(&request.prefix)) + { + request.live = None; + } + } + let standing: Vec = self + .requests + .values() + .filter(|request| request.live.is_some()) + .map(|request| request.prefix.clone()) + .collect(); + self.entries + .retain(|(namespace, _), _| standing.iter().any(|prefix| namespace.has_prefix(prefix))); + } + + /// Fail every listing the request covering `namespace` serves with `err`, and cancel it: + /// they ask again rather than miss a track that may still be live. + fn fail(&mut self, namespace: &Path, err: Error) { + let Some(key) = self.covering(namespace) else { + return; + }; + let request = self.requests.get_mut(&key).expect("covering"); + request.live = None; + let prefix = request.prefix.clone(); + tracing::debug!(%prefix, %err, "track listing failed"); + for listener in self.listeners.values() { + if listener.path.has_prefix(&prefix) + && let Ok(mut failed) = listener.failed.write() + { + *failed = Some(err.clone()); + } + } + self.release(); + } + + fn insert(&mut self, key: TrackKey, entry: Entry) { + if let Some(info) = &entry.info { + for lister in self.listers(&key.0) { + let _ = lister.add(key.1.clone(), info.clone()); + } + } + self.entries.insert(key, entry); + } + + /// Unlist the entry for `key`, as a track that `finished` or left unfinished. + fn remove(&mut self, key: &TrackKey, finished: bool) { + let Some(entry) = self.entries.remove(key) else { + return; + }; + if entry.info.is_some() { + for lister in self.listers(&key.0) { + let _ = match finished { + true => lister.finish(&key.1), + false => lister.remove(&key.1), + }; + } + } + } + + /// The entry held by the subscription with `request_id`, if this is it. + fn held_by(&mut self, key: &TrackKey, request_id: RequestId) -> Option<&mut Entry> { + self.entries + .get_mut(key) + .filter(|entry| entry.request_id == Some(request_id)) + } + + /// The entry `handoff` belongs to, if it still stands. + fn watched_by(&mut self, key: &TrackKey, handoff: &kio::Shared) -> Option<&mut Entry> { + self.entries + .get_mut(key) + .filter(|entry| entry.handoff.as_ref().is_some_and(|held| held.same_channel(handoff))) + } + + /// End the entry held by the subscription with `request_id`: TRACK_ENDED unlists it as + /// finished, and anything else fails its listings. + fn ended(&mut self, key: &TrackKey, request_id: RequestId, end: Result<(), Error>) { + if self.held_by(key, request_id).is_none() { + return; + } + match end { + Ok(()) => self.remove(key, true), + Err(err) => { + self.remove(key, false); + self.fail(&key.0, err); + } + } + } +} + +/// A broadcast's listing through this session, until dropped. +pub(super) struct Listening { + listings: kio::Lock, + key: u64, + failed: kio::Consumer>, +} + +impl Listening { + /// Ready with the error that failed the listing. + pub(super) fn poll_failed(&self, waiter: &kio::Waiter) -> Poll { + match self.failed.poll(waiter, |failed| match &**failed { + Some(err) => Poll::Ready(err.clone()), + None => Poll::Pending, + }) { + Poll::Ready(Ok(err)) => Poll::Ready(err), + Poll::Ready(Err(_)) => Poll::Ready(Error::Cancel), + Poll::Pending => Poll::Pending, + } + } +} + +impl Drop for Listening { + fn drop(&mut self) { + self.listings.lock().unlisten(self.key); + } +} + +/// What to do with a PUBLISH. +enum Offer { + /// No listing asked for it. + Unsolicited, + /// Refused with this code and reason. + Refuse(u64, &'static str), + /// Accepted paused and held as the entry `handoff` watches. + Hold(kio::Shared), +} + +impl Subscriber { + /// List `path`'s tracks into `lister` until the returned guard drops, through a + /// SUBSCRIBE_TRACKS covering it. + pub(super) fn listen(&self, path: Path, lister: Arc) -> Listening { + let (key, failed, opening) = self.listings.lock().listen(path, lister); + if let Some(opening) = opening { + let mut task = SubscribeTracks::new(self.clone(), opening); + self.tasks.push(move |waiter: &kio::Waiter| task.poll(waiter)); + } + Listening { + listings: self.listings.clone(), + key, + failed, + } + } + + /// A fresh subscription of ours to `name` in `namespace`, and its info once accepted. + fn subscribed(&self, namespace: &Path, name: &str) -> Option<(RequestId, Option)> { + let state = self.state.lock(); + let aliases = state.aliases.read(); + // A paused one is on its way out with the entry it held. + let paused = |id: &RequestId, track: &TrackState| { + track + .alias + .is_some_and(|alias| aliases.map.get(&alias) == Some(&Alias::Paused(*id))) + }; + state + .subscribes + .iter() + .find(|(id, track)| track.broadcast == *namespace && track.name == name && !paused(id, track)) + .map(|(id, track)| (*id, track.producer.as_ref().map(|producer| producer.info().clone()))) + } + + /// The peer skipped a listed track: list it through a subscription of ours, opening + /// one paused if we have none. + fn skipped(&self, namespace: Path, name: Arc) { + let key = (namespace, name); + let mut listings = self.listings.lock(); + if listings.entries.contains_key(&key) || listings.covering(&key.0).is_none() { + return; + } + let slot = match self.announces.acquire() { + Ok(slot) => slot, + Err(err) => return listings.fail(&key.0, err), + }; + if let Some((request_id, info)) = self.subscribed(&key.0, &key.1) { + let entry = Entry { + info, + request_id: Some(request_id), + handoff: None, + _slot: slot, + }; + return listings.insert(key, entry); + } + let handoff = kio::Shared::::default(); + let entry = Entry { + info: None, + request_id: None, + handoff: Some(handoff.clone()), + _slot: slot, + }; + listings.insert(key.clone(), entry); + drop(listings); + let mut task = Skipped::Opening { + this: self.clone(), + key, + handoff, + open: OpenPaused::new(), + }; + self.tasks.push(move |waiter: &kio::Waiter| task.poll(waiter)); + } + + /// Bind the alias a held subscription's PUBLISH or SUBSCRIBE_OK chose, paused, closing + /// the session if it names another track. + fn bind_alias(&self, request_id: RequestId, alias: u64) -> Result<(), Error> { + let err = match self.register_alias(request_id, alias) { + Ok(()) => { + self.rebind_alias(request_id, Alias::Paused(request_id)); + return Ok(()); + } + Err(err) => err, + }; + if matches!(err, Error::Duplicate) { + tracing::warn!(track_alias = %alias, "publisher reused a live track alias for another track"); + self.session.clone().close( + super::super::error::to_session_code(&SessionError::from(&err)), + err.to_string().as_ref(), + ); + } + Err(err) + } + + /// What to do with a PUBLISH whose token, if it decides, granted it. + fn offer( + &self, + stream: Stream, + msg: ietf::Publish<'static>, + token_grant: Option, + flate: bool, + ) -> ServePublishState { + let key: TrackKey = (msg.track_namespace.clone(), msg.track_name.as_ref().into()); + let info = accepted_info(&msg.properties, self.version); + let offer = { + let mut listings = self.listings.lock(); + // Draft-18 allows one subscription per track (section 5.1); later drafts allow + // more, but the one we hold already lists the track and carries its data. + let duplicate = match self.version { + Version::Draft18 => request::DUPLICATE_SUBSCRIPTION, + _ => request::to_code(&Error::Unroutable, request::Kind::Publish, self.version), + }; + if listings.covering(&key.0).is_none() { + match listings + .requests + .values() + .any(|request| key.0.has_prefix(&request.prefix)) + { + true => Offer::Refuse( + request::to_code(&Error::Unroutable, request::Kind::Publish, self.version), + "listing cancelled", + ), + false => Offer::Unsolicited, + } + } else if listings.entries.contains_key(&key) { + Offer::Refuse(duplicate, "already subscribed") + } else if msg.forward && !matches!(self.version, Version::Draft18) { + // From draft-22 the PUBLISH sets its own Forward State, and PUBLISH_OK has no + // FORWARD to pause it: its objects would be refused unseen behind a healthy + // listing. + listings.fail(&key.0, Error::Unsupported); + Offer::Refuse( + request::to_code(&Error::Unsupported, request::Kind::Publish, self.version), + "PUBLISH forwards for a paused listing", + ) + } else if let Some(err) = refused(&msg.properties, self.version, flate) { + // The peer sends nothing else for the track, so a listing left standing would + // silently miss it, as with a refused paused SUBSCRIBE. + let code = request::to_code(&err, request::Kind::Publish, self.version); + listings.fail(&key.0, err); + Offer::Refuse(code, "track properties not supported") + } else { + match self.announces.acquire() { + Err(err) => { + listings.fail(&key.0, err); + Offer::Refuse(request::EXCESSIVE_LOAD, "too many listed tracks") + } + Ok(slot) => match self.subscribed(&key.0, &key.1) { + // Our own SUBSCRIBE holds the track, so it is the entry. + Some((request_id, _)) => { + let entry = Entry { + info: Some(info), + request_id: Some(request_id), + handoff: None, + _slot: slot, + }; + listings.insert(key.clone(), entry); + Offer::Refuse(duplicate, "already subscribed") + } + None => { + let handoff = kio::Shared::::default(); + let entry = Entry { + info: Some(info), + request_id: Some(msg.request_id), + handoff: Some(handoff.clone()), + _slot: slot, + }; + listings.insert(key.clone(), entry); + Offer::Hold(handoff) + } + }, + } + } + }; + let reject = match offer { + Offer::Unsolicited => self.decline_publish(stream, msg), + Offer::Refuse(code, reason) => { + tracing::debug!(broadcast = %key.0, track = %key.1, reason, "refusing publish"); + let error = ietf::RequestError { + error_code: code, + reason_phrase: reason.into(), + // The rule judges the refusal as the peer reads it. + retry_interval: request::retry_interval( + &request::from_code(code, request::Kind::Publish, self.version), + code, + ), + }; + request_stream::Reject::new(stream, error) + } + Offer::Hold(handoff) => { + self.state.lock().subscribes.insert( + msg.request_id, + TrackState { + ..TrackState::pending(key.1.to_string(), key.0.clone(), kio::Producer::new(Fill::Done)) + }, + ); + return ServePublishState::Accepting { + stream, + msg, + token_grant, + key, + handoff, + answered: false, + }; + } + }; + ServePublishState::Rejecting { + reject, + _credit: None, + _grant: token_grant, + } + } + + /// Decline a PUBLISH no listing asked for. + /// + /// PUBLISH offers a single track, so honoring it means routing per + /// (namespace, track). Our model routes per namespace: a source attaches at a + /// path and serves every track under it, resolved on demand via SUBSCRIBE. + /// Accepting a PUBLISH would mean inventing a namespace-level source out of a + /// track-level offer, and that fiction then contradicts any real + /// PUBLISH_NAMESPACE for the same path. + /// + /// Declining the request rather than failing the session, since a peer using + /// a feature we don't implement is not a protocol violation. The rejection is the + /// whole exchange, but it still has to arrive: [`request_stream::Reject`] delivers it. + pub(super) fn decline_publish( + &self, + mut stream: Stream, + msg: ietf::Publish<'static>, + ) -> request_stream::Reject { + if let Some(error) = super::super::namespace::refusal(&msg.track_namespace, self.version) { + return request_stream::Reject::new(stream, error); + } + tracing::debug!(broadcast = %msg.track_namespace, track = %msg.track_name, "rejecting publish"); + + // We decline the method itself rather than this particular track, which would be + // UNINTERESTED. + // + // The alias the message carries is deliberately not recorded. Nothing will ever bind + // 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. + let (kind, err) = (request::Kind::Publish, &Error::Unsupported); + let buffered = + request_stream::buffer_error(&mut stream.writer, kind, err, "PUBLISH is not supported", self.version); + request_stream::Reject::buffered(stream, buffered) + } + + /// Ask for the paused subscription a listing holds for this track, to resume it. + pub(super) fn want(&self, key: &TrackKey) -> Option> { + let listings = self.listings.lock(); + let handoff = listings.entries.get(key)?.handoff.clone()?; + handoff.lock().wanted = true; + Some(handoff) + } + + /// Ready with the subscription `handoff` hands over, or `None` once it ended instead. + pub(super) fn poll_handoff(handoff: &kio::Shared, waiter: &kio::Waiter) -> Poll> { + let mut handoff = ready!( + handoff.poll(waiter, |handoff| match handoff.gone || handoff.upstream.is_some() { + true => Poll::Ready(()), + false => Poll::Pending, + }) + ); + Poll::Ready(handoff.upstream.take()) + } + + /// Give up wanting `handoff`, holding again what it already handed over. + pub(super) fn unwant(&self, key: TrackKey, handoff: &kio::Shared) { + let upstream = { + let mut held = handoff.lock(); + held.wanted = false; + held.upstream.take() + }; + if let Some(upstream) = upstream { + self.hold(key, upstream); + } + } + + /// The subscription with `request_id` took over the entry it resumes. + pub(super) fn adopted(&self, key: &TrackKey, request_id: RequestId) { + if let Some(entry) = self.listings.lock().held_by(key, request_id) { + entry.handoff = None; + } + } + + /// Whether the subscription with `request_id` holds this track's listing entry, so + /// it pauses rather than ends once nobody reads it. + pub(super) fn is_entry(&self, key: &TrackKey, request_id: RequestId) -> bool { + self.listings + .lock() + .held_by(key, request_id) + .is_some_and(|entry| entry.handoff.is_none()) + } + + /// The subscription with `request_id` learned the info of the track it holds, which + /// lists it if a skip made it the entry before its SUBSCRIBE_OK. + pub(super) fn entry_info(&self, key: &TrackKey, request_id: RequestId, info: &track::Info) { + let mut listings = self.listings.lock(); + let Some(entry) = listings.held_by(key, request_id) else { + return; + }; + if entry.info.is_some() { + return; + } + entry.info = Some(info.clone()); + for lister in listings.listers(&key.0) { + let _ = lister.add(key.1.clone(), info.clone()); + } + } + + /// The subscription with `request_id` ended: so does the entry it held. + pub(super) fn entry_ended(&self, key: &TrackKey, request_id: RequestId, end: Result<(), Error>) { + self.listings.lock().ended(key, request_id, end); + } + + /// Hold a paused subscription again as its track's entry, watched until a reader + /// resumes it, or cancel it if the entry is gone. + pub(super) fn hold(&self, key: TrackKey, upstream: Upstream) { + let handoff = kio::Shared::::default(); + let held = match self.listings.lock().held_by(&key, upstream.request_id) { + Some(entry) => { + entry.handoff = Some(handoff.clone()); + true + } + None => false, + }; + let mut task = Watch::new(self.clone(), key, upstream, handoff, held); + self.tasks.push(move |waiter: &kio::Waiter| task.poll(waiter)); + } + + /// Pause a subscription nobody reads that is its track's entry, and hold it again. The + /// held watch flushes the pause, so the entry going still ends it under send pressure. + pub(super) fn pause(&self, key: TrackKey, mut upstream: Upstream) { + self.park_subscribe(upstream.request_id); + let pause = Forward { + forward: false, + ..Default::default() + }; + match self.buffer_forward(&mut upstream.stream.writer, &pause) { + Ok(()) => { + upstream.owed += 1; + tracing::debug!(broadcast = %key.0, track = %key.1, "subscription paused"); + self.hold(key, upstream); + } + Err(err) => { + tracing::debug!(%err, "failed to pause the subscription"); + self.remove_subscribe(upstream.request_id); + self.entry_ended(&key, upstream.request_id, Err(err)); + } + } + } + + /// Reset a subscription's state to paused, keeping the alias it is bound to: objects + /// still arriving for it are refused rather than buffered. + fn park_subscribe(&self, request_id: RequestId) { + let mut state = self.state.lock(); + let Some(held) = state.subscribes.get_mut(&request_id) else { + return; + }; + if let Ok(mut fill) = held.fill.write() { + fill.cancel(); + } + let parked = TrackState { + alias: held.alias, + tail: std::mem::take(&mut held.tail), + ..TrackState::pending( + std::mem::take(&mut held.name), + held.broadcast.clone(), + kio::Producer::new(Fill::Done), + ) + }; + let fetch_id = std::mem::replace(held, parked).fetch_id; + if let Some(fetch_id) = fetch_id { + state.fetches.remove(&fetch_id); + } + if let Some(alias) = state.subscribes.get(&request_id).and_then(|track| track.alias) { + rebind_track_alias(&state.aliases, alias, request_id, Alias::Paused(request_id)); + } + } + + /// Buffer the REQUEST_UPDATE setting a held subscription's Forward State, for the + /// caller to flush. + pub(super) fn buffer_forward( + &self, + writer: &mut Writer, + forward: &Forward, + ) -> Result<(), Error> { + let request_id = self.control.next_request_id(); + writer.buffer_message( + ietf::SubscribeUpdate::ID, + &ietf::SubscribeUpdate { + max_age: forward.max_age, + request_id, + subscriber_priority: forward.priority.map(super::super::priority::to_wire), + forward: Some(forward.forward), + filter: forward.filter, + authorization_token: None, + new_group: forward.new_group, + fill: forward.fill, + }, + ) + } + + /// Check the answer to a REQUEST_UPDATE a held subscription still owes. It carries + /// nothing to act on: a refused one is followed by the PUBLISH_DONE that ends it. + fn owed_answer(&self, id: u64, body: &ietf::Body) -> Result<(), Error> { + match id { + ietf::RequestOk::ID => body.message::(self.version).map(|_| ())?, + _ => body.message::(self.version).map(|_| ())?, + } + Ok(()) + } + + /// Read the answer to a SUBSCRIBE, or to the REQUEST_UPDATE resuming `held`. + pub(super) fn poll_read_answer( + &self, + waiter: &kio::Waiter, + stream: &mut Stream, + messages: &mut Messages, + held: Option<&mut Resumed>, + ) -> Poll> { + let Some(held) = held else { + let Framed(id, body) = ready!(messages.poll_message(&mut stream.reader, waiter))?; + return Poll::Ready(self.subscribe_response(id, &body)); + }; + loop { + let Framed(id, body) = ready!(messages.poll_message(&mut stream.reader, waiter))?; + match id { + // The answer to a pause that went out before this resume. + ietf::RequestOk::ID | ietf::RequestError::ID if held.owed > 0 => { + self.owed_answer(id, &body)?; + held.owed -= 1; + } + ietf::RequestOk::ID => { + let ok = body.message::(self.version)?; + tracing::debug!(message = ?ok, "subscription resumed"); + return Poll::Ready(Ok(Answer::Accepted(Accepted { + alias: held.alias, + properties: held.properties.clone(), + largest: ok.largest, + }))); + } + ietf::RequestError::ID => { + let refused = body.message::(self.version)?; + tracing::debug!(message = ?refused, "resume refused"); + return Poll::Ready(Ok(self.refused(&refused))); + } + ietf::PublishDone::ID => { + let done = body.message::(self.version)?; + let end = listing_end(&done, self.version); + held.ended = Some(end.clone()); + return Poll::Ready(Err(end.err().unwrap_or(Error::NotFound))); + } + _ => return Poll::Ready(Err(Error::UnexpectedMessage)), + } + } + } + + /// The answer to a SUBSCRIBE: its SUBSCRIBE_OK, or the REQUEST_ERROR refusing it. + fn subscribe_response(&self, id: u64, body: &ietf::Body) -> Result { + let mut data = body.decoder(self.version); + match id { + ietf::SubscribeOk::ID => { + let msg = ietf::SubscribeOk::decode_msg(&mut data, self.version)?; + tracing::debug!(message = ?msg, "received subscribe ok"); + Ok(Answer::Accepted(Accepted { + alias: msg.track_alias, + properties: msg.properties, + largest: msg.largest, + })) + } + ietf::RequestError::ID => { + let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; + tracing::warn!(message = ?msg, "request error"); + Ok(self.refused(&msg)) + } + _ => Err(Error::UnexpectedMessage), + } + } + + /// The REQUEST_ERROR refusing a SUBSCRIBE or its resume, as an [`Answer`]. + fn refused(&self, msg: &ietf::RequestError<'_>) -> Answer { + let err = request::from_code(msg.error_code, request::Kind::Subscribe, self.version); + Answer::Refused(err, Duration::from_millis(msg.retry_interval)) + } +} + +/// One SUBSCRIBE_TRACKS, sent once the overlapping requests leave the wire, then read until +/// it is cancelled and the peer has closed it, or it fails. +struct SubscribeTracks { + this: Subscriber, + key: u64, + prefix: Path, + /// The overlapping requests that must leave the wire first. + blockers: Vec>, + live: kio::Consumer<()>, + /// Dropped once it is off the wire, so an overlapping one may go out. + gone: Option>, + state: SubscribeTracksState, +} + +enum SubscribeTracksState { + /// Waiting for the overlapping requests to leave the wire. + Blocked, + /// Waiting for a stream to send it on. + Opening(crate::transport::open::Open), + /// Reading its answers until it is cancelled. + Listing { + stream: Stream, + messages: Messages, + /// Its REQUEST_OK arrived. + established: bool, + /// A peer streaming skips must not hold the session's other tasks. + budget: kio::coop::Budget, + }, + /// Cancelled: reading until the peer closes its own half. A draft-18 FIN leaves the + /// writer to reset only once that is done. + Draining { + reader: crate::coding::Reader, + _writer: Option>, + messages: Messages, + budget: kio::coop::Budget, + }, + Done, +} + +impl SubscribeTracks { + fn new(this: Subscriber, opening: Opening) -> Self { + let Opening { + key, + prefix, + blockers, + live, + gone, + } = opening; + Self { + this, + key, + prefix, + blockers, + live, + gone: Some(gone), + state: SubscribeTracksState::Blocked, + } + } + + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + let result = ready!(self.poll_request(waiter)); + self.state = SubscribeTracksState::Done; + let mut listings = self.this.listings.lock(); + if let Err(err) = result + && listings + .requests + .get(&self.key) + .is_some_and(|request| request.live.is_some()) + { + listings.fail(&self.prefix, err); + } + listings.requests.remove(&self.key); + listings.release(); + drop(listings); + // Off the wire: an overlapping request may go out. + self.gone = None; + Poll::Ready(()) + } + + fn poll_request(&mut self, waiter: &kio::Waiter) -> Poll> { + let this = &self.this; + let prefix = &self.prefix; + loop { + match &mut self.state { + SubscribeTracksState::Blocked => { + if self.live.poll_closed(waiter).is_ready() { + return Poll::Ready(Ok(())); + } + // The peer refuses a prefix overlapping one it still holds, so wait for each + // overlapping request to leave the wire. + for blocker in &self.blockers { + if blocker.poll_closed(waiter).is_pending() { + return Poll::Pending; + } + } + self.state = SubscribeTracksState::Opening(Default::default()); + } + SubscribeTracksState::Opening(open) => { + if self.live.poll_closed(waiter).is_ready() { + return Poll::Ready(Ok(())); + } + let stream = ready!(Stream::poll_open(&this.session, open, this.version, waiter))?; + let mut stream = stream.with_stats(this.origin.stats()); + let request_id = this.control.next_request_id(); + stream.writer.buffer_message( + ietf::SubscribeTracks::ID, + &ietf::SubscribeTracks { + request_id, + prefix: prefix.clone(), + forward: false, + group_order: None, + properties_wanted: true, + authorization_token: None, + }, + )?; + tracing::debug!(prefix = %crate::path::Rooted(this.origin.root(), prefix), id = %request_id, "subscribe_tracks started"); + self.state = SubscribeTracksState::Listing { + stream, + messages: Messages::new(this.session.clone(), this.control.client()), + established: false, + budget: kio::coop::Budget::new(32), + }; + } + SubscribeTracksState::Listing { + stream, + messages, + established, + budget, + } => { + loop { + ready!(budget.poll_yield(waiter)); + if this.session.poll_closed(waiter).is_ready() { + return Poll::Ready(Ok(())); + } + if self.live.poll_closed(waiter).is_ready() { + break; + } + ready!(stream.writer.poll_flush(waiter))?; + let Framed(id, body) = match ready!(messages.poll_next(&mut stream.reader, waiter))? { + // The peer ended the listing, which only a cancel of ours should do. + None if *established => return Poll::Ready(Err(Error::Closed)), + None => return Poll::Ready(Err(messages.failed(Error::ProtocolViolation))), + Some(framed) => framed, + }; + match id { + ietf::RequestOk::ID if !*established => { + body.message::(this.version) + .map_err(|err| messages.failed(err.into()))?; + *established = true; + } + ietf::RequestError::ID if !*established => { + let refused = body + .message::(this.version) + .map_err(|err| messages.failed(err.into()))?; + tracing::debug!(prefix = %prefix, code = refused.error_code, reason = %refused.reason_phrase, "subscribe_tracks refused"); + return Poll::Ready(Err(request::from_code( + refused.error_code, + request::Kind::SubscribeNamespace, + this.version, + ))); + } + ietf::PublishSkipped::ID if *established => { + let skipped = body + .message::(this.version) + .map_err(|err| messages.failed(err.into()))?; + let namespace = prefix + .join(&skipped.suffix) + .map_err(|err| messages.failed(err.into()))?; + this.skipped(namespace, skipped.track_name.as_ref().into()); + } + _ => return Poll::Ready(Err(messages.failed(Error::UnexpectedMessage))), + } + } + + // Cancelled: close our half (FIN on draft-18, RESET_STREAM from draft-22, where + // a FIN is not a cancel), then read until the peer closes its own. The + // PUBLISHes it made stay ours. + tracing::debug!(prefix = %crate::path::Rooted(this.origin.root(), prefix), "subscribe_tracks cancelled"); + let SubscribeTracksState::Listing { + stream: Stream { writer, mut reader }, + messages, + budget, + .. + } = std::mem::replace(&mut self.state, SubscribeTracksState::Done) + else { + unreachable!() + }; + // The request never reached the peer whole, so it holds nothing to wait on. + if !writer.is_flushed() { + reader.abort(&Error::Cancel); + writer.abort(&Error::Cancel); + return Poll::Ready(Ok(())); + } + let writer = match request_stream::fin_cancels(this.version) { + true => { + let mut writer = writer; + let _ = writer.finish(); + Some(writer) + } + false => { + writer.abort(&Error::Cancel); + None + } + }; + self.state = SubscribeTracksState::Draining { + reader, + _writer: writer, + messages, + budget, + }; + } + SubscribeTracksState::Draining { + reader, + messages, + budget, + .. + } => { + if this.session.poll_closed(waiter).is_ready() { + return Poll::Ready(Ok(())); + } + loop { + ready!(budget.poll_yield(waiter)); + match ready!(messages.poll_next(reader, waiter)) { + Ok(Some(_)) => continue, + Ok(None) | Err(_) => return Poll::Ready(Ok(())), + } + } + } + SubscribeTracksState::Done => unreachable!("polled after it ended"), + } + } + } +} + +/// A skipped track's paused SUBSCRIBE: opened, then watched as the track's entry. +#[expect( + clippy::large_enum_variant, + reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk" +)] +enum Skipped { + Opening { + this: Subscriber, + key: TrackKey, + handoff: kio::Shared, + open: OpenPaused, + }, + Watching(Watch), + Done, +} + +impl Skipped { + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + loop { + match self { + Self::Opening { + this, + key, + handoff, + open, + } => { + let result = ready!(open.poll(waiter, this, key, handoff)); + let Self::Opening { this, key, handoff, .. } = std::mem::replace(self, Self::Done) else { + unreachable!() + }; + match result { + Ok(Some(upstream)) => *self = Self::Watching(Watch::new(this, key, upstream, handoff, true)), + Ok(None) => return Poll::Ready(()), + Err(err) => { + tracing::debug!(broadcast = %key.0, track = %key.1, %err, "paused subscribe failed"); + let mut listings = this.listings.lock(); + if listings.watched_by(&key, &handoff).is_some() { + listings.remove(&key, false); + listings.fail(&key.0, err); + } + return Poll::Ready(()); + } + } + } + Self::Watching(watch) => return watch.poll(waiter), + Self::Done => return Poll::Ready(()), + } + } + } +} + +/// Opens a paused SUBSCRIBE for a skipped track and lists it once its SUBSCRIBE_OK +/// arrives. `None` once its entry was let go. +struct OpenPaused { + state: OpenPausedState, +} + +#[expect( + clippy::large_enum_variant, + reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk" +)] +enum OpenPausedState { + /// Waiting for a stream to send it on. + Opening(crate::transport::open::Open), + /// Flushed while waiting for the answer, so the entry going still ends a request the + /// peer's send window holds back. + Answering { + stream: Stream, + messages: Messages, + request_id: RequestId, + }, + /// Accepted, waiting for the peer's SETUP to judge its properties. + Accepted { + stream: Stream, + messages: Messages, + request_id: RequestId, + accepted: Accepted, + }, + /// Abandoned: cancelling it before resolving with `result`. + Cancelling { + cancel: request_stream::Cancel, + result: Option, Error>>, + }, + Done, +} + +impl OpenPausedState { + /// Remove and cancel the subscription, then resolve with `result`. + fn cancel( + this: &Subscriber, + request_id: RequestId, + stream: Stream, + result: Result, Error>, + ) -> Self { + this.remove_subscribe(request_id); + Self::Cancelling { + cancel: request_stream::Cancel::new(stream), + result: Some(result), + } + } +} + +impl OpenPaused { + fn new() -> Self { + Self { + state: OpenPausedState::Opening(Default::default()), + } + } + + fn poll( + &mut self, + waiter: &kio::Waiter, + this: &Subscriber, + key: &TrackKey, + handoff: &kio::Shared, + ) -> Poll, Error>> { + let gone = |waiter: &kio::Waiter| { + handoff + .poll(waiter, |handoff| match handoff.gone { + true => Poll::Ready(()), + false => Poll::Pending, + }) + .is_ready() + }; + loop { + match &mut self.state { + OpenPausedState::Opening(open) => { + if gone(waiter) { + return Poll::Ready(Ok(None)); + } + let stream = ready!(Stream::poll_open(&this.session, open, this.version, waiter))?; + let mut stream = stream.with_stats(this.origin.stats()); + let request_id = this.control.next_request_id(); + this.state.lock().subscribes.insert( + request_id, + TrackState { + ..TrackState::pending(key.1.to_string(), key.0.clone(), kio::Producer::new(Fill::Done)) + }, + ); + let messages = Messages::new(this.session.clone(), this.control.client()); + let buffered = stream.writer.buffer_message( + ietf::Subscribe::ID, + &ietf::Subscribe { + max_age: None, + new_group: None, + request_id, + track_namespace: key.0.clone(), + track_name: key.1.as_ref().into(), + subscriber_priority: super::super::priority::DEFAULT_WIRE, + // No update can change it, so it takes the order a reader gets by default. + group_order: track::Order::default().into(), + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + forward: false, + rendezvous: false, + range_filters: false, + authorization_token: None, + }, + ); + if let Err(err) = buffered { + this.remove_subscribe(request_id); + return Poll::Ready(Err(err)); + } + self.state = OpenPausedState::Answering { + stream, + messages, + request_id, + }; + } + OpenPausedState::Answering { + stream, + messages, + request_id, + } => { + let request_id = *request_id; + if gone(waiter) { + let OpenPausedState::Answering { stream, .. } = + std::mem::replace(&mut self.state, OpenPausedState::Done) + else { + unreachable!() + }; + self.state = OpenPausedState::cancel(this, request_id, stream, Ok(None)); + continue; + } + let answered = match stream.writer.poll_flush(waiter) { + Poll::Ready(Ok(())) => this.poll_read_answer(waiter, stream, messages, None), + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Pending => Poll::Pending, + }; + let accepted = match ready!(answered) { + Ok(Answer::Accepted(accepted)) => accepted, + Ok(Answer::Refused(err, _)) | Err(err) => { + this.remove_subscribe(request_id); + return Poll::Ready(Err(err)); + } + }; + let OpenPausedState::Answering { stream, messages, .. } = + std::mem::replace(&mut self.state, OpenPausedState::Done) + else { + unreachable!() + }; + self.state = OpenPausedState::Accepted { + stream, + messages, + request_id, + accepted, + }; + } + OpenPausedState::Accepted { .. } => { + let flate = ready!(this.peer_setup.poll_get(waiter)).flate; + let OpenPausedState::Accepted { + stream, + messages, + request_id, + accepted, + } = std::mem::replace(&mut self.state, OpenPausedState::Done) + else { + unreachable!() + }; + if let Some(err) = refused(&accepted.properties, this.version, flate) { + self.state = OpenPausedState::cancel(this, request_id, stream, Err(err)); + continue; + } + if let Err(err) = this.bind_alias(request_id, accepted.alias) { + self.state = OpenPausedState::cancel(this, request_id, stream, Err(err)); + continue; + } + let info = accepted_info(&accepted.properties, this.version); + let listed = { + let mut listings = this.listings.lock(); + match listings.watched_by(key, handoff) { + Some(entry) => { + entry.request_id = Some(request_id); + entry.info = Some(info.clone()); + for lister in listings.listers(&key.0) { + let _ = lister.add(key.1.clone(), info.clone()); + } + true + } + None => false, + } + }; + if !listed { + self.state = OpenPausedState::cancel(this, request_id, stream, Ok(None)); + continue; + } + return Poll::Ready(Ok(Some(Upstream { + token_grant: None, + stream, + messages, + request_id, + alias: accepted.alias, + properties: accepted.properties, + owed: 0, + copy: None, + }))); + } + OpenPausedState::Cancelling { cancel, result } => { + ready!(cancel.poll(waiter)); + let result = result.take().expect("resolved once"); + self.state = OpenPausedState::Done; + return Poll::Ready(result); + } + OpenPausedState::Done => unreachable!("polled after it resolved"), + } + } + } +} + +/// Watches a paused subscription until it ends, its entry is let go, or a subscription +/// takes it to resume. +struct Watch { + this: Subscriber, + key: TrackKey, + /// Taken once it is handed over, cancelled, or ended. + upstream: Option, + handoff: kio::Shared, + state: WatchState, +} + +#[expect( + clippy::large_enum_variant, + reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk" +)] +enum WatchState { + /// Its entry was already gone when it was held, so it is cancelled at once. + Unheld, + Watching, + /// Cancelling it. The rest of the subscription, its grant and lingering copy included, + /// is released only after. + Cancelling { + cancel: request_stream::Cancel, + _rest: (Option, Messages, Option), + }, + Done, +} + +impl Watch { + /// Watch `upstream` as the entry `handoff` belongs to, or cancel it unless `held`. + fn new(this: Subscriber, key: TrackKey, upstream: Upstream, handoff: kio::Shared, held: bool) -> Self { + Self { + this, + key, + upstream: Some(upstream), + handoff, + state: match held { + true => WatchState::Watching, + false => WatchState::Unheld, + }, + } + } + + /// Remove and cancel the subscription. + fn cancel(&mut self) { + let Upstream { + token_grant, + stream, + messages, + request_id, + copy, + .. + } = self.upstream.take().expect("cancelled once"); + self.this.remove_subscribe(request_id); + self.state = WatchState::Cancelling { + cancel: request_stream::Cancel::new(stream), + _rest: (token_grant, messages, copy), + }; + } + + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + enum Step { + Closed, + Gone, + Wanted, + Message(Result, Error>), + } + let end = loop { + match &mut self.state { + WatchState::Unheld => { + self.cancel(); + continue; + } + WatchState::Cancelling { cancel, .. } => { + ready!(cancel.poll(waiter)); + self.state = WatchState::Done; + return Poll::Ready(()); + } + WatchState::Done => return Poll::Ready(()), + WatchState::Watching => {} + } + let upstream = self.upstream.as_mut().expect("watched"); + let step = 'step: { + if self.this.session.poll_closed(waiter).is_ready() { + break 'step Step::Closed; + } + // A held subscription is handed over only once flushed, so resumes and pauses + // can't pile up behind a peer withholding credit. + let flushed = match upstream.stream.writer.poll_flush(waiter) { + Poll::Ready(Err(err)) => break 'step Step::Message(Err(err)), + Poll::Ready(Ok(())) => true, + Poll::Pending => false, + }; + if let Poll::Ready(handoff) = + self.handoff + .poll(waiter, |handoff| match handoff.gone || (handoff.wanted && flushed) { + true => Poll::Ready(()), + false => Poll::Pending, + }) { + break 'step match handoff.gone { + true => Step::Gone, + false => Step::Wanted, + }; + } + if let Some(grant) = &upstream.token_grant + && let Poll::Ready(refusal) = grant.poll_ended(waiter) + { + break 'step Step::Message(Err(refusal.error)); + } + Step::Message(ready!(upstream.messages.poll_next(&mut upstream.stream.reader, waiter))) + }; + match step { + Step::Closed => { + self.upstream = None; + self.state = WatchState::Done; + return Poll::Ready(()); + } + Step::Wanted => { + let mut held = self.handoff.lock(); + match (held.gone, held.wanted) { + (true, _) => {} + // The subscription gave up before it was handed over. + (false, false) => continue, + (false, true) => { + held.upstream = self.upstream.take(); + drop(held); + self.state = WatchState::Done; + return Poll::Ready(()); + } + } + drop(held); + self.cancel(); + } + Step::Gone => self.cancel(), + Step::Message(Ok(Some(Framed(id, body)))) => match id { + // The answer to a pause, or to a resume that paused again before it landed. + ietf::RequestOk::ID | ietf::RequestError::ID if upstream.owed > 0 => { + if let Err(err) = self.this.owed_answer(id, &body) { + break Err(upstream.messages.failed(err)); + } + upstream.owed -= 1; + } + ietf::PublishDone::ID => { + break body + .message::(self.this.version) + .map_err(|err| upstream.messages.failed(err.into())) + .and_then(|done| listing_end(&done, self.this.version)); + } + _ => break Err(upstream.messages.failed(Error::UnexpectedMessage)), + }, + // PUBLISH_DONE must come before the FIN (draft-22 section 6.4.2.2). + Step::Message(Ok(None)) => break Err(Error::ProtocolViolation), + Step::Message(Err(err)) => break Err(err), + } + }; + + self.state = WatchState::Done; + let key = &self.key; + let mut upstream = self.upstream.take().expect("watched"); + tracing::debug!(broadcast = %key.0, track = %key.1, ?end, "held subscription ended"); + self.this.remove_subscribe(upstream.request_id); + // A pause still held back would go out truncated behind a FIN. + match upstream.stream.writer.is_flushed() { + true => { + let _ = upstream.stream.writer.finish(); + } + false => upstream.stream.writer.abort(&Error::Cancel), + } + // A failed upstream fails the copy, as it would while forwarding. TRACK_ENDED leaves it + // idle: the end is past groups it never saw, so a returning reader's SUBSCRIBE learns + // where, as for a copy whose subscription was cancelled. + if let (Err(err), Some(copy)) = (&end, upstream.copy.take()) { + let _ = copy.abort(err.clone()); + } + let mut listings = self.this.listings.lock(); + if listings.watched_by(key, &self.handoff).is_some() { + match end { + Ok(()) => listings.remove(key, true), + Err(err) => { + listings.remove(key, false); + listings.fail(&key.0, err); + } + } + } + Poll::Ready(()) + } +} + +/// Serve a PUBLISH: hold one answering our SUBSCRIBE_TRACKS paused as its track's entry, +/// refuse a redundant one, and decline one no listing asked for. +pub(super) struct ServePublish { + this: Subscriber, + state: ServePublishState, +} + +#[expect( + clippy::large_enum_variant, + reason = "a state machine's enum is its storage: one transient instance per stream, so the big variant is the working state, not padding held in bulk" +)] +enum ServePublishState { + /// Waiting for the acceptor to judge the PUBLISH's token. + Verifying { + stream: Stream, + msg: ietf::Publish<'static>, + verdict: crate::auth::RequestVerdict, + /// What the acceptor holds while it decides: its credit lasts as long as this. + credit: kio::Producer<()>, + }, + /// The requester withdrew before the verdict: waiting for the acceptor to let go. + Withdrawn { + credit: kio::Producer<()>, + _stream: Stream, + }, + /// Waiting for the peer's SETUP to judge the track's properties. + Setup { + stream: Stream, + msg: ietf::Publish<'static>, + token_grant: Option, + }, + /// Refusing or declining it, holding what the request held until it is delivered. + Rejecting { + reject: request_stream::Reject, + _credit: Option>, + _grant: Option, + }, + /// Answering PUBLISH_OK, under the token's grant. + Accepting { + stream: Stream, + msg: ietf::Publish<'static>, + token_grant: Option, + key: TrackKey, + handoff: kio::Shared, + /// The alias is bound and PUBLISH_OK buffered. + answered: bool, + }, + /// Held as its track's entry. + Watching(Watch), + Done, +} + +impl ServePublish { + /// Start serving `msg`, the PUBLISH that opened `stream`. + pub(super) fn new(this: Subscriber, stream: Stream, msg: ietf::Publish<'static>) -> Self { + let state = match msg + .authorization_token + .clone() + .filter(|_| this.auth.token_decides(crate::auth::Direction::Subscribe)) + { + Some(token) => { + let credit = kio::Producer::<()>::default(); + let hold: crate::auth::Hold = std::sync::Arc::new(credit.consume()); + let verdict = this.auth.verify_request(token.0, Some(hold)); + ServePublishState::Verifying { + stream, + msg, + verdict, + credit, + } + } + None => ServePublishState::Setup { + stream, + msg, + token_grant: None, + }, + }; + Self { this, state } + } + + /// Ready once the PUBLISH is refused, declined, or its held subscription ends. + pub(super) fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { + let this = &self.this; + loop { + match &mut self.state { + ServePublishState::Verifying { stream, verdict, .. } => { + let withdrawn = this.session.poll_closed(waiter).is_ready() + || request_stream::poll_withdrawn(stream, request_stream::fin_cancels(this.version), waiter) + .is_ready(); + if withdrawn { + let ServePublishState::Verifying { stream, credit, .. } = + std::mem::replace(&mut self.state, ServePublishState::Done) + else { + unreachable!() + }; + self.state = ServePublishState::Withdrawn { + credit, + _stream: stream, + }; + continue; + } + let verified = ready!(verdict.poll_grant(waiter)); + let ServePublishState::Verifying { + mut stream, + msg, + verdict, + credit, + } = std::mem::replace(&mut self.state, ServePublishState::Done) + else { + unreachable!() + }; + let granted = crate::auth::RequestGrant::new( + &this.runtime, + verdict, + verified, + msg.track_namespace.clone(), + crate::auth::RequestKind::Publish, + None, + ); + self.state = match granted { + Ok(grant) => ServePublishState::Setup { + stream, + msg, + token_grant: Some(grant), + }, + Err(refusal) => { + let (kind, err) = (request::Kind::Publish, &refusal.error); + let buffered = request_stream::buffer_error( + &mut stream.writer, + kind, + err, + &refusal.reason, + this.version, + ); + // Dropping an unfinished writer resets it, which could discard the refusal. + ServePublishState::Rejecting { + reject: request_stream::Reject::buffered(stream, buffered), + _credit: Some(credit), + _grant: None, + } + } + }; + } + ServePublishState::Withdrawn { credit, .. } => { + if this.session.poll_closed(waiter).is_ready() + || credit.poll_wanted_changed(waiter, true).is_ready() + { + self.state = ServePublishState::Done; + return Poll::Ready(Ok(())); + } + return Poll::Pending; + } + ServePublishState::Setup { .. } => { + let flate = ready!(this.peer_setup.poll_get(waiter)).flate; + let ServePublishState::Setup { + stream, + msg, + token_grant, + } = std::mem::replace(&mut self.state, ServePublishState::Done) + else { + unreachable!() + }; + self.state = this.offer(stream, msg, token_grant, flate); + } + ServePublishState::Rejecting { reject, .. } => { + let result = ready!(reject.poll(waiter)); + self.state = ServePublishState::Done; + return Poll::Ready(result); + } + ServePublishState::Accepting { + stream, + msg, + token_grant, + answered, + .. + } => { + // The grant ending at any point ends the request there, and is checked first, + // so a request never acts on authority that already ended. + let ok = match token_grant.as_ref().map(|grant| grant.poll_ended(waiter)) { + Some(Poll::Ready(refusal)) => Err(refusal.error), + _ => { + let mut answer = || { + if !std::mem::replace(answered, true) { + this.bind_alias(msg.request_id, msg.track_alias)?; + // PUBLISH_OK is a REQUEST_OK on draft-22. FORWARD=0 holds it paused on + // draft-18; on draft-22 the PUBLISH our FORWARD=0 asked for already is. + let id = match this.version { + Version::Draft18 => ietf::PublishOk::ID, + _ => ietf::RequestOk::ID, + }; + stream.writer.buffer_message( + id, + &ietf::PublishOk { + forward: false, + subscriber_priority: None, + group_order: None, + filter: None, + new_group: None, + }, + )?; + } + Ok(()) + }; + match answer() { + Ok(()) => ready!(stream.writer.poll_flush(waiter)), + Err(err) => Err(err), + } + } + }; + let ServePublishState::Accepting { + stream, + msg, + token_grant, + key, + handoff, + .. + } = std::mem::replace(&mut self.state, ServePublishState::Done) + else { + unreachable!() + }; + let request_id = msg.request_id; + if let Err(err) = ok { + tracing::debug!(broadcast = %key.0, track = %key.1, %err, "failed to hold publish"); + this.remove_subscribe(request_id); + let mut listings = this.listings.lock(); + if listings.watched_by(&key, &handoff).is_some() { + listings.remove(&key, false); + listings.fail(&key.0, err); + } + return Poll::Ready(Ok(())); + } + tracing::debug!(broadcast = %crate::path::Rooted(this.origin.root(), &key.0), track = %key.1, id = %request_id, "holding publish"); + let upstream = Upstream { + token_grant, + stream, + messages: Messages::new(this.session.clone(), this.control.client()), + request_id, + alias: msg.track_alias, + properties: msg.properties, + owed: 0, + copy: None, + }; + self.state = ServePublishState::Watching(Watch::new(this.clone(), key, upstream, handoff, true)); + } + ServePublishState::Watching(watch) => { + ready!(watch.poll(waiter)); + self.state = ServePublishState::Done; + return Poll::Ready(Ok(())); + } + ServePublishState::Done => unreachable!("polled after it resolved"), + } + } + } +} + +/// A REQUEST_UPDATE setting a held subscription's Forward State. +#[derive(Default)] +pub(super) struct Forward { + pub(super) forward: bool, + /// The Location Filter it resumes with. + pub(super) filter: Option, + /// FILL_PARAMETERS (draft-22), the backfill it resumes with. + pub(super) fill: Option, + /// The aggregate priority, in the model's higher-first terms. + pub(super) priority: Option, + pub(super) max_age: Option, + pub(super) new_group: Option, +} + +/// What a subscription resuming a held one keeps of it. +pub(super) struct Resumed { + pub(super) alias: u64, + pub(super) properties: ietf::Properties, + /// REQUEST_UPDATE answers still owed. + pub(super) owed: u64, + /// How a PUBLISH_DONE ended it before the resume was answered. + pub(super) ended: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ProduceTest as _; + use crate::transport::memory; + + const TIMEOUT: Duration = Duration::from_secs(1); + + type PeerStream = Stream; + + /// A subscriber listing `broadcast` over a raw peer that plays the publisher. + struct Harness { + subscriber: Subscriber, + peer: memory::Session, + tracks: crate::broadcast::Tracks, + listening: Listening, + version: Version, + _broadcast: crate::broadcast::Producer, + _driver: kio::clock::sim::JoinHandle<()>, + } + + async fn settle() { + kio::clock::sleep(Duration::from_millis(1)).await; + } + + fn harness(version: Version) -> Harness { + harness_with(version, memory::Config::default()) + } + + fn harness_with(version: Version, config: memory::Config) -> Harness { + let (session, peer) = memory::pair(config); + let (tasks, mut task_set) = TaskSet::new(); + let driver = kio::clock::sim::spawn(async move { kio::wait(|waiter| task_set.poll(waiter)).await }); + let subscriber = Subscriber::new( + crate::time::sim(), + crate::driver::Transport::new(session.clone()), + crate::origin::Config::new(std::num::NonZeroU64::new(1).unwrap()).produce(), + Control::new(false), + None, + peer::PeerSetup::declared(peer::Peer::default()), + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ); + let broadcast = crate::broadcast::Info::default().produce(); + let lister = Arc::new(broadcast.requests().lister().unwrap()); + let tracks = broadcast.consume().tracks().unwrap(); + let listening = subscriber.listen(crate::Path::from_static("bcast"), lister); + Harness { + subscriber, + peer, + tracks, + listening, + version, + _broadcast: broadcast, + _driver: driver, + } + } + + async fn read(stream: &mut PeerStream) -> (u64, ietf::Body) { + let read = async { + let id = stream.reader.varint().await.unwrap(); + (id, stream.reader.decode::().await.unwrap()) + }; + kio::clock::timeout(TIMEOUT, read) + .await + .expect("the subscriber sent nothing") + } + + /// One message in one write, so a peer holding one message per stream still delivers it. + async fn write<'m, M: ietf::Message<'m> + std::fmt::Debug>(stream: &mut PeerStream, id: u64, msg: &M) { + stream.writer.buffer_message(id, msg).unwrap(); + kio::wait(|waiter| stream.writer.poll_flush(waiter)).await.unwrap(); + settle().await; + } + + /// The next listing event as `+name`, `-name`, or `synced`; `None` while pending. + fn event(tracks: &mut crate::broadcast::Tracks) -> Option { + match tracks.poll_next(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(crate::broadcast::TracksEvent::Added { name, .. }))) => Some(format!("+{name}")), + Poll::Ready(Ok(Some(crate::broadcast::TracksEvent::Removed { name }))) => Some(format!("-{name}")), + Poll::Ready(Ok(Some(crate::broadcast::TracksEvent::Finished { name }))) => Some(format!("~{name}")), + Poll::Ready(Ok(Some(crate::broadcast::TracksEvent::Synced))) => Some("synced".into()), + Poll::Ready(other) => Some(format!("{other:?}")), + Poll::Pending => None, + } + } + + impl Harness { + /// Accept the SUBSCRIBE_TRACKS and establish it. + async fn establish(&mut self) -> PeerStream { + let mut stream = kio::clock::timeout( + TIMEOUT, + Stream::accept(&mut crate::driver::Transport::new(self.peer.clone()), self.version), + ) + .await + .expect("no SUBSCRIBE_TRACKS") + .unwrap(); + let (id, body) = read(&mut stream).await; + assert_eq!(id, ietf::SubscribeTracks::ID); + let msg: ietf::SubscribeTracks = body.message(self.version).unwrap(); + assert_eq!(msg.prefix.as_str(), "bcast"); + assert!(!msg.forward, "the listing asks for every track's objects"); + let ok = ietf::RequestOk { + active: None, + largest: None, + }; + write(&mut stream, ietf::RequestOk::ID, &ok).await; + assert_eq!(event(&mut self.tracks).as_deref(), Some("synced")); + stream + } + + /// PUBLISH `name` paused, returning the stream once the subscriber holds it. + async fn publish(&mut self, name: &str, request_id: u64) -> PeerStream { + let (stream, id, _) = self.offer(offered("bcast", name, request_id)).await; + assert!( + [ietf::RequestOk::ID, ietf::PublishOk::ID].contains(&id), + "PUBLISH refused: {id:#x}" + ); + stream + } + + /// A reader of `name`, served by the subscriber as a broadcast's request would be. + async fn read_track( + &mut self, + name: &str, + ) -> ( + crate::track::Consumer, + kio::Pending, + kio::clock::sim::JoinHandle<()>, + ) { + let mut dynamic = self._broadcast.requests(); + let track = self._broadcast.consume().track(name).unwrap(); + let reader = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let mut subscriber = self.subscriber.clone(); + let serving = kio::clock::sim::spawn(async move { + subscriber + .run_subscribe(Path::from_static("bcast"), dynamic, None, request) + .await; + }); + (track, reader, serving) + } + + /// Resume the held `video` for a reader, returning it once accepted. + async fn resume( + &mut self, + video: &mut PeerStream, + ) -> ( + crate::track::Consumer, + crate::track::Subscriber, + kio::clock::sim::JoinHandle<()>, + ) { + let (track, reader, serving) = self.read_track("video").await; + let (id, body) = read(video).await; + assert_eq!(id, ietf::SubscribeUpdate::ID, "not resumed"); + let resume: ietf::SubscribeUpdate = body.message(self.version).unwrap(); + assert_eq!(resume.forward, Some(true)); + let ok = ietf::RequestOk { + active: None, + largest: None, + }; + write(video, ietf::RequestOk::ID, &ok).await; + let reader = kio::clock::timeout(TIMEOUT, reader) + .await + .expect("never accepted") + .unwrap(); + (track, reader, serving) + } + + /// Resume the held `video` for a reader, then let it go so the subscription pauses. + async fn resume_and_pause( + &mut self, + video: &mut PeerStream, + ) -> (crate::track::Consumer, kio::clock::sim::JoinHandle<()>) { + let (track, reader, serving) = self.resume(video).await; + drop(reader); + let (id, body) = read(video).await; + assert_eq!(id, ietf::SubscribeUpdate::ID, "not paused"); + let pause: ietf::SubscribeUpdate = body.message(self.version).unwrap(); + assert_eq!(pause.forward, Some(false)); + settle().await; + (track, serving) + } + + /// Send `publish`, returning the answer. + async fn offer(&mut self, publish: ietf::Publish<'_>) -> (PeerStream, u64, ietf::Body) { + let mut stream = Stream::open(&crate::driver::Transport::new(self.peer.clone()), self.version) + .await + .unwrap(); + stream.writer.encode_message(&publish).await.unwrap(); + + let mut ours = Stream::accept(&mut self.subscriber.session.clone(), self.version) + .await + .unwrap(); + let id = ours.reader.varint().await.unwrap(); + assert_eq!(id, ietf::Publish::ID); + let body: ietf::Body = ours.reader.decode().await.unwrap(); + let subscriber = self.subscriber.clone(); + let version = self.version; + kio::clock::sim::spawn(async move { + let msg = body.message::(version).unwrap().into_owned(); + let mut serve = ServePublish::new(subscriber, ours, msg); + let _ = kio::wait(|waiter| serve.poll(waiter)).await; + }); + let (id, body) = read(&mut stream).await; + (stream, id, body) + } + } + + /// Readers coming and going while the peer withholds credit on a held track never pile + /// updates up behind it: the held subscription is handed over only once its pause is out, + /// and a reader leaving meanwhile stops waiting for it. + #[kio::test] + async fn churn_under_withheld_credit_buffers_one_resume_and_pause() { + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + // One message in flight per stream, so a peer that stops reading withholds credit. + let config = memory::Config { + stream_buffer: 0, + ..Default::default() + }; + let mut h = harness_with(version, config); + let _listing = h.establish().await; + let mut video = h.publish("video", 1).await; + + for _ in 0..3 { + let (track, reader, serving) = h.read_track("video").await; + settle().await; + drop(reader); + drop(track); + kio::clock::timeout(TIMEOUT, serving) + .await + .unwrap_or_else(|_| panic!("{version}: the abandoned resume stayed alive")) + .unwrap(); + } + + let mut updates = Vec::new(); + while let Ok(Ok(id)) = kio::clock::timeout(TIMEOUT, video.reader.varint()).await { + let body: ietf::Body = video.reader.decode().await.unwrap(); + assert_eq!(id, ietf::SubscribeUpdate::ID, "{version}"); + updates.push(body.message::(version).unwrap().forward); + } + assert_eq!(updates, [Some(true), Some(false)], "{version}"); + } + } + + /// A paused PUBLISH of `name` in `namespace`, aliased by its request ID. + fn offered<'a>(namespace: &'a str, name: &'a str, request_id: u64) -> ietf::Publish<'a> { + ietf::Publish { + authorization_token: None, + request_id: RequestId(request_id), + track_namespace: Path::new(namespace).unwrap(), + track_name: name.into(), + track_alias: request_id, + largest_location: None, + forward: false, + properties: ietf::Properties::default(), + subscriber_priority: None, + group_order: None, + filter: None, + } + } + + /// One SUBSCRIBE_TRACKS covers each nesting chain, since the peer refuses an overlapping + /// prefix: a nested listing reuses the broader request, and a broader one cancels the + /// nested requests and goes out only once they have left the wire. + #[test] + fn overlapping_listings_share_one_request() { + let broadcasts: Vec<_> = (0..5).map(|_| crate::broadcast::Info::default().produce()).collect(); + let lister = |i: usize| Arc::new(broadcasts[i].requests().lister().unwrap()); + let live = |listings: &Listings| listings.requests.values().filter(|r| r.live.is_some()).count(); + + let mut listings = Listings::default(); + let (_, _, outer) = listings.listen(Path::from_static("a"), lister(0)); + assert!( + outer.is_some_and(|outer| outer.blockers.is_empty()), + "the first listing waits" + ); + let (_, _, nested) = listings.listen(Path::from_static("a/b"), lister(1)); + assert!(nested.is_none(), "a nested listing asked again"); + assert_eq!(live(&listings), 1); + + let mut listings = Listings::default(); + for (i, path) in ["a/b", "a/c"].into_iter().enumerate() { + let (_, _, nested) = listings.listen(Path::new(path).unwrap(), lister(2 + i)); + assert!(nested.is_some_and(|nested| nested.blockers.is_empty()), "{path}"); + } + let (_, _, outer) = listings.listen(Path::from_static("a"), lister(4)); + assert_eq!(outer.expect("the broader listing asks").blockers.len(), 2); + assert_eq!(live(&listings), 1, "the nested requests stand beside it"); + } + + /// The error code a refused PUBLISH answered with. + fn refusal(id: u64, body: &ietf::Body, version: Version) -> u64 { + assert_eq!(id, ietf::RequestError::ID, "accepted"); + body.message::(version).unwrap().error_code + } + + /// A PUBLISH no listing asked for stays refused, and one for a track already held is + /// redundant: DUPLICATE_SUBSCRIPTION on draft-18, which allows one subscription per + /// track, and UNINTERESTED after. + #[kio::test] + async fn an_unasked_or_redundant_publish_is_refused() { + for (version, duplicate) in [(Version::Draft18, 0x19), (Version::Draft22, 0x20)] { + let mut h = harness(version); + let _listing = h.establish().await; + let (_, id, body) = h.offer(offered("other", "video", 1)).await; + assert_eq!(refusal(id, &body, version), 0x3, "{version}: unsolicited"); + let _video = h.publish("video", 3).await; + let (_, id, body) = h.offer(offered("bcast", "video", 5)).await; + assert_eq!(refusal(id, &body, version), duplicate, "{version}: redundant"); + } + } + + /// A PUBLISH over the listed-track limit is refused as EXCESSIVE_LOAD, which may pass once + /// the peer unlists a track, so it invites a retry (draft-18 section 10.6.2). + #[kio::test] + async fn an_over_limit_publish_invites_a_retry() { + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + let mut h = harness(version); + h.subscriber.announces = crate::session::Slots::new(0); + let _listing = h.establish().await; + let (_, id, body) = h.offer(offered("bcast", "video", 1)).await; + assert_eq!(id, ietf::RequestError::ID, "{version}: accepted"); + let refused: ietf::RequestError = body.message(version).unwrap(); + assert_eq!(refused.error_code, request::EXCESSIVE_LOAD, "{version}"); + assert_eq!( + refused.retry_interval, + crate::auth::RETRY_INITIAL.as_millis() as u64, + "{version}" + ); + } + } + + /// A PUBLISH_DONE with TRACK_ENDED unlists the track as finished, and the listing stands. + #[kio::test] + async fn a_track_ended_publish_finishes_the_track() { + let version = Version::Draft22; + let mut h = harness(version); + let _listing = h.establish().await; + let mut video = h.publish("video", 1).await; + assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); + + let done = ietf::PublishDone { + status_code: ietf::PublishDoneStatus::TrackEnded.code(), + stream_count: 0, + reason_phrase: "".into(), + }; + write(&mut video, ietf::PublishDone::ID, &done).await; + assert_eq!(event(&mut h.tracks).as_deref(), Some("~video")); + assert!(h.listening.poll_failed(&kio::Waiter::noop()).is_pending()); + } + + /// Any other PUBLISH_DONE, INTERNAL_ERROR included, fails the listing rather than hide a + /// track that may be live. + #[kio::test] + async fn another_publish_done_fails_the_listing() { + let version = Version::Draft22; + for (status, expected) in [ + (ietf::PublishDoneStatus::Unauthorized, Error::Unauthorized), + (ietf::PublishDoneStatus::InternalError, Error::Remote(0)), + ] { + let mut h = harness(version); + let _listing = h.establish().await; + let mut video = h.publish("video", 1).await; + assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); + + let done = ietf::PublishDone { + status_code: status.code(), + stream_count: 0, + reason_phrase: "".into(), + }; + write(&mut video, ietf::PublishDone::ID, &done).await; + match h.listening.poll_failed(&kio::Waiter::noop()) { + Poll::Ready(err) => assert_eq!(err.to_string(), expected.to_string(), "{status:?}"), + Poll::Pending => panic!("{status:?}: the listing stood"), + } + } + } + + /// A resumed listed track ending any way but TRACK_ENDED fails the listing, even a + /// clean SUBSCRIPTION_ENDED (draft 18), which says nothing of the track itself. + #[kio::test] + async fn a_resumed_track_ending_otherwise_fails_the_listing() { + let version = Version::Draft18; + let mut h = harness(version); + let _listing = h.establish().await; + let mut video = h.publish("video", 1).await; + assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); + + let (_track, _reader, _serving) = h.resume(&mut video).await; + assert!(h.listening.poll_failed(&kio::Waiter::noop()).is_pending()); + + // SUBSCRIPTION_ENDED, which draft 18 registers. + let done = ietf::PublishDone { + status_code: 0x3, + stream_count: 0, + reason_phrase: "".into(), + }; + write(&mut video, ietf::PublishDone::ID, &done).await; + // The subscription waits out its data streams before it ends. + kio::clock::sleep(Duration::from_secs(5)).await; + assert!(h.listening.poll_failed(&kio::Waiter::noop()).is_ready()); + } + + /// A skipped track is listed through a paused SUBSCRIBE of ours, once it answers. + #[kio::test] + async fn a_skipped_track_lists_once_its_paused_subscribe_answers() { + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + let mut h = harness(version); + let mut listing = h.establish().await; + let skipped = ietf::PublishSkipped { + suffix: Path::from_static(""), + track_name: "audio".into(), + }; + write(&mut listing, ietf::PublishSkipped::ID, &skipped).await; + assert_eq!(event(&mut h.tracks), None, "{version}: listed before it answered"); + + let mut stream = kio::clock::timeout( + TIMEOUT, + Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), + ) + .await + .expect("no paused SUBSCRIBE") + .unwrap(); + let (id, body) = read(&mut stream).await; + assert_eq!(id, ietf::Subscribe::ID, "{version}"); + let msg: ietf::Subscribe = body.message(version).unwrap(); + assert_eq!(msg.track_name, "audio", "{version}"); + assert_eq!( + msg.group_order, + crate::track::Order::default().into(), + "{version}: the order a reader gets by default" + ); + assert!(!msg.forward, "{version}: the SUBSCRIBE forwards"); + + let ok = ietf::SubscribeOk { + track_alias: 3, + largest: None, + properties: ietf::Properties { + timescale: Some(crate::Timescale::MILLI), + ..Default::default() + }, + }; + write(&mut stream, ietf::SubscribeOk::ID, &ok).await; + assert_eq!(event(&mut h.tracks).as_deref(), Some("+audio"), "{version}"); + } + } + + /// A refused paused SUBSCRIBE fails the listing. + #[kio::test] + async fn a_refused_paused_subscribe_fails_the_listing() { + let version = Version::Draft22; + let mut h = harness(version); + let mut listing = h.establish().await; + let skipped = ietf::PublishSkipped { + suffix: Path::from_static(""), + track_name: "audio".into(), + }; + write(&mut listing, ietf::PublishSkipped::ID, &skipped).await; + let mut stream = kio::clock::timeout( + TIMEOUT, + Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), + ) + .await + .expect("no paused SUBSCRIBE") + .unwrap(); + let _ = read(&mut stream).await; + let refused = ietf::RequestError { + error_code: 0x3, + reason_phrase: "".into(), + retry_interval: 0, + }; + write(&mut stream, ietf::RequestError::ID, &refused).await; + assert!(matches!( + h.listening.poll_failed(&kio::Waiter::noop()), + Poll::Ready(Error::Unsupported) + )); + } + + /// A listed PUBLISH whose properties we cannot take fails the listing: the peer sends + /// nothing else for that track, so a listing left standing would silently miss it. + #[kio::test] + async fn an_unsupported_publish_fails_the_listing() { + let version = Version::Draft22; + let mut h = harness(version); + let _listing = h.establish().await; + let compressed = ietf::Publish { + properties: ietf::Properties { + compress: true, + ..Default::default() + }, + ..offered("bcast", "video", 1) + }; + let (_, id, _) = h.offer(compressed).await; + assert_eq!(id, ietf::RequestError::ID, "accepted"); + assert!(matches!( + h.listening.poll_failed(&kio::Waiter::noop()), + Poll::Ready(Error::UnsupportedExtension) + )); + } + + /// On draft-22 a PUBLISH that forwards contradicts the paused listing it answers, and + /// nothing we send can pause it: it is refused and the listing fails, rather than have its + /// objects refused unseen. On draft-18 our PUBLISH_OK pauses it. + #[kio::test] + async fn a_forwarding_publish_fails_the_listing_on_draft_22() { + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + let mut h = harness(version); + let _listing = h.establish().await; + let forwarding = ietf::Publish { + forward: true, + ..offered("bcast", "video", 1) + }; + let (_, id, _) = h.offer(forwarding).await; + let failed = h.listening.poll_failed(&kio::Waiter::noop()); + match version { + Version::Draft18 => { + assert_ne!(id, ietf::RequestError::ID, "{version}: refused"); + assert!(failed.is_pending(), "{version}: {failed:?}"); + } + _ => { + assert_eq!(id, ietf::RequestError::ID, "{version}: accepted"); + assert!( + matches!(failed, Poll::Ready(Error::Unsupported)), + "{version}: {failed:?}" + ); + } + } + } + } + + /// A copy that learned DYNAMIC_GROUPS keeps it through a draft-22 resume that opts out of + /// the properties, so once that subscription pauses as the listing's entry, the + /// REQUEST_UPDATE resuming it still carries the group a join asked for. + #[kio::test] + async fn a_paused_resume_that_opted_out_keeps_dynamic_groups() { + let version = Version::Draft22; + let mut h = harness(version); + let mut listing = h.establish().await; + let accept = |peer: memory::Session| async move { + let mut stream = kio::clock::timeout(TIMEOUT, Stream::accept(&mut Transport::new(peer), version)) + .await + .expect("no SUBSCRIBE") + .unwrap(); + let (id, body) = read(&mut stream).await; + assert_eq!(id, ietf::Subscribe::ID); + let msg: ietf::Subscribe = body.message(version).unwrap(); + (stream, msg.properties_wanted, msg.new_group) + }; + let subscribe_ok = |dynamic_groups| ietf::SubscribeOk { + track_alias: 3, + largest: Some(ietf::Location { group: 5, object: 0 }), + properties: ietf::Properties { + dynamic_groups, + ..Default::default() + }, + }; + let answer = ietf::RequestOk { + active: None, + largest: None, + }; + + // The first answer declares DYNAMIC_GROUPS, and group 5 lands with a delta. + let (track, first, _serving) = h.read_track("video").await; + let (mut stream, wanted, _) = accept(h.peer.clone()).await; + assert!(wanted); + write(&mut stream, ietf::SubscribeOk::ID, &subscribe_ok(true)).await; + let first = kio::clock::timeout(TIMEOUT, first) + .await + .expect("never accepted") + .unwrap(); + let copy = h + .subscriber + .state + .lock() + .subscribes + .values() + .next() + .unwrap() + .producer + .clone() + .unwrap(); + let mut group = copy.create_group(crate::model::group::Info { sequence: 5 }).unwrap(); + group.write_frame(None, b"key".as_slice()).unwrap(); + group.write_frame(None, b"delta".as_slice()).unwrap(); + + // A join past the ratio asks for group 6, which stays outstanding. + let join = track.subscribe(None).await.unwrap(); + settle().await; + let (id, body) = read(&mut stream).await; + assert_eq!(id, ietf::SubscribeUpdate::ID); + assert_eq!( + body.message::(version).unwrap().new_group, + Some(6) + ); + write(&mut stream, ietf::RequestOk::ID, &answer).await; + + // Everyone leaves, and the copy lingers. + drop((first, join)); + settle().await; + + // A returning reader resumes it, opting out of the properties: the answer has none. + let reader = track.subscribe(None); + let (mut stream, wanted, new_group) = accept(h.peer.clone()).await; + assert!(!wanted, "the resume opts out"); + assert_eq!(new_group, Some(6)); + write(&mut stream, ietf::SubscribeOk::ID, &subscribe_ok(false)).await; + let reader = kio::clock::timeout(TIMEOUT, reader) + .await + .expect("never accepted") + .unwrap(); + + // Skipped in the listing, so this subscription becomes the entry and pauses. + let skipped = ietf::PublishSkipped { + suffix: Path::from_static(""), + track_name: "video".into(), + }; + write(&mut listing, ietf::PublishSkipped::ID, &skipped).await; + assert_eq!(event(&mut h.tracks).as_deref(), Some("+video")); + drop(reader); + let (id, body) = read(&mut stream).await; + assert_eq!(id, ietf::SubscribeUpdate::ID, "not paused"); + assert_eq!( + body.message::(version).unwrap().forward, + Some(false) + ); + write(&mut stream, ietf::RequestOk::ID, &answer).await; + + // The resume still asks for group 6, since the track declared DYNAMIC_GROUPS. + let _reader = track.subscribe(None); + let (id, body) = read(&mut stream).await; + assert_eq!(id, ietf::SubscribeUpdate::ID, "not resumed"); + let resume: ietf::SubscribeUpdate = body.message(version).unwrap(); + assert_eq!(resume.forward, Some(true)); + assert_eq!(resume.new_group, Some(6), "the pause forgot DYNAMIC_GROUPS"); + } + + /// A listed track that finishes before its resume is answered goes back to lingering, and + /// its waiting reader SUBSCRIBEs: the idle copy cannot place the end, which the SUBSCRIBE + /// learns, rather than abort a clean finish. + #[kio::test] + async fn a_track_ending_before_its_resume_resubscribes() { + let version = Version::Draft22; + let mut h = harness(version); + let _listing = h.establish().await; + let mut video = h.publish("video", 1).await; + let (track, _serving) = h.resume_and_pause(&mut video).await; + + let _reader = track.subscribe(None); + let (id, body) = read(&mut video).await; + assert_eq!(id, ietf::SubscribeUpdate::ID, "not resumed"); + assert_eq!( + body.message::(version).unwrap().forward, + Some(true) + ); + let done = ietf::PublishDone { + status_code: ietf::PublishDoneStatus::TrackEnded.code(), + stream_count: 0, + reason_phrase: "".into(), + }; + write(&mut video, ietf::PublishDone::ID, &done).await; + assert!( + h.listening.poll_failed(&kio::Waiter::noop()).is_pending(), + "TRACK_ENDED failed the listing" + ); + + let mut stream = kio::clock::timeout( + TIMEOUT, + Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), + ) + .await + .expect("the copy did not SUBSCRIBE") + .unwrap(); + let (id, _) = read(&mut stream).await; + assert_eq!(id, ietf::Subscribe::ID); + } + + /// A paused track's upstream failing aborts the copy a reader left lingering, rather + /// than leave it open with nothing upstream. + #[kio::test] + async fn a_paused_track_failing_aborts_its_lingering_copy() { + let version = Version::Draft22; + let mut h = harness(version); + let _listing = h.establish().await; + let mut video = h.publish("video", 1).await; + let (track, serving) = h.resume_and_pause(&mut video).await; + assert!(!serving.is_finished(), "the copy did not linger"); + + let done = ietf::PublishDone { + status_code: ietf::PublishDoneStatus::InternalError.code(), + stream_count: 0, + reason_phrase: "".into(), + }; + write(&mut video, ietf::PublishDone::ID, &done).await; + assert!(serving.is_finished(), "the copy outlived its upstream"); + drop(track); + } + + /// The answer a pause owes is decoded like any other, so a malformed one closes the + /// session rather than count as an answer. + #[kio::test] + async fn a_malformed_pause_answer_closes_the_session() { + let version = Version::Draft22; + let mut h = harness(version); + let _listing = h.establish().await; + let mut video = h.publish("video", 1).await; + let (_track, _serving) = h.resume_and_pause(&mut video).await; + + // A REQUEST_OK with no parameters, then a trailing byte. + video.writer.varint(ietf::RequestOk::ID).await.unwrap(); + video + .writer + .write_all(&mut &[0x00, 0x02, 0x00, 0xff][..]) + .await + .unwrap(); + let peer = h.peer.clone(); + let closed = kio::wait(move |waiter| crate::transport::poll::Session::poll_closed(&peer, waiter)); + kio::clock::timeout(TIMEOUT, closed) + .await + .expect("the session stayed up"); + } + + /// A peer that refuses SUBSCRIBE_TRACKS, resets it, or never answers it fails at most + /// that listing: a plain SUBSCRIBE still goes out and is accepted on the session. + #[kio::test] + async fn a_peer_not_answering_the_listing_still_serves_subscribe() { + let version = Version::Draft22; + for case in ["refused", "reset", "silent"] { + let mut h = harness(version); + let mut listing = kio::clock::timeout( + TIMEOUT, + Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), + ) + .await + .expect("no SUBSCRIBE_TRACKS") + .unwrap(); + let (id, _) = read(&mut listing).await; + assert_eq!(id, ietf::SubscribeTracks::ID); + let _held = match case { + "refused" => { + let refused = ietf::RequestError { + error_code: 0x3, + reason_phrase: "".into(), + retry_interval: 0, + }; + write(&mut listing, ietf::RequestError::ID, &refused).await; + None + } + "reset" => { + let Stream { writer, reader } = listing; + writer.abort(&Error::Cancel); + settle().await; + Some(reader) + } + _ => { + settle().await; + Some(listing.reader) + } + }; + let failed = h.listening.poll_failed(&kio::Waiter::noop()); + match case { + "refused" => assert!(matches!(failed, Poll::Ready(Error::Unsupported)), "{case}: {failed:?}"), + "reset" => assert!(failed.is_ready(), "{case}: the listing stood"), + _ => assert!(failed.is_pending(), "{case}: the listing failed"), + } + + let (_track, reader, _serving) = h.read_track("video").await; + let mut stream = kio::clock::timeout( + TIMEOUT, + Stream::accept(&mut crate::driver::Transport::new(h.peer.clone()), version), + ) + .await + .expect("no SUBSCRIBE") + .unwrap(); + let (id, body) = read(&mut stream).await; + assert_eq!(id, ietf::Subscribe::ID, "{case}"); + let msg: ietf::Subscribe = body.message(version).unwrap(); + assert!(msg.forward, "{case}: a paused SUBSCRIBE"); + let ok = ietf::SubscribeOk { + track_alias: 7, + largest: None, + properties: ietf::Properties::default(), + }; + write(&mut stream, ietf::SubscribeOk::ID, &ok).await; + kio::clock::timeout(TIMEOUT, reader) + .await + .expect("never accepted") + .unwrap_or_else(|err| panic!("{case}: {err}")); + } + } + + /// Dropping the listing cancels its SUBSCRIBE_TRACKS: FIN on draft-18, RESET_STREAM on + /// draft-22, where a FIN is no cancel. The held PUBLISHes are let go with it. + #[kio::test] + async fn dropping_the_listing_cancels_it() { + let version = Version::Draft18; + let mut h = harness(version); + let mut listing = h.establish().await; + let mut video = h.publish("video", 1).await; + drop(h.listening); + settle().await; + let ended = kio::clock::timeout(TIMEOUT, listing.reader.varint_maybe()) + .await + .expect("the listing stayed open"); + match version { + Version::Draft18 => assert!(matches!(ended, Ok(None)), "{version}: {ended:?}"), + _ => assert!(ended.is_err(), "{version}: {ended:?}"), + } + let ended = kio::clock::timeout(TIMEOUT, video.reader.varint_maybe()) + .await + .expect("the PUBLISH stayed held"); + assert!(!matches!(ended, Ok(Some(_))), "{version}: {ended:?}"); + } +} diff --git a/rs/moq-net/tests/integration/tracks_ietf.rs b/rs/moq-net/tests/integration/tracks_ietf.rs index ee9f8e17bd3..a8c2040c506 100644 --- a/rs/moq-net/tests/integration/tracks_ietf.rs +++ b/rs/moq-net/tests/integration/tracks_ietf.rs @@ -1,7 +1,9 @@ #![cfg(feature = "async")] -//! We never list a moq-transport peer's tracks: a TRACKS listing over that session fails -//! as unsupported without sending SUBSCRIBE_TRACKS, and the session and its SUBSCRIBEs carry on. +//! A TRACKS listing over a moq-transport session (draft-18+) sends SUBSCRIBE_TRACKS with +//! FORWARD=0, only on demand. Our own publisher refuses it, so a listing of our peer fails +//! alone: the session and its SUBSCRIBEs carry on. The listing's handling of a peer that +//! answers is covered against a scripted peer in `ietf::subscriber::tracks`. //! //! Deterministic: paused time and a single-threaded runtime over the mock transport. @@ -15,7 +17,7 @@ use support::harness::{MockConnectOptions, MockPair, connect_mock}; const TIMEOUT: Duration = Duration::from_secs(10); -/// Every supported moq-transport draft. +/// Every draft that has SUBSCRIBE_TRACKS. const DRAFTS: [&str; 3] = ["moq-transport-18", "moq-transport-20", "moq-transport-22"]; /// SUBSCRIBE (0x03), as the first byte of a request stream. @@ -91,10 +93,11 @@ async fn read(sub: &mut moq_net::track::Subscriber) -> Option<(u64, Vec)> { Some((group.sequence, frame.payload.to_vec())) } -/// A listing fails as unsupported rather than claim the peer lists nothing, sends no -/// SUBSCRIBE_TRACKS, and leaves the SUBSCRIBE delivering. +/// SUBSCRIBE_TRACKS goes out only for a listing, since many peers do not answer it: +/// connecting, an announce, and a reader's SUBSCRIBE send none. Our own publisher refuses +/// it, which fails only that listing, and the SUBSCRIBE keeps delivering. #[kio::test] -async fn a_listing_fails_alone() { +async fn a_listing_of_our_own_peer_fails_alone() { for version in DRAFTS { let publisher = produce_origin(1); let broadcast = publisher @@ -117,14 +120,13 @@ async fn a_listing_fails_alone() { group.write_frame(None, &b"before"[..]).unwrap(); group.finish().unwrap(); assert_eq!(read(&mut sub).await, Some((0, b"before".to_vec())), "{version}"); + assert_eq!(opened(&pair, SUBSCRIBE_TRACKS), 0, "{version}: listed unasked"); let mut tracks = remote.tracks().unwrap(); - assert_eq!( - next(&mut tracks).await, - format!("error: {}", moq_net::Error::Unsupported), - "{version}" - ); - assert_eq!(opened(&pair, SUBSCRIBE_TRACKS), 0, "{version}"); + assert_eq!(next(&mut tracks).await, "synced", "{version}"); + let failed = next(&mut tracks).await; + assert!(failed.starts_with("error:"), "{version}: {failed}"); + assert_eq!(opened(&pair, SUBSCRIBE_TRACKS), 1, "{version}"); let mut group = video.append_group().unwrap(); group.write_frame(None, &b"after"[..]).unwrap();