Skip to content
2 changes: 1 addition & 1 deletion doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ subset drops. What a peer actually observes against this implementation:
- **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We send `PUBLISH` only to answer a `SUBSCRIBE_TRACKS` (below).
- **`SUBSCRIBE_TRACKS` lists, it never pushes.** It lists every track of every broadcast under its prefix, later ones included. With `FORWARD=0`, each track gets a paused `PUBLISH` carrying its Track Properties, and a `PUBLISH_DONE` once the track is no longer listed: `TRACK_ENDED` if it finished, even just before its broadcast ended, and an error otherwise, since the track may go on elsewhere. A `REQUEST_UPDATE` with `FORWARD=1` resumes one, served from the cache like a `SUBSCRIBE` with the update's filter and answered with `LARGEST_OBJECT`; `FORWARD=0` pauses it again. Each paused `PUBLISH` opens only as the peer's stream credit admits it, so a peer that grants none holds none. A listing that omits `FORWARD` or sets it to 1 asks for every track's objects: on draft 22 each track gets `PUBLISH_SKIPPED`, and the subscriber `SUBSCRIBE`s to what it wants; draft 18 has no such skip, so the request is refused with `NOT_SUPPORTED`. A prefix overlapping another listing gets `PREFIX_OVERLAP`, a `TRACK_PROPERTY_FILTER` gets `NOT_SUPPORTED`, and a `REQUEST_UPDATE` on the listing itself ends it. On draft 18, a `SUBSCRIBE` to a track we `PUBLISH`ed gets `DUPLICATE_SUBSCRIPTION`. Cancelling the listing stops new `PUBLISH`es, while those sent live on. A broadcast is listed only while our publish grant covers it, from scratch once it does. Rust sends it upstream for a listing (below); JavaScript does not.
- **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end, including the error our own answerer sends for a track that left unfinished, fails the listing, and readers list again.
- **History is fetched in ranges.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns a range of groups from the cache, which a relay fills upstream with one `FETCH` per run of missing groups, or on draft 18 the saved prefix of the group a new subscription just joined. The range ends at Largest Object's group and reads it whole, so a `FETCH` of a group still being written waits for it to finish. That departs from draft 22's cap at Largest Object: a cache cannot know an open group's real Largest Object, and a cut group would be cached downstream as complete. On draft 18, `FETCH_OK` End Object 0 covers the entire End Group, matching the request; Rust also accepts the legacy answer naming the next group with Object 0. On draft 22, `FETCH_OK`'s End Location is the requested end, capped at Largest Object's group: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A Rust relay retains the upstream Largest Object from `TRACK_STATUS`, so even a fetch-only copy can resolve a draft 22 `LOCATION_FILTER` relative to it; without a publisher report or live feed, it refuses that filter as `NOT_SUPPORTED`. JavaScript publishing answers a `FETCH` from its cache alone, and a joining `FETCH` only for the head of its subscription's own group. It honors `FILL_TIMEOUT` on a `FETCH` or a fill as a total budget for waiting on objects that have not arrived, zero meaning cache only; once it runs out, the rest of each group still arriving is marked an Unknown gap on draft 18 and a Timed-Out gap on draft 22, never left implying the objects do not exist. It bounds the wait, not how old a cached object is. Rust still refuses `FILL_TIMEOUT` with `NOT_SUPPORTED`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED` still subscribes.
- **History is fetched in ranges.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns a range of groups from the cache, which a relay fills upstream with one `FETCH` per run of missing groups, or on draft 18 the saved prefix of the group a new subscription just joined. The range ends at Largest Object's group and reads it whole, so a `FETCH` of a group still being written waits for it to finish. That departs from draft 22's cap at Largest Object: a cache cannot know an open group's real Largest Object, and a cut group would be cached downstream as complete. On draft 18, `FETCH_OK` End Object 0 covers the entire End Group, matching the request; Rust also accepts the legacy answer naming the next group with Object 0. On draft 22, `FETCH_OK`'s End Location is the requested end, capped at Largest Object's group: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A Rust relay retains the upstream Largest Object from `TRACK_STATUS`, so even a fetch-only copy can resolve a draft 22 `LOCATION_FILTER` relative to it; without a publisher report or live feed, it refuses that filter as `NOT_SUPPORTED`. JavaScript publishing answers a `FETCH` from its cache alone, and a joining `FETCH` only for the head of its subscription's own group. It honors `FILL_TIMEOUT` on a `FETCH` or a fill as a total budget for waiting on objects that have not arrived, zero meaning cache only; once it runs out, the rest of each group still arriving is marked an Unknown gap on draft 18 and a Timed-Out gap on draft 22, never left implying the objects do not exist. It bounds the wait, not how old a cached object is. Rust still refuses `FILL_TIMEOUT` with `NOT_SUPPORTED`. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and on draft 22 asks for it with a fill. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED` still subscribes.
- **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension.
- **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed. A Rust reader that never subscribed takes the units from `TRACK_STATUS`.
- **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. A `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. An unknown message parameter, or one defined only for another message, closes the session. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a 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.
Expand Down
7 changes: 5 additions & 2 deletions js/net/src/ietf/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,8 +527,11 @@ export class Connection implements Established {
return;
}

// We never FETCH, so a fetch response answers nothing of ours.
if (type === BigInt(FetchHeader.type)) throw new Error("unexpected fetch stream");
// The head of a join, answering our draft-18 joining FETCH or draft-22 fill.
if (type === BigInt(FetchHeader.type)) {
await this.#subscriber.handleFetch(stream);
return;
}

// Anything else is unknown, and a second SETUP is a violation too.
throw new ProtocolViolation(`unknown uni stream type: 0x${type.toString(16)}`);
Expand Down
13 changes: 8 additions & 5 deletions js/net/src/ietf/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import { Version } from "./version.ts";
const OLD = Version.DRAFT_18;
const NEW = Version.DRAFT_22;

test("legacy live joins do not exclude the prefix without a joining FETCH", () => {
for (const version of [Version.DRAFT_18]) {
expect([...Filter.encode(joinFilter(version), version)]).toEqual([3, 0, 0]);
}
expect([...Filter.encode(joinFilter(NEW), NEW)]).toEqual([0x01, 0x01]);
test("a live join is Next Object, its head left to a joining FETCH or a StartGroup=1 fill", () => {
const old = joinFilter(OLD);
expect([...Filter.encode(old.filter, OLD)]).toEqual([0x02]);
expect(old.fill).toBeUndefined();

const draft22 = joinFilter(NEW);
expect([...Filter.encode(draft22.filter, NEW)]).toEqual([0x05]);
expect(draft22.fill).toEqual({ filter: { kind: "relative", groups: 1n }, rangeFilters: false });
});

function roundTrip(filter: Filter.Filter, version: typeof OLD | typeof NEW): Filter.Filter {
Expand Down
Loading