From 678b5739e9b5bf75239924f1c13bdde0c21adffd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 16:48:30 -0700 Subject: [PATCH 01/22] chore(quest): claim quest/m1/subscribe-ranges/js.md Quest-Claim: f7f1e9f622015209 From f08917505e50142c3c0b7949aba11ace5742e8b9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 16:55:14 -0700 Subject: [PATCH 02/22] feat(net)!: a null JS maxDelay asks for the publisher's max age alone Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/subscriber.ts | 5 +++-- js/net/src/internal.ts | 17 +++++++++++++++++ js/net/src/lite/publisher.ts | 10 +++++----- js/net/src/lite/subscriber.ts | 19 ++++++++++++++----- js/net/src/resume.ts | 3 ++- js/net/src/track.test.ts | 13 +++++++++++++ js/net/src/track.ts | 20 +++++++++++++------- 7 files changed, 67 insertions(+), 20 deletions(-) diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 9e7aa82e3ba..05a9be35fad 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -29,6 +29,7 @@ import { scopeCaptures, scopeHead, scopeOverlaps, + wireDelay, } from "../internal.ts"; import type { Lister } from "../listing.ts"; import * as Path from "../path.ts"; @@ -1022,7 +1023,7 @@ export class Subscriber { // keeps what it last asked for until one returns, as on lite and in Rust. const subscription = producer.subscription.peek(); const wanted = toWire(subscription?.priority ?? 0); - const wantedMaxAge = this.#maxAge ? subscription?.maxDelay : undefined; + const wantedMaxAge = this.#maxAge ? wireDelay(subscription) : undefined; if (!subscription || (wanted === sent && wantedMaxAge === sentMaxAge) || unanswered.peek() > 0) { const disposes: Dispose[] = []; const wake = new Promise((resolve) => { @@ -1109,7 +1110,7 @@ export class Subscriber { trackName: request.name, // Read now, since a subscriber can join while the stream opened. Updates follow it. subscriberPriority: toWire(request.priority), - maxAge: this.#maxAge ? (request.subscription.maxDelay ?? 0) : undefined, + maxAge: this.#maxAge ? wireDelay(request.subscription) : undefined, // Fixed for the subscription's life: no REQUEST_UPDATE parameter carries it. groupOrder: request.subscription.order, // No fill is requested: the fill's fetch stream and the live subscription split diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 3038261aaa0..ca322d05bbf 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -20,6 +20,23 @@ import type { Timestamp } from "./time.ts"; import type { Demand, Groups, Info, Position, Producer, Request, Subscriber, Subscription } from "./track.ts"; import type { Advertised, Advertisements } from "./wire.ts"; +/** + * A subscription's `maxDelay` as a budget in milliseconds, `0` without one: `null` waits as + * long as anything could, which the publisher's max age clamps. + */ +export function delayBudget(subscription: Pick | undefined): number { + const maxDelay = subscription?.maxDelay; + return maxDelay === null ? Number.POSITIVE_INFINITY : (maxDelay ?? 0); +} + +/** + * The `Subscriber Max Age` a wire carries for `maxDelay`: `null` is the publisher's max age + * alone, which the largest value every peer can read leaves to its clamp. + */ +export function wireDelay(subscription: Pick | undefined): number { + return Math.min(delayBudget(subscription), Number.MAX_SAFE_INTEGER); +} + /** Normalize public group bounds into an inclusive start and exclusive end. */ export function groupBounds(groups: Groups = {}): { start: number; end?: number } { const bound = (value: Groups["start"] | undefined, start: boolean): number | undefined => { diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index f7f03b36f04..54662900ce4 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -368,11 +368,11 @@ function waitForSubscription( * * A version without the field decodes as `0`, which is indistinguishable from a peer * genuinely asking for the live edge. Serving that as real time would discard backlog - * a legacy subscriber never declined, so fall back to a window wide enough not to drop + * a legacy subscriber never declined, so fall back to the publisher's max age alone * and leave enforcement to the receiver, as the IETF path does for the same reason. */ -function servingMaxDelay(version: Version, requested: number | undefined): number { - return carriesMaxDelay(version) ? (requested ?? 0) : Number.MAX_SAFE_INTEGER; +function servingMaxDelay(version: Version, requested: number): Milli | null { + return carriesMaxDelay(version) ? Milli(requested) : null; } /** Whether this version's SUBSCRIBE carries Subscriber Max Age at all. */ @@ -851,7 +851,7 @@ export class Publisher { const track = front.track(msg.track).subscribe({ priority: msg.priority, order: msg.order, - maxDelay: Milli(servingMaxDelay(this.version, msg.maxDelay)), + maxDelay: servingMaxDelay(this.version, msg.maxDelay), groups: { start: msg.startGroup === undefined ? undefined : { included: msg.startGroup }, end: endGroup === undefined ? undefined : { excluded: endGroup }, @@ -946,7 +946,7 @@ export class Publisher { track.update({ priority: update.priority, order: update.order, - maxDelay: Milli(servingMaxDelay(this.version, update.maxDelay)), + maxDelay: servingMaxDelay(this.version, update.maxDelay), groups: { start: update.startGroup === undefined ? undefined : { included: update.startGroup }, end: end === undefined ? undefined : { excluded: end }, diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index c0aed54db74..7d105a05833 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -22,7 +22,16 @@ import { } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; -import { groupBounds, hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; +import { + delayBudget, + groupBounds, + hiddenBelow, + hooks, + scopeCaptures, + scopeHead, + scopeOverlaps, + wireDelay, +} from "../internal.ts"; import { grace } from "../ledger.ts"; import type { Lister } from "../listing.ts"; import * as Path from "../path.ts"; @@ -898,7 +907,7 @@ export class Subscriber { track: request.name, priority: subscription.priority ?? 0, order: subscription.order, - maxDelay: subscription.maxDelay, + maxDelay: wireDelay(subscription), startGroup: subscription.groups?.start === undefined ? undefined : bounds.start, endGroup: inclusiveGroupEnd(bounds.end), }); @@ -1086,7 +1095,7 @@ export class Subscriber { // presentation-time drift), but it is how long the subscriber was willing to wait // for a late group anyway. Already the smaller of the subscriber's and the track's. tail: new Tail({ - grace: () => grace(producer.subscription.peek()?.maxDelay ?? Milli.zero), + grace: () => grace(Milli(delayBudget(producer.subscription.peek()))), }), requested: msg.startGroup, bounded: msg.endGroup !== undefined, @@ -1604,7 +1613,7 @@ export class Subscriber { wantedGroup: refresh ?? sentWanted, priority: current.priority ?? 0, order: current.order, - maxDelay: current.maxDelay, + maxDelay: wireDelay(current), startGroup: current.groups?.start === undefined ? undefined : bounds.start, endGroup: inclusiveGroupEnd(bounds.end), }); @@ -1636,7 +1645,7 @@ export class Subscriber { return ( (a.priority ?? 0) === (b.priority ?? 0) && sameOrder && - (a.maxDelay ?? 0) === (b.maxDelay ?? 0) && + wireDelay(a) === wireDelay(b) && sameStart && ag.start === bg.start && ag.end === bg.end diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 9bb4abf29a7..2a58627077a 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -52,6 +52,7 @@ import type { Consumer as GroupConsumer } from "./group.ts"; import { type DropRun, finalSequence, + delayBudget, type GroupRecover, groupBounds, hooks, @@ -912,7 +913,7 @@ class Reader implements ResumeSource { /** The reader's budget: how far behind a group may fall before it is skipped. */ #budget(): Milli { - return this.#shell.subscription.peek()?.maxDelay ?? Milli.zero; + return Milli(delayBudget(this.#shell.subscription.peek())); } /** diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 85db29036dd..b430a468dc2 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -553,6 +553,19 @@ test("the producer aggregate is clamped without changing subscriber options", as expect(track.subscription.peek()?.maxDelay).toBe(Milli(10_000)); }); +test("a null maxDelay asks for the publisher's max age alone", async () => { + const producer = new TrackProducer("test"); + const history = producer.subscribe({ maxDelay: null }); + producer.subscribe({ maxDelay: Milli(250) }); + expect(history.subscription.peek()?.maxDelay).toBeNull(); + // The most tolerant subscriber wins the aggregate, so null dominates. + expect(producer.subscription.peek()?.maxDelay).toBeNull(); + + const clamped = producer.subscription.changed(); + producer.accept({ timescale: Timescale.MILLI, maxAge: Milli(2_000) }); + expect((await clamped)?.maxDelay).toBe(Milli(2_000)); +}); + test("nextGroup skips late arrivals", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe().ordered(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index acb6088e554..766c7d3ab78 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -22,6 +22,7 @@ import { type Frame, type Consumer as GroupConsumer, Producer as GroupProducer } import type { IetfVersion } from "./ietf/version.ts"; import { type DropRun, + delayBudget, dropAt, finalSequence, groupBounds, @@ -226,11 +227,14 @@ export interface Subscription { * How long (milliseconds) a non-latest group stays worth delivering, by the same two * clocks as {@link Info.maxAge}: it is skipped once its successor arrived this long ago * or starts this far behind the live edge. Defaults to `0`, the live edge only, timed or not. + * `null` is the publisher's {@link Info.maxAge} alone, what a reader reaching back for + * history wants; it dominates when subscriptions combine and goes out as the largest + * delay every peer can read. * Rounded up to a whole millisecond, so a value derived from a measurement is never * shortened. A negative or non-finite value, or one past `Number.MAX_SAFE_INTEGER` * after rounding, is refused. */ - maxDelay?: Milli; + maxDelay?: Milli | null; /** * The groups this reader may receive, with an optional floor and cap. * @@ -271,7 +275,7 @@ function subscriptionDefaults(subscription: Subscription = {}): Subscription { const bounds = groupBounds(subscription.groups ?? {}); return { priority: priorityValue(subscription.priority ?? DEFAULT_PRIORITY), - maxDelay: wireMillis("maxDelay", subscription.maxDelay ?? Milli.zero), + maxDelay: subscription.maxDelay === null ? null : wireMillis("maxDelay", subscription.maxDelay ?? Milli.zero), order: orderOf(subscription.order), groups: { start: subscription.groups?.start === undefined ? undefined : { included: bounds.start }, @@ -299,7 +303,10 @@ function combineSubscriptions(states: Iterable): Subscription | unde } combined.priority = Math.max(combined.priority ?? 0, subscription.priority ?? 0); - combined.maxDelay = Milli(Math.max(combined.maxDelay ?? Milli.zero, subscription.maxDelay ?? Milli.zero)); + combined.maxDelay = + combined.maxDelay === null || subscription.maxDelay === null + ? null + : Milli(Math.max(combined.maxDelay ?? Milli.zero, subscription.maxDelay ?? Milli.zero)); // One live subscriber held behind another's backlog is worse than a backlog sent late. combined.order = combined.order === "ascending" && subscription.order === "ascending" ? "ascending" : "descending"; @@ -1065,8 +1072,7 @@ export class Producer { const combined = combineSubscriptions(this.#sinks); this.#settle(); const retained = this.#state.info.peek()?.maxAge; - if (combined && retained !== undefined) - combined.maxDelay = Milli(Math.min(combined.maxDelay ?? Milli.zero, retained)); + if (combined && retained !== undefined) combined.maxDelay = Milli(Math.min(delayBudget(combined), retained)); this.#state.update.set(combined); // The requested floor bounds what a pending tail owes. this.#owe(); @@ -1560,7 +1566,7 @@ export class Producer { // The grace the session's tail uses, from the max delay the track's subscribers ask for. #grace(): Milli { - return grace(this.#state.update.peek()?.maxDelay ?? Milli.zero); + return grace(Milli(delayBudget(this.#state.update.peek()))); } // Tell every reader whether the pending tail still owes a group below the end. @@ -1832,7 +1838,7 @@ export class Subscriber { } } - const requested = this.#fetchBudget ?? this.#state.update.peek()?.maxDelay ?? 0; + const requested = this.#fetchBudget ?? delayBudget(this.#state.update.peek()); const retained = info?.maxAge; return { budget: retained === undefined ? requested : Math.min(requested, retained), From 7c8e53c02831988628d8bbb4517fe0629d710d66 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 17:30:28 -0700 Subject: [PATCH 03/22] feat(net)!: JS subscriptions carry ranges, as in Rust Replace the single Subscription.groups span with ranges of absolute and relative anchors. Each reader reads only its own ranges; the aggregate is their union, which lite-07 carries as a list and older wires as the span covering it. A lite-07 publisher FINs a bounded subscription once it covers its ranges. Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 3 +- js/net/api/index.api.md | 19 +- js/net/api/zod.api.md | 19 +- js/net/src/broadcast.test.ts | 6 +- js/net/src/ietf/publisher.test.ts | 6 +- js/net/src/ietf/publisher.ts | 18 +- js/net/src/ietf/subscriber.ts | 15 +- js/net/src/integration.test.ts | 67 +++- js/net/src/internal.ts | 25 ++ js/net/src/ledger.ts | 17 +- js/net/src/lite/publisher.test.ts | 12 +- js/net/src/lite/publisher.ts | 73 ++-- js/net/src/lite/subscribe.test.ts | 53 ++- js/net/src/lite/subscribe.ts | 128 ++++--- js/net/src/lite/subscriber.test.ts | 16 +- js/net/src/lite/subscriber.ts | 112 +++--- js/net/src/lite/tail.test.ts | 17 +- js/net/src/order.test.ts | 4 +- js/net/src/resume.test.ts | 8 +- js/net/src/resume.ts | 26 +- js/net/src/track.test.ts | 137 ++++++-- js/net/src/track.ts | 231 +++++++++---- js/net/src/window.test.ts | 104 ++++++ js/net/src/window.ts | 422 +++++++++++++++++++++++ test/interop/clients/js-native/finish.ts | 2 +- test/interop/clients/js-native/goaway.ts | 4 +- test/interop/clients/js-native/tail.ts | 2 +- 27 files changed, 1205 insertions(+), 341 deletions(-) create mode 100644 js/net/src/window.test.ts create mode 100644 js/net/src/window.ts diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index af8f55ea520..4246010a667 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -49,7 +49,8 @@ publication.announce(); - **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`, syncs at once with what this side holds, and the peer's tracks follow as `added`. 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. - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. -- **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. +- **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first; `null` leaves the publisher's retention alone to bound it, for a reader reaching back for history. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. +- **A subscription asks for `ranges`.** Each [range](/concept/moq-lite#subscriptions) starts and ends at an `absolute` group or one `relative` to the latest, and a reader reads only its own ranges. JavaScript has no time anchors yet. Passing the old `groups` key throws a `TypeError` naming `ranges`; `withRange` only narrows reads locally. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). - **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Credential` it returns (`publication.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 48a1ebaa242..616b2918e50 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -37,6 +37,14 @@ class Allocator implements Handle_2 { static unlimited(): Allocator; } +type Anchor = { + absolute: number; + relative?: never; +} | { + relative: number; + absolute?: never; +}; + declare namespace Announce { export { Announce_2 as Announce, @@ -630,6 +638,11 @@ class Publication { unannounce(): void; } +interface Range_2 { + end?: Anchor; + start?: Anchor; +} + interface ReadOptions { from?: number; } @@ -822,10 +835,10 @@ class Subscriber { } interface Subscription { - groups?: Groups; - maxDelay?: Milli; + maxDelay?: Milli | null; order?: Order; priority?: number; + ranges?: Range_2[]; } interface Table { @@ -894,6 +907,8 @@ declare namespace Track { Info, Bound, Groups, + Anchor, + Range_2 as Range, Subscription, Order, Request_2 as Request, diff --git a/js/net/api/zod.api.md b/js/net/api/zod.api.md index 8119cbfb81a..9b4586ef25a 100644 --- a/js/net/api/zod.api.md +++ b/js/net/api/zod.api.md @@ -9,6 +9,14 @@ import { Getter } from '@moq/signals'; import { Milli } from '@moq/clock'; import type * as z from '@zod/mini'; +type Anchor = { + absolute: number; + relative?: never; +} | { + relative: number; + absolute?: never; +}; + type Bound = { included: number; excluded?: never; @@ -165,6 +173,11 @@ class Producer_2 { writeFrame(frame: Frame): void; } +interface Range_2 { + end?: Anchor; + start?: Anchor; +} + export function read(source: track.Ordered | group.Consumer, schema: z.ZodMiniType): Promise; interface ReadOptions { @@ -197,10 +210,10 @@ class Subscriber { } interface Subscription { - groups?: Groups; - maxDelay?: Milli; + maxDelay?: Milli | null; order?: Order; priority?: number; + ranges?: Range_2[]; } type Timescale = number & { @@ -235,6 +248,8 @@ declare namespace track { Info, Bound, Groups, + Anchor, + Range_2 as Range, Subscription, Order, Request_2 as Request, diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 821de20676e..4aba5ae9c82 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -356,12 +356,12 @@ test("a request exposes the aggregate subscription options", async () => { consumer.track("video").subscribe({ priority: 3, maxDelay: Milli(100), - groups: { start: { included: 10 }, end: { excluded: 20 } }, + ranges: [{ start: { absolute: 10 }, end: { absolute: 20 } }], }); consumer.track("video").subscribe({ priority: 7, maxDelay: Milli(250), - groups: { start: { included: 0 }, end: { excluded: 30 } }, + ranges: [{ start: { absolute: 0 }, end: { absolute: 30 } }], }); const request = await pendingRequest(consumer); @@ -369,7 +369,7 @@ test("a request exposes the aggregate subscription options", async () => { priority: 7, maxDelay: Milli(250), order: "descending", - groups: { start: { included: 0 }, end: { excluded: 30 } }, + ranges: [{ start: { absolute: 0 }, end: { absolute: 30 } }], }); expect(request?.priority).toBe(7); }); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 72ca463f41f..0cca3829591 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -2580,7 +2580,7 @@ test("draft-22: an absolute filter trims the range it serves", async () => { // The request forwarded upstream carries the model's exclusive end, one past the // filter's inclusive last group. expect(track.subscription.peek()).toMatchObject({ - groups: { start: { included: 1 }, end: { excluded: 3 } }, + ranges: [{ start: { absolute: 1 }, end: { absolute: 3 } }], }); const first = await nextUni(fx.uni); @@ -3677,7 +3677,7 @@ test("REQUEST_UPDATE applies priority and preserves it when omitted", async () = expect(track.subscription.peek()?.priority).toBe(117); // Only the priority changes; retention and the group range survive. expect(track.subscription.peek()?.maxDelay).toBe(before?.maxDelay); - expect(track.subscription.peek()?.groups).toEqual(before?.groups); + expect(track.subscription.peek()?.ranges).toEqual(before?.ranges); await client.writer.write(new Uint8Array([0x02, 0, 2, 4, 0])); expect(await client.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(client.reader, fx.version); @@ -4130,7 +4130,7 @@ test("IETF draft-18: range updates preserve the shared subscription", async () = await new SubscribeUpdate({ requestId: id, parameters }).encode(client.writer, fx.version); expect(await client.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(client.reader, fx.version); - expect(track.subscription.peek()?.groups).toEqual({ start: { included: 6 }, end: { excluded: 8 } }); + expect(track.subscription.peek()?.ranges).toEqual([{ start: { absolute: 6 }, end: { absolute: 8 } }]); } } finally { client.close(); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 8719a3005b7..3e79073fe7b 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -29,7 +29,7 @@ import * as Path from "../path.ts"; import { Priority } from "../priority.ts"; import { type Stream, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; -import type { Groups, Info, Order, Position as TrackPosition, Subscriber as TrackSubscriber } from "../track.ts"; +import type { Groups, Info, Order, Range, Position as TrackPosition, Subscriber as TrackSubscriber } from "../track.ts"; import { untilAborted } from "../util/abort.ts"; import { nextChange } from "../util/change.ts"; import { Deadline, REQUEST_TIMEOUT_MS, requestOptions } from "../util/deadline.ts"; @@ -550,7 +550,7 @@ export class Publisher { order: msg.groupOrder, // An absent budget preserves everything retained by the producer. maxDelay: Milli(msg.maxAge ?? Varint.MAX_U53), - groups: fixed.start ? rangeGroups(fixed) : undefined, + ranges: fixed.start ? serveRanges(fixed) : undefined, }); }; let track = subscribe(broadcast); @@ -758,7 +758,7 @@ export class Publisher { priority, order, maxDelay: Milli(msg.maxAge ?? Varint.MAX_U53), - groups: rangeGroups(range), + ranges: serveRanges(range), }); const startGroup = range.start ? Number(range.start.group) : hooks.latestSequence(track); if (startGroup !== undefined) track.withRange({ ...rangeGroups(range), start: { included: startGroup } }); @@ -914,7 +914,7 @@ export class Publisher { range.start = tail.start; range.end = tail.end; bounds.set(requested, true); - track.update({ ...track.subscription.peek(), groups: rangeGroups(range) }); + track.update({ ...track.subscription.peek(), ranges: serveRanges(range) }); // Below its floor the live cursor already passed every group or never owned it, // so lowering it for a fill-only update would hand those groups out a second time. let start = range.start ? Number(range.start.group) : 0; @@ -2751,7 +2751,7 @@ export class Publisher { priority: fromWire(subscription.subscriberPriority), order: subscription.groupOrder, maxDelay: Milli(subscription.maxAge ?? Varint.MAX_U53), - groups: fixed.start ? rangeGroups(fixed) : undefined, + ranges: fixed.start ? serveRanges(fixed) : undefined, }); let ready: Info; try { @@ -3029,6 +3029,14 @@ async function servingGroup(track: TrackSubscriber): Promise { - if (subscription?.groups?.start?.included === 1) resolveProducer?.(producer); + if (subscription?.ranges?.[0]?.start?.absolute === 1) resolveProducer?.(producer); }; check(); producer.subscription.subscribe(check); @@ -208,27 +208,27 @@ test("integration: lite subscription options and updates reach the publisher", a const subscriber = remote.track("video").subscribe({ priority: 3, maxDelay: Milli(250), - groups: { start: { included: 1 }, end: { excluded: 9 } }, + ranges: [{ start: { absolute: 1 }, end: { absolute: 9 } }], }); const producer = await accepted; expect(producer.subscription.peek()).toEqual({ priority: 3, maxDelay: Milli(250), order: "descending", - groups: { start: { included: 1 }, end: { excluded: 9 } }, + ranges: [{ start: { absolute: 1 }, end: { absolute: 9 } }], }); const updated = producer.subscription.changed(); subscriber.update({ priority: 8, maxDelay: Milli(500), - groups: { start: { included: 2 }, end: { excluded: 12 } }, + ranges: [{ start: { absolute: 2 }, end: { absolute: 12 } }], }); expect(await updated).toEqual({ priority: 8, maxDelay: Milli(500), order: "descending", - groups: { start: { included: 2 }, end: { excluded: 12 } }, + ranges: [{ start: { absolute: 2 }, end: { absolute: 12 } }], }); subscriber.close(); @@ -294,7 +294,7 @@ test("integration: lite carries a fractional maxDelay as a whole millisecond", a const producer = request.accept({ timescale: Timescale.MILLI }); // The TRACK lookup opens the request and the SUBSCRIBE joins it, so its options land after. const check = (subscription = producer.subscription.peek()) => { - if (subscription?.groups?.start?.included === 1) resolveProducer?.(producer); + if (subscription?.ranges?.[0]?.start?.absolute === 1) resolveProducer?.(producer); }; check(); producer.subscription.subscribe(check); @@ -304,7 +304,9 @@ test("integration: lite carries a fractional maxDelay as a whole millisecond", a const remote = wireOf(client).consume(Path.from("test")); // A varint cannot encode 38.75, so an unrounded value fails the SUBSCRIBE outright and // nothing resubscribes. The publisher must see the budget rounded up instead. - const subscriber = remote.track("video").subscribe({ maxDelay: Milli(38.75), groups: { start: { included: 1 } } }); + const subscriber = remote + .track("video") + .subscribe({ maxDelay: Milli(38.75), ranges: [{ start: { absolute: 1 } }] }); // A failed subscribe never reaches the publisher, so race its closure to report the // encode error rather than block until the suite times out. @@ -316,7 +318,7 @@ test("integration: lite carries a fractional maxDelay as a whole millisecond", a expect(producer.subscription.peek()?.maxDelay).toBe(Milli(39)); const updated = producer.subscription.changed(); - subscriber.update({ maxDelay: Milli(500.25), groups: { start: { included: 1 } } }); + subscriber.update({ maxDelay: Milli(500.25), ranges: [{ start: { absolute: 1 } }] }); expect((await Promise.race([updated, failed]))?.maxDelay).toBe(Milli(501)); subscriber.close(); @@ -354,7 +356,7 @@ test("integration: lite applies initial and updated group bounds", async () => { .track("video") .subscribe({ maxDelay: Milli(REPLAY_LATENCY_MS), - groups: { start: { included: INITIAL_START_GROUP }, end: { excluded: INITIAL_END_GROUP } }, + ranges: [{ start: { absolute: INITIAL_START_GROUP }, end: { absolute: INITIAL_END_GROUP } }], }) .ordered(); try { @@ -366,7 +368,7 @@ test("integration: lite applies initial and updated group bounds", async () => { subscriber.update({ maxDelay: Milli(REPLAY_LATENCY_MS), - groups: { start: { included: UPDATED_GROUP }, end: { excluded: UPDATED_END_GROUP } }, + ranges: [{ start: { absolute: UPDATED_GROUP }, end: { absolute: UPDATED_END_GROUP } }], }); expect( (await withTimeout(pending, Nano.fromMilli(Milli(UPDATE_TIMEOUT_MS)), "updated group bound timed out")) @@ -406,7 +408,7 @@ test("integration: lite refuses an empty requested range on open and on update", // Bounds that meet cannot go on the wire: the nearest encoding inverts the range. const empty = video.subscribe({ maxDelay: Milli(REPLAY_LATENCY_MS), - groups: { start: { included: 2 }, end: { excluded: 2 } }, + ranges: [{ start: { absolute: 2 }, end: { absolute: 2 } }], }); await expect( withTimeout(empty.recvGroup(), Nano.fromMilli(Milli(TIMEOUT_MS)), "empty open never settled"), @@ -417,13 +419,13 @@ test("integration: lite refuses an empty requested range on open and on update", const live = video .subscribe({ maxDelay: Milli(REPLAY_LATENCY_MS), - groups: { start: { included: 1 }, end: { excluded: 2 } }, + ranges: [{ start: { absolute: 1 }, end: { absolute: 2 } }], }) .ordered(); expect((await live.nextGroup())?.sequence).toBe(1); live.update({ maxDelay: Milli(REPLAY_LATENCY_MS), - groups: { start: { included: 3 }, end: { excluded: 3 } }, + ranges: [{ start: { absolute: 3 }, end: { absolute: 3 } }], }); await expect( withTimeout(live.nextGroup(), Nano.fromMilli(Milli(TIMEOUT_MS)), "empty update never settled"), @@ -437,6 +439,45 @@ test("integration: lite refuses an empty requested range on open and on update", } }); +// Lite-07 carries every range, so a sparse request counting back from the latest group delivers +// just its groups, and the publisher ends it once they are accounted for. +test("integration: lite-07 serves sparse and relative ranges", async () => { + const pair = createMockTransportPair(Lite.ALPN_07_WIP); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + + const broadcast = publish(origin, Path.from("test")); + const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); + for (let sequence = 0; sequence < 10; sequence++) producer.appendGroup().finish(); + + const remote = wireOf(client).consume(Path.from("test")); + try { + const reader = remote + .track("video") + .subscribe({ + maxDelay: null, + order: "ascending", + ranges: [ + { start: { absolute: 1 }, end: { absolute: 3 } }, + { start: { relative: 2 }, end: { absolute: 9 } }, + ], + }) + .ordered(); + const got: number[] = []; + for (let group = await reader.nextGroup(); group; group = await reader.nextGroup()) got.push(group.sequence); + expect(got).toEqual([1, 2, 7, 8]); + reader.close(); + } finally { + remote.close(); + broadcast.close(); + client.abort(); + server.abort(); + } +}); + test("integration: lite draft-06", async () => { // Exercises announce ids: every active assigns an ordinal on the wire. await runPublishSubscribeFlow(Lite.ALPN_06); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index ca322d05bbf..f2f8098c11c 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -424,6 +424,12 @@ export const hooks: { * as the Rust `request_start`. */ startTrack: (producer: Producer, served: { start?: number; requested?: number }) => void; + /** + * Mark a track as a copy received over a wire without ranges, which cannot carry a relative + * anchor other than the live start, so its readers refuse one rather than guess from a + * partial copy. Mirrors the Rust `Holdings::drops_anchors`; assigned by `track.ts`. + */ + dropAnchors: (producer: Producer) => void; /** Mint a track {@link Request}; assigned by `track.ts`. */ makeRequest: (options: TrackRequestOptions) => Request; /** @@ -469,6 +475,16 @@ export const hooks: { * kept for it, so a resumed reader takes groups the plain way and drops from here. */ tryDrop: (subscriber: Subscriber) => (Recv & { kind: "drop" }) | undefined; + /** + * The runs of `[start, end)` a subscriber reads: inside its ranges and its local bounds. + * Assigned by `track.ts`. + */ + servingRuns: (subscriber: Subscriber, start: number, end: number) => Run[]; + /** + * The exclusive end past every group a subscriber reads, or undefined while one of its ranges + * stays open. Assigned by `track.ts`. + */ + servingEnd: (subscriber: Subscriber) => number | undefined; /** The sequence {@link tryNextGroup} (`ordered`) or {@link tryRecvGroup} would take next, without taking it. */ peekGroup: (subscriber: Subscriber, ordered: boolean) => number | undefined; /** Take the next group in sequence order without waiting, as {@link tryRecvGroup} does in arrival order. */ @@ -626,6 +642,15 @@ export const hooks: { startTrack: () => { throw new Error("track.ts not loaded"); }, + dropAnchors: () => { + throw new Error("track.ts not loaded"); + }, + servingRuns: () => { + throw new Error("track.ts not loaded"); + }, + servingEnd: () => { + throw new Error("track.ts not loaded"); + }, makeRequest: () => { throw new Error("track.ts not loaded"); }, diff --git a/js/net/src/ledger.ts b/js/net/src/ledger.ts index a145a2ba556..c607b0596f2 100644 --- a/js/net/src/ledger.ts +++ b/js/net/src/ledger.ts @@ -15,9 +15,20 @@ const BTree = (btree.default as unknown as { default?: typeof btree.default }).d */ export const GRACE = Milli(1000); -/** The grace for a subscription with `maxDelay`: how long it was willing to wait for a late group anyway, or {@link GRACE} without one. */ -export function grace(maxDelay: Milli): Milli { - return maxDelay > 0 ? maxDelay : GRACE; +/** + * How long idle cached content is kept, matching Rust `cache::DEFAULT_EXPIRY`: a group older + * than this is evicted, so nothing waits for one longer. + */ +export const CACHE_EXPIRY = Milli(30_000); + +/** + * The grace for a subscription with a `maxDelay` budget in milliseconds: how long it was willing + * to wait for a late group anyway, {@link GRACE} without one, and {@link CACHE_EXPIRY} for an + * unbounded one. + */ +export function grace(budget: number): Milli { + if (budget <= 0) return GRACE; + return Number.isFinite(budget) ? Milli(budget) : CACHE_EXPIRY; } // A run of accounted sequences `[start, end)`, and when the gap below it opened, which is diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 6087e2a02c4..a242111cc2e 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -929,8 +929,8 @@ test.each([Version.DRAFT_06, Version.DRAFT_07])("lite %s: datagrams honor group // The publisher applies an update's datagram bounds before forwarding its group range, and // each update here moves the range's end. const applied = async (end: number) => { - let groups = sub.track.subscription.peek()?.groups; - while (groups?.end?.excluded !== end) groups = (await sub.track.subscription.changed())?.groups; + let ranges = sub.track.subscription.peek()?.ranges; + while (ranges?.[0]?.end?.absolute !== end) ranges = (await sub.track.subscription.changed())?.ranges; }; try { send([0, 2, 1]); @@ -1285,7 +1285,7 @@ test("lite draft-05: a group popped before a cap update is still served", async expect(await sub.servedSequence()).toBe(1); while (ranges.mock.calls.length === 0) await flush(); // Dropping the named floor re-pins to the latest group, as an unfloored SUBSCRIBE does. - expect(lastGroups(ranges)).toEqual({ start: { included: 1 }, end: { included: 0 } }); + expect(lastGroups(ranges)).toEqual({ start: { included: 1 } }); } finally { ranges.mockRestore(); sub.release(); @@ -1463,7 +1463,7 @@ test("lite draft-06: a burst of updates coalesces before the next group pop", as expect(await sub.servedGroup()).toEqual({ sequence: 1, frameStart: 0, payloads: ["a"] }); expect(ranges).toHaveBeenCalledTimes(1); // Draft-06 encodes a floor of group 0 as no floor, which assigns 0. - expect(lastGroups(ranges)).toEqual({ start: { included: 0 }, end: { included: 1 } }); + expect(lastGroups(ranges)).toEqual({ start: { included: 0 } }); } finally { ranges.mockRestore(); } @@ -1532,14 +1532,14 @@ test("lite draft-06: scheduling updates apply while SUBSCRIBE_START is blocked", priority: 9, maxDelay: TEST_MAX_DELAY_MS, order: "descending", - groups: { start: undefined, end: { excluded: 6 } }, + ranges: [{ end: { absolute: 6 } }], }); expect(ranges).not.toHaveBeenCalled(); sub.release(); expect(await sub.servedSequence()).toBe(0); while (ranges.mock.calls.length === 0) await flush(); - expect(lastGroups(ranges)).toEqual({ start: { included: 0 }, end: { included: 5 } }); + expect(lastGroups(ranges)).toEqual({ start: { included: 0 } }); } finally { ranges.mockRestore(); sub.release(); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 54662900ce4..f532b408a63 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -31,7 +31,7 @@ import { Group as GroupMessage } from "./group.ts"; import { Probe } from "./probe.ts"; import { encodeSubscribeResponse, - exclusiveGroupEnd, + requestedRanges, type Subscribe, SubscribeDrop, SubscribeEnd, @@ -178,7 +178,7 @@ type FrameBounds = { * The frames of `sequence` a subscription asked for, as a start index and an inclusive end. * * The frame bounds qualify the start and end group only; every other group is served whole. - * Which groups are served at all is the subscriber's read cursor (`withRange`), + * Which groups are served at all is the serving subscriber's ranges and floor, * applied when a group is popped rather than re-checked here. * * The serving loop calls this synchronously after the pop, before any SUBSCRIBE_UPDATE can @@ -847,22 +847,16 @@ export class Publisher { return; } - const endGroup = exclusiveGroupEnd(msg.endGroup); const track = front.track(msg.track).subscribe({ priority: msg.priority, order: msg.order, maxDelay: servingMaxDelay(this.version, msg.maxDelay), - groups: { - start: msg.startGroup === undefined ? undefined : { included: msg.startGroup }, - end: endGroup === undefined ? undefined : { excluded: endGroup }, - }, + ranges: requestedRanges(msg), }); serving(track); const floor = cursorFloor(this.version, msg.startGroup, hooks.latestSequence(track)); - track.withRange({ - start: { included: floor }, - end: endGroup === undefined ? undefined : { excluded: endGroup }, - }); + // The ranges cap what is served; the floor only pins where an older wire starts. + track.withRange({ start: { included: floor } }); // The best-effort datagram loop, started once serving begins. It parks when the // track finishes (recvDatagram returns undefined), so #runTrack alone ends the @@ -942,15 +936,11 @@ export class Publisher { const largest = hooks.peekLargest(track).largest?.group; if (update.wantedGroup !== undefined && (largest === undefined || update.wantedGroup > largest)) hooks.joinSubscriber(track); - const end = exclusiveGroupEnd(update.endGroup); track.update({ priority: update.priority, order: update.order, maxDelay: servingMaxDelay(this.version, update.maxDelay), - groups: { - start: update.startGroup === undefined ? undefined : { included: update.startGroup }, - end: end === undefined ? undefined : { excluded: end }, - }, + ranges: requestedRanges(update), }); }, }); @@ -1130,13 +1120,9 @@ export class Publisher { }, ): Promise { const { sub, broadcast, stamp, publisher, compress, served, bounds } = serving; - // The read cursor's floor, assigned with the cap in {@link bounds} on every move. + // The read cursor's floor; the subscription's ranges cap it. let floor = serving.floor; - const moveCursor = () => - track.withRange({ - start: { included: floor }, - end: bounds.endGroup === undefined ? undefined : { included: bounds.endGroup }, - }); + const moveCursor = () => track.withRange({ start: { included: floor } }); // Raise the floor to `start`, keeping any higher floor already set. const raiseFloor = (start: number) => { if (start <= floor) return; @@ -1190,6 +1176,16 @@ export class Publisher { ); }; + // The FIN tells the subscriber every group is accounted for, so it waits until each group + // stream finished or reset. False once the subscriber left instead, which cancels whatever + // is still queued. `race`, since this repeats per batch of drops against the subscription's end. + const drained = async (): Promise => { + const done = Symbol("drained"); + const end = await race([Promise.all(groups).then(() => done), controls.ended]); + if (end instanceof Error) throw end; + return end === done; + }; + // One ranking for the whole subscription, shared by every group it serves. const priority = new Priority(track, publisher); @@ -1327,6 +1323,23 @@ export class Publisher { if (!(await sendEnd())) return false; continue; } + // Lite-07 ends a subscription whose every range has an end once it accounted for + // each group in them, without SUBSCRIBE_END: the FIN ends the subscription, not the + // track. What lies below SUBSCRIBE_START was accounted for by it. + if (waitsForSubscriberFin(this.version) && served && startSequence !== undefined) { + const end = hooks.servingEnd(track); + const covered = + end !== undefined && + hooks + .servingRuns(track, startSequence, end) + .every((run) => runGaps(served, run.start, run.end).length === 0); + if (covered) { + if (!(await drained())) return false; + if (pendingDrops.length > 0) continue; + finished = true; + return true; + } + } await waitForSubscription(controls, track, drops); continue; case "boundary": @@ -1342,23 +1355,17 @@ export class Publisher { // Name every sequence from the start to the end this subscription never // got. A cap below the end bounds what the subscriber is owed, as on its side. if (startSequence !== undefined) { - const end = - bounds.endGroup === undefined ? boundary() : Math.min(boundary(), bounds.endGroup + 1); const unserved = new StreamError(StreamCode.NotFound, { message: "group not served" }); - if (!(await drop(startSequence, end, unserved))) return false; + // Only the groups inside the ranges are owed. + for (const run of hooks.servingRuns(track, startSequence, boundary())) { + if (!(await drop(run.start, run.end, unserved))) return false; + } } if (!endSent) { if (!(await sendEnd())) return false; continue; } - // The FIN tells the subscriber every group is accounted for, so it waits - // until each group stream finished or reset. The subscriber leaving - // instead cancels whatever is still queued. - const drained = Symbol("drained"); - // `race`, since this repeats per batch of drops against the subscription's end. - const end = await race([Promise.all(groups).then(() => drained), controls.ended]); - if (end instanceof Error) throw end; - if (end !== drained) return false; + if (!(await drained())) return false; if (pendingDrops.length > 0) continue; finished = true; return true; diff --git a/js/net/src/lite/subscribe.test.ts b/js/net/src/lite/subscribe.test.ts index 08f1e66ae99..3b2f7e37772 100644 --- a/js/net/src/lite/subscribe.test.ts +++ b/js/net/src/lite/subscribe.test.ts @@ -4,9 +4,7 @@ import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { decodeSubscribeResponse, - emptyRange, encodeSubscribeResponse, - exclusiveGroupEnd, inclusiveGroupEnd, Subscribe, SubscribeDrop, @@ -207,16 +205,51 @@ test("a lite-07 list decodes as the span covering it", async () => { // Groups 2 through 4 and 7 through frame 1 of group 9. const sparse = await update([2, 1, 2, 0, 1, 5, 0, 1, 7, 0, 1, 9, 2]); expect([sparse.startGroup, sparse.startFrame, sparse.endGroup, sparse.endFrame]).toEqual([2, 0, 9, 1]); + // The ranges stay whole groups, so an end past frame 0 includes its group. + expect(sparse.ranges).toEqual([ + { start: { absolute: 2 }, end: { absolute: 5 } }, + { start: { absolute: 7 }, end: { absolute: 10 } }, + ]); - // A relative start resolves upstream of nothing here, so it widens to no floor. + // The span covering a relative start has no floor; the ranges keep it for the serving reader. const relative = await update([2, 2, 3, 1, 5, 0, 1, 7, 0, 1, 9, 0]); expect([relative.startGroup, relative.endGroup, relative.endFrame]).toEqual([undefined, 8, undefined]); + expect(relative.ranges).toEqual([ + { start: { relative: 3 }, end: { absolute: 5 } }, + { start: { absolute: 7 }, end: { absolute: 9 } }, + ]); + // A time anchor is not resolved here, so it serves from the oldest group or through the live edge. + expect((await update([1, 3, 40, 3, 90])).ranges).toEqual([{}]); await expect(update([0])).rejects.toThrow(ProtocolViolation); await expect(update([1, 4, 0, 0])).rejects.toThrow(ProtocolViolation); await expect(update([1, 0, 1, 0, 0])).rejects.toThrow(ProtocolViolation); }); +test("lite-07 carries every range, relative anchors included, and older wires the span alone", async () => { + const ranges = [ + { start: { absolute: 2 }, end: { absolute: 5 } }, + { start: { relative: 1 } }, + { end: { relative: 0 } }, + ]; + const message = new Subscribe({ + id: 1n, + broadcast: Path.from("test"), + track: "video", + priority: 0, + startGroup: 0, + ranges, + }); + const decode = async (version: Version) => + Subscribe.decode(new Reader(undefined, await encodeMessage(version, message), version), version); + expect((await decode(Version.DRAFT_07)).ranges).toEqual(ranges); + const older = await decode(Version.DRAFT_05); + expect([older.ranges, older.startGroup, older.endGroup]).toEqual([undefined, 0, undefined]); + await expect(encodeMessage(Version.DRAFT_07, new Subscribe({ ...message, ranges: [] }))).rejects.toThrow( + "empty subscription range cannot be encoded", + ); +}); + test("SubscribeStart round-trips on draft-05", async () => { const got = await responseRoundtrip(Version.DRAFT_05, { start: new SubscribeStart(42) }); expect("start" in got).toBe(true); @@ -296,25 +329,13 @@ test("frame bounds without their group bounds are rejected before encoding", asy ); }); -test("model and wire group ends convert without an off-by-one", () => { - expect(exclusiveGroupEnd(undefined)).toBeUndefined(); - expect(exclusiveGroupEnd(0)).toBe(1); - expect(exclusiveGroupEnd(9)).toBe(10); +test("model group ends convert to the wire without an off-by-one", () => { expect(inclusiveGroupEnd(undefined)).toBeUndefined(); expect(inclusiveGroupEnd(1)).toBe(0); expect(inclusiveGroupEnd(10)).toBe(9); expect(() => inclusiveGroupEnd(0)).toThrow("empty subscription range cannot be encoded"); }); -test("a requested range is empty when its bounds meet anywhere", () => { - expect(emptyRange({})).toBe(false); - expect(emptyRange({ endGroup: 0 })).toBe(true); - expect(emptyRange({ startGroup: 5, endGroup: 5 })).toBe(true); - expect(emptyRange({ startGroup: 6, endGroup: 5 })).toBe(true); - expect(emptyRange({ startGroup: 5, endGroup: 6 })).toBe(false); - expect(emptyRange({ startGroup: 5 })).toBe(false); -}); - // Lite-07 carries `Group Order` right after the priority, on SUBSCRIBE and SUBSCRIBE_UPDATE // alike, and refuses a value it does not define. test.each(["ascending", "descending"] as const)("lite-07 round-trips a %s group order", async (order) => { diff --git a/js/net/src/lite/subscribe.ts b/js/net/src/lite/subscribe.ts index 79599498a23..9d09fa5f014 100644 --- a/js/net/src/lite/subscribe.ts +++ b/js/net/src/lite/subscribe.ts @@ -2,7 +2,7 @@ import type * as Epoch from "../epoch.ts"; import { fromTransport, ProtocolViolation, StreamCode, StreamError, toStreamCode } from "../error.ts"; import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; -import type { Order, Position } from "../track.ts"; +import type { Anchor, Order, Position, Range } from "../track.ts"; import { decodeEpoch, encodeEpoch } from "./epoch.ts"; import * as Message from "./message.ts"; import { fromWire, toWire } from "./priority.ts"; @@ -120,11 +120,35 @@ interface Span { endFrame?: number; } +/** Encode one lite-07 range end, `undefined` for none: an absolute end names a group's first frame. */ +async function encodeAnchor(w: Writer, anchor: Anchor | undefined) { + if (anchor?.absolute !== undefined) { + await w.u53(1); + await w.u53(anchor.absolute); + await w.u53(0); + } else if (anchor?.relative !== undefined) { + await w.u53(2); + await w.u53(anchor.relative); + } else { + await w.u53(0); + } +} + /** * Encode the span after the max delay. Before lite-07 that is `Group Start`, `Group End`, - * and the frame bounds; lite-07 carries a list of ranges, and this span is its only one. + * and the frame bounds; lite-07 carries a list of ranges, which is `ranges` when given and + * otherwise this span as its only one. */ -async function encodeSpan(w: Writer, version: Version, span: Span) { +async function encodeSpan(w: Writer, version: Version, span: Span, ranges?: readonly Range[]) { + if (ranges !== undefined && hasRanges(version)) { + if (ranges.length === 0) throw new Error(EMPTY_RANGE); + await w.u53(ranges.length); + for (const range of ranges) { + await encodeAnchor(w, range.start); + await encodeAnchor(w, range.end); + } + return; + } if (!hasRanges(version)) { await encodeStartGroup(w, version, span.startGroup); await w.u53(span.endGroup !== undefined ? span.endGroup + 1 : 0); @@ -155,6 +179,18 @@ async function encodeSpan(w: Writer, version: Version, span: Span) { } } +/** + * The model ranges a decoded SUBSCRIBE or SUBSCRIBE_UPDATE asks for: its lite-07 list, or the one + * span an older wire carries, whole groups either way. + */ +export function requestedRanges(msg: Span & { ranges?: Range[] }): Range[] { + if (msg.ranges) return msg.ranges; + const range: Range = {}; + if (msg.startGroup !== undefined) range.start = { absolute: msg.startGroup }; + if (msg.endGroup !== undefined) range.end = { absolute: msg.endGroup + 1 }; + return [range]; +} + /** A position as a comparable pair, group then frame. */ type Point = [group: number, frame: number]; @@ -163,31 +199,35 @@ function before(a: Point, b: Point): boolean { } /** - * Decode one lite-07 range end, `undefined` for none. A relative or time anchor is also - * `undefined`: this publisher resolves neither, so it serves from the oldest group its - * budget allows, or through the live edge, and the subscriber keeps what it asked for. + * Decode one lite-07 range end: its absolute position, if any, and the model anchor, `undefined` + * for none. An absolute end past frame 0 includes its group. A time anchor is also `undefined`: + * this publisher does not resolve one, so it serves from the oldest group its budget allows, or + * through the live edge, and the subscriber keeps what it asked for. */ -async function decodeAnchor(r: Reader): Promise { +async function decodeAnchor(r: Reader, end: boolean): Promise<{ point?: Point; anchor?: Anchor }> { const kind = await r.u53(); switch (kind) { case 0: - return undefined; - case 1: - return [await r.u53(), await r.u53()]; + return {}; + case 1: { + const point: Point = [await r.u53(), await r.u53()]; + return { point, anchor: { absolute: end && point[1] > 0 ? point[0] + 1 : point[0] } }; + } case 2: + return { anchor: { relative: await r.u53() } }; case 3: await r.u53(); - return undefined; + return {}; default: throw new ProtocolViolation(`unknown range anchor: ${kind}`); } } /** - * The inverse of {@link encodeSpan}. A lite-07 list decodes as the one span covering every - * range in it, which the subscriber narrows again. + * The inverse of {@link encodeSpan}. A lite-07 list decodes as its ranges and as the one span + * covering every range in it, which the subscriber narrows again. */ -async function decodeSpan(r: Reader, version: Version): Promise { +async function decodeSpan(r: Reader, version: Version): Promise { if (!hasRanges(version)) { const startGroup = await decodeStartGroup(r, version); const endGroup = (await r.u53()) || undefined; @@ -202,13 +242,18 @@ async function decodeSpan(r: Reader, version: Version): Promise { let end: Point | undefined; let floor = true; let live = false; + const ranges: Range[] = []; for (let i = 0; i < count; i++) { - const from = await decodeAnchor(r); - const to = await decodeAnchor(r); - if (from === undefined) floor = false; - else if (start === undefined || before(from, start)) start = from; - if (to === undefined) live = true; - else if (end === undefined || before(end, to)) end = to; + const from = await decodeAnchor(r, false); + const to = await decodeAnchor(r, true); + const range: Range = {}; + if (from.anchor) range.start = from.anchor; + if (to.anchor) range.end = to.anchor; + ranges.push(range); + if (from.point === undefined) floor = false; + else if (start === undefined || before(from.point, start)) start = from.point; + if (to.point === undefined) live = true; + else if (end === undefined || before(end, to.point)) end = to.point; } if (!floor) start = undefined; if (live) end = undefined; @@ -217,6 +262,7 @@ async function decodeSpan(r: Reader, version: Version): Promise { const startFrame = start?.[1] ?? 0; return { + ranges, startGroup: canonicalStartGroup(version, start?.[0], startFrame), startFrame, endGroup: end === undefined ? undefined : end[1] === 0 ? end[0] - 1 : end[0], @@ -266,24 +312,14 @@ async function encodeOrder(w: Writer, version: Version, order: Order) { await w.u8(order === "ascending" ? 1 : 0); } -/** - * Exclusive model/local cap from a decoded inclusive last group. - * - * The wire's `Group End` is inclusive once decoded; the track model and `withRange` are - * exclusive. `undefined` stays unbounded. - */ -export function exclusiveGroupEnd(inclusive?: number): number | undefined { - return inclusive === undefined ? undefined : inclusive + 1; -} - -/** The error for a requested range the wire cannot carry; see {@link emptyRange}. */ +/** The error for a requested range the wire cannot carry: one asking for nothing. */ export const EMPTY_RANGE = "empty subscription range cannot be encoded"; /** * Inclusive last group a Subscribe message carries, from an exclusive model end. * * Empty (`0`) cannot be encoded: the wire's 0 means unbounded. Callers refuse an empty - * requested range with {@link emptyRange} before reaching here. + * requested range before reaching here. */ export function inclusiveGroupEnd(exclusive?: number): number | undefined { if (exclusive === undefined) return undefined; @@ -291,18 +327,6 @@ export function inclusiveGroupEnd(exclusive?: number): number | undefined { return exclusive - 1; } -/** - * Whether a requested range asks for nothing. - * - * The wire has no encoding for one: its bounds are inclusive, so flooring the end would - * either hand back the group the caller excluded (0 means unbounded) or invert the - * range once the two bounds meet. An absent start is the live edge, so it only empties - * the range when the end is 0. - */ -export function emptyRange({ startGroup, endGroup }: { startGroup?: number; endGroup?: number }): boolean { - return endGroup !== undefined && (startGroup ?? 0) >= endGroup; -} - export class SubscribeUpdate { /** A join requesting this group or later, judged against the local refresh ratio. */ wantedGroup?: number; @@ -317,6 +341,8 @@ export class SubscribeUpdate { startFrame: number; /** See {@link Subscribe.endFrame}. */ endFrame?: number; + /** See {@link Subscribe.ranges}. */ + ranges?: Range[]; constructor(props: { priority: number; @@ -327,6 +353,7 @@ export class SubscribeUpdate { endGroup?: number; startFrame?: number; endFrame?: number; + ranges?: Range[]; }) { if (props.wantedGroup !== undefined && (!Number.isSafeInteger(props.wantedGroup) || props.wantedGroup < 0)) throw new RangeError("wanted group must be a safe non-negative integer"); @@ -338,6 +365,7 @@ export class SubscribeUpdate { this.endGroup = props.endGroup; this.startFrame = props.startFrame ?? 0; this.endFrame = props.endFrame; + this.ranges = props.ranges; } async #encode(w: Writer, version: Version) { @@ -350,7 +378,7 @@ export class SubscribeUpdate { await w.u8(toWire(this.priority)); await encodeOrder(w, version, this.order); await w.u53(this.maxDelay); - await encodeSpan(w, version, this); + await encodeSpan(w, version, this, this.ranges); if (hasRefresh(version)) await w.u62(this.wantedGroup === undefined ? 0n : BigInt(this.wantedGroup) + 1n); break; @@ -427,6 +455,12 @@ export class Subscribe { */ endFrame?: number; + /** + * Every range. Lite-07 encodes these in place of the span when given, and older versions + * the span alone; decoding a lite-07 list fills them alongside the span covering them all. + */ + ranges?: Range[]; + constructor(props: { id: bigint; broadcast: Path.Valid; @@ -439,6 +473,7 @@ export class Subscribe { endGroup?: number; startFrame?: number; endFrame?: number; + ranges?: Range[]; }) { this.id = props.id; this.broadcast = props.broadcast; @@ -451,6 +486,7 @@ export class Subscribe { this.endGroup = props.endGroup; this.startFrame = props.startFrame ?? 0; this.endFrame = props.endFrame; + this.ranges = props.ranges; } async #encode(w: Writer, version: Version) { @@ -467,7 +503,7 @@ export class Subscribe { default: await encodeOrder(w, version, this.order); await w.u53(this.maxDelay); - await encodeSpan(w, version, this); + await encodeSpan(w, version, this, this.ranges); break; } } diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index c0395a9b2e3..94e982be020 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -2160,7 +2160,7 @@ test("a lite-07 publisher FIN ends a covered bounded subscription", async () => const { quic, streams, opened, closes } = fakeSession(); const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); const broadcast = subscriber.consume(Path.from("room")); - const track = broadcast.track("video").subscribe({ groups: { start: { included: 0 }, end: { excluded: 2 } } }); + const track = broadcast.track("video").subscribe({ ranges: [{ start: { absolute: 0 }, end: { absolute: 2 } }] }); try { await opened(0); await answerTrackInfo(streams[0], version); @@ -2193,17 +2193,17 @@ test("a lite-07 publisher FIN ends a covered bounded subscription", async () => // The FIN may answer the bounded range before the publisher read an update that widened it, // so the track fails rather than ending clean short of the demand. test.each([ - ["an open widening after SUBSCRIBE_START", { start: { included: 0 } }, true, false], - ["a bounded widening without SUBSCRIBE_START", { start: { included: 0 }, end: { excluded: 5 } }, false, false], - ["a widening the publisher stopped reading", { start: { included: 0 }, end: { excluded: 5 } }, false, true], -] as const)("a lite-07 FIN short of %s fails the track", async (_, groups, start, stopped) => { + ["an open widening after SUBSCRIBE_START", { start: { absolute: 0 } }, true, false], + ["a bounded widening without SUBSCRIBE_START", { start: { absolute: 0 }, end: { absolute: 5 } }, false, false], + ["a widening the publisher stopped reading", { start: { absolute: 0 }, end: { absolute: 5 } }, false, true], +] as const)("a lite-07 FIN short of %s fails the track", async (_, range, start, stopped) => { const clock = new Manual(); const installed = clock.install(); const version = Version.DRAFT_07; const { quic, streams, opened, closes } = fakeSession(); const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); const broadcast = subscriber.consume(Path.from("room")); - const track = broadcast.track("video").subscribe({ groups: { start: { included: 0 }, end: { excluded: 1 } } }); + const track = broadcast.track("video").subscribe({ ranges: [{ start: { absolute: 0 }, end: { absolute: 1 } }] }); try { await opened(0); await answerTrackInfo(streams[0], version); @@ -2217,7 +2217,7 @@ test.each([ }), ) : undefined; - track.update({ groups }); + track.update({ ranges: [range] }); await refused; await drainReactions(); @@ -2313,7 +2313,7 @@ test("an update whose bound the wire can't carry fails the track", async () => { await opened(0); await answerTrackInfo(streams[0], Version.DRAFT_05); await (await opened(1)).reading; - track.update({ groups: { start: { included: Number.MAX_SAFE_INTEGER } } }); + track.update({ ranges: [{ start: { absolute: Number.MAX_SAFE_INTEGER } }] }); expect(await track.closed).toBeInstanceOf(RangeError); } finally { track.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 7d105a05833..2dde46ee26d 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -22,16 +22,7 @@ import { } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; -import { - delayBudget, - groupBounds, - hiddenBelow, - hooks, - scopeCaptures, - scopeHead, - scopeOverlaps, - wireDelay, -} from "../internal.ts"; +import { delayBudget, hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps, wireDelay } from "../internal.ts"; import { grace } from "../ledger.ts"; import type { Lister } from "../listing.ts"; import * as Path from "../path.ts"; @@ -46,6 +37,7 @@ import { Deadline, REQUEST_TIMEOUT_MS, requestOptions } from "../util/deadline.t import { abandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; import { Budget } from "../util/yield.ts"; +import { bounded, rangesStart, Window, wireRanges, wireSpan } from "../window.ts"; import { overrideBroadcastWire, wireOf } from "../wire.ts"; import { AnnounceHistory, @@ -65,8 +57,6 @@ import { decodeSubscribeResponse, decodeSubscribeResponseMaybe, EMPTY_RANGE, - emptyRange, - exclusiveGroupEnd, inclusiveGroupEnd, Subscribe, SubscribeUpdate, @@ -150,9 +140,9 @@ interface SubscribeEntry { // Whether the stream ever asked for a bounded range, which a lite-07 publisher FINs without // SUBSCRIBE_END once it covers it. bounded: boolean; - // The groups the SUBSCRIBE asked for, which that FIN covers unless the demand moved since + // The ranges the SUBSCRIBE asked for, which that FIN covers unless the demand moved since // in an update the publisher may not have read. - asked: { start: number; end?: number }; + asked: track.Range[]; // The TRACK stream that answered (lite-05+), held open as interest in the track until // the SUBSCRIBE has its first response, so the publisher's demand never lapses between // the two. @@ -842,9 +832,7 @@ export class Subscriber { async #runSubscribe(broadcast: Path.Valid, consumed: Consumed, request: track.Request) { const id = this.#subscribeNext++; - const subscription = request.subscription; - const initialBounds = groupBounds(subscription.groups); - if (emptyRange({ startGroup: initialBounds.start, endGroup: initialBounds.end })) { + if (wireRanges(request.subscription.ranges ?? [{}]).length === 0) { request.reject(new Error(EMPTY_RANGE)); return; } @@ -898,7 +886,8 @@ export class Subscriber { const subscription = request.subscription; console.debug(`subscribe start: id=${id} broadcast=${broadcast} track=${request.name}`); - const bounds = groupBounds(subscription.groups); + const ranges = wireRanges(subscription.ranges ?? [{}]); + const span = wireSpan(ranges); const msg = new Subscribe({ id, @@ -908,8 +897,9 @@ export class Subscriber { priority: subscription.priority ?? 0, order: subscription.order, maxDelay: wireDelay(subscription), - startGroup: subscription.groups?.start === undefined ? undefined : bounds.start, - endGroup: inclusiveGroupEnd(bounds.end), + startGroup: span.start, + endGroup: inclusiveGroupEnd(span.end), + ranges, }); // Open the stream under a timeout. The stream handles flow back via `state` @@ -1087,6 +1077,7 @@ export class Subscriber { if (resolvesStart(this.version)) hooks.startTrack(producer, { requested: msg.startGroup }); // Register before opening SUBSCRIBE so a racing GROUP stream finds the entry. + if (!hasRanges(this.version)) hooks.dropAnchors(producer); const entry: SubscribeEntry = { track: producer, compress, @@ -1095,11 +1086,11 @@ export class Subscriber { // presentation-time drift), but it is how long the subscriber was willing to wait // for a late group anyway. Already the smaller of the subscriber's and the track's. tail: new Tail({ - grace: () => grace(Milli(delayBudget(producer.subscription.peek()))), + grace: () => grace(delayBudget(producer.subscription.peek())), }), requested: msg.startGroup, - bounded: msg.endGroup !== undefined, - asked: groupBounds(request.subscription.groups), + bounded: bounded(msg.ranges ?? []), + asked: msg.ranges ?? [], held: state.track, }; this.#subscribes.set(id, entry); @@ -1511,31 +1502,33 @@ export class Subscriber { const { tail, track } = entry; const complete = () => { - const groups = track.subscription.peek()?.groups; - const bounds = groupBounds(groups ?? {}); + const ranges = wireRanges(track.subscription.peek()?.ranges ?? [{}]); + const span = wireSpan(ranges); // Without SUBSCRIBE_END (older drafts) nothing says which groups are owed, unless a - // lite-07 publisher covered the bounded range. + // lite-07 publisher covered the bounded ranges. const end = entry.end === undefined ? hasRanges(this.version) - ? bounds.end + ? span.end : undefined - : Math.min(entry.end, bounds.end ?? entry.end); + : Math.min(entry.end, span.end ?? entry.end); if (end === undefined) return false; // Without SUBSCRIBE_START the publisher served no group at all. if (entry.start === undefined) return true; // Owed from the floor the demand last asked for, which an update can move either // way, or where SUBSCRIBE_START resolved a live-edge one. The groups the SUBSCRIBE - // asked for below its SUBSCRIBE_START were accounted for when it arrived. - const start = groups?.start === undefined ? entry.start : bounds.start; - return tail.covers(start, end); + // asked for below its SUBSCRIBE_START were accounted for when it arrived. Only the + // groups inside the ranges are owed: the publisher accounts for nothing between them. + const start = rangesStart(ranges) ?? entry.start; + const window = new Window(ranges, track.largest()?.group); + return window.runs(start, end).every((run) => tail.covers(run.start, run.end)); }; const released = hooks.trackReleased(track); await tail.settle(complete, released); if (entry.end !== undefined || !hasRanges(this.version) || released.peek() !== undefined) return true; - const demand = groupBounds(track.subscription.peek()?.groups); - if (demand.start === entry.asked.start && demand.end === entry.asked.end) return true; + const demand = wireRanges(track.subscription.peek()?.ranges ?? [{}]); + if (JSON.stringify(demand) === JSON.stringify(entry.asked)) return true; // Without SUBSCRIBE_START nothing says which ranges the publisher read, so only the // groups that arrived prove the demand covered. return entry.start !== undefined && complete(); @@ -1565,10 +1558,7 @@ export class Subscriber { priority: msg.priority, order: msg.order, maxDelay: Milli(msg.maxDelay), - groups: { - start: msg.startGroup === undefined ? undefined : { included: msg.startGroup }, - end: msg.endGroup === undefined ? undefined : { excluded: exclusiveGroupEnd(msg.endGroup) ?? 0 }, - }, + ranges: msg.ranges, }; // The wanted group is subscription state: an update announces each new one past our @@ -1598,13 +1588,15 @@ export class Subscriber { // Demand collapsing to nothing is refused the same way an initial empty // request is: the error closes the track, so every local subscriber sees it. - const bounds = groupBounds(current.groups); - if (emptyRange({ startGroup: bounds.start, endGroup: bounds.end })) throw new Error(EMPTY_RANGE); + const ranges = wireRanges(current.ranges ?? [{}]); + if (ranges.length === 0) throw new Error(EMPTY_RANGE); + const span = wireSpan(ranges); // A lowered floor owes groups nobody asked for until now. - if (current.groups?.start !== undefined) { - const floor = lastSent.groups?.start === undefined ? entry.start : groupBounds(lastSent.groups).start; - entry.tail.demand(bounds.start, floor ?? Number.POSITIVE_INFINITY); + const start = rangesStart(ranges); + if (start !== undefined) { + const floor = rangesStart(wireRanges(lastSent.ranges ?? [{}])) ?? entry.start; + entry.tail.demand(start, floor ?? Number.POSITIVE_INFINITY); } // Round-trip the other Subscribe parameters so the publisher doesn't @@ -1614,8 +1606,9 @@ export class Subscriber { priority: current.priority ?? 0, order: current.order, maxDelay: wireDelay(current), - startGroup: current.groups?.start === undefined ? undefined : bounds.start, - endGroup: inclusiveGroupEnd(bounds.end), + startGroup: span.start, + endGroup: inclusiveGroupEnd(span.end), + ranges, }); // Encoded apart from the write, so a bound the wire can't carry still fails the track. const encoded = await Writer.collect(this.version, (w) => update.encode(w, this.version)); @@ -1627,29 +1620,30 @@ export class Subscriber { return; } if (refresh !== undefined) sentWanted = refresh; - entry.bounded ||= update.endGroup !== undefined; + entry.bounded ||= bounded(ranges); lastSent = { ...current }; console.debug(`subscribe update: id=${id} broadcast=${broadcast} track=${track.name}`); } } #sameSubscription(a: track.Subscription, b: track.Subscription): boolean { - const ag = groupBounds(a.groups); - const bg = groupBounds(b.groups); - // `groupBounds` reads an omitted start as 0. A pre-06 wire tells them apart: omitted - // joins at the publisher's start, and 0 is group 0. Lite-06 encodes both as 0. - const sameStart = - resolvesStart(this.version) || (a.groups?.start === undefined) === (b.groups?.start === undefined); + const ar = wireRanges(a.ranges ?? [{}]); + const br = wireRanges(b.ranges ?? [{}]); + let sameRanges: boolean; + if (hasRanges(this.version)) sameRanges = JSON.stringify(ar) === JSON.stringify(br); + else { + const as = wireSpan(ar); + const bs = wireSpan(br); + // A lite-06 `Group Start` of 0 is no floor, so the two spell the same; a pre-06 wire tells + // them apart: omitted joins at the publisher's start, and 0 is group 0. + const start = resolvesStart(this.version) + ? (span: typeof as) => span.start ?? 0 + : (span: typeof as) => span.start; + sameRanges = start(as) === start(bs) && as.end === bs.end; + } // A wire without `Group Order` cannot carry a change to it. const sameOrder = !hasOrder(this.version) || (a.order ?? "descending") === (b.order ?? "descending"); - return ( - (a.priority ?? 0) === (b.priority ?? 0) && - sameOrder && - wireDelay(a) === wireDelay(b) && - sameStart && - ag.start === bg.start && - ag.end === bg.end - ); + return (a.priority ?? 0) === (b.priority ?? 0) && sameOrder && wireDelay(a) === wireDelay(b) && sameRanges; } /** diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index 1fdcbea68e4..3f70fe6edc7 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -8,7 +8,7 @@ import { createMockTransportPair } from "../mock.ts"; import * as Path from "../path.ts"; import { Reader, Stream } from "../stream.ts"; import { Timescale } from "../time.ts"; -import type { Groups } from "../track.ts"; +import type { Range } from "../track.ts"; import { Group as GroupMessage } from "./group.ts"; import { StreamId } from "./stream.ts"; import { @@ -54,10 +54,13 @@ function groupStream(subscriber: Subscriber, sequence: number) { * it answers TRACK_INFO, then writes whatever responses the test asks for on the subscribe * stream and FINs it when told. */ -async function subscribed(version: Version, maxDelay = GRACE, groups?: Groups) { +async function subscribed(version: Version, maxDelay = GRACE, range?: Range) { const pair = createMockTransportPair(ALPN_05); const subscriber = new Subscriber(pair.client, version, randomHop()); - const reader = subscriber.consume(Path.from("room")).track("video").subscribe({ maxDelay, groups }); + const reader = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ maxDelay, ranges: range && [range] }); const info = await Stream.accept(pair.server, version); if (!info) throw new Error("the subscriber never asked for TRACK_INFO"); @@ -110,7 +113,7 @@ async function settlesWithin(promise: Promise, ms: number): Promise { test("the source start waits for SUBSCRIBE_START where it resolves the start", async () => { - const { reader, respond } = await subscribed(version, GRACE, { start: { included: 2 } }); + const { reader, respond } = await subscribed(version, GRACE, { start: { absolute: 2 } }); expect(hooks.sourceStart(reader)).toBe(resolvesStart(version) ? "pending" : undefined); await respond({ start: new SubscribeStart(3) }); await settle(); @@ -344,7 +347,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver test("a floor below SUBSCRIBE_START owes nothing below it", async () => { const { subscriber, reader, respond, fin } = await subscribed(version, Milli(60_000), { - start: { included: 1 }, + start: { absolute: 1 }, }); await respond({ start: new SubscribeStart(3) }); const group = groupStream(subscriber, 3); @@ -360,7 +363,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver test("a lowered floor owes the groups it newly asked for", async () => { const maxDelay = Milli(60_000); - const { subscriber, respond, fin } = await subscribed(version, maxDelay, { start: { included: 3 } }); + const { subscriber, respond, fin } = await subscribed(version, maxDelay, { start: { absolute: 3 } }); await respond({ start: new SubscribeStart(3) }); const group = groupStream(subscriber, 3); group.write("3.0"); @@ -371,7 +374,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const lower = subscriber .consume(Path.from("room")) .track("video") - .subscribe({ maxDelay, groups: { start: { included: 1 } } }); + .subscribe({ maxDelay, ranges: [{ start: { absolute: 1 } }] }); await respond({ end: new SubscribeEnd(4) }); await fin(); // Long enough for a subscription that owed nothing more to have ended. diff --git a/js/net/src/order.test.ts b/js/net/src/order.test.ts index 79e1e89bc63..7711ef16916 100644 --- a/js/net/src/order.test.ts +++ b/js/net/src/order.test.ts @@ -95,12 +95,12 @@ async function backlog(protocol: string, order: Order, update?: Order) { const subscriber = remote.track("video").subscribe({ maxDelay: MAX_DELAY, order, - groups: { start: { included: 0 } }, + ranges: [{ start: { absolute: 0 } }], }); await receive(subscriber, groups.length); if (update !== undefined) { - subscriber.update({ maxDelay: MAX_DELAY, order: update, groups: { start: { included: 0 } } }); + subscriber.update({ maxDelay: MAX_DELAY, order: update, ranges: [{ start: { absolute: 0 } }] }); await aggregate(video, (upstream) => upstream === update); } diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 24c7b143c0f..ec23328e1c5 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -193,7 +193,7 @@ test("a routed source start follows the serving copy and its subscriber floor", hooks.startTrack(tb.producer, { start: 5, requested: 3 }); const front = new Front(); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ groups: { start: { included: 3 } } }); + const sub = front.consumer.track("seq").subscribe({ ranges: [{ start: { absolute: 3 } }] }); try { expect(hooks.sourceStart(sub)).toBe(3); const subscribed = tb.producer.subscription.changed(); @@ -270,7 +270,7 @@ test("a resumed fork has its own preferences and replay cursor", async () => { front.serve(a.consume()); const parent = front.consumer.track("seq").subscribe({ priority: 2, - groups: { start: { included: 1 } }, + ranges: [{ start: { absolute: 1 } }], }); ta.write(0); ta.write(1); @@ -1865,7 +1865,7 @@ test("the largest honours the reader's bounds on the serving copy", async () => .track("seq") .subscribe() .withRange({ end: { excluded: 3 } }); - const floored = front.consumer.track("seq").subscribe({ groups: { start: { included: 8 } } }); + const floored = front.consumer.track("seq").subscribe({ ranges: [{ start: { absolute: 8 } }] }); for (let sequence = 0; sequence < 5; sequence++) ta.write(sequence); const next = capped.largest(); @@ -2527,7 +2527,7 @@ test.each([ } return sequences; }; - const asked = () => tb.producer.subscription.peek()?.groups?.start?.included; + const asked = () => tb.producer.subscription.peek()?.ranges?.[0]?.start?.absolute; try { sub.withRange({ start: { included: 4 } }); await clock.advance(Nano.zero); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 2a58627077a..fe49852b2ad 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -43,7 +43,7 @@ * * @module */ -import { Instant, Milli, now, sleepUntil } from "@moq/clock"; +import { Instant, type Milli, now, sleepUntil } from "@moq/clock"; import { type Dispose, type Getter, race, Signal } from "@moq/signals"; import * as broadcast from "./broadcast.ts"; import { Delivered } from "./delivered.ts"; @@ -51,10 +51,9 @@ import { NotFound, SessionError, StreamCode, StreamError, sessionClosed } from " import type { Consumer as GroupConsumer } from "./group.ts"; import { type DropRun, - finalSequence, delayBudget, + finalSequence, type GroupRecover, - groupBounds, hooks, insertDrop, insertRun, @@ -70,6 +69,7 @@ import { import { grace } from "./ledger.ts"; import type { Lister, Tracks, TracksEvent } from "./listing.ts"; import * as track from "./track.ts"; +import { clamped } from "./window.ts"; import { type Broadcast as BroadcastWire, registerWire, wireOf } from "./wire.ts"; // Stream codes that report the route failing to deliver rather than judging what it carried: @@ -464,17 +464,9 @@ class Reader implements ResumeSource { /** What `copy` is asked for: the reader's own preferences, from its floor and through its replacement. */ #wire(copy: Pick): track.Subscription { const preferences = this.#shell.subscription.peek() ?? {}; - const bounds = groupBounds(preferences.groups); - const start = Math.max(bounds.start, copy.floor ?? 0); - const replaced = copyEnd(copy); - const end = replaced === undefined ? bounds.end : Math.min(bounds.end ?? replaced, replaced); - return { - ...preferences, - groups: { - start: start > 0 ? { included: start } : undefined, - end: end === undefined ? undefined : { excluded: end }, - }, - }; + const floor = copy.floor ? copy.floor : undefined; + // The copies resolve relative anchors against the groups they hold. + return { ...preferences, ranges: clamped(preferences.ranges ?? [{}], floor, copyEnd(copy), []) }; } #update(copy: Copy): void { @@ -912,15 +904,15 @@ class Reader implements ResumeSource { } /** The reader's budget: how far behind a group may fall before it is skipped. */ - #budget(): Milli { - return Milli(delayBudget(this.#shell.subscription.peek())); + #budget(): number { + return delayBudget(this.#shell.subscription.peek()); } /** * Whether `within` passed since `copy` was superseded; see {@link Copy.since}. Arms a wakeup * for then, so a read waiting on the copy looks again. */ - #expired(copy: Copy, within: Milli): boolean { + #expired(copy: Copy, within: number): boolean { if (copy.since === undefined) return false; // Plain arithmetic: an unenforced budget is infinite, which no duration brand holds. const at = copy.since + within * 1_000_000; diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index b430a468dc2..daa84bd63b2 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -185,7 +185,7 @@ test("joins without refresh ask for no group", () => { test("a fixed start is no join", () => { const producer = new TrackProducer("test").accept({ refresh: 1 }); refreshGroup(producer, 4); - const subscriber = producer.subscribe({ groups: { start: { included: 0 } } }); + const subscriber = producer.subscribe({ ranges: [{ start: { absolute: 0 } }] }); expect(producer.demand().wanted.peek()).toBe(0); subscriber.close(); }); @@ -394,14 +394,14 @@ test("appendDatagram rejects a payload over the QUIC datagram frame ceiling", () test("subscriber options and updates are forwarded to the producer's aggregate", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ groups: { start: { included: 0 } } }); + const track = producer.subscribe({ ranges: [{ start: { absolute: 0 } }] }); // The initial options are available before the request is accepted or put on the wire. expect(producer.subscription.peek()).toEqual({ priority: 0, maxDelay: Milli(0), order: "descending", - groups: { start: { included: 0 } }, + ranges: [{ start: { absolute: 0 } }], }); // The wire layer watches the producer's signal to emit SUBSCRIBE_UPDATE. @@ -410,13 +410,13 @@ test("subscriber options and updates are forwarded to the producer's aggregate", priority: 7, maxDelay: Milli(250), order: "ascending", - groups: { start: { included: 2 }, end: { excluded: 9 } }, + ranges: [{ start: { absolute: 2 }, end: { absolute: 9 } }], }); expect(await next).toEqual({ priority: 7, maxDelay: Milli(250), order: "ascending", - groups: { start: { included: 2 }, end: { excluded: 9 } }, + ranges: [{ start: { absolute: 2 }, end: { absolute: 9 } }], }); }); @@ -506,9 +506,9 @@ test("multiple subscriber options aggregate like Rust", async () => { priority: 2, maxDelay: Milli(100), order: "ascending", - groups: { start: { included: 10 }, end: { excluded: 20 } }, + ranges: [{ start: { absolute: 10 }, end: { absolute: 20 } }], }); - const live = producer.subscribe({ priority: 7, maxDelay: Milli(250), groups: { start: { included: 5 } } }); + const live = producer.subscribe({ priority: 7, maxDelay: Milli(250), ranges: [{ start: { absolute: 5 } }] }); // One descending subscriber makes the aggregate descending, so a live reader is never held // behind another's backlog. @@ -516,7 +516,7 @@ test("multiple subscriber options aggregate like Rust", async () => { priority: 7, maxDelay: Milli(250), order: "descending", - groups: { start: { included: 5 } }, + ranges: [{ start: { absolute: 5 } }], }); const narrowed = producer.subscription.changed(); @@ -525,7 +525,7 @@ test("multiple subscriber options aggregate like Rust", async () => { priority: 2, maxDelay: Milli(100), order: "ascending", - groups: { start: { included: 10 }, end: { excluded: 20 } }, + ranges: [{ start: { absolute: 10 }, end: { absolute: 20 } }], }); const none = producer.subscription.changed(); @@ -533,13 +533,77 @@ test("multiple subscriber options aggregate like Rust", async () => { expect(await none).toBeUndefined(); }); +test("each reader reads only its own ranges, whatever the union asks for", () => { + const producer = new TrackProducer("test"); + const sparse = producer.subscribe({ + maxDelay: null, + ranges: [ + { start: { absolute: 1 }, end: { absolute: 3 } }, + { start: { absolute: 5 }, end: { absolute: 6 } }, + ], + }); + const tail = producer.subscribe({ maxDelay: null, ranges: [{ start: { absolute: 4 } }] }); + expect(producer.subscription.peek()?.ranges).toEqual([ + { start: { absolute: 1 }, end: { absolute: 3 } }, + { start: { absolute: 4 } }, + ]); + for (let sequence = 0; sequence < 7; sequence++) producer.createGroup(sequence).finish(); + + const read = (subscriber: typeof sparse) => { + const got: number[] = []; + for (let group = subscriber.tryRecvGroup(); group; group = subscriber.tryRecvGroup()) got.push(group.sequence); + return got; + }; + expect(read(sparse)).toEqual([1, 2, 5]); + expect(read(tail)).toEqual([4, 5, 6]); + + // Replacing the ranges offers the held groups the new ones take in, never one read already. + sparse.update({ maxDelay: null, ranges: [{ start: { absolute: 0 }, end: { absolute: 5 } }] }); + expect(read(sparse)).toEqual([0, 3, 4]); + producer.finish(); + expect(hooks.tryRecvGroup(sparse, false)).toEqual({ kind: "done" }); +}); + +test("a relative range counts back from the latest group, or waits for the first", () => { + const producer = new TrackProducer("test"); + for (let sequence = 0; sequence < 5; sequence++) producer.createGroup(sequence).finish(); + const back = producer.subscribe({ maxDelay: null, ranges: [{ start: { relative: 2 }, end: { relative: 1 } }] }); + expect([back.tryRecvGroup()?.sequence, back.tryRecvGroup()?.sequence, back.tryRecvGroup()]).toEqual([ + 2, + 3, + undefined, + ]); + + const empty = new TrackProducer("test"); + const live = empty.subscribe({ maxDelay: null, ranges: [{ start: { relative: 0 } }] }); + empty.createGroup(7).finish(); + empty.createGroup(6).finish(); + empty.createGroup(8).finish(); + // Resolved at the first group to arrive: group 6 sits below where it started. + expect([live.tryRecvGroup()?.sequence, live.tryRecvGroup()?.sequence, live.tryRecvGroup()]).toEqual([ + 7, + 8, + undefined, + ]); +}); + +test("a copy over a wire without ranges refuses a relative anchor it cannot carry", () => { + const producer = new TrackProducer("test"); + const back = producer.subscribe({ ranges: [{ start: { relative: 3 } }] }); + const live = producer.subscribe({ ranges: [{ start: { relative: 0 } }] }); + hooks.dropAnchors(producer); + producer.createGroup(0).finish(); + expect(() => back.tryRecvGroup()).toThrow("moq-lite-07"); + expect(live.tryRecvGroup()?.sequence).toBe(0); +}); + // A resume floor above the live edge must not hide the latest group from a reader that // joins after it: on a quiet track that group may be the only one for a long time. test("an unfloored subscriber clears a resume floor above the live edge", () => { const producer = new TrackProducer("test"); - producer.subscribe({ groups: { start: { included: 4 } } }); + producer.subscribe({ ranges: [{ start: { absolute: 4 } }] }); producer.subscribe({}); - expect(producer.subscription.peek()?.groups?.start).toBeUndefined(); + expect(producer.subscription.peek()?.ranges).toEqual([{}]); }); test("the producer aggregate is clamped without changing subscriber options", async () => { @@ -870,13 +934,13 @@ test("a named start is a floor, not a request", async () => { // The budget is the only thing that asks for data; a named start only bounds how far // back it may reach. - const floored = producer.subscribe({ groups: { start: { included: 3 } }, maxDelay: Milli(60_000) }); + const floored = producer.subscribe({ ranges: [{ start: { absolute: 3 } }], maxDelay: Milli(60_000) }); expect((await floored.recvGroup())?.sequence).toBe(3); expect((await floored.recvGroup())?.sequence).toBe(4); // Naming group 1 at real time still delivers the live edge alone: the zero budget // calls everything older stale. - const named = producer.subscribe({ groups: { start: { included: 1 } } }); + const named = producer.subscribe({ ranges: [{ start: { absolute: 1 } }] }); expect((await named.recvGroup())?.sequence).toBe(4); }); @@ -1650,7 +1714,7 @@ test.each(["arrival", "ordered"] as const)("subscription bounds stay reader-loca const everything = producer.subscribe({ maxDelay: Milli(5000) }); const subscriber = producer.subscribe({ maxDelay: Milli(5000), - groups: { start: { included: 1 }, end: { excluded: 3 } }, + ranges: [{ start: { absolute: 1 }, end: { absolute: 3 } }], }); const ordered = order === "ordered" ? subscriber.ordered() : undefined; const read = () => (ordered ? ordered.nextGroup() : subscriber.recvGroup()); @@ -1661,11 +1725,11 @@ test.each(["arrival", "ordered"] as const)("subscription bounds stay reader-loca expect((await read())?.sequence).toBe(2); const pending = read(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); - subscriber.update({ maxDelay: Milli(5000), groups: { start: { included: 3 }, end: { excluded: 4 } } }); + subscriber.update({ maxDelay: Milli(5000), ranges: [{ start: { absolute: 3 }, end: { absolute: 4 } }] }); expect((await pending)?.sequence).toBe(3); const capped = read(); expect(await Promise.race([capped, Promise.resolve("pending")])).toBe("pending"); - subscriber.update({ maxDelay: Milli(5000), groups: { end: { included: 4 } } }); + subscriber.update({ maxDelay: Milli(5000), ranges: [{ end: { absolute: 5 } }] }); // Arrival readers can now offer the unread group below the old floor; ordered ones cannot rewind. if (!ordered) expect((await capped)?.sequence).toBe(0); expect((await (ordered ? capped : read()))?.sequence).toBe(4); @@ -1686,7 +1750,7 @@ test("subscription updates narrow the cursor before its first read", async () => const track = producer.subscribe({ maxDelay: Milli(5000) }); try { for (let sequence = 0; sequence < 5; sequence++) producer.createGroup(sequence); - track.update({ maxDelay: Milli(5000), groups: { start: { excluded: 2 }, end: { included: 3 } } }); + track.update({ maxDelay: Milli(5000), ranges: [{ start: { absolute: 3 }, end: { absolute: 4 } }] }); expect((await track.recvGroup())?.sequence).toBe(3); const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); @@ -1703,7 +1767,7 @@ test("subscription updates narrow the cursor before its first read", async () => test("a subscription cap chooses its own live edge", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); const everything = producer.subscribe({ maxDelay: Milli(5000) }); - const track = producer.subscribe({ groups: { end: { excluded: 2 } } }); + const track = producer.subscribe({ ranges: [{ end: { absolute: 2 } }] }); try { for (let sequence = 0; sequence < 5; sequence++) { producer.writeFrame({ @@ -1712,7 +1776,7 @@ test("a subscription cap chooses its own live edge", async () => { }); } expect((await track.recvGroup())?.sequence).toBe(1); - track.update({ groups: { start: { included: 3 }, end: { excluded: 4 } } }); + track.update({ ranges: [{ start: { absolute: 3 }, end: { absolute: 4 } }] }); expect((await track.recvGroup())?.sequence).toBe(3); } finally { track.close(); @@ -1725,12 +1789,12 @@ test("a subscription cap chooses its own live edge", async () => { test("subscription updates bound each reader's datagrams", async () => { const producer = new TrackProducer("test"); const everything = producer.subscribe({ maxDelay: Milli(5000) }); - const track = producer.subscribe({ groups: { start: { included: 1 }, end: { excluded: 2 } } }); + const track = producer.subscribe({ ranges: [{ start: { absolute: 1 }, end: { absolute: 2 } }] }); try { for (let sequence = 0; sequence < 4; sequence++) producer.insertDatagram(sequence, undefined, new Uint8Array([sequence])); expect((await track.recvDatagram())?.sequence).toBe(1); - track.update({ groups: { start: { included: 3 }, end: { excluded: 4 } } }); + track.update({ ranges: [{ start: { absolute: 3 }, end: { absolute: 4 } }] }); for (let sequence = 2; sequence < 5; sequence++) producer.insertDatagram(sequence, undefined, new Uint8Array([sequence])); expect((await track.recvDatagram())?.sequence).toBe(3); @@ -1749,8 +1813,7 @@ test("subscription updates bound each reader's datagrams", async () => { test("withRange with an excluded end of 0 is the empty range and does not ride another subscriber's demand", async () => { const producer = new TrackProducer("test"); const everything = producer.subscribe({ maxDelay: Milli(5000) }); - const empty = producer.subscribe({ maxDelay: Milli(5000), groups: { end: { excluded: 0 } } }); - empty.withRange({ end: { excluded: 0 } }); + const empty = producer.subscribe({ maxDelay: Milli(5000) }).withRange({ end: { excluded: 0 } }); for (let sequence = 0; sequence < 3; sequence++) producer.createGroup(sequence); @@ -2405,7 +2468,7 @@ test("group ranges preserve progress and spell out inclusion", async () => { // Mirrors the Rust `a_lowered_range_reoffers_the_unread_cached_groups`. test("a lowered range re-offers the unread cached groups", () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 10 } } }); + const track = producer.subscribe({ maxDelay: Milli(5000) }).withRange({ start: { included: 10 } }); for (const sequence of [5, 6, 10]) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(10); expect(track.tryRecvGroup()).toBeUndefined(); @@ -2423,7 +2486,7 @@ test("a lowered range re-offers the unread cached groups", () => { test("a lowered range re-offers a cached cut with its error", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); + const track = producer.subscribe({ maxDelay: Milli(5000) }).withRange({ start: { included: 1 } }); const cut = producer.createGroup(0); const error = new StreamError(StreamCode(70), { message: "cut" }); cut.abort(error); @@ -2441,7 +2504,7 @@ test("a lowered range re-offers a cached cut with its error", async () => { test("a lowered range still re-offers after the producer closes", () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); + const track = producer.subscribe({ maxDelay: Milli(5000) }).withRange({ start: { included: 1 } }); for (const sequence of [0, 1]) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(1); producer.finish(); @@ -2456,7 +2519,7 @@ test("a lowered range still re-offers after the producer closes", () => { for (const abort of [undefined, new Error("gone")]) { test(`a lowered range offers nothing to a reader that ${abort ? "aborted" : "closed"}`, () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); + const track = producer.subscribe({ maxDelay: Milli(5000) }).withRange({ start: { included: 1 } }); for (const sequence of [0, 1]) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(1); track.close(abort); @@ -3013,7 +3076,7 @@ test("an untimed subscriber starts at the oldest fresh group", () => { test("an untimed subscriber without a start begins at the latest group under its end", () => { const producer = untimedTrack(5); // As the lite publisher serves a SUBSCRIBE with an end and no start. - const subscriber = producer.subscribe({ groups: { end: { excluded: 3 } } }); + const subscriber = producer.subscribe({ ranges: [{ end: { absolute: 3 } }] }); subscriber.withRange({ end: { excluded: 3 } }); expect(drain(subscriber)).toEqual([2]); }); @@ -3051,9 +3114,9 @@ test("a single-frame write that throws aborts its group", () => { test("an explicit start floors an untimed track", () => { const producer = untimedTrack(5); - const resumed = producer.subscribe({ maxDelay: Milli(60_000), groups: { start: { included: 2 } } }); + const resumed = producer.subscribe({ maxDelay: Milli(60_000), ranges: [{ start: { absolute: 2 } }] }); expect(drain(resumed)).toEqual([2, 3, 4]); - const live = producer.subscribe({ groups: { start: { included: 2 } } }); + const live = producer.subscribe({ ranges: [{ start: { absolute: 2 } }] }); expect(drain(live)).toEqual([4]); }); @@ -3126,7 +3189,7 @@ test("an upstream drop ends a group still arriving with its error", async () => test("a drop below the window is reported once the window lowers to it", () => { const producer = new TrackProducer("video").accept({}); - const reader = producer.subscribe({ maxDelay: Milli(60_000), groups: { start: { included: 5 } } }); + const reader = producer.subscribe({ maxDelay: Milli(60_000) }).withRange({ start: { included: 5 } }); hooks.dropTrackGroups(producer, 2, 3, DROPPED); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); @@ -3578,11 +3641,11 @@ test("without a declared start, the first arrival stands in for it", async () => test("a source start follows the current requested floor after an update", async () => { const producer = new TrackProducer("test"); - const reader = producer.subscribe({ groups: { start: { included: 5 } } }); + const reader = producer.subscribe({ ranges: [{ start: { absolute: 5 } }] }); try { hooks.startTrack(producer, { start: 6, requested: 5 }); expect(hooks.sourceStart(reader)).toBe(6); - const lower = producer.subscribe({ groups: { start: { included: 2 } } }); + const lower = producer.subscribe({ ranges: [{ start: { absolute: 2 } }] }); try { expect(hooks.sourceStart(lower)).toBe(2); expect(hooks.sourceStart(reader)).toBe(5); @@ -3593,7 +3656,7 @@ test("a source start follows the current requested floor after an update", async } expect(hooks.sourceStart(reader)).toBe(6); const raised = producer.subscription.changed(); - reader.update({ groups: { start: { included: 9 } } }); + reader.update({ ranges: [{ start: { absolute: 9 } }] }); await raised; expect(hooks.sourceStart(reader)).toBe(9); } finally { @@ -3607,7 +3670,7 @@ test("a source start follows the current requested floor after an update", async // keeps that floor: a lowered floor owes the groups it newly asks for. test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor", async () => { const producer = new TrackProducer("test"); - const reader = producer.subscribe({ maxDelay: Milli(60_000), groups: { start: { included: 1 } } }); + const reader = producer.subscribe({ maxDelay: Milli(60_000), ranges: [{ start: { absolute: 1 } }] }); hooks.startTrack(producer, { start: 3, requested: 1 }); hooks.endTrack(producer, { group: 4, frame: 0 }); const group = producer.createGroup(3); @@ -3615,7 +3678,7 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" expect((await reader.recvGroup())?.sequence).toBe(3); expect(await reader.recvGroup()).toBeUndefined(); - const lower = producer.subscribe({ maxDelay: Milli(60_000), groups: { start: { included: 0 } } }); + const lower = producer.subscribe({ maxDelay: Milli(60_000), ranges: [{ start: { absolute: 0 } }] }); expect((await lower.recvGroup())?.sequence).toBe(3); const next = lower.recvGroup(); expect(await settled(next)).toBe(false); @@ -3844,7 +3907,7 @@ test("a widened maxDelay re-offers nothing below a raised floor", () => { } expect(drainGroups(subscriber)).toEqual([3, 4]); - subscriber.update({ maxDelay: Milli(10_000), groups: { start: { included: 2 } } }); + subscriber.update({ maxDelay: Milli(10_000), ranges: [{ start: { absolute: 2 } }] }); expect(drainGroups(subscriber)).toEqual([2]); producer.close(); }); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 766c7d3ab78..5ac79215bae 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -40,8 +40,9 @@ import { type StagedTrack, type TrackRequestOptions, } from "./internal.ts"; -import { grace, Ledger } from "./ledger.ts"; +import { CACHE_EXPIRY, grace, Ledger } from "./ledger.ts"; import { Timescale, type Timestamp } from "./time.ts"; +import { isWire, joinsLive, rangesOf, union, Window, wireSpan } from "./window.ts"; import { type Broadcast as BroadcastWire, registerTrackConsumer } from "./wire.ts"; export type { Datagram } from "./datagram.ts"; @@ -57,9 +58,8 @@ function taken(read: () => Recv): GroupConsumer | undefined { } } -// Wall-clock bound for idle cached content, matching Rust cache::DEFAULT_EXPIRY. // Media maxDelay only bounds how far behind the live edge a reader may fall. -const CACHE_WINDOW = Nano.fromMilli(Milli(30_000)); +const CACHE_WINDOW = Nano.fromMilli(CACHE_EXPIRY); // The cache scans at most this many times per idle cache window. const PRUNE_SLICES = 8; @@ -207,7 +207,7 @@ function refreshRatio(value: number): number { /** An explicitly included or excluded group sequence. */ export type Bound = { included: number; excluded?: never } | { excluded: number; included?: never }; -/** Group limits; an omitted endpoint is unbounded. */ +/** Group limits for {@link Subscriber.withRange}; an omitted endpoint is unbounded. */ export interface Groups { /** The first group boundary. */ start?: Bound; @@ -215,6 +215,34 @@ export interface Groups { end?: Bound; } +/** + * Where one end of a {@link Range} falls: a fixed group (`absolute`), or a count of groups back + * from the latest one (`relative`, clamped to group 0, so `{ relative: 0 }` is the latest group). + * Mirrors the Rust `Anchor`. + * + * Every anchor names a whole group: a subscription always starts at a group's first frame. A + * relative anchor resolves at the hop that serves it, when it starts serving and again whenever + * the subscriber replaces its ranges. Before any group exists it resolves at the first one, so + * `{ start: { relative: 0 } }` starts there. Older moq-lite versions carry only absolute anchors, + * the live start, and the oldest start, refusing any other relative anchor. + */ +export type Anchor = { absolute: number; relative?: never } | { relative: number; absolute?: never }; + +/** + * A span of a track to deliver: from `start` up to `end`. Mirrors the Rust `Range`. + * + * An omitted start is the oldest group still around (Rust's `Range::oldest`), and an omitted end + * keeps following the live edge. An absolute end is exclusive, so `{absolute: 2}..{absolute: 6}` + * delivers groups 2 through 5; a relative end includes its group, so `{absolute: 0}..{relative: 0}` + * delivers through the latest group. + */ +export interface Range { + /** Where delivery starts, or the oldest group when omitted. */ + start?: Anchor; + /** Where delivery stops, or onward when omitted. */ + end?: Anchor; +} + /** * Per-subscription options, requested when a subscription opens and adjustable later via * {@link Subscriber.update}. Mirrors the Rust `Subscription`. @@ -236,14 +264,19 @@ export interface Subscription { */ maxDelay?: Milli | null; /** - * The groups this reader may receive, with an optional floor and cap. + * The spans of the track to deliver; see {@link Range}. * - * Only {@link maxDelay} asks for data; these bounds limit which groups it may reach. - * An omitted start means 0, and an omitted end leaves the range open. A floor above - * the live edge waits there (a resumed subscription naming where it left off). - * The latency budget uses the latest eligible group within these bounds. + * Defaults to `[{}]`: everything {@link maxDelay} still considers fresh. + * `[{ start: { relative: 0 } }]` starts at the latest group instead, and an empty list asks + * for nothing. {@link maxDelay} caps every range: a range names what may be delivered, and + * the budget skips what inside it has gone stale. + * + * A request to the publisher, aggregated across every live subscriber as the union of their + * ranges, and this subscriber's read filter: it reads only the groups its own ranges cover, + * whatever another subscriber widens the union to. {@link Subscriber.withRange} narrows the + * reads further. */ - groups?: Groups; + ranges?: Range[]; /** * Which pending group the publisher sends first. Defaults to `"descending"`. * @@ -272,15 +305,12 @@ export type Order = "ascending" | "descending"; // subscription rather than interpreting an omitted field differently. function subscriptionDefaults(subscription: Subscription = {}): Subscription { if ("maxAge" in subscription) throw new TypeError("Subscriber maxAge is unsupported; use maxDelay"); - const bounds = groupBounds(subscription.groups ?? {}); + if ("groups" in subscription) throw new TypeError("Subscription groups is unsupported; use ranges"); return { priority: priorityValue(subscription.priority ?? DEFAULT_PRIORITY), maxDelay: subscription.maxDelay === null ? null : wireMillis("maxDelay", subscription.maxDelay ?? Milli.zero), order: orderOf(subscription.order), - groups: { - start: subscription.groups?.start === undefined ? undefined : { included: bounds.start }, - end: bounds.end === undefined ? undefined : { excluded: bounds.end }, - }, + ranges: subscription.ranges === undefined ? [{}] : rangesOf(subscription.ranges), }; } @@ -311,18 +341,10 @@ function combineSubscriptions(states: Iterable): Subscription | unde combined.order = combined.order === "ascending" && subscription.order === "ascending" ? "ascending" : "descending"; - // A floor only restricts, so a subscriber without one clears the aggregate: - // its budget may reach below any floor the others set. - const a = groupBounds(combined.groups ?? {}); - const b = groupBounds(subscription.groups ?? {}); - combined.groups = { - start: - combined.groups?.start === undefined || subscription.groups?.start === undefined - ? undefined - : { included: Math.min(a.start, b.start) }, - end: a.end === undefined || b.end === undefined ? undefined : { excluded: Math.max(a.end, b.end) }, - }; + // A range above the live edge never hides the latest group from a merged live subscriber. + combined.ranges = [...(combined.ranges ?? []), ...(subscription.ranges ?? [])]; } + if (combined) combined.ranges = union(combined.ranges ?? []); return combined; } @@ -515,6 +537,8 @@ class TrackState { // First timestamps mutate group state rather than track state, so held groups // watch this revision as well as arrivals when enforcing latency after handoff. timelineChanged = new Signal(0); + // A copy received over a wire without ranges: see `hooks.dropAnchors`. + dropsAnchors = new Signal(false); // Bumped when a wire subscription declares or resolves where its live feed starts, so a // publisher holding its first group for that answer wakes. startChanged = new Signal(0); @@ -542,6 +566,9 @@ class TrackState { info = new Signal(undefined); /** Newest group head, datagram, frame, or location reported by the publisher. */ largest = new Signal(undefined); + // The group {@link largest} first named, where a relative anchor resolved before any group + // existed starts. + first?: number; // Set once {@link largest} can no longer advance while the track stays open. largestEnded = new Signal(false); @@ -635,11 +662,6 @@ let ordered_: { recvDatagram(subscriber: Subscriber): Promise; }; -// Whether a subscription joins at the live edge rather than resuming from a fixed group. -function joinsLive(subscription: Subscription | undefined): boolean { - return subscription?.groups?.start === undefined && subscription?.groups?.end === undefined; -} - // Constructs a Demand from within this module; assigned in the class's static block. let makeDemand: (name: string, wanted: Getter, state: TrackState) => Demand; @@ -790,6 +812,9 @@ export class Producer { hooks.trackReleased = (producer) => producer.#state.closed; hooks.trackFinished = (producer) => producer.#state.end.peek() !== undefined; hooks.dropTrackGroups = (producer, start, end, error) => producer.#drop(start, end, error, true); + hooks.dropAnchors = (producer) => { + for (const state of [producer.#state, ...producer.#sinks]) state.dropsAnchors.set(true); + }; hooks.startTrack = (producer, served) => { producer.#settle(); producer.#served = served; @@ -1010,11 +1035,13 @@ export class Producer { const info = this.#state.info.peek(); if (info) sink.info.set(info); sink.largest.set(this.#state.largest.peek()); + sink.first = this.#state.largest.peek()?.group; sink.largestEnded.set(this.#state.largestEnded.peek()); + sink.dropsAnchors.set(this.#state.dropsAnchors.peek()); const closed = this.#state.closed.peek(); if (closed === undefined) { - if (joinsLive(sink.update.peek())) this.#join(); + if (joinsLive(sink.update.peek()?.ranges)) this.#join(); this.#sinks.add(sink); this.#reportWanted(); @@ -1213,8 +1240,10 @@ export class Producer { const current = this.#state.largest.peek(); if (current && !after(largest, current)) return; this.#largestOpen = true; - this.#state.largest.set(largest); - for (const sink of this.#sinks) sink.largest.set(largest); + for (const state of [this.#state, ...this.#sinks]) { + state.first ??= largest.group; + state.largest.set(largest); + } } #advanceObject(largest: Position | undefined): void { @@ -1566,7 +1595,7 @@ export class Producer { // The grace the session's tail uses, from the max delay the track's subscribers ask for. #grace(): Milli { - return grace(Milli(delayBudget(this.#state.update.peek()))); + return grace(delayBudget(this.#state.update.peek())); } // Tell every reader whether the pending tail still owes a group below the end. @@ -1594,8 +1623,8 @@ export class Producer { // back restores the START. Without either, the first arrival stands in for it. // Until the START arrives, the feed start is pending; the requested floor still bounds it. #feedStart(): number | "pending" | undefined { - const groups = this.#state.update.peek()?.groups; - const requested = groups?.start === undefined ? undefined : groupBounds(groups).start; + const ranges = this.#state.update.peek()?.ranges; + const requested = ranges && wireSpan(ranges).start; const served = this.#served; if (!served || served.requested !== requested) return requested; if (served.start !== undefined) return served.start; @@ -1789,6 +1818,13 @@ export class Subscriber { #largestSeen?: Position; #largestDone = false; #nextSequence = 0; + // The local bounds {@link withRange} set. + #local: { start: number; end?: number } = { start: 0 }; + // This reader's own ranges resolved to the groups it reads, whatever another subscriber + // widens the aggregate to. Assigned in the constructor. + #window!: Window; + // The hull of what this reader reads now: its local bounds narrowed to the window's floor + // and end. A group inside it is read only if the window contains it too. #cursor = new Signal<{ start: number; end?: number }>({ start: 0 }); // A FETCH's own budget in place of the subscription's; unlike SUBSCRIBE's zero // default, an omitted one is unbounded, leaving publisher retention alone. @@ -1802,7 +1838,7 @@ export class Subscriber { // the track's drops last folded in, and every sequence already reported. Drops are // persistent track state, so they are tracked here rather than removed for everyone. #pending: DropRun[] = []; - #dropsSeen?: { drops: readonly DropRun[]; start: number; end?: number }; + #dropsSeen?: { drops: readonly DropRun[]; start: number; end?: number; window: Window }; #reported: Run[] = []; // The group the frame-level helpers are currently draining, acquired through the // sequence cursor so frame reads and {@link Ordered.nextGroup} share one floor. @@ -1830,7 +1866,7 @@ export class Subscriber { // stamped group's latest frame: walk back from the cap to the first one. for (let i = (end === undefined ? timeline.length : timelineIndex(timeline, end)) - 1; i >= 0; i--) { const group = timeline[i]; - if (group.closed.peek() instanceof Error) continue; + if (group.closed.peek() instanceof Error || !this.#window.contains(group.sequence)) continue; const timestamp = hooks.groupTimestamp(group); if (timestamp === undefined) continue; presentation = { sequence: group.sequence, timestamp: hooks.groupLatest(group) ?? timestamp }; @@ -1877,7 +1913,7 @@ export class Subscriber { for (let i = timelineIndex(timeline, sequence + 1); i < timeline.length; i++) { const successor = timeline[i]; if (end !== undefined && successor.sequence >= end) break; - if (successor.closed.peek() instanceof Error) continue; + if (successor.closed.peek() instanceof Error || !this.#window.contains(successor.sequence)) continue; if (counts(successor)) return successor; } return undefined; @@ -2112,7 +2148,47 @@ export class Subscriber { this.name = name; this.#state = state; // Each reader filters locally even when another reader widens upstream demand. - this.#cursor.set(groupBounds(state.update.peek()?.groups)); + this.#resolve(); + } + + // The latest group to count relative anchors back from. A reader following route copies + // leaves them to the copies, which hold the groups. + #latestGroup(): number | undefined { + return this.#resume ? undefined : this.#state.largest.peek()?.group; + } + + // Resolve this reader's ranges again, as when they are replaced. + #resolve(): void { + this.#window = new Window(this.#state.update.peek()?.ranges ?? [{}], this.#latestGroup()); + this.#bound(); + } + + // Resolve a relative anchor still waiting on a first group, once one exists. Returns the + // refusal of ranges this copy's wire cannot carry, which every read reports. + #settle(): Error | undefined { + if (this.#state.dropsAnchors.peek() && !this.#window.ranges.every(isWire)) { + return new Error("a relative anchor other than the live start needs moq-lite-07"); + } + // The live feed had no group when the ranges resolved, so the newest one then was the first. + if (this.#window.pending && !this.#resume && this.#window.settle(this.#state.first)) this.#bound(); + return undefined; + } + + // Narrow the local bounds to the window into the hull every read starts from. + #bound(): void { + const end = this.#window.end(); + const local = this.#local; + this.#cursor.set({ + start: Math.max(local.start, this.#window.floor() ?? 0), + end: end === undefined ? local.end : Math.min(local.end ?? end, end), + }); + } + + // Whether a held group waits on the local cap rising: one past it may still be read. A + // group outside the window alone never holds a finished track open. + #parked(groups: readonly GroupConsumer[]): boolean { + const cap = this.#local.end; + return cap !== undefined && (groups.at(-1)?.sequence ?? -1) >= cap; } static { @@ -2132,6 +2208,12 @@ export class Subscriber { }; hooks.tryRecvGroup = (subscriber, dispositions) => subscriber.#tryRecvGroup(dispositions); hooks.tryDrop = (subscriber) => subscriber.#tryDrop(); + hooks.servingRuns = (subscriber, start, end) => { + const cursor = subscriber.#cursor.peek(); + return subscriber.#window.runs(Math.max(start, cursor.start), Math.min(end, cursor.end ?? end)); + }; + hooks.servingEnd = (subscriber) => + subscriber.#window.end() === undefined ? undefined : subscriber.#cursor.peek().end; hooks.tryNextGroup = (subscriber) => subscriber.#tryNextGroup(); hooks.peekGroup = (subscriber, ordered) => subscriber.#peekGroup(ordered); hooks.forgetPastBudget = (subscriber, delivered) => subscriber.#forgetPastBudget(delivered); @@ -2174,7 +2256,8 @@ export class Subscriber { .some( (datagram) => datagram.sequence >= cursor.start && - (cursor.end === undefined || datagram.sequence < cursor.end), + (cursor.end === undefined || datagram.sequence < cursor.end) && + subscriber.#window.contains(datagram.sequence), ); return ( (state.closed.peek() !== undefined || subscriber.#complete()) && @@ -2412,14 +2495,21 @@ export class Subscriber { * the route that delivered them may be gone. {@link largest} reports within these bounds. */ withRange(groups: Groups): this { - const { start, end } = groupBounds(groups); const before = this.#cursor.peek(); - this.#cursor.set({ start, end }); + this.#local = groupBounds(groups); + this.#bound(); + const { start } = this.#cursor.peek(); const producer = this.#state.producer; if (!this.#resume && !this.#closed && producer && start < before.start) { // The ordered cursor let go of what it passed below the old floor; the arrival // cursor keeps it buffered, which this skips. - reofferCached(producer, this.#state, start, before.start, (sequence) => !this.#delivered.has(sequence)); + reofferCached( + producer, + this.#state, + start, + before.start, + (sequence) => !this.#delivered.has(sequence) && this.#window.contains(sequence), + ); } return this; } @@ -2506,6 +2596,8 @@ export class Subscriber { // control state, popping the group, and positioning its frames are one JavaScript turn. #tryRecvGroup(dispositions = false): Recv { if (this.#resume) return this.#tryResumed(false, dispositions); + const refused = this.#settle(); + if (refused) return { kind: "error", error: refused }; const groups = this.#state.groups.peek(); const { start, end } = this.#cursor.peek(); const old = () => new StreamError(StreamCode.Old, { message: "group is past the budget" }); @@ -2519,13 +2611,18 @@ export class Subscriber { // Keep unread groups below the floor until cache eviction: replacing the // subscription can lower it, just as raising the cap re-offers held groups. Found // after the re-offer, which may insert below the floor. - const index = timelineIndex(groups, start); + let index = timelineIndex(groups, start); for (;;) { // The buffer is sequence-sorted, so an in-range group that arrives behind a // beyond-cap one sorts in front of it and is never blocked by it. const group = groups[index]; if (!group || (end !== undefined && group.sequence >= end)) break; + // Kept, like a group below the floor: replacing the ranges can take it in. + if (!this.#window.contains(group.sequence)) { + index++; + continue; + } groups.splice(index, 1); // A sequence this cursor already reported dropped stays dropped: the subscriber // accounted for it, so a group re-created under it is not served. @@ -2561,22 +2658,24 @@ export class Subscriber { return { kind: "group", group: this.#guard(group) }; } - const group = groups[index]; const closed = this.#state.closed.peek(); if (closed instanceof Error) return { kind: "error", error: closed }; if (closed === undefined && !this.#complete()) return { kind: "idle" }; // A group beyond the cap outlives a clean close: it becomes deliverable if // the cap rises, so the track isn't over while any are held. - return group ? { kind: "boundary" } : { kind: "done" }; + return this.#parked(groups) ? { kind: "boundary" } : { kind: "done" }; } // The first part of `[from, to)` this cursor has not reported yet, now reported, with why. #report(from: number, to: number, error: Error): (Recv & { kind: "drop" }) | undefined { const { start, end } = this.#cursor.peek(); - const [gap] = runGaps(this.#reported, Math.max(from, start), Math.min(to, end ?? to)); - if (!gap) return undefined; - insertRun(this.#reported, gap.start, gap.end); - return { kind: "drop", ...gap, error }; + for (const run of this.#window.runs(Math.max(from, start), Math.min(to, end ?? to))) { + const [gap] = runGaps(this.#reported, run.start, run.end); + if (!gap) continue; + insertRun(this.#reported, gap.start, gap.end); + return { kind: "drop", ...gap, error }; + } + return undefined; } // The next run this cursor gave up and has not reported, without taking a group. From the @@ -2589,13 +2688,15 @@ export class Subscriber { if (dropped) this.#delivered.addRun(dropped.start, dropped.end); return dropped; } + this.#settle(); const { start, end } = this.#cursor.peek(); // Rescanned only after it changed, so an idle poll costs nothing per drop held. // Out-of-window runs were skipped, so a moved window rescans too. const drops = this.#state.drops.peek(); + const window = this.#window; const seen = this.#dropsSeen; - if (!seen || seen.drops !== drops || seen.start !== start || seen.end !== end) { - this.#dropsSeen = { drops, start, end }; + if (!seen || seen.drops !== drops || seen.start !== start || seen.end !== end || seen.window !== window) { + this.#dropsSeen = { drops, start, end, window }; for (const run of drops) this.#pending = insertDrop(this.#pending, run.start, run.end, run.error); } for (let run = this.#pending.shift(); run; run = this.#pending.shift()) { @@ -2642,6 +2743,7 @@ export class Subscriber { this.#state.groups.changed(fn), this.#state.drops.changed(fn), this.#state.startChanged.changed(fn), + this.#state.dropsAnchors.changed(fn), this.#cursor.changed(fn), // A widened budget re-offers the groups it skipped as stale. this.#state.update.changed(fn), @@ -2716,9 +2818,13 @@ export class Subscriber { if (this.#resume && closed instanceof Error) return { kind: "error", error: closed }; // The group range bounds datagrams too, judged when each is read. One outside it is // gone for good: nothing holds it for a later raise, unlike a group past the cap. + const refused = this.#settle(); + if (refused) return { kind: "error", error: refused }; const { start, end } = this.#cursor.peek(); const inRange = (datagram: Datagram) => - datagram.sequence >= start && (end === undefined || datagram.sequence < end); + datagram.sequence >= start && + (end === undefined || datagram.sequence < end) && + this.#window.contains(datagram.sequence); if (this.#resume) { for (;;) { const recv = this.#resume.tryDatagram(this.#delivered); @@ -2753,11 +2859,12 @@ export class Subscriber { // The sequence #tryNextGroup (`ordered`) or #tryRecvGroup would take next: the lowest buffered // group in range, not yet handed out, that the budget spares. #peekGroup(ordered: boolean): number | undefined { + this.#settle(); const cursor = this.#cursor.peek(); const start = ordered ? Math.max(cursor.start, this.#nextSequence) : cursor.start; const drift = this.#drift(); for (const group of this.#state.groups.peek()) { - if (group.sequence < start) continue; + if (group.sequence < start || !this.#window.contains(group.sequence)) continue; if (cursor.end !== undefined && group.sequence >= cursor.end) return undefined; if (!this.#delivered.has(group.sequence) && !this.#staleAt(group.sequence, drift)) return group.sequence; } @@ -2777,6 +2884,8 @@ export class Subscriber { } #tryNextBuffered(): Recv { + const refused = this.#settle(); + if (refused) return { kind: "error", error: refused }; const groups = this.#state.groups.peek(); const cursor = this.#cursor.peek(); const start = Math.max(cursor.start, this.#nextSequence); @@ -2788,6 +2897,11 @@ export class Subscriber { const group = groups[0]; if (!group || (cursor.end !== undefined && group.sequence >= cursor.end)) break; groups.shift(); + // Out of range for now: an update taking it in offers it again from the cache. + if (!this.#window.contains(group.sequence)) { + group.close(); + continue; + } this.#nextSequence = group.sequence + 1; // Every frame this group could still hold is past the budget, so keep // scanning: one pass walks a whole backlog off rather than replaying it. @@ -2806,7 +2920,7 @@ export class Subscriber { // drained track reports finished. const end = this.#state.end.peek(); if ( - !groups[0] && + !this.#parked(groups) && (closed !== undefined || this.#complete() || (end !== undefined && start >= finalSequence(end))) ) { return { kind: "done" }; @@ -2910,8 +3024,9 @@ export class Subscriber { */ update(options: Subscription) { const subscription = subscriptionDefaults(options); - this.#cursor.set(groupBounds(subscription.groups)); + const replaced = JSON.stringify(subscription.ranges) !== JSON.stringify(this.#window.ranges); this.#state.update.set(subscription); + if (replaced) this.#resolve(); } } diff --git a/js/net/src/window.test.ts b/js/net/src/window.test.ts new file mode 100644 index 00000000000..1eb8d33cf79 --- /dev/null +++ b/js/net/src/window.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import type { Range } from "./track.ts"; +import { clamped, joinsLive, rangesOf, union, Window, wireRanges, wireSpan } from "./window.ts"; + +const abs = (start: number, end?: number): Range => + end === undefined ? { start: { absolute: start } } : { start: { absolute: start }, end: { absolute: end } }; + +test("union merges absolute spans and drops what an open range covers", () => { + expect(union([abs(4, 6), abs(0, 2), abs(2, 3), abs(5, 9)])).toEqual([abs(0, 3), abs(4, 9)]); + // The open absolute span drops a relative-ended range starting inside it. + expect(union([abs(3), { start: { absolute: 5 }, end: { relative: 0 } }])).toEqual([abs(3)]); + // The farthest open relative range drops relative ranges starting no farther back. + expect( + union([ + { start: { relative: 2 } }, + { start: { relative: 5 } }, + { start: { relative: 1 }, end: { relative: 0 } }, + ]), + ).toEqual([{ start: { relative: 5 } }]); + // A relative range above the live edge never hides an absolute one, nor the reverse. + expect(union([abs(10), { start: { relative: 0 } }])).toEqual([abs(10), { start: { relative: 0 } }]); + // The oldest start covers everything, and empty ranges vanish. + expect(union([abs(2, 4), {}])).toEqual([{}]); + expect(union([abs(4, 4), { end: { absolute: 0 } }])).toEqual([]); +}); + +test("a window reads only its ranges, counting relative anchors back from the latest group", () => { + const window = new Window( + [abs(2, 4), { start: { relative: 1 } }, { start: { absolute: 0 }, end: { relative: 8 } }], + 10, + ); + expect([0, 1, 2, 3, 4, 8, 9, 10, 11].map((sequence) => window.contains(sequence))).toEqual([ + true, + true, + true, + true, + false, + false, + true, + true, + true, + ]); + expect(window.floor()).toBe(0); + expect(window.start()).toBe(0); + expect(window.end()).toBeUndefined(); + expect(window.next(4)).toBe(9); + expect(window.runs(1, 12)).toEqual([ + { start: 1, end: 4 }, + { start: 9, end: 12 }, + ]); + expect(window.resolved()).toEqual([{ start: 2, end: 4 }, { start: 9 }, { start: 0, end: 3 }]); +}); + +test("a relative anchor clamps to group 0 and waits for a first group", () => { + expect(new Window([{ start: { relative: 50 }, end: { relative: 40 } }], 10).runs()).toEqual([{ start: 0, end: 1 }]); + + const window = new Window([{ start: { relative: 0 } }], undefined); + expect(window.pending).toBe(true); + expect(window.contains(3)).toBe(true); + expect(window.floor()).toBeUndefined(); + expect(window.settle(7)).toBe(true); + expect(window.pending).toBe(false); + expect(window.contains(6)).toBe(false); + expect(window.floor()).toBe(7); + // Resolved once: a later group never moves it. + expect(window.settle(9)).toBe(false); +}); + +test("a bounded window ends past its last group, and nothing in range ends at 0", () => { + expect(new Window([abs(2, 4), abs(6, 9)], undefined).end()).toBe(9); + expect(new Window([], undefined).end()).toBe(0); + // The oldest start names no floor, so a wire without ranges asks with none. + expect(new Window([{ end: { absolute: 5 } }], undefined).start()).toBeUndefined(); +}); + +test("a wire without ranges carries one span covering them all", () => { + expect(wireSpan([abs(4, 6), abs(2, 3)])).toEqual({ start: 2, end: 6 }); + expect(wireSpan([{ start: { relative: 0 } }])).toEqual({ start: undefined, end: undefined }); + // No single start covers both an absolute and a relative one. + expect(wireSpan([abs(9), { start: { relative: 0 } }])).toEqual({ start: 0, end: undefined }); + // A relative end goes out open, since the copy resolves it against what it receives. + expect(wireRanges([{ start: { relative: 3 }, end: { relative: 1 } }, abs(5, 5)])).toEqual([ + { start: { relative: 3 } }, + ]); +}); + +test("clamping raises starts to the floor and caps ends without widening", () => { + const ranges: Range[] = [abs(1, 9), {}, { start: { relative: 2 }, end: { relative: 0 } }]; + expect(clamped(ranges, 4, 6, [undefined, undefined, { start: 8, end: 11 }])).toEqual([ + abs(4, 6), + abs(4, 6), + abs(8, 6), + ]); + // A relative start not resolved keeps its anchor for the caller to floor. + expect(clamped([{ start: { relative: 2 } }], 4, undefined, [])).toEqual([{ start: { relative: 2 } }]); +}); + +test("ranges are checked rather than served widened", () => { + expect(() => rangesOf([{ start: { absolute: -1 } }])).toThrow(TypeError); + expect(() => rangesOf([{ start: { absolute: 1, relative: 2 } as never }])).toThrow(TypeError); + expect(() => rangesOf([{ end: { time: 5 } as never }])).toThrow(TypeError); + expect(joinsLive([{}])).toBe(true); + expect(joinsLive([abs(3)])).toBe(false); +}); diff --git a/js/net/src/window.ts b/js/net/src/window.ts new file mode 100644 index 00000000000..4247b5566ad --- /dev/null +++ b/js/net/src/window.ts @@ -0,0 +1,422 @@ +/** + * A subscriber's own ranges, resolved to the group sequences it reads, and the helpers that + * translate ranges between the model and the wire. Mirrors the Rust `subscription` module. + * + * The aggregate a publisher sees is the union of every subscriber's ranges, so it says what + * arrives, not what one subscriber asked for. A {@link Window} is the per-subscriber side: + * resolved at the hop serving the subscriber, it filters that subscriber's reads. + * + * @module + */ + +import type { Anchor, Range } from "./track.ts"; + +/** A run of group sequences `[start, end)`; an open run ends at infinity. */ +export type Run = { start: number; end: number }; + +/** One range resolved to groups: from `start` up to the exclusive `end`, undefined while open. */ +export type Resolved = { start: number; end?: number }; + +/** Everything from the latest group onward. */ +export const LIVE: Range = Object.freeze({ start: Object.freeze({ relative: 0 }) }); + +/** Check one anchor's shape, naming `field` in the error. */ +function anchorOf(field: string, anchor: Anchor): Anchor { + const keys = typeof anchor === "object" && anchor !== null ? Object.keys(anchor) : []; + const value = keys.length === 1 ? (anchor as Record)[keys[0]] : undefined; + if ((keys[0] !== "absolute" && keys[0] !== "relative") || !Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`${field} must be {absolute} or {relative}, a non-negative safe integer`); + } + return keys[0] === "absolute" ? { absolute: value as number } : { relative: value as number }; +} + +/** Check and copy a caller's ranges, refusing a malformed one rather than serving it widened. */ +export function rangesOf(ranges: readonly Range[]): Range[] { + if (!Array.isArray(ranges)) throw new TypeError("ranges must be an array"); + return ranges.map((range) => { + if (typeof range !== "object" || range === null) throw new TypeError("a range must be an object"); + const out: Range = {}; + if (range.start !== undefined) out.start = anchorOf("range start", range.start); + if (range.end !== undefined) out.end = anchorOf("range end", range.end); + return out; + }); +} + +/** The exclusive end of an absolute anchor, or undefined for any other end. */ +function absolute(anchor: Anchor | undefined): number | undefined { + return anchor?.absolute; +} + +/** How many groups back a relative anchor counts, or undefined for any other anchor. */ +function relative(anchor: Anchor | undefined): number | undefined { + return anchor?.relative; +} + +/** Whether `range` certainly delivers nothing, wherever the latest group lands. */ +export function isEmpty(range: Range): boolean { + const end = absolute(range.end); + if (end === undefined) return false; + // Nothing sorts below group 0, whatever the start. + if (end === 0) return true; + // Nothing sorts below group 0, so the oldest start floors there. + const start = range.start === undefined ? 0 : absolute(range.start); + return start !== undefined && end <= start; +} + +/** A canonical sort key: absolute starts first, then relative ones, then the oldest. */ +function rank(range: Range): [number, number, number] { + const end = absolute(range.end) ?? relative(range.end); + const endKey = range.end === undefined ? Number.POSITIVE_INFINITY : (end ?? 0); + const start = absolute(range.start); + if (start !== undefined) return [0, start, endKey]; + const back = relative(range.start); + return back === undefined ? [2, 0, endKey] : [1, back, endKey]; +} + +/** + * The union of `ranges` in a canonical order, without empty or redundant ranges. + * + * Absolute spans merge where they overlap or touch. The rest can't be placed without the + * latest group, so only an open range drops what it certainly covers: the open absolute span + * drops ranges starting inside it, and the farthest open relative range drops relative ranges + * starting no farther back. The oldest start covers everything. Mirrors the Rust `union`. + */ +export function union(ranges: readonly Range[]): Range[] { + const kept = ranges.filter((range) => !isEmpty(range)); + if (kept.some((range) => range.start === undefined && range.end === undefined)) return [{}]; + + const spans: Run[] = []; + for (const range of kept) { + const start = absolute(range.start); + if (start === undefined) continue; + if (range.end === undefined) spans.push({ start, end: Number.POSITIVE_INFINITY }); + else { + const end = absolute(range.end); + if (end !== undefined) spans.push({ start, end }); + } + } + spans.sort((a, b) => a.start - b.start || a.end - b.end); + const runs: Run[] = []; + for (const span of spans) { + const last = runs.at(-1); + if (last && span.start <= last.end) last.end = Math.max(last.end, span.end); + else runs.push({ ...span }); + } + const tail = runs.at(-1)?.end === Number.POSITIVE_INFINITY ? runs.at(-1)?.start : undefined; + let farthest: number | undefined; + for (const range of kept) { + const back = relative(range.start); + if (back !== undefined && range.end === undefined) farthest = Math.max(farthest ?? back, back); + } + + const out: Range[] = runs.map((run) => + run.end === Number.POSITIVE_INFINITY + ? { start: { absolute: run.start } } + : { start: { absolute: run.start }, end: { absolute: run.end } }, + ); + for (const range of kept) { + const start = absolute(range.start); + const back = relative(range.start); + if (start !== undefined) { + // Merged above unless it ends relative to the latest group. + if (range.end === undefined || absolute(range.end) !== undefined) continue; + if (tail === undefined || start < tail) out.push(range); + } else if (back !== undefined) { + if (range.end === undefined) continue; + if (farthest === undefined || back > farthest) out.push(range); + } else out.push(range); + } + if (farthest !== undefined) out.push({ start: { relative: farthest } }); + + out.sort((a, b) => { + const [x, y] = [rank(a), rank(b)]; + return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; + }); + return out.filter((range, index) => index === 0 || JSON.stringify(range) !== JSON.stringify(out[index - 1])); +} + +/** + * The lowest absolute group any range asks for, or undefined when one counts back from the + * latest group or reaches back to the oldest one: the floor a wire without ranges carries. + */ +export function rangesStart(ranges: readonly Range[]): number | undefined { + let floor: number | undefined; + for (const range of ranges) { + const start = absolute(range.start); + if (start === undefined) return undefined; + floor = Math.min(floor ?? start, start); + } + return floor; +} + +/** + * The exclusive end past every range, or undefined when one is open or ends relative to the + * latest group: the cap a wire without ranges carries. No range at all ends at group 0. + */ +export function rangesEnd(ranges: readonly Range[]): number | undefined { + let end = 0; + for (const range of ranges) { + const next = absolute(range.end); + if (next === undefined) return undefined; + end = Math.max(end, next); + } + return end; +} + +/** + * Whether a wire without ranges carries `range` upstream: absolute anchors, and the live and + * oldest starts it spells with no floor. Mirrors the Rust `Range::is_wire`. + */ +export function isWire(range: Range): boolean { + const back = relative(range.start); + return (back === undefined || back === 0) && relative(range.end) === undefined; +} + +/** + * The ranges a SUBSCRIBE carries for `ranges`, without the empty ones. Mirrors the Rust + * `wire_ranges`. + * + * The copy resolves a relative end itself, against the groups it receives past it, so it asks + * through the live edge: SUBSCRIBE_START names a resolved start, but nothing names a resolved + * end, so one resolved upstream would leave the copy short. + */ +export function wireRanges(ranges: readonly Range[]): Range[] { + return ranges + .filter((range) => !isEmpty(range)) + .map((range) => (relative(range.end) === undefined ? range : range.start ? { start: range.start } : {})); +} + +/** Whether every range has an end, so a lite-07 publisher ends the subscription once it covers them. */ +export function bounded(ranges: readonly Range[]): boolean { + return ranges.every((range) => range.end !== undefined); +} + +/** + * Whether a subscriber joins at the live edge: a range counting back from the latest group (or + * reaching back to the oldest) and following it. A fixed start resumes a known group instead, + * such as a route switch picking up where the reader stopped. + */ +export function joinsLive(ranges: readonly Range[] | undefined): boolean { + return (ranges ?? [{}]).some((range) => absolute(range.start) === undefined && range.end === undefined); +} + +/** + * The one span a wire without ranges carries for `ranges`, which the subscribers' own reads + * narrow again: from the lowest absolute start up to {@link rangesEnd}. + * + * A subscription counting only back from the latest group asks with no start, which a pre-06 + * publisher reads as the latest group and a lite-06 one as no floor. One mixing the two asks + * from group 0, since no single start covers both: the latest group may sit below the absolute + * start. Mirrors the Rust `wire_span`. + */ +export function wireSpan(ranges: readonly Range[]): { start?: number; end?: number } { + let start = rangesStart(ranges); + if (start === undefined && ranges.some((range) => absolute(range.start) !== undefined)) start = 0; + return { start, end: rangesEnd(ranges) }; +} + +/** + * `ranges` with every range raised to start no earlier than `floor` and stop no later than the + * exclusive `end`, without ever widening one. Mirrors the Rust `Subscription::clamped`. + * + * `resolved` names where each range resolved, by index, so a relative anchor becomes its own + * group before it is clamped. A relative start not resolved keeps its anchor, since the floor + * may lie before it, so the caller drops what it resolves to below `floor`. The oldest start + * never lies above the floor, so it becomes the floor. + */ +export function clamped( + ranges: readonly Range[], + floor: number | undefined, + end: number | undefined, + resolved: readonly (Resolved | undefined)[], +): Range[] { + if (floor === undefined && end === undefined) return [...ranges]; + return ranges.map((range, index) => { + const at = resolved[index]; + let start = range.start; + if (at && relative(start) !== undefined) start = { absolute: at.start }; + let requested = range.end; + if (at?.end !== undefined && relative(requested) !== undefined) requested = { absolute: at.end }; + const named = absolute(start); + if (floor !== undefined && named !== undefined) start = { absolute: Math.max(named, floor) }; + else if (floor !== undefined && start === undefined) start = { absolute: floor }; + if (end !== undefined) { + const capped = absolute(requested); + requested = { absolute: capped === undefined ? end : Math.min(capped, end) }; + } + const out: Range = {}; + if (start !== undefined) out.start = start; + if (requested !== undefined) out.end = requested; + return out; + }); +} + +/** One non-empty range, resolved. */ +type Span = { + /** Which of the ranges it came from. */ + index: number; + start: number; + /** The exclusive end, infinity while open. */ + end: number; + /** Whether the start names a group, rather than reaching back to the oldest one. */ + floored: boolean; +}; + +/** + * A subscriber's ranges resolved to the group sequences it reads. Mirrors the Rust `Window`, + * without time anchors. + * + * A relative anchor resolves when the window is built, counting back from the latest group, + * and again only when the ranges are replaced. Before any group exists it waits, reading + * everything, until {@link settle} names the first one. + */ +export class Window { + /** What these were resolved from, so only replacing the ranges resolves again. */ + readonly ranges: readonly Range[]; + #spans: Span[] = []; + /** The groups the spans cover, merged, sorted, and disjoint. */ + #runs: Run[] = []; + /** A relative anchor waits on a first group, so nothing is filtered yet. */ + #pending = false; + + /** Resolve `ranges`, counting relative anchors back from `latest`. */ + constructor(ranges: readonly Range[], latest: number | undefined) { + this.ranges = ranges; + this.#place(latest); + } + + /** Whether a relative anchor still waits on a first group. */ + get pending(): boolean { + return this.#pending; + } + + /** Resolve a waiting relative anchor once `latest` exists. Returns whether the groups in range moved. */ + settle(latest: number | undefined): boolean { + if (!this.#pending || latest === undefined) return false; + this.#place(latest); + return true; + } + + /** Whether group `sequence` is in range. Everything is while a relative anchor waits. */ + contains(sequence: number): boolean { + if (this.#pending) return true; + const run = this.#runAt(sequence); + return run !== undefined && sequence >= run.start; + } + + /** The lowest group in range at or above `from`, undefined when none is. `from` itself while a relative anchor waits. */ + next(from: number): number | undefined { + if (this.#pending) return from; + const run = this.#runAt(from); + return run === undefined ? undefined : Math.max(run.start, from); + } + + /** The lowest group in range, undefined while a relative anchor waits. Nothing in range floors at group 0. */ + floor(): number | undefined { + if (this.#pending) return undefined; + return this.#runs[0]?.start ?? 0; + } + + /** + * The lowest group the ranges name, undefined while one reaches back to the oldest group or + * a relative anchor waits on a first group. + */ + start(): number | undefined { + if (this.#pending || this.#spans.some((span) => !span.floored)) return undefined; + let start: number | undefined; + for (const span of this.#spans) start = Math.min(start ?? span.start, span.start); + return start; + } + + /** The exclusive end past every group in range, undefined when one stays open. Nothing in range ends at 0. */ + end(): number | undefined { + if (this.#pending) return undefined; + const end = this.#runs.at(-1)?.end ?? 0; + return end === Number.POSITIVE_INFINITY ? undefined : end; + } + + /** The runs in range from `from` up to the exclusive `end`, lowest first. */ + runs(from = 0, end = Number.POSITIVE_INFINITY): Run[] { + if (from >= end) return []; + if (this.#pending) return [{ start: from, end }]; + const out: Run[] = []; + for (const run of this.#runs) { + if (run.end <= from) continue; + if (run.start >= end) break; + out.push({ start: Math.max(run.start, from), end: Math.min(run.end, end) }); + } + return out; + } + + /** Each range resolved, in the order given: undefined for one that is empty or still waits on a first group. */ + resolved(): (Resolved | undefined)[] { + const resolved: (Resolved | undefined)[] = this.ranges.map(() => undefined); + for (const span of this.#spans) { + resolved[span.index] = { + start: span.start, + end: span.end === Number.POSITIVE_INFINITY ? undefined : span.end, + }; + } + return resolved; + } + + // The first run ending past `sequence`. + #runAt(sequence: number): Run | undefined { + let lo = 0; + let hi = this.#runs.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (this.#runs[mid].end <= sequence) lo = mid + 1; + else hi = mid; + } + return this.#runs[lo]; + } + + #place(latest: number | undefined): void { + this.#spans = []; + this.#pending = false; + this.ranges.forEach((range, index) => { + if (isEmpty(range)) return; + const start = startOf(range.start, latest); + const end = endOf(range.end, latest); + if (start === undefined || end === undefined) { + this.#pending = true; + return; + } + this.#spans.push({ index, start: start.group, end, floored: start.floored }); + }); + const spans = this.#spans.filter((span) => span.end > span.start).sort((a, b) => a.start - b.start); + this.#runs = []; + for (const span of spans) { + const last = this.#runs.at(-1); + if (last && span.start <= last.end) last.end = Math.max(last.end, span.end); + else this.#runs.push({ start: span.start, end: span.end }); + } + } +} + +/** + * The first group a range starts at, and whether it names that group rather than reaching back + * to the oldest one. Undefined while it waits on a latest group. + */ +function startOf( + anchor: Anchor | undefined, + latest: number | undefined, +): { group: number; floored: boolean } | undefined { + const group = absolute(anchor); + if (group !== undefined) return { group, floored: true }; + const back = relative(anchor); + if (back === undefined) return { group: 0, floored: false }; + if (latest === undefined) return undefined; + return { group: Math.max(0, latest - back), floored: true }; +} + +/** Where a range stops, exclusive and infinity while open, or undefined while it waits on a latest group. */ +function endOf(anchor: Anchor | undefined, latest: number | undefined): number | undefined { + const end = absolute(anchor); + if (end !== undefined) return end; + const back = relative(anchor); + if (back === undefined) return Number.POSITIVE_INFINITY; + if (latest === undefined) return undefined; + return Math.max(0, latest - back) + 1; +} diff --git a/test/interop/clients/js-native/finish.ts b/test/interop/clients/js-native/finish.ts index 9410e834946..301303c08dd 100644 --- a/test/interop/clients/js-native/finish.ts +++ b/test/interop/clients/js-native/finish.ts @@ -37,7 +37,7 @@ export async function readFinish( if (!format) throw new Error("unsupported finish media container"); const subscription = broadcast .track(Catalog.track(name, config)) - .subscribe({ maxDelay, groups: { start: { included: 0 } } }); + .subscribe({ maxDelay, ranges: [{ start: { absolute: 0 } }] }); const media = new Container.Consumer(subscription, { format, maxDelay }); let count = 0; try { diff --git a/test/interop/clients/js-native/goaway.ts b/test/interop/clients/js-native/goaway.ts index 16e36a0eac4..86c9fe0639b 100644 --- a/test/interop/clients/js-native/goaway.ts +++ b/test/interop/clients/js-native/goaway.ts @@ -28,7 +28,7 @@ async function active(request: (typeof requests)[number]) { return broadcast; } try { - const track = (await active(requests[0])).track("video").subscribe({ groups: { start: { included: 0 } } }); + const track = (await active(requests[0])).track("video").subscribe({ ranges: [{ start: { absolute: 0 } }] }); try { for (let sequence = 0; sequence < 3; sequence++) { const group = await track.recvGroup(); @@ -64,7 +64,7 @@ try { replacementProbe = wireOf(replacement) .consume(Moq.Path.from("handover")) .track("video") - .subscribe({ groups: { start: { included: 1 } } }); + .subscribe({ ranges: [{ start: { absolute: 1 } }] }); const probe = await replacementProbe.recvGroup(); assert(probe, "replacement has no group"); try { diff --git a/test/interop/clients/js-native/tail.ts b/test/interop/clients/js-native/tail.ts index d630cde8cd2..65a84ef9bb6 100644 --- a/test/interop/clients/js-native/tail.ts +++ b/test/interop/clients/js-native/tail.ts @@ -93,7 +93,7 @@ try { } const track = broadcast .track("tail") - .subscribe({ maxDelay: Milli(drop ? 60_000 : 5000), groups: { start: { included: 0 } } }); + .subscribe({ maxDelay: Milli(drop ? 60_000 : 5000), ranges: [{ start: { absolute: 0 } }] }); if (compress) assert.equal((await track.info()).compress, version === "moq-lite-07-wip"); const seen: number[] = []; try { From 8fcc86fa66d48c4d582ec8e72805eadcdad53076 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 17:32:26 -0700 Subject: [PATCH 04/22] docs(net): describe ranges on the JS subscribe surface Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 5ac79215bae..af48053c507 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -466,9 +466,9 @@ export class Consumer { /** * Open a live subscription to the track. * - * The cursor starts at the group the subscription named (its floor), or 0. - * {@link Subscription.maxDelay} is what asks for data: delivery skips everything above - * the floor that the budget convicts, so the default budget of zero delivers only the + * It reads only the groups its {@link Subscription.ranges} cover. + * {@link Subscription.maxDelay} is what asks for data: delivery skips everything in range + * that the budget convicts, so the default budget of zero delivers only the * latest group and a larger one reaches back over what it can still use. */ subscribe(options?: Subscription): Subscriber { @@ -991,9 +991,9 @@ export class Producer { /** * An independent {@link Subscriber} reading this track's groups. * - * Its cursor starts at the group the subscription named (its floor), or 0. - * {@link Subscription.maxDelay} is what asks for data: delivery skips everything above - * the floor that the budget convicts, so the default budget of zero delivers only the + * It reads only the groups its {@link Subscription.ranges} cover. + * {@link Subscription.maxDelay} is what asks for data: delivery skips everything in range + * that the budget convicts, so the default budget of zero delivers only the * latest group and a larger one reaches back over what it can still use. */ subscribe(options: Subscription = {}): Subscriber { @@ -2487,12 +2487,14 @@ export class Subscriber { * Rust `with_range`. * * Both bounds are assigned, in either direction: an omitted start is group 0 and an - * omitted end removes the cap. Lowering the floor or raising the cap offers the groups it - * newly covers that the track still caches and this reader never handed out; a group is - * never handed out twice. A local filter, not a request: it leaves the wire subscription - * alone. One exception: on a track served across route changes, lowering the floor asks the - * serving route again for the groups it newly covers within the subscription's groups, since - * the route that delivered them may be gone. {@link largest} reports within these bounds. + * omitted end removes the cap. They narrow the subscription's ranges, never widen them. + * Lowering the floor or raising the cap offers the groups it newly covers that the track + * still caches and this reader never handed out; a group is never handed out twice. A local + * filter, not a request: it leaves the wire subscription alone, so to change what is asked + * for, replace {@link Subscription.ranges} with {@link update}. One exception: on a track + * served across route changes, lowering the floor asks the serving route again for the groups + * it newly covers within the subscription's ranges, since the route that delivered them may + * be gone. {@link largest} reports within these bounds. */ withRange(groups: Groups): this { const before = this.#cursor.peek(); @@ -3018,8 +3020,9 @@ export class Subscriber { /** * Update this subscription's options (e.g. priority), triggering a SUBSCRIBE_UPDATE to the - * publisher. Replaces this reader's group bounds without redelivering groups already - * read; an arrival reader can receive unread cached groups below its previous floor. + * publisher. Replacing the ranges resolves them again, without redelivering groups already + * read; an arrival reader can receive the unread groups it still holds that the new ranges + * take in. * Mirrors the Rust `Subscriber::update`. */ update(options: Subscription) { From 36583d711570f2bb1a20f11396cb09abdd34cdee Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 17:32:26 -0700 Subject: [PATCH 05/22] chore(quest): narrow JS ranges to what remains Co-Authored-By: Claude Opus 5.5 --- quest/m1/subscribe-ranges/README.md | 2 +- quest/m1/subscribe-ranges/js.md | 82 ++++++++++++++--------------- 2 files changed, 40 insertions(+), 44 deletions(-) diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index c0a2c04e4c6..e674216c750 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -101,7 +101,7 @@ every sequence delivered or dropped. - [Linear fill path](/quest/m1/subscribe-ranges/fill-linear.md) - filling stays flat as range count and span grow, so a subscription's range count cannot multiply per-group cost - [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - `fetch_group` becomes a one-range subscription that ends once its range is accounted for - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) - the lite-07 wire, Rust publisher and subscriber, and the draft -- [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/net` model and lite-07 parity, and the on-demand request surface +- [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/net` time anchors, reopened demand, and the on-demand request surface - [Remove FETCH from lite-07](/quest/m1/subscribe-ranges/lite07-fetch.md) - FETCH removed from lite-07 in Rust, JS, and the draft - [Delete the old start bookkeeping](/quest/m1/subscribe-ranges/start-bookkeeping.md) - what each lite cursor is owed and dropped lives in the model, and `TrackRun` only translates it per version diff --git a/quest/m1/subscribe-ranges/js.md b/quest/m1/subscribe-ranges/js.md index c8ea0c9db4f..02ae2f6f779 100644 --- a/quest/m1/subscribe-ranges/js.md +++ b/quest/m1/subscribe-ranges/js.md @@ -2,52 +2,48 @@ ## Goal -`@moq/net` matches Rust: `Subscription` carries ranges beside its existing -`order`, the lite-07 SUBSCRIBE carries them on the wire, and `fetchGroup` -becomes a one-range subscription or is removed. A JavaScript publisher serves -requested ranges that are no longer in its live cache. +`@moq/net` matches Rust: `Subscription` ranges take `Time(pts)` anchors too, +`fetchGroup` becomes a one-range subscription or is removed, and a JavaScript +publisher serves requested ranges that are no longer in its live cache. ## Plan -Decided 2026-10-27 (maintainer): removing FETCH from lite-07 moved to -[Remove FETCH from lite-07](/quest/m1/subscribe-ranges/lite07-fetch.md), and -this quest no longer waits on -[Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md). The rest starts now: -the ranges model, anchors, `maxDelay: null`, and the on-demand request -surface. Rust's lite-07 SUBSCRIBE already carries ranges -([moq-dev/moq#6715](https://github.com/moq-dev/moq/pull/6715)). - -Mirror lite-07 bounded completion in the publisher: a covered bounded -subscription FINs without SUBSCRIBE_END. The JS subscriber already accepts that -FIN, closing the request's track once its tail settles, and failing it when an -update moved the range and the groups that arrived leave it uncovered. Reopen -that demand on the same track instead, as the Rust copy does. - -Mirror the Rust names, including `Time(pts)` ends and their refusal on an untimed track. -Also mirror Rust's optional `Subscription::max_delay` -([moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171)): a `null` -`maxDelay` asks for the publisher's max age alone, dominates when subscriptions -combine, and goes out as the largest delay the wire allows, where today an -omitted one means the live edge only. - -Decided in the 2026-10-05 audit: the producer half of -[JavaScript FETCH](/quest/m1/js-fetch.md) folds in here, so `@moq/net`'s -on-demand request surface takes range requests from the start instead of the -per-group `requested_group` shape Rust replaced with `requested_fill`. IETF -FETCH dispatch onto it is -[on-demand JavaScript FETCH](/quest/m1/js-fetch-on-demand.md). Resized from -[M] to [L] for it. - -- Expose an owned request handle rather than a storage callback; dropping a - request refuses it rather than leaving the subscriber waiting. Preserve - group sequence, frame boundaries, payload bytes, and track properties. - Storage, retention, and media catalog interpretation stay outside `js/net`. -- One logical dynamic track per broadcast and name: a cache-miss request - never creates a duplicate live producer. -- Verify with an in-memory responder, without OPFS or an archive writer: a - browser publisher serves a native subscriber after a group is evicted or - was never cached, covering exact replay, empty and missing groups, - concurrent requests, and cancellation while awaiting a reply. +Landed in [moq-dev/moq#6949](https://github.com/moq-dev/moq/pull/6949): +`Subscription.ranges` of `absolute` and `relative` anchors in place of +`groups`, a per-reader window (`js/net/src/window.ts`), the union aggregate, +lite-07 SUBSCRIBE carrying the list (older wires the covering span, refusing +other relative anchors per reader), `maxDelay: null`, and the lite-07 publisher +FINning a covered bounded subscription without SUBSCRIBE_END. + +What remains: + +- **Time anchors.** Mirror Rust's `Anchor::Time(pts)`: resolved from the + cache's group starts (the time-end settle walk in Rust's `window.rs`), + refused on an untimed track with `TimestampMismatch`, sent upstream on + lite-07, and a datagram judged by its own timestamp. Today a JS publisher + decoding a time anchor serves from the oldest group or through the live edge. +- **Reopen uncovered demand.** When a lite-07 FIN leaves the demand uncovered + because an update moved the ranges, the JS subscriber fails the track + (`#settleTail`). Reopen that demand on the same track instead, as the Rust + copy does. +- **On-demand request surface.** Decided in the 2026-10-05 audit: the producer + half of [JavaScript FETCH](/quest/m1/js-fetch.md) folds in here, so + `@moq/net`'s on-demand request surface takes range requests from the start + instead of the per-group `requested_group` shape Rust replaced with + `requested_fill`. IETF FETCH dispatch onto it is + [on-demand JavaScript FETCH](/quest/m1/js-fetch-on-demand.md). + - Expose an owned request handle rather than a storage callback; dropping a + request refuses it rather than leaving the subscriber waiting. Preserve + group sequence, frame boundaries, payload bytes, and track properties. + Storage, retention, and media catalog interpretation stay outside `js/net`. + - One logical dynamic track per broadcast and name: a cache-miss request + never creates a duplicate live producer. + - Verify with an in-memory responder, without OPFS or an archive writer: a + browser publisher serves a native subscriber after a group is evicted or + was never cached, covering exact replay, empty and missing groups, + concurrent requests, and cancellation while awaiting a reply. +- **`fetchGroup`.** Becomes a one-range subscription on the request surface + above, or is removed. Ranges stay whole groups ([moq-dev/moq#6465](https://github.com/moq-dev/moq/pull/6465)), so the group model keeps refusing a nonzero lite `frameStart` as a protocol From f7b87fed966671594211826658d2d8a8cc454782 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 18:05:50 -0700 Subject: [PATCH 06/22] feat(net): time range anchors and reopening uncovered demand in JS Co-Authored-By: Claude Opus 5.5 --- js/net/src/integration.test.ts | 47 ++- js/net/src/ledger.ts | 6 + js/net/src/lite/publisher.ts | 20 +- js/net/src/lite/subscribe.test.ts | 34 +- js/net/src/lite/subscribe.ts | 96 ++++-- js/net/src/lite/subscriber.test.ts | 16 +- js/net/src/lite/subscriber.ts | 205 ++++++++---- js/net/src/track.test.ts | 55 ++++ js/net/src/track.ts | 137 ++++++-- js/net/src/window.test.ts | 211 ++++++++++++- js/net/src/window.ts | 484 ++++++++++++++++++++++++----- 11 files changed, 1124 insertions(+), 187 deletions(-) diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index e5a3acb9cfb..c9adbf80805 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -12,7 +12,7 @@ import { type Established, } from "./connection/index.ts"; import * as Epoch from "./epoch.ts"; -import { SessionCode, SessionError, StreamCode, StreamError, TooFarBehind } from "./error.ts"; +import { SessionCode, SessionError, StreamCode, StreamError, TimestampMismatch, TooFarBehind } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; import { Parameters } from "./ietf/parameters.ts"; import { Publisher as IetfPublisher } from "./ietf/publisher.ts"; @@ -478,6 +478,51 @@ test("integration: lite-07 serves sparse and relative ranges", async () => { } }); +// A time range goes upstream in the track's timescale, the publisher resolves its start from +// where its groups start, and the copy's reader resolves the end from the groups it receives. +test("integration: lite-07 serves a time range", async () => { + const pair = createMockTransportPair(Lite.ALPN_07_WIP); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + + const broadcast = publish(origin, Path.from("test")); + const producer = broadcast.createTrack("video", { timescale: Timescale.MICRO }); + for (let sequence = 0; sequence < 10; sequence++) { + const group = producer.appendGroup(); + group.writeFrame(textFrame(`${sequence}`, Timestamp.fromMillis(sequence * 1000))); + group.finish(); + } + + const remote = wireOf(client).consume(Path.from("test")); + try { + const reader = remote + .track("video") + .subscribe({ + maxDelay: null, + order: "ascending", + ranges: [{ start: { time: Timestamp.fromMillis(2500) }, end: { time: Timestamp.fromMillis(5000) } }], + }) + .ordered(); + const got: number[] = []; + for (let i = 0; i < 4; i++) got.push((await reader.nextGroup())?.sequence ?? -1); + expect(got).toEqual([2, 3, 4, 5]); + reader.close(); + + // An untimed track refuses it. + broadcast.createTrack("untimed", {}); + const untimed = remote.track("untimed").subscribe({ ranges: [{ start: { time: Timestamp.fromMillis(0) } }] }); + await expect(untimed.recvGroup()).rejects.toThrow(TimestampMismatch); + } finally { + remote.close(); + broadcast.close(); + client.abort(); + server.abort(); + } +}); + test("integration: lite draft-06", async () => { // Exercises announce ids: every active assigns an ordinal on the wire. await runPublishSubscribeFlow(Lite.ALPN_06); diff --git a/js/net/src/ledger.ts b/js/net/src/ledger.ts index c607b0596f2..f439957c227 100644 --- a/js/net/src/ledger.ts +++ b/js/net/src/ledger.ts @@ -124,6 +124,12 @@ export class Ledger { return this.#runs.minKey(); } + /** The exclusive end of the run accounting for `sequence`, undefined when none does. */ + runEnd(sequence: number): number | undefined { + const run = this.#runs.getPairOrNextLower(sequence)?.[1]; + return run !== undefined && sequence < run.end ? run.end : undefined; + } + /** Whether every sequence in `[start, end)` is accounted for. */ covers(start: number, end: number): boolean { if (start >= end) return true; diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index f532b408a63..345ce5f166d 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -31,6 +31,7 @@ import { Group as GroupMessage } from "./group.ts"; import { Probe } from "./probe.ts"; import { encodeSubscribeResponse, + hasWireTime, requestedRanges, type Subscribe, SubscribeDrop, @@ -847,11 +848,24 @@ export class Publisher { return; } - const track = front.track(msg.track).subscribe({ + const consumer = front.track(msg.track); + let ranges: track.Range[]; + try { + // A time anchor counts in the timescale this peer was told, so it waits for the info. + const timescale = hasWireTime(msg.ranges) + ? liteTimescale(await untilAborted(consumer.info(), left.signal)) + : undefined; + ranges = requestedRanges(msg, timescale); + } catch (err: unknown) { + stream.writer.reset(error(err)); + return; + } + if (watch.revoked()) return; + const track = consumer.subscribe({ priority: msg.priority, order: msg.order, maxDelay: servingMaxDelay(this.version, msg.maxDelay), - ranges: requestedRanges(msg), + ranges, }); serving(track); const floor = cursorFloor(this.version, msg.startGroup, hooks.latestSequence(track)); @@ -940,7 +954,7 @@ export class Publisher { priority: update.priority, order: update.order, maxDelay: servingMaxDelay(this.version, update.maxDelay), - ranges: requestedRanges(update), + ranges: requestedRanges(update, liteTimescale(info)), }); }, }); diff --git a/js/net/src/lite/subscribe.test.ts b/js/net/src/lite/subscribe.test.ts index 3b2f7e37772..30aa924fd92 100644 --- a/js/net/src/lite/subscribe.test.ts +++ b/js/net/src/lite/subscribe.test.ts @@ -1,11 +1,13 @@ import { expect, test } from "bun:test"; -import { ProtocolViolation } from "../error.ts"; +import { ProtocolViolation, TimestampMismatch } from "../error.ts"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; +import { Timescale, Timestamp } from "../time.ts"; import { decodeSubscribeResponse, encodeSubscribeResponse, inclusiveGroupEnd, + requestedRanges, Subscribe, SubscribeDrop, SubscribeEnd, @@ -13,6 +15,7 @@ import { type SubscribeResponse, SubscribeStart, SubscribeUpdate, + toWireRanges, } from "./subscribe.ts"; import { Version } from "./version.ts"; @@ -218,20 +221,33 @@ test("a lite-07 list decodes as the span covering it", async () => { { start: { relative: 3 }, end: { absolute: 5 } }, { start: { absolute: 7 }, end: { absolute: 9 } }, ]); - // A time anchor is not resolved here, so it serves from the oldest group or through the live edge. - expect((await update([1, 3, 40, 3, 90])).ranges).toEqual([{}]); + // A time anchor carries ticks of the track's timescale, which only the serving track knows. + const timed = await update([1, 3, 40, 3, 90]); + expect(timed.ranges).toEqual([{ start: { time: 40 }, end: { time: 90 } }]); + expect([timed.startGroup, timed.endGroup]).toEqual([undefined, undefined]); + expect(requestedRanges(timed, Timescale.MILLI)).toEqual([ + { start: { time: Timestamp.fromMillis(40) }, end: { time: Timestamp.fromMillis(90) } }, + ]); + expect(() => requestedRanges(timed, undefined)).toThrow(TimestampMismatch); await expect(update([0])).rejects.toThrow(ProtocolViolation); await expect(update([1, 4, 0, 0])).rejects.toThrow(ProtocolViolation); await expect(update([1, 0, 1, 0, 0])).rejects.toThrow(ProtocolViolation); }); -test("lite-07 carries every range, relative anchors included, and older wires the span alone", async () => { - const ranges = [ - { start: { absolute: 2 }, end: { absolute: 5 } }, - { start: { relative: 1 } }, - { end: { relative: 0 } }, - ]; +test("lite-07 carries every range, relative and time anchors included, and older wires the span alone", async () => { + const ranges = toWireRanges( + [ + { start: { absolute: 2 }, end: { absolute: 5 } }, + { start: { relative: 1 } }, + { end: { relative: 0 } }, + // In the track's timescale, rounded down. + { start: { time: Timestamp.fromMicros(1_500_900) }, end: { time: Timestamp.fromMillis(3_000) } }, + ], + Timescale.MILLI, + ); + expect(ranges[3]).toEqual({ start: { time: 1_500 }, end: { time: 3_000 } }); + expect(() => toWireRanges([{ start: { time: Timestamp.fromMillis(1) } }], undefined)).toThrow(TimestampMismatch); const message = new Subscribe({ id: 1n, broadcast: Path.from("test"), diff --git a/js/net/src/lite/subscribe.ts b/js/net/src/lite/subscribe.ts index 9d09fa5f014..33093a9d229 100644 --- a/js/net/src/lite/subscribe.ts +++ b/js/net/src/lite/subscribe.ts @@ -1,7 +1,8 @@ import type * as Epoch from "../epoch.ts"; -import { fromTransport, ProtocolViolation, StreamCode, StreamError, toStreamCode } from "../error.ts"; +import { fromTransport, ProtocolViolation, StreamCode, StreamError, TimestampMismatch, toStreamCode } from "../error.ts"; import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; +import { type Timescale, Timestamp } from "../time.ts"; import type { Anchor, Order, Position, Range } from "../track.ts"; import { decodeEpoch, encodeEpoch } from "./epoch.ts"; import * as Message from "./message.ts"; @@ -120,8 +121,48 @@ interface Span { endFrame?: number; } +/** + * One end of a lite-07 range as the wire carries it: a model {@link Anchor}, except that a time + * counts in the track's `Timescale` as the peer was told it. + */ +export type WireAnchor = + | { absolute: number; relative?: never; time?: never } + | { relative: number; absolute?: never; time?: never } + | { time: number; absolute?: never; relative?: never }; + +/** A lite-07 range as the wire carries it; see {@link WireAnchor}. */ +export interface WireRange { + start?: WireAnchor; + end?: WireAnchor; +} + +/** + * The wire form of `ranges`, with time anchors in `timescale`, the track's as the publisher + * declared it. Throws {@link TimestampMismatch} for a time anchor on an untimed track. + */ +export function toWireRanges(ranges: readonly Range[], timescale: Timescale | undefined): WireRange[] { + const anchor = (anchor: Anchor | undefined): WireAnchor | undefined => { + if (anchor?.time === undefined) return anchor as WireAnchor | undefined; + if (timescale === undefined) throw new TimestampMismatch(); + return { time: Math.floor(anchor.time.as(timescale)) }; + }; + return ranges.map((range) => { + const out: WireRange = {}; + const start = anchor(range.start); + const end = anchor(range.end); + if (start) out.start = start; + if (end) out.end = end; + return out; + }); +} + +/** Whether any range starts or ends at a presentation time, which needs the track's timescale. */ +export function hasWireTime(ranges: readonly WireRange[] | undefined): boolean { + return ranges?.some((range) => range.start?.time !== undefined || range.end?.time !== undefined) ?? false; +} + /** Encode one lite-07 range end, `undefined` for none: an absolute end names a group's first frame. */ -async function encodeAnchor(w: Writer, anchor: Anchor | undefined) { +async function encodeAnchor(w: Writer, anchor: WireAnchor | undefined) { if (anchor?.absolute !== undefined) { await w.u53(1); await w.u53(anchor.absolute); @@ -129,6 +170,9 @@ async function encodeAnchor(w: Writer, anchor: Anchor | undefined) { } else if (anchor?.relative !== undefined) { await w.u53(2); await w.u53(anchor.relative); + } else if (anchor?.time !== undefined) { + await w.u53(3); + await w.u53(anchor.time); } else { await w.u53(0); } @@ -139,7 +183,7 @@ async function encodeAnchor(w: Writer, anchor: Anchor | undefined) { * and the frame bounds; lite-07 carries a list of ranges, which is `ranges` when given and * otherwise this span as its only one. */ -async function encodeSpan(w: Writer, version: Version, span: Span, ranges?: readonly Range[]) { +async function encodeSpan(w: Writer, version: Version, span: Span, ranges?: readonly WireRange[]) { if (ranges !== undefined && hasRanges(version)) { if (ranges.length === 0) throw new Error(EMPTY_RANGE); await w.u53(ranges.length); @@ -181,10 +225,25 @@ async function encodeSpan(w: Writer, version: Version, span: Span, ranges?: read /** * The model ranges a decoded SUBSCRIBE or SUBSCRIBE_UPDATE asks for: its lite-07 list, or the one - * span an older wire carries, whole groups either way. + * span an older wire carries, whole groups either way. A time anchor counts in `timescale`, the + * track's as the peer was told it, and is refused with {@link TimestampMismatch} without one. */ -export function requestedRanges(msg: Span & { ranges?: Range[] }): Range[] { - if (msg.ranges) return msg.ranges; +export function requestedRanges(msg: Span & { ranges?: WireRange[] }, timescale: Timescale | undefined): Range[] { + if (msg.ranges) { + const anchor = (anchor: WireAnchor | undefined): Anchor | undefined => { + if (anchor?.time === undefined) return anchor as Anchor | undefined; + if (timescale === undefined) throw new TimestampMismatch(); + return { time: new Timestamp(anchor.time, timescale) }; + }; + return msg.ranges.map((range) => { + const out: Range = {}; + const start = anchor(range.start); + const end = anchor(range.end); + if (start) out.start = start; + if (end) out.end = end; + return out; + }); + } const range: Range = {}; if (msg.startGroup !== undefined) range.start = { absolute: msg.startGroup }; if (msg.endGroup !== undefined) range.end = { absolute: msg.endGroup + 1 }; @@ -199,12 +258,10 @@ function before(a: Point, b: Point): boolean { } /** - * Decode one lite-07 range end: its absolute position, if any, and the model anchor, `undefined` - * for none. An absolute end past frame 0 includes its group. A time anchor is also `undefined`: - * this publisher does not resolve one, so it serves from the oldest group its budget allows, or - * through the live edge, and the subscriber keeps what it asked for. + * Decode one lite-07 range end: its absolute position, if any, and the wire anchor, `undefined` + * for none. An absolute end past frame 0 includes its group. */ -async function decodeAnchor(r: Reader, end: boolean): Promise<{ point?: Point; anchor?: Anchor }> { +async function decodeAnchor(r: Reader, end: boolean): Promise<{ point?: Point; anchor?: WireAnchor }> { const kind = await r.u53(); switch (kind) { case 0: @@ -216,8 +273,7 @@ async function decodeAnchor(r: Reader, end: boolean): Promise<{ point?: Point; a case 2: return { anchor: { relative: await r.u53() } }; case 3: - await r.u53(); - return {}; + return { anchor: { time: await r.u53() } }; default: throw new ProtocolViolation(`unknown range anchor: ${kind}`); } @@ -227,7 +283,7 @@ async function decodeAnchor(r: Reader, end: boolean): Promise<{ point?: Point; a * The inverse of {@link encodeSpan}. A lite-07 list decodes as its ranges and as the one span * covering every range in it, which the subscriber narrows again. */ -async function decodeSpan(r: Reader, version: Version): Promise { +async function decodeSpan(r: Reader, version: Version): Promise { if (!hasRanges(version)) { const startGroup = await decodeStartGroup(r, version); const endGroup = (await r.u53()) || undefined; @@ -242,11 +298,11 @@ async function decodeSpan(r: Reader, version: Version): Promise } }); -// The FIN may answer the bounded range before the publisher read an update that widened it, -// so the track fails rather than ending clean short of the demand. +// The FIN may answer the bounded range before the publisher read an update that widened it, so +// the copy asks again for the demand rather than end clean short of it or fail its readers. test.each([ ["an open widening after SUBSCRIBE_START", { start: { absolute: 0 } }, true, false], ["a bounded widening without SUBSCRIBE_START", { start: { absolute: 0 }, end: { absolute: 5 } }, false, false], ["a widening the publisher stopped reading", { start: { absolute: 0 }, end: { absolute: 5 } }, false, true], -] as const)("a lite-07 FIN short of %s fails the track", async (_, range, start, stopped) => { +] as const)("a lite-07 FIN short of %s subscribes again on the same track", async (_, range, start, stopped) => { const clock = new Manual(); const installed = clock.install(); const version = Version.DRAFT_07; @@ -2239,7 +2240,14 @@ test.each([ await drainReactions(); await clock.advance(Nano.fromMilli(GRACE)); - expect(await track.closed).toBeInstanceOf(Error); + const reopened = await opened(2); + await reopened.reading; + const reader = new Reader(undefined, new Uint8Array(Buffer.concat(reopened.written)), version); + expect(await reader.u53()).toBe(StreamId.Subscribe); + const msg = await Subscribe.decode(reader, version); + expect(msg.id).toBe(1n); + expect(msg.ranges).toEqual([range]); + expect(track.closed.peek()).toBeUndefined(); expect(closes).toEqual([]); } finally { track.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 2dde46ee26d..e58f73979e7 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -60,6 +60,7 @@ import { inclusiveGroupEnd, Subscribe, SubscribeUpdate, + toWireRanges, } from "./subscribe.ts"; import { TrackInfo, Track as TrackMessage } from "./track.ts"; import { decodeTracksEventMaybe, Tracks as TracksMessage } from "./tracks.ts"; @@ -128,6 +129,8 @@ interface SubscribeEntry { compress: boolean; // Per-frame timestamp scale (0 = none), resolved before this entry is registered. timescale: number; + // The track's timescale as its model declares it, which a time anchor counts in. + scale?: Time.Timescale; // The group streams received, so the subscription can wait for the ones still owed // after the publisher ends it. tail: Tail; @@ -140,8 +143,8 @@ interface SubscribeEntry { // Whether the stream ever asked for a bounded range, which a lite-07 publisher FINs without // SUBSCRIBE_END once it covers it. bounded: boolean; - // The ranges the SUBSCRIBE asked for, which that FIN covers unless the demand moved since - // in an update the publisher may not have read. + // The ranges the SUBSCRIBE asked for, as `wireRanges` gives them, which that FIN covers + // unless the demand moved since in an update the publisher may not have read. asked: track.Range[]; // The TRACK stream that answered (lite-05+), held open as interest in the track until // the SUBSCRIBE has its first response, so the publisher's demand never lapses between @@ -882,40 +885,21 @@ export class Subscriber { request: track.Request, state: SubscribeSetup, ) { - const { epoch, valid, answering } = consumed; - const subscription = request.subscription; + const { valid } = consumed; + const ranges = wireRanges(request.subscription.ranges ?? [{}]); + const msg = this.#subscribeMessage(id, broadcast, consumed, request.name, request.subscription, ranges); console.debug(`subscribe start: id=${id} broadcast=${broadcast} track=${request.name}`); - const ranges = wireRanges(subscription.ranges ?? [{}]); - const span = wireSpan(ranges); - - const msg = new Subscribe({ - id, - broadcast, - epoch, - track: request.name, - priority: subscription.priority ?? 0, - order: subscription.order, - maxDelay: wireDelay(subscription), - startGroup: span.start, - endGroup: inclusiveGroupEnd(span.end), - ranges, - }); // Open the stream under a timeout. The stream handles flow back via `state` // so the timeout path can close them if they finish opening after the deadline, // and `cancel` ends a setup still running once the deadline passed, resetting // its TRACK stream so a peer that never answers can't hold one per attempt. - const retirer = (signal?: AbortSignal) => () => { - state.cancel.abort(signal?.reason); - state.producer?.abort(error(signal?.reason)); - state.stream?.abort(error(signal?.reason)); - }; - const retire = retirer(valid); + const retire = this.#retirer(state, valid); valid?.addEventListener("abort", retire); if (valid?.aborted) retire(); const deadline = new Deadline(); - const setup = this.#openSubscribe(state, msg, request, id, deadline); + const setup = this.#openSubscribe(state, msg, ranges, request, id, deadline); let opened: { stream: Stream; entry: SubscribeEntry }; try { @@ -943,18 +927,99 @@ export class Subscriber { setup.then(leave, leave); return; } - - const { stream, entry } = opened; valid?.removeEventListener("abort", retire); + + let { stream, entry } = opened; + let current = { id, msg }; + // A lite-07 publisher that FINs a covered bounded subscription leaves the copy to its + // readers. If the demand moved past what it covered, ask again on the same track, as the + // Rust copy does, rather than fail the readers still waiting on it. + while (await this.#serveStream(current.id, broadcast, consumed, state, stream, entry, current.msg)) { + const producer = entry.track; + const subscription = producer.subscription.peek(); + const ranges = wireRanges(subscription?.ranges ?? [{}]); + if (!subscription || ranges.length === 0) { + hooks.releaseTrack(producer); + return; + } + const id = this.#subscribeNext++; + const msg = this.#subscribeMessage(id, broadcast, consumed, producer.name, subscription, ranges); + console.debug(`subscribe reopen: id=${id} broadcast=${broadcast} track=${producer.name}`); + valid?.addEventListener("abort", retire); + try { + if (valid?.aborted) retire(); + state.cancel.signal.throwIfAborted(); + entry = this.#registerSubscribe(id, msg, ranges, producer, entry.compress, entry.timescale, entry.scale); + stream = await this.#sendSubscribe(state, msg, new Deadline()); + current = { id, msg }; + } catch (err) { + const e = state.revoked ?? (await sessionCause(this.#quic, err)); + this.#subscribes.delete(id); + state.stream?.abort(e); + producer.abort(e); + console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${producer.name} error=${reason(e)}`); + return; + } finally { + valid?.removeEventListener("abort", retire); + } + } + } + + // The SUBSCRIBE for `subscription`, asking for `ranges`, its wire ranges in the model. Its own + // wire ranges wait for the track's timescale (see `#registerSubscribe`). + #subscribeMessage( + id: bigint, + broadcast: Path.Valid, + consumed: Consumed, + name: string, + subscription: Readonly, + ranges: readonly track.Range[], + ): Subscribe { + const span = wireSpan(ranges); + return new Subscribe({ + id, + broadcast, + epoch: consumed.epoch, + track: name, + priority: subscription.priority ?? 0, + order: subscription.order, + maxDelay: wireDelay(subscription), + startGroup: span.start, + endGroup: inclusiveGroupEnd(span.end), + }); + } + + // Ends a subscription's setup once `signal` aborts: its streams, and the track it accepted. + #retirer(state: SubscribeSetup, signal?: AbortSignal): () => void { + return () => { + state.cancel.abort(signal?.reason); + state.producer?.abort(error(signal?.reason)); + state.stream?.abort(error(signal?.reason)); + }; + } + + // Serve one SUBSCRIBE stream into its track until it ends. Resolves true when a lite-07 + // publisher covered the bounded ranges it read but an update since moved the demand past + // them, leaving the track open for the caller to ask again. + async #serveStream( + id: bigint, + broadcast: Path.Valid, + consumed: Consumed, + state: SubscribeSetup, + stream: Stream, + entry: SubscribeEntry, + msg: Subscribe, + ): Promise { + const { answering } = consumed; + const producer = entry.track; // Sent but unanswered (lite-05+; older drafts already waited for SUBSCRIBE_OK), only a // replacement refuses it, since that instance may be the one answering. - const replaced = retirer(answering); + const replaced = this.#retirer(state, answering); if (hasTrackStream(this.version) && answering) { answering.addEventListener("abort", replaced); entry.answered = () => answering.removeEventListener("abort", replaced); if (answering.aborted) replaced(); } - const producer = entry.track; try { // Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02 // don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there @@ -967,9 +1032,9 @@ export class Subscriber { const responses = hasTrackStream(this.version) ? this.runResponses(stream, entry) : stream.reader.closed; let tailSettled = false; // Set when the publisher ended the subscription short of the demand. - let short: Error | undefined; + let short = false; const closed = responses.then(async () => { - if (!(await this.#settleTail(entry))) short = new Error("subscription ended before covering its range"); + short = !(await this.#settleTail(entry)); tailSettled = true; }); // A reset that lands after the race below settled is moot; the race observes one before. @@ -994,10 +1059,11 @@ export class Subscriber { // down (e.g. a quickly unmuted tile) resumes on the same subscription. const idle = Symbol("idle"); const demand = producer.demand(); + let reason_: unknown; for (;;) { - const reason = await race([done, unwanted(demand).then(() => idle)]); + reason_ = await race([done, unwanted(demand).then(() => idle)]); if ( - reason === idle && + reason_ === idle && hooks.demandReleased(demand).peek() === undefined && demand.wanted.peek() !== undefined ) @@ -1005,22 +1071,24 @@ export class Subscriber { break; } - // A publisher that covered the range before reading an update widening it sent - // fewer groups than the track has, so it fails rather than ending clean. Otherwise + // A publisher that covered the range before reading an update widening it sent fewer + // groups than the demand asks for, so the caller asks again for the rest. Otherwise // only a received SUBSCRIBE_END declares the track's end: a range covered or a // subscription nobody wants anymore is a released copy, not finished content. - if (short) producer.abort(short); - else hooks.releaseTrack(producer); + const reopen = short && reason_ !== idle && hooks.trackReleased(producer).peek() === undefined; + if (!reopen) hooks.releaseTrack(producer); // A settled lite07 tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. if (tailSettled && waitsForSubscriberFin(this.version)) stream.writer.close(); else stream.close(); - console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.name}`); + console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${producer.name}`); + return reopen; } catch (err) { const e = await sessionCause(this.#quic, err); producer.abort(e); - console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); + console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${producer.name} error=${reason(e)}`); stream.abort(e); + return false; } finally { answering?.removeEventListener("abort", replaced); entry.held?.close(); @@ -1041,15 +1109,16 @@ export class Subscriber { async #openSubscribe( state: SubscribeSetup, msg: Subscribe, + ranges: readonly track.Range[], request: track.Request, id: bigint, deadline: Deadline, ): Promise<{ stream: Stream; entry: SubscribeEntry }> { state.cancel.signal.throwIfAborted(); let producer: track.Producer; - let drainOk = false; let compress = false; let timescale = 0; + let scale: Time.Timescale | undefined; if (hasTrackStream(this.version)) { // Fetch the immutable properties once via the TRACK stream. @@ -1064,24 +1133,43 @@ export class Subscriber { // The deadline passed as TRACK_INFO landed: the request is already rejected, so don't // register it again or send its SUBSCRIBE. state.cancel.signal.throwIfAborted(); - producer = request.accept(this.#toModelInfo(info)); + const model = this.#toModelInfo(info); + producer = request.accept(model); compress = info.compress; timescale = info.timescale ?? 0; + scale = model.timescale ?? undefined; } else { // Older drafts negotiate nothing per-track: verbatim frames with no timeline. producer = request.accept(); - drainOk = true; } + if (!hasRanges(this.version)) hooks.dropAnchors(producer); + const entry = this.#registerSubscribe(id, msg, ranges, producer, compress, timescale, scale); + entry.held = state.track; + state.producer = producer; + return { stream: await this.#sendSubscribe(state, msg, deadline), entry }; + } + + // Register a SUBSCRIBE for `ranges` into `producer`, filling in its wire ranges, before it is + // sent, so a racing GROUP stream finds the entry. + #registerSubscribe( + id: bigint, + msg: Subscribe, + ranges: readonly track.Range[], + producer: track.Producer, + compress: boolean, + timescale: number, + scale: Time.Timescale | undefined, + ): SubscribeEntry { + // A time anchor counts in the timescale the track declares, refused on an untimed one. + if (hasRanges(this.version)) msg.ranges = toWireRanges(ranges, scale); // Lite-06+ resolves where the feed starts only with SUBSCRIBE_START, so a relay serving // this copy holds its first group until the answer arrives. if (resolvesStart(this.version)) hooks.startTrack(producer, { requested: msg.startGroup }); - - // Register before opening SUBSCRIBE so a racing GROUP stream finds the entry. - if (!hasRanges(this.version)) hooks.dropAnchors(producer); const entry: SubscribeEntry = { track: producer, compress, timescale, + scale, // The effective max delay is the stopgap grace: the wrong clock (it bounds // presentation-time drift), but it is how long the subscriber was willing to wait // for a late group anyway. Already the smaller of the subscriber's and the track's. @@ -1089,13 +1177,17 @@ export class Subscriber { grace: () => grace(delayBudget(producer.subscription.peek())), }), requested: msg.startGroup, - bounded: bounded(msg.ranges ?? []), - asked: msg.ranges ?? [], - held: state.track, + bounded: hasRanges(this.version) && bounded(ranges), + asked: [...ranges], }; this.#subscribes.set(id, entry); - state.producer = producer; + return entry; + } + // Open the SUBSCRIBE stream and send `msg` on it. `state.stream` is populated as soon as it + // opens so the caller can clean it up on timeout even before this promise settles. Older + // drafts answer with SUBSCRIBE_OK, which is drained here. + async #sendSubscribe(state: SubscribeSetup, msg: Subscribe, deadline: Deadline): Promise { state.stream = await deadline.open(Stream.open(this.#quic, { version: this.version, ...requestOptions })); // The deadline passed while the open waited: the late-setup handler resets the stream, so // don't send a SUBSCRIBE on it first. @@ -1103,15 +1195,14 @@ export class Subscriber { await state.stream.writer.u53(StreamId.Subscribe); await msg.encode(state.stream.writer, this.version); - if (drainOk) { + if (!hasTrackStream(this.version)) { // The first response MUST be a SUBSCRIBE_OK (older drafts only). const resp = await decodeSubscribeResponse(state.stream.reader, this.version); if (!("ok" in resp)) { throw new Error("first subscribe response must be SUBSCRIBE_OK"); } } - - return { stream: state.stream, entry }; + return state.stream; } // Opens a TRACK stream and reads the single TRACK_INFO. Lite-05+ only. The stream @@ -1520,7 +1611,9 @@ export class Subscriber { // asked for below its SUBSCRIBE_START were accounted for when it arrived. Only the // groups inside the ranges are owed: the publisher accounts for nothing between them. const start = rangesStart(ranges) ?? entry.start; - const window = new Window(ranges, track.largest()?.group); + // A time start resolved at the publisher, which named it with SUBSCRIBE_START. + const owed = ranges.map((range) => (range.start?.time ? { ...range, start: { absolute: start } } : range)); + const window = new Window(owed, track.largest()?.group); return window.runs(start, end).every((run) => tail.covers(run.start, run.end)); }; @@ -1558,7 +1651,7 @@ export class Subscriber { priority: msg.priority, order: msg.order, maxDelay: Milli(msg.maxDelay), - ranges: msg.ranges, + ranges: entry.asked, }; // The wanted group is subscription state: an update announces each new one past our @@ -1608,7 +1701,7 @@ export class Subscriber { maxDelay: wireDelay(current), startGroup: span.start, endGroup: inclusiveGroupEnd(span.end), - ranges, + ranges: hasRanges(this.version) ? toWireRanges(ranges, entry.scale) : undefined, }); // Encoded apart from the write, so a bound the wire can't carry still fails the track. const encoded = await Writer.collect(this.version, (w) => update.encode(w, this.version)); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index daa84bd63b2..d93b32c609e 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -597,6 +597,61 @@ test("a copy over a wire without ranges refuses a relative anchor it cannot carr expect(live.tryRecvGroup()?.sequence).toBe(0); }); +test("a time range reads the groups containing its times, settling its end as groups start", () => { + const producer = new TrackProducer("test"); + producer.accept({ timescale: Timescale.MILLI }); + const start = (sequence: number, millis: number) => { + const group = producer.createGroup(sequence); + group.writeFrame(textFrame(`${sequence}`, Timestamp.fromMillis(millis))); + return group; + }; + for (let sequence = 0; sequence < 3; sequence++) start(sequence, sequence * 1000).finish(); + const reader = producer.subscribe({ + maxDelay: null, + ranges: [{ start: { time: Timestamp.fromMillis(1500) }, end: { time: Timestamp.fromMillis(3500) } }], + }); + const read = () => { + const got: number[] = []; + for (let group = reader.tryRecvGroup(); group; group = reader.tryRecvGroup()) got.push(group.sequence); + return got; + }; + // Group 1 contains 1500, and group 3, the next to arrive, may still start inside the end. + expect(read()).toEqual([1, 2]); + const three = producer.createGroup(3); + expect(read()).toEqual([3]); + // Group 4 waits until group 3 shows where it starts. + const four = producer.createGroup(4); + expect(read()).toEqual([]); + three.writeFrame(textFrame("3", Timestamp.fromMillis(3000))); + expect(read()).toEqual([4]); + // Group 4 starts past the end, which settles before it, though it was already read on speculation. + producer.createGroup(5).writeFrame(textFrame("5", Timestamp.fromMillis(5000))); + expect(read()).toEqual([]); + expect(hooks.servingEnd(reader)).toBeUndefined(); + four.writeFrame(textFrame("4", Timestamp.fromMillis(4000))); + expect(read()).toEqual([]); + expect(hooks.servingEnd(reader)).toBe(4); +}); + +test("a datagram is judged against a time range by its own timestamp", () => { + const producer = new TrackProducer("test"); + producer.accept({ timescale: Timescale.MILLI }); + const reader = producer.subscribe({ ranges: [{ start: { absolute: 0 }, end: { time: Timestamp.fromMillis(3500) } }] }); + producer.insertDatagram(0, Timestamp.fromMillis(3600), enc.encode("late")); + producer.insertDatagram(1, Timestamp.fromMillis(3400), enc.encode("in")); + const recv = hooks.tryRecvDatagram(reader); + expect(recv.kind === "datagram" && dec.decode(recv.datagram.payload)).toBe("in"); + expect(hooks.tryRecvDatagram(reader)).toEqual({ kind: "idle" }); +}); + +test("an untimed track refuses a time anchor", () => { + const producer = new TrackProducer("test"); + producer.accept({}); + const reader = producer.subscribe({ ranges: [{ start: { time: Timestamp.fromMillis(0) } }] }); + producer.createGroup(0).finish(); + expect(() => reader.tryRecvGroup()).toThrow(TimestampMismatch); +}); + // A resume floor above the live edge must not hide the latest group from a reader that // joins after it: on a quiet track that group may be the only one for a long time. test("an unfloored subscriber clears a resume floor above the live edge", () => { diff --git a/js/net/src/track.ts b/js/net/src/track.ts index fdbaf0dd385..d82e3b1c408 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -42,7 +42,18 @@ import { } from "./internal.ts"; import { CACHE_EXPIRY, grace, Ledger } from "./ledger.ts"; import { Timescale, type Timestamp } from "./time.ts"; -import { isWire, joinsLive, rangesOf, union, Window, wireSpan } from "./window.ts"; +import { + type Holdings, + hasTime, + isWire, + joinsLive, + later, + rangesOf, + UNHELD, + union, + Window, + wireSpan, +} from "./window.ts"; import { type Broadcast as BroadcastWire, registerTrackConsumer } from "./wire.ts"; export type { Datagram } from "./datagram.ts"; @@ -216,25 +227,39 @@ export interface Groups { } /** - * Where one end of a {@link Range} falls: a fixed group (`absolute`), or a count of groups back - * from the latest one (`relative`, clamped to group 0, so `{ relative: 0 }` is the latest group). + * Where one end of a {@link Range} falls: a fixed group (`absolute`), a count of groups back from + * the latest one (`relative`, clamped to group 0, so `{ relative: 0 }` is the latest group), or a + * presentation time (`time`, the group containing it: the newest one starting at or before it). * Mirrors the Rust `Anchor`. * * Every anchor names a whole group: a subscription always starts at a group's first frame. A - * relative anchor resolves at the hop that serves it, when it starts serving and again whenever - * the subscriber replaces its ranges. Before any group exists it resolves at the first one, so - * `{ start: { relative: 0 } }` starts there. Older moq-lite versions carry only absolute anchors, - * the live start, and the oldest start, refusing any other relative anchor. + * relative or time anchor resolves at the hop that serves it, when it starts serving and again + * whenever the subscriber replaces its ranges. Before any group exists a relative anchor resolves + * at the first one, so `{ start: { relative: 0 } }` starts there. + * + * A time anchor resolves from where the groups the hop holds start. A start before every group + * held reaches back to the oldest one, and one resolved before any group has started waits for + * one that has, reading everything meanwhile as a waiting relative anchor does. An end settles once a group starting after it is known and every sequence + * below that one is accounted for; until then reads stop at the first held group that may still + * start, so at most that one group past the end is read before its first frame shows where it + * starts. A datagram is judged by its own timestamp. A time anchor on an untimed track is refused + * with {@link TimestampMismatch}. + * + * Older moq-lite versions carry only absolute anchors, the live start, and the oldest start, + * refusing any other. */ -export type Anchor = { absolute: number; relative?: never } | { relative: number; absolute?: never }; +export type Anchor = + | { absolute: number; relative?: never; time?: never } + | { relative: number; absolute?: never; time?: never } + | { time: Timestamp; absolute?: never; relative?: never }; /** * A span of a track to deliver: from `start` up to `end`. Mirrors the Rust `Range`. * * An omitted start is the oldest group still around (Rust's `Range::oldest`), and an omitted end * keeps following the live edge. An absolute end is exclusive, so `{absolute: 2}..{absolute: 6}` - * delivers groups 2 through 5; a relative end includes its group, so `{absolute: 0}..{relative: 0}` - * delivers through the latest group. + * delivers groups 2 through 5; relative and time ends include their group, so + * `{absolute: 0}..{relative: 0}` delivers through the latest group. */ export interface Range { /** Where delivery starts, or the oldest group when omitted. */ @@ -645,6 +670,11 @@ let reofferSequences: (producer: Producer, sink: TrackState, sequences: readonly // Whether a group may still land on the producer's live feed; assigned in its static block. let mayLand: (producer: Producer, land: Land) => boolean; let sourceStart: (producer: Producer) => number | "pending" | undefined; +// The producer's checked group starts, sorted by sequence; assigned in its static block. +let startsOf: (producer: Producer) => readonly Start[]; +// The exclusive end of the run of sequences from `sequence` the producer's feed can no longer +// deliver, undefined while it may; assigned in its static block. +let goneAt: (producer: Producer, sequence: number) => number | undefined; // Actual object metadata outlives cached payloads, unlike empty group heads in model largest. let objectLargest: (producer: Producer) => Position | undefined; @@ -805,6 +835,14 @@ export class Producer { static { hooks.writeDatagram = (producer, datagram, inflated) => producer.#insertDatagram(datagram, inflated); sourceStart = (producer) => producer.#served && producer.#feedStart(); + startsOf = (producer) => producer.#bounds; + goneAt = (producer, sequence) => { + // Without a declared start, the first arrival stands in for it, as the tail's does. + const floor = producer.#served?.start ?? producer.#arrivals.first() ?? 0; + if (sequence < floor) return floor; + const dropped = producer.#state.drops.peek().find((run) => run.start <= sequence && sequence < run.end); + return dropped?.end ?? producer.#arrivals.runEnd(sequence); + }; hooks.producerLargest = (producer) => producer.#state.largest; hooks.joinProducer = (producer) => producer.#join(); hooks.endTrack = (producer, end) => producer.#endPending(end); @@ -2159,18 +2197,62 @@ export class Subscriber { // Resolve this reader's ranges again, as when they are replaced. #resolve(): void { - this.#window = new Window(this.#state.update.peek()?.ranges ?? [{}], this.#latestGroup()); + this.#window = new Window(this.#state.update.peek()?.ranges ?? [{}], this.#latestGroup(), this.#holdings()); this.#bound(); } - // Resolve a relative anchor still waiting on a first group, once one exists. Returns the - // refusal of ranges this copy's wire cannot carry, which every read reports. + // Where the groups this reader's track holds start, for resolving its time anchors: the + // producer's checked starts, and this reader's mirror of its cache. A reader following route + // copies leaves them to the copies, which hold the groups. + #holdings(): Holdings { + const state = this.#state; + const producer = state.producer; + if (this.#resume || !producer) return UNHELD; + return { + containing: (pts) => { + const starts = startsOf(producer); + if (starts.length === 0) return "unknown"; + if (later(starts[0].start, pts)) return "before"; + // Checked starts never rewind, so they ascend with the sequence. + let lo = 1; + let hi = starts.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (later(starts[mid].start, pts)) hi = mid; + else lo = mid + 1; + } + return { group: starts[lo - 1].sequence }; + }, + nextStart: (from) => { + const starts = startsOf(producer); + return starts[timelineIndex(starts, from)]; + }, + nextHeld: (from) => { + const group = state.timeline[timelineIndex(state.timeline, from)]; + if (!group) return undefined; + const started = hooks.groupTimestamp(group); + if (started) return { sequence: group.sequence, held: { started } }; + return { sequence: group.sequence, held: hooks.groupStarted(group) ? "never" : "pending" }; + }, + gone: (sequence) => goneAt(producer, sequence), + complete: () => state.closed.peek() !== undefined || this.#complete(), + }; + } + + // Resolve what the groups now held settle: a relative anchor waiting on a first group once + // one exists, and a time end as groups start. Returns the refusal of ranges this copy cannot + // resolve, which every read reports. #settle(): Error | undefined { - if (this.#state.dropsAnchors.peek() && !this.#window.ranges.every(isWire)) { - return new Error("a relative anchor other than the live start needs moq-lite-07"); + const ranges = this.#window.ranges; + if (this.#state.dropsAnchors.peek() && !ranges.every(isWire)) { + return new Error("a relative or time anchor other than the live start needs moq-lite-07"); } + const info = this.#state.info.peek(); + if (info && info.timescale == null && hasTime(ranges)) return new TimestampMismatch(); + if (this.#resume || this.#window.settled) return undefined; // The live feed had no group when the ranges resolved, so the newest one then was the first. - if (this.#window.pending && !this.#resume && this.#window.settle(this.#state.first)) this.#bound(); + const latest = this.#window.pending ? this.#state.first : this.#latestGroup(); + if (this.#window.settle(latest, this.#holdings())) this.#bound(); return undefined; } @@ -2257,7 +2339,7 @@ export class Subscriber { (datagram) => datagram.sequence >= cursor.start && (cursor.end === undefined || datagram.sequence < cursor.end) && - subscriber.#window.contains(datagram.sequence), + subscriber.#window.admitsDatagram(datagram.sequence, datagram.timestamp), ); return ( (state.closed.peek() !== undefined || subscriber.#complete()) && @@ -2745,6 +2827,8 @@ export class Subscriber { this.#state.groups.changed(fn), this.#state.drops.changed(fn), this.#state.startChanged.changed(fn), + // A group starting may settle a time end, or move the cap it holds reads back with. + ...(this.#window.settled ? [] : [this.#state.timelineChanged.changed(fn)]), this.#state.dropsAnchors.changed(fn), this.#cursor.changed(fn), // A widened budget re-offers the groups it skipped as stale. @@ -2826,7 +2910,7 @@ export class Subscriber { const inRange = (datagram: Datagram) => datagram.sequence >= start && (end === undefined || datagram.sequence < end) && - this.#window.contains(datagram.sequence); + this.#window.admitsDatagram(datagram.sequence, datagram.timestamp); if (this.#resume) { for (;;) { const recv = this.#resume.tryDatagram(this.#delivered); @@ -2895,15 +2979,24 @@ export class Subscriber { // One anchor for the whole pass, so walking a backlog off stays linear. const drift = this.#drift(); - for (;;) { - const group = groups[0]; + for (let index = 0; ; ) { + const group = groups[index]; if (!group || (cursor.end !== undefined && group.sequence >= cursor.end)) break; - groups.shift(); - // Out of range for now: an update taking it in offers it again from the cache. if (!this.#window.contains(group.sequence)) { + // Kept while an anchor still resolves, which may take it in. + if (!this.#window.settled) { + index++; + continue; + } + // Out of range for now: an update taking it in offers it again from the cache. + groups.splice(index, 1); group.close(); continue; } + groups.splice(index, 1); + // Passed over below the one handed out, so never read in sequence order. + for (const passed of groups.splice(0, index)) passed.close(); + index = 0; this.#nextSequence = group.sequence + 1; // Every frame this group could still hold is past the budget, so keep // scanning: one pass walks a whole backlog off rather than replaying it. diff --git a/js/net/src/window.test.ts b/js/net/src/window.test.ts index 1eb8d33cf79..7836e733e0b 100644 --- a/js/net/src/window.test.ts +++ b/js/net/src/window.test.ts @@ -1,6 +1,18 @@ import { expect, test } from "bun:test"; -import type { Range } from "./track.ts"; -import { clamped, joinsLive, rangesOf, union, Window, wireRanges, wireSpan } from "./window.ts"; +import { Timestamp } from "./time.ts"; +import type { Anchor, Range } from "./track.ts"; +import { + clamped, + type Held, + type Holdings, + isWire, + joinsLive, + rangesOf, + union, + Window, + wireRanges, + wireSpan, +} from "./window.ts"; const abs = (start: number, end?: number): Range => end === undefined ? { start: { absolute: start } } : { start: { absolute: start }, end: { absolute: end } }; @@ -102,3 +114,198 @@ test("ranges are checked rather than served widened", () => { expect(joinsLive([{}])).toBe(true); expect(joinsLive([abs(3)])).toBe(false); }); + +const time = (millis: number): Anchor => ({ time: Timestamp.fromMillis(millis) }); +const started = (millis: number): Held => ({ started: Timestamp.fromMillis(millis) }); + +// A hop's groups by sequence, the runs that can no longer arrive, and how many queries a walk made. +class Groups implements Holdings { + held = new Map(); + gones: { start: number; end: number }[] = []; + done = false; + asked = 0; + + constructor(held: [number, Held][] = []) { + for (const [sequence, state] of held) this.held.set(sequence, state); + } + + #sorted(from: number): [number, Held][] { + return [...this.held].filter(([sequence]) => sequence >= from).sort((a, b) => a[0] - b[0]); + } + + containing(pts: Timestamp) { + const starts = this.#sorted(0).filter(([, held]) => typeof held === "object"); + if (starts.length === 0) return "unknown" as const; + const at = starts.filter(([, held]) => !pts.isBefore((held as { started: Timestamp }).started)).at(-1); + return at ? { group: at[0] } : ("before" as const); + } + + nextStart(from: number) { + this.asked++; + const next = this.#sorted(from).find(([, held]) => typeof held === "object"); + return next && { sequence: next[0], start: (next[1] as { started: Timestamp }).started }; + } + + nextHeld(from: number) { + this.asked++; + const next = this.#sorted(from)[0]; + return next && { sequence: next[0], held: next[1] }; + } + + gone(sequence: number) { + return this.gones.find((run) => run.start <= sequence && sequence < run.end)?.end; + } + + complete() { + return this.done; + } +} + +const covered = (window: Window, upto: number) => + Array.from({ length: upto }, (_, sequence) => sequence).filter((sequence) => window.contains(sequence)); + +test("a time start is the group containing it, and an end stops past it once a later group starts after it", () => { + const groups = new Groups([[2, started(2000)], [3, started(3000)], [4, started(4000)], [5, started(5000)], [6, "pending"]]); + let window = new Window([{ start: time(3500), end: time(4500) }], 6, groups); + expect(covered(window, 8)).toEqual([3, 4]); + expect(window.settled).toBe(true); + expect(window.resolved()).toEqual([{ start: 3, end: 5 }]); + + // Group 6 may yet start at 5000 too: read on speculation, the end left open. + window = new Window([{ start: time(3500), end: time(5000) }], 6, groups); + expect(window.settled).toBe(false); + expect(covered(window, 9)).toEqual([3, 4, 5, 6]); + expect(window.resolved()).toEqual([{ start: 3, end: undefined }]); + expect(window.end()).toBeUndefined(); + + // Before every start held: reach back to the oldest. + expect(covered(new Window([{ start: time(1000), end: time(2500) }], 6, groups), 8)).toEqual([0, 1, 2]); + + // Nothing started yet: a time start waits for a group that has, reading none meanwhile, since a + // group arrives before the frame that shows where it starts. + window = new Window([{ start: time(1000) }], undefined, new Groups()); + expect([window.contains(3), window.floor(), window.settled]).toEqual([false, undefined, false]); + expect(window.settle(4, new Groups([[4, "pending"]]))).toBe(false); + expect(window.settle(4, new Groups([[4, started(500)]]))).toBe(true); + expect([window.floor(), window.settled]).toEqual([4, true]); + // Nothing can start any more: it reaches back to the oldest. + const complete = new Groups([[4, "never"]]); + complete.done = true; + expect(new Window([{ start: time(1000) }], 4, complete).floor()).toBe(0); +}); + +test("a time end settles once nothing below its end can arrive", () => { + const groups = new Groups([[3, started(3000)], [6, started(6000)]]); + const window = new Window([{ start: time(3000), end: time(4000) }], 6, groups); + // 4 and 5 may still arrive inside the end: only 4 is read, on speculation, and never 6. + expect(window.settled).toBe(false); + expect(covered(window, 8)).toEqual([3, 4]); + + // 4 arrives yet to start: still the one read on speculation, so 5 waits. + groups.held.set(4, "pending"); + expect(window.settle(6, groups)).toBe(false); + groups.held.set(4, started(3500)); + expect(window.settle(6, groups)).toBe(true); + expect(window.settled).toBe(false); + groups.gones.push({ start: 5, end: 6 }); + expect(window.settle(6, groups)).toBe(true); + expect(window.settled).toBe(true); + expect(covered(window, 8)).toEqual([3, 4, 5]); + + // A group that never starts proves nothing, so it never holds the end open. + const never = new Groups([[3, started(3000)], [4, "never"], [5, started(5000)]]); + const settled = new Window([{ start: time(3000), end: time(4000) }], 5, never); + expect(settled.settled).toBe(true); + expect(covered(settled, 8)).toEqual([3, 4]); +}); + +test("an unsettled time end reads one group on speculation", () => { + const groups = new Groups([[3, "pending"], [4, "pending"], [5, "pending"]]); + const window = new Window([{ start: { absolute: 3 }, end: time(3500) }], 5, groups); + expect(covered(window, 8)).toEqual([3]); + + groups.held.set(3, started(3000)); + expect(window.settle(5, groups)).toBe(true); + expect(covered(window, 8)).toEqual([3, 4]); + + // 4 never starts, so 5 is the one read on speculation. + groups.held.set(4, "never"); + expect(window.settle(5, groups)).toBe(true); + expect(covered(window, 8)).toEqual([3, 4, 5]); + + // 5 stalls and 6 starts inside the end: 5 is in range then, and 7 is speculative. + groups.held.set(6, started(3400)); + groups.held.set(7, "pending"); + expect(window.settle(7, groups)).toBe(true); + expect(covered(window, 9)).toEqual([3, 4, 5, 6, 7]); + + // 7 starts past the end, which settles with 5 still stalled below it. + groups.held.set(7, started(3600)); + expect(window.settle(7, groups)).toBe(true); + expect(window.settled).toBe(true); + expect(covered(window, 9)).toEqual([3, 4, 5, 6]); + + // Nothing more can arrive or start: uncapped. + const complete = new Groups([[3, started(3000)], [4, "pending"], [5, "pending"]]); + complete.done = true; + expect(covered(new Window([{ start: time(3000), end: time(3500) }], 5, complete), 7)).toEqual([3, 4, 5, 6]); +}); + +test("a time end walks each group once behind a missing sequence", () => { + const groups = new Groups([[0, started(0)]]); + const window = new Window([{ start: time(0), end: time(1_000_000) }], 0, groups); + const count = 2_000; + for (let sequence = 2; sequence < count; sequence++) { + groups.held.set(sequence, started(sequence * 10)); + window.settle(sequence, groups); + } + // Group 1 never arrived. + expect(window.settled).toBe(false); + expect(window.contains(count - 1)).toBe(true); + expect(groups.asked).toBeLessThan(8 * count); +}); + +test("a datagram is judged by its own timestamp, one range at a time", () => { + const window = new Window([{ start: { absolute: 0 }, end: time(3500) }], undefined, new Groups()); + expect(window.admitsDatagram(9, Timestamp.fromMillis(3500))).toBe(true); + expect(window.admitsDatagram(9, Timestamp.fromMillis(3600))).toBe(false); + + // A start still waiting judges a datagram by its time alone. + expect(window.admitsDatagram(0, Timestamp.fromMillis(0))).toBe(true); + const waiting = new Window([{ start: time(3000), end: time(3500) }], undefined, new Groups()); + expect(waiting.admitsDatagram(0, Timestamp.fromMillis(2900))).toBe(false); + expect(waiting.admitsDatagram(1, Timestamp.fromMillis(3000))).toBe(true); + expect(waiting.admitsDatagram(3, Timestamp.fromMillis(3600))).toBe(false); + + // Group 1 arrived only as a datagram: the end settles before 2 by sequence, but 1 presents past it. + const groups = new Groups([[0, started(0)], [2, started(10_000)]]); + groups.gones.push({ start: 1, end: 2 }); + const settled = new Window([{ start: time(0), end: time(5000) }], undefined, groups); + expect(settled.resolved()).toEqual([{ start: 0, end: 2 }]); + expect(settled.admitsDatagram(1, Timestamp.fromMillis(4000))).toBe(true); + expect(settled.admitsDatagram(1, Timestamp.fromMillis(7000))).toBe(false); + + // The sequence and the time must pass the same range. + const both = new Window([{ start: { absolute: 6 } }, { start: time(5500) }], 5, new Groups([[5, started(5000)]])); + expect(both.admitsDatagram(5, Timestamp.fromMillis(5000))).toBe(false); + expect(both.admitsDatagram(5, Timestamp.fromMillis(5500))).toBe(true); + expect(both.admitsDatagram(6, Timestamp.fromMillis(5000))).toBe(true); +}); + +test("a time anchor needs moq-lite-07, is resolved by the copy, and clamps to its group", () => { + expect(isWire({ start: time(1) })).toBe(false); + expect(isWire({ start: { absolute: 1 }, end: time(1) })).toBe(false); + // The copy resolves a time end itself, so it asks through the live edge. + expect(wireRanges([{ start: time(1), end: time(5) }])).toEqual([{ start: time(1) }]); + expect(joinsLive([{ start: time(1) }])).toBe(false); + // A time can't be placed against the others without the hop's groups. + expect(union([{ start: time(5) }, {}])).toEqual([{}]); + expect(union([{ start: time(5) }, { start: { relative: 2 } }])).toEqual([{ start: { relative: 2 } }, { start: time(5) }]); + // Resolved anchors become their groups; an unresolved time end stays so the cap applies apart. + expect(clamped([{ start: time(1), end: time(5) }], 2, 9, [{ start: 3, end: 7 }])).toEqual([ + { start: { absolute: 3 }, end: { absolute: 7 } }, + ]); + expect(clamped([{ start: time(1), end: time(5) }], 2, 9, [])).toEqual([{ start: time(1), end: time(5) }]); + expect(rangesOf([{ start: time(1) }])).toEqual([{ start: time(1) }]); + expect(() => rangesOf([{ start: { time: 1 } as unknown as Anchor }])).toThrow(TypeError); +}); diff --git a/js/net/src/window.ts b/js/net/src/window.ts index 4247b5566ad..9d879a29493 100644 --- a/js/net/src/window.ts +++ b/js/net/src/window.ts @@ -9,6 +9,7 @@ * @module */ +import { Timestamp } from "./time.ts"; import type { Anchor, Range } from "./track.ts"; /** A run of group sequences `[start, end)`; an open run ends at infinity. */ @@ -24,8 +25,9 @@ export const LIVE: Range = Object.freeze({ start: Object.freeze({ relative: 0 }) function anchorOf(field: string, anchor: Anchor): Anchor { const keys = typeof anchor === "object" && anchor !== null ? Object.keys(anchor) : []; const value = keys.length === 1 ? (anchor as Record)[keys[0]] : undefined; + if (keys[0] === "time" && value instanceof Timestamp) return { time: value }; if ((keys[0] !== "absolute" && keys[0] !== "relative") || !Number.isSafeInteger(value) || (value as number) < 0) { - throw new TypeError(`${field} must be {absolute} or {relative}, a non-negative safe integer`); + throw new TypeError(`${field} must be {absolute} or {relative}, a non-negative safe integer, or {time}, a Timestamp`); } return keys[0] === "absolute" ? { absolute: value as number } : { relative: value as number }; } @@ -52,6 +54,21 @@ function relative(anchor: Anchor | undefined): number | undefined { return anchor?.relative; } +/** The presentation time a time anchor names, or undefined for any other anchor. */ +function time(anchor: Anchor | undefined): Timestamp | undefined { + return anchor?.time; +} + +/** Whether `time` presents strictly after `other`, as instants, whatever their scales. */ +export function later(time: Timestamp, other: Timestamp): boolean { + return other.isBefore(time); +} + +/** Whether any range starts or ends at a presentation time, which needs a timed track. */ +export function hasTime(ranges: readonly Range[]): boolean { + return ranges.some((range) => time(range.start) !== undefined || time(range.end) !== undefined); +} + /** Whether `range` certainly delivers nothing, wherever the latest group lands. */ export function isEmpty(range: Range): boolean { const end = absolute(range.end); @@ -63,14 +80,22 @@ export function isEmpty(range: Range): boolean { return start !== undefined && end <= start; } -/** A canonical sort key: absolute starts first, then relative ones, then the oldest. */ -function rank(range: Range): [number, number, number] { - const end = absolute(range.end) ?? relative(range.end); - const endKey = range.end === undefined ? Number.POSITIVE_INFINITY : (end ?? 0); - const start = absolute(range.start); - if (start !== undefined) return [0, start, endKey]; - const back = relative(range.start); - return back === undefined ? [2, 0, endKey] : [1, back, endKey]; +/** An anchor as a sort key: absolute, then relative, then time, with `none` for an omitted one. */ +function anchorKey(anchor: Anchor | undefined, none: [number, number]): [number, number] { + if (anchor === undefined) return none; + if (anchor.absolute !== undefined) return [0, anchor.absolute]; + if (anchor.relative !== undefined) return [1, anchor.relative]; + return [2, anchor.time.value / anchor.time.scale]; +} + +/** + * A canonical order: by start, absolute ones first, then relative ones, the oldest (which counts + * back the farthest), and times; then by end, open last. + */ +function compare(a: Range, b: Range): number { + const [x, y] = [anchorKey(a.start, [1, Number.POSITIVE_INFINITY]), anchorKey(b.start, [1, Number.POSITIVE_INFINITY])]; + const [p, q] = [anchorKey(a.end, [3, 0]), anchorKey(b.end, [3, 0])]; + return x[0] - y[0] || x[1] - y[1] || p[0] - q[0] || p[1] - q[1]; } /** @@ -79,7 +104,8 @@ function rank(range: Range): [number, number, number] { * Absolute spans merge where they overlap or touch. The rest can't be placed without the * latest group, so only an open range drops what it certainly covers: the open absolute span * drops ranges starting inside it, and the farthest open relative range drops relative ranges - * starting no farther back. The oldest start covers everything. Mirrors the Rust `union`. + * starting no farther back. The oldest start covers everything, and a time can't be placed + * against the others without the hop's groups. Mirrors the Rust `union`. */ export function union(ranges: readonly Range[]): Range[] { const kept = ranges.filter((range) => !isEmpty(range)); @@ -128,10 +154,7 @@ export function union(ranges: readonly Range[]): Range[] { } if (farthest !== undefined) out.push({ start: { relative: farthest } }); - out.sort((a, b) => { - const [x, y] = [rank(a), rank(b)]; - return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; - }); + out.sort(compare); return out.filter((range, index) => index === 0 || JSON.stringify(range) !== JSON.stringify(out[index - 1])); } @@ -169,21 +192,32 @@ export function rangesEnd(ranges: readonly Range[]): number | undefined { */ export function isWire(range: Range): boolean { const back = relative(range.start); - return (back === undefined || back === 0) && relative(range.end) === undefined; + return ( + (back === undefined || back === 0) && + time(range.start) === undefined && + relative(range.end) === undefined && + time(range.end) === undefined + ); } /** * The ranges a SUBSCRIBE carries for `ranges`, without the empty ones. Mirrors the Rust * `wire_ranges`. * - * The copy resolves a relative end itself, against the groups it receives past it, so it asks - * through the live edge: SUBSCRIBE_START names a resolved start, but nothing names a resolved - * end, so one resolved upstream would leave the copy short. + * The copy resolves a relative or time end itself, against the groups it receives past it, so + * it asks through the live edge: SUBSCRIBE_START names a resolved start, but nothing names a + * resolved end, so one resolved upstream would leave the copy short. */ export function wireRanges(ranges: readonly Range[]): Range[] { return ranges .filter((range) => !isEmpty(range)) - .map((range) => (relative(range.end) === undefined ? range : range.start ? { start: range.start } : {})); + .map((range) => + range.end === undefined || absolute(range.end) !== undefined + ? range + : range.start + ? { start: range.start } + : {}, + ); } /** Whether every range has an end, so a lite-07 publisher ends the subscription once it covers them. */ @@ -197,7 +231,9 @@ export function bounded(ranges: readonly Range[]): boolean { * such as a route switch picking up where the reader stopped. */ export function joinsLive(ranges: readonly Range[] | undefined): boolean { - return (ranges ?? [{}]).some((range) => absolute(range.start) === undefined && range.end === undefined); + return (ranges ?? [{}]).some( + (range) => absolute(range.start) === undefined && time(range.start) === undefined && range.end === undefined, + ); } /** @@ -219,10 +255,11 @@ export function wireSpan(ranges: readonly Range[]): { start?: number; end?: numb * `ranges` with every range raised to start no earlier than `floor` and stop no later than the * exclusive `end`, without ever widening one. Mirrors the Rust `Subscription::clamped`. * - * `resolved` names where each range resolved, by index, so a relative anchor becomes its own - * group before it is clamped. A relative start not resolved keeps its anchor, since the floor - * may lie before it, so the caller drops what it resolves to below `floor`. The oldest start - * never lies above the floor, so it becomes the floor. + * `resolved` names where each range resolved, by index, so a relative or time anchor becomes its + * own group before it is clamped. A start not resolved keeps its anchor, since the floor may lie + * before it, so the caller drops what it resolves to below `floor`. The oldest start never lies + * above the floor, so it becomes the floor. An end not resolved becomes the cap, except a time + * end, which stays so the caller enforces `end` apart from it. */ export function clamped( ranges: readonly Range[], @@ -234,13 +271,14 @@ export function clamped( return ranges.map((range, index) => { const at = resolved[index]; let start = range.start; - if (at && relative(start) !== undefined) start = { absolute: at.start }; + if (at && absolute(start) === undefined && start !== undefined) start = { absolute: at.start }; let requested = range.end; - if (at?.end !== undefined && relative(requested) !== undefined) requested = { absolute: at.end }; + if (at?.end !== undefined && requested !== undefined && absolute(requested) === undefined) + requested = { absolute: at.end }; const named = absolute(start); if (floor !== undefined && named !== undefined) start = { absolute: Math.max(named, floor) }; else if (floor !== undefined && start === undefined) start = { absolute: floor }; - if (end !== undefined) { + if (end !== undefined && time(requested) === undefined) { const capped = absolute(requested); requested = { absolute: capped === undefined ? end : Math.min(capped, end) }; } @@ -251,38 +289,232 @@ export function clamped( }); } +/** Where a presentation time falls among the groups a hop holds. Mirrors the Rust `Containing`. */ +export type Containing = + /** The newest group starting at or before it. */ + | { group: number } + /** Every group with a known start begins after it. */ + | "before" + /** No group has a known start. */ + | "unknown"; + +/** What a held group says about where it starts. Mirrors the Rust `Held`. */ +export type Held = + /** Its frame 0 presented at this time. */ + | { started: Timestamp } + /** It holds frame 0 and has not presented it yet. */ + | "pending" + /** It never will: it ended empty or failed. */ + | "never"; + +/** + * When a serving hop's groups start, for resolving time anchors. Mirrors the Rust `Holdings`. + * + * Each query answers from ordered indexes, so a time end that walks them forward a step at a + * time pays for each group or run it passes once, however long it stays unsettled. + */ +export interface Holdings { + /** Where `pts` falls among the groups with a known start. */ + containing(pts: Timestamp): Containing; + /** The lowest group at or above `from` known to start, with where it starts. */ + nextStart(from: number): { sequence: number; start: Timestamp } | undefined; + /** The lowest held group at or above `from`, with what it says about its start. */ + nextHeld(from: number): { sequence: number; held: Held } | undefined; + /** + * The exclusive end, past `sequence`, of the run of sequences from it that can no longer + * arrive (dropped, given up, or below where the feed starts), undefined while it may. + */ + gone(sequence: number): number | undefined; + /** Whether nothing more can arrive or start here, so a time end has nothing to wait on. */ + complete(): boolean; +} + +/** + * A hop that holds no groups of its own, such as a reader following route copies that resolve + * its ranges for it: a time end there filters nothing. + */ +export const UNHELD: Holdings = Object.freeze({ + containing: () => "unknown" as const, + nextStart: () => undefined, + nextHeld: () => undefined, + gone: () => undefined, + complete: () => true, +}); + +/** + * A time end not settled yet, and how far the walk to settle it got. Mirrors the Rust `TimeEnd`. + * + * Timestamps never rewind, so the end falls before the lowest group starting past `pts`, once + * every sequence between it and the newest one starting at or before `pts` is accounted for. + * Every position below only moves forward, so each group or run of sequences is passed once, + * whatever a peer leaves missing. + */ +class TimeEnd { + readonly pts: Timestamp; + /** The newest group known to start at or before `pts`: it and everything below it in the span is in range. */ + known?: number; + /** The lowest group known to start after `pts`: where the end falls once settled. */ + after?: number; + /** Where the walk over held groups resumes: those below it started, or never will. */ + held: number; + /** Where settling resumes: every sequence from above `known` up to it is accounted for. */ + accounted: number; + /** The exclusive cap on what is read meanwhile, undefined for none. */ + limit?: number; + /** The group the cap admits on speculation: the first above `known` that may still start, which may start past `pts`. */ + speculative?: number; + + constructor(pts: Timestamp, start: number) { + this.pts = pts; + this.held = start; + this.accounted = start; + this.limit = start; + } + + /** The lowest sequence not known to be in range. */ + unknown(start: number): number { + return this.known === undefined ? start : Math.max(this.known + 1, start); + } + + /** Walk the held groups on from where the last call stopped. Returns where the end falls once it settles. */ + advance(start: number, holdings: Holdings): number | undefined { + // The starts index passes a group that may still start, so a stalled one never keeps a + // later group known to start inside the end from being read. + for (;;) { + const next = holdings.nextStart(this.unknown(start)); + if (!next || (this.after !== undefined && next.sequence >= this.after)) break; + if (later(next.start, this.pts)) this.after = next.sequence; + else this.known = next.sequence; + } + + // The first sequence above `known` that may still start is the one read on speculation: + // a held group yet to start, or a missing one that may arrive. Only a group starting + // inside the end is jumped to, since it puts all below it in range. + this.speculative = undefined; + for (;;) { + const at = Math.max(this.held, this.unknown(start)); + if (this.after !== undefined && at >= this.after) break; + let next = holdings.nextHeld(at); + if (next && this.after !== undefined && next.sequence >= this.after) next = undefined; + const held = next?.held; + if (next && next.sequence === at) { + if (held === "pending") { + this.held = at; + this.speculative = at; + break; + } + if (typeof held === "object") { + if (later(held.started, this.pts)) this.after = at; + else this.known = at; + } + } else if (next && typeof held === "object" && !later(held.started, this.pts)) { + this.known = next.sequence; + } else if (!next && this.after === undefined) { + // Past every held group: the next to arrive is the speculative one, judged once a + // settle sees it, before it is read. + this.held = at; + break; + } else { + const end = holdings.gone(at); + if (end !== undefined && end > at) { + this.held = end; + continue; + } + this.held = at; + this.speculative = holdings.complete() ? undefined : at; + break; + } + this.held = at + 1; + } + if (holdings.complete()) this.limit = undefined; + else if (this.speculative !== undefined) this.limit = this.speculative + 1; + // Every held group is accounted for: the next to arrive is the speculative one. + else this.limit = this.after; + + // Settle once every sequence up to the first group starting past `pts` can no longer + // start inside it. A sequence that arrived only as a datagram never indexes where its + // group starts, so it counts as gone here. + const after = this.after; + if (after === undefined) return undefined; + // Everything at or below `known` is in range, and `after` lies above it. + this.accounted = Math.max(this.accounted, this.unknown(start)); + while (this.accounted < after) { + const next = holdings.nextHeld(this.accounted); + if (next && next.sequence === this.accounted) { + if (next.held === "pending") return undefined; + // It started past `pts` since the walk above stopped on it: everything below is + // accounted for, so the end falls here. + if (typeof next.held === "object" && later(next.held.started, this.pts)) { + this.after = next.sequence; + return this.after; + } + this.accounted++; + } else { + const below = next ? Math.min(next.sequence, after) : after; + if (holdings.complete()) this.accounted = below; + else { + const gone = holdings.gone(this.accounted); + if (gone === undefined || gone <= this.accounted) return undefined; + this.accounted = Math.min(gone, below); + } + } + } + return this.after; + } +} + +/** Where a resolved range stops. */ +type End = + /** Before this group. */ + | { before: number } + /** Past every group. */ + | "open" + /** Before the first group starting after a time, once that is known. */ + | { time: TimeEnd }; + /** One non-empty range, resolved. */ type Span = { /** Which of the ranges it came from. */ index: number; start: number; - /** The exclusive end, infinity while open. */ - end: number; + end: End; /** Whether the start names a group, rather than reaching back to the oldest one. */ floored: boolean; }; +/** The exclusive end of the groups a span reads now, infinity for none: an unsettled time end reads up to its cap. */ +function limitOf(span: Span): number { + if (span.end === "open") return Number.POSITIVE_INFINITY; + if ("before" in span.end) return span.end.before; + return span.end.time.limit ?? Number.POSITIVE_INFINITY; +} + /** - * A subscriber's ranges resolved to the group sequences it reads. Mirrors the Rust `Window`, - * without time anchors. + * A subscriber's ranges resolved to the group sequences it reads. Mirrors the Rust `Window`. * - * A relative anchor resolves when the window is built, counting back from the latest group, - * and again only when the ranges are replaced. Before any group exists it waits, reading - * everything, until {@link settle} names the first one. + * A relative or time anchor resolves when the window is built, and again only when the ranges + * are replaced. Before any group exists a relative anchor waits, reading everything, until + * {@link settle} names the first one. A time start before any group has started waits too, but + * reading nothing of its range, since a group arrives before the frame that shows where it + * starts. A time end settles as the groups it waits on start. */ export class Window { /** What these were resolved from, so only replacing the ranges resolves again. */ readonly ranges: readonly Range[]; #spans: Span[] = []; - /** The groups the spans cover, merged, sorted, and disjoint. */ + /** The groups the spans cover, an unsettled time end through its cap: merged, sorted, and disjoint. */ #runs: Run[] = []; /** A relative anchor waits on a first group, so nothing is filtered yet. */ #pending = false; + /** The ranges, by index, whose time start waits on a group starting, so they read nothing yet. */ + #waiting: number[] = []; + /** Nothing is left to resolve as groups arrive and start. */ + #settled = false; - /** Resolve `ranges`, counting relative anchors back from `latest`. */ - constructor(ranges: readonly Range[], latest: number | undefined) { + /** Resolve `ranges`, counting relative anchors back from `latest` and time anchors from `holdings`. */ + constructor(ranges: readonly Range[], latest: number | undefined, holdings: Holdings = UNHELD) { this.ranges = ranges; - this.#place(latest); + this.#place(latest, holdings); } /** Whether a relative anchor still waits on a first group. */ @@ -290,11 +522,40 @@ export class Window { return this.#pending; } - /** Resolve a waiting relative anchor once `latest` exists. Returns whether the groups in range moved. */ - settle(latest: number | undefined): boolean { - if (!this.#pending || latest === undefined) return false; - this.#place(latest); - return true; + /** Whether nothing is left to resolve as groups arrive and start. */ + get settled(): boolean { + return this.#settled; + } + + /** + * Resolve what the groups now held settle: relative anchors once `latest` exists, and time + * starts and ends as groups start. Returns whether the groups in range moved. + */ + settle(latest: number | undefined, holdings: Holdings = UNHELD): boolean { + if (this.#pending) { + if (latest === undefined) return false; + this.#place(latest, holdings); + return !this.#pending; + } + let moved = false; + const waiting = this.#waiting; + this.#waiting = []; + for (const index of waiting) { + // Its end resolved against `latest` now, as a relative start resolves once one exists. + if (this.#span(index, latest, holdings)) moved = true; + } + for (const span of this.#spans) { + if (span.end === "open" || !("time" in span.end)) continue; + const time = span.end.time; + const limit = time.limit; + const end = time.advance(span.start, holdings); + if (end !== undefined) { + span.end = { before: end }; + moved = true; + } else if (time.limit !== limit) moved = true; + } + if (moved) this.#rebuild(); + return moved; } /** Whether group `sequence` is in range. Everything is while a relative anchor waits. */ @@ -304,6 +565,38 @@ export class Window { return run !== undefined && sequence >= run.start; } + /** + * Whether a datagram is in range, judged on its own and never through a time end's settling: + * its `timestamp` against each range's times, and its `sequence` against the range's absolute + * groups and the relative ones resolved so far. + */ + admitsDatagram(sequence: number, timestamp: Timestamp | undefined): boolean { + if (timestamp === undefined) { + return this.#pending || this.#spans.some((span) => sequence >= span.start && sequence < limitOf(span)); + } + return this.ranges.some((range, index) => { + if (isEmpty(range)) return false; + // A relative anchor still waiting on a latest group bounds nothing yet. + const span = this.#spans.find((span) => span.index === index); + const { start, end } = range; + const started = + start?.time !== undefined + ? !later(start.time, timestamp) + : start?.absolute !== undefined + ? sequence >= start.absolute + : start === undefined || !span || sequence >= span.start; + const ended = + end === undefined + ? true + : end.time !== undefined + ? !later(timestamp, end.time) + : end.absolute !== undefined + ? sequence < end.absolute + : !span || span.end === "open" || !("before" in span.end) || sequence < span.end.before; + return started && ended; + }); + } + /** The lowest group in range at or above `from`, undefined when none is. `from` itself while a relative anchor waits. */ next(from: number): number | undefined { if (this.#pending) return from; @@ -311,9 +604,9 @@ export class Window { return run === undefined ? undefined : Math.max(run.start, from); } - /** The lowest group in range, undefined while a relative anchor waits. Nothing in range floors at group 0. */ + /** The lowest group in range, undefined while an anchor waits. Nothing in range floors at group 0. */ floor(): number | undefined { - if (this.#pending) return undefined; + if (this.#pending || this.#waiting.length > 0) return undefined; return this.#runs[0]?.start ?? 0; } @@ -322,15 +615,19 @@ export class Window { * a relative anchor waits on a first group. */ start(): number | undefined { - if (this.#pending || this.#spans.some((span) => !span.floored)) return undefined; + if (this.#pending || this.#waiting.length > 0 || this.#spans.some((span) => !span.floored)) return undefined; let start: number | undefined; for (const span of this.#spans) start = Math.min(start ?? span.start, span.start); return start; } - /** The exclusive end past every group in range, undefined when one stays open. Nothing in range ends at 0. */ + /** + * The exclusive end past every group in range, undefined when one stays open. An unsettled + * time end counts as open: its cap only holds reads back meanwhile. Nothing in range ends at 0. + */ end(): number | undefined { - if (this.#pending) return undefined; + if (this.#pending || this.#waiting.length > 0) return undefined; + if (this.#spans.some((span) => span.end !== "open" && "time" in span.end)) return undefined; const end = this.#runs.at(-1)?.end ?? 0; return end === Number.POSITIVE_INFINITY ? undefined : end; } @@ -348,13 +645,16 @@ export class Window { return out; } - /** Each range resolved, in the order given: undefined for one that is empty or still waits on a first group. */ + /** + * Each range resolved, in the order given: undefined for one that is empty or whose start + * still waits. An unsettled time end is reported open, never at its cap. + */ resolved(): (Resolved | undefined)[] { const resolved: (Resolved | undefined)[] = this.ranges.map(() => undefined); for (const span of this.#spans) { resolved[span.index] = { start: span.start, - end: span.end === Number.POSITIVE_INFINITY ? undefined : span.end, + end: span.end !== "open" && "before" in span.end ? span.end.before : undefined, }; } return resolved; @@ -372,20 +672,46 @@ export class Window { return this.#runs[lo]; } - #place(latest: number | undefined): void { + #place(latest: number | undefined, holdings: Holdings): void { this.#spans = []; this.#pending = false; - this.ranges.forEach((range, index) => { - if (isEmpty(range)) return; - const start = startOf(range.start, latest); - const end = endOf(range.end, latest); - if (start === undefined || end === undefined) { - this.#pending = true; - return; - } - this.#spans.push({ index, start: start.group, end, floored: start.floored }); - }); - const spans = this.#spans.filter((span) => span.end > span.start).sort((a, b) => a.start - b.start); + this.#waiting = []; + this.ranges.forEach((_, index) => this.#span(index, latest, holdings)); + this.#rebuild(); + } + + // Resolve range `index` into a span, or note what it waits on. Returns whether it resolved. + #span(index: number, latest: number | undefined, holdings: Holdings): boolean { + const range = this.ranges[index]; + if (isEmpty(range)) return false; + const start = startOf(range.start, latest, holdings); + if (start === "waiting") { + this.#waiting.push(index); + return false; + } + const until = endOf(range.end, latest); + if (start === undefined || until === undefined) { + this.#pending = true; + return false; + } + let end: End = until === "open" || "before" in until ? until : { time: new TimeEnd(until.pts, start.group) }; + if (end !== "open" && "time" in end) { + const settled = end.time.advance(start.group, holdings); + if (settled !== undefined) end = { before: settled }; + } + this.#spans.push({ index, start: start.group, end, floored: start.floored }); + return true; + } + + #rebuild(): void { + this.#settled = + !this.#pending && + this.#waiting.length === 0 && + this.#spans.every((span) => span.end === "open" || !("time" in span.end)); + const spans = this.#spans + .map((span) => ({ start: span.start, end: limitOf(span) })) + .filter((span) => span.end > span.start) + .sort((a, b) => a.start - b.start); this.#runs = []; for (const span of spans) { const last = this.#runs.at(-1); @@ -397,26 +723,44 @@ export class Window { /** * The first group a range starts at, and whether it names that group rather than reaching back - * to the oldest one. Undefined while it waits on a latest group. + * to the oldest one. Undefined while it waits on a latest group, and `"waiting"` for a time + * start while no group has started. */ function startOf( anchor: Anchor | undefined, latest: number | undefined, -): { group: number; floored: boolean } | undefined { + holdings: Holdings, +): { group: number; floored: boolean } | "waiting" | undefined { const group = absolute(anchor); if (group !== undefined) return { group, floored: true }; + const pts = time(anchor); + if (pts !== undefined) { + const containing = holdings.containing(pts); + // The group holding it is older than any held, so reach back to the oldest, as when + // nothing can start any more. + if (containing === "before" || (containing === "unknown" && holdings.complete())) { + return { group: 0, floored: false }; + } + // Nothing has started yet, so wait for a group that has. A copy may hear the live edge + // before the groups its publisher resolved the time to, which the edge would skip. + return containing === "unknown" ? "waiting" : { group: containing.group, floored: true }; + } const back = relative(anchor); if (back === undefined) return { group: 0, floored: false }; if (latest === undefined) return undefined; return { group: Math.max(0, latest - back), floored: true }; } -/** Where a range stops, exclusive and infinity while open, or undefined while it waits on a latest group. */ -function endOf(anchor: Anchor | undefined, latest: number | undefined): number | undefined { +/** + * Where a range stops, exclusive, or undefined while it waits on a latest group. A time end's + * walk starts once its start is known. + */ +function endOf(anchor: Anchor | undefined, latest: number | undefined): { before: number } | "open" | { pts: Timestamp } | undefined { + if (anchor === undefined) return "open"; const end = absolute(anchor); - if (end !== undefined) return end; - const back = relative(anchor); - if (back === undefined) return Number.POSITIVE_INFINITY; + if (end !== undefined) return { before: end }; + const pts = time(anchor); + if (pts !== undefined) return { pts }; if (latest === undefined) return undefined; - return Math.max(0, latest - back) + 1; + return { before: Math.max(0, latest - (relative(anchor) ?? 0)) + 1 }; } From 43b53a4153d3dbca89cc94a7f6191e3b7877bcf3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 18:08:32 -0700 Subject: [PATCH 07/22] feat(net): refuse JS time anchors on moq-transport copies; docs and quest remainder Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 3 ++- doc/lib/js/net.md | 2 +- js/net/api/index.api.md | 21 +++++++++++++-- js/net/src/lite/subscribe.ts | 9 ++++++- js/net/src/lite/subscriber.ts | 14 ++++++++-- js/net/src/track.test.ts | 19 ++++++++----- js/net/src/track.ts | 6 ++++- js/net/src/window.test.ts | 41 ++++++++++++++++++++++++----- js/net/src/window.ts | 16 ++++++++--- quest/m1/subscribe-ranges/README.md | 2 +- quest/m1/subscribe-ranges/js.md | 29 ++++++++------------ 11 files changed, 118 insertions(+), 44 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 23d6e26371a..5434304deaa 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -297,7 +297,8 @@ covering them. A relative or time anchor resolves on the hop that publishes the track, and over moq-lite-07, which carries a relative or time start upstream and reports the live edge and media time, so a copy resolves the ends itself. A Rust moq-transport copy accepts anchors its cache can already -resolve and sends group bounds upstream; it refuses an unknown time end. Older +resolve and sends group bounds upstream; it refuses an unknown time end. A +JavaScript one refuses every time anchor. Older moq-lite versions accept absolute anchors but refuse time anchors and relative anchors other than the live and default starts. An untimed track refuses a time anchor. At the live edge, a time end diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index edb75a38174..2b293817851 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -51,7 +51,7 @@ publication.announce(); - **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. - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first; `null` leaves the publisher's retention alone to bound it, for a reader reaching back for history. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. -- **A subscription asks for `ranges`.** Each [range](/concept/moq-lite#subscriptions) starts and ends at an `absolute` group or one `relative` to the latest, and a reader reads only its own ranges. JavaScript has no time anchors yet. Passing the old `groups` key throws a `TypeError` naming `ranges`; `withRange` only narrows reads locally. +- **A subscription asks for `ranges`.** Each [range](/concept/moq-lite#subscriptions) starts and ends at an `absolute` group, one `relative` to the latest, or the group containing a `time` (a `Timestamp`), and a reader reads only its own ranges. A time anchor needs a timed track and `moq-lite-07-wip` over a session. Passing the old `groups` key throws a `TypeError` naming `ranges`; `withRange` only narrows reads locally. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). - **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Credential` it returns (`publication.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 48487d7b159..4854c1c69a9 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -540,6 +540,19 @@ export interface Timed { } export namespace Track { + export type Anchor = { + absolute: number; + relative?: never; + time?: never; + } | { + absolute?: never; + relative: number; + time?: never; + } | { + absolute?: never; + relative?: never; + time: Time.Timestamp; + }; export type Bound = { excluded?: never; included: number; @@ -626,6 +639,10 @@ export namespace Track { get subscription(): Signals.Getter; writeFrame(frame: Group.Frame): void; } + export interface Range { + end?: Track.Anchor; + start?: Track.Anchor; + } export class Request { accept(info?: Partial): Track.Producer; demand(): Track.Demand; @@ -650,10 +667,10 @@ export namespace Track { withRange(groups: Track.Groups): this; } export interface Subscription { - groups?: Track.Groups; - maxDelay?: Milli; + maxDelay?: Milli | null; order?: Track.Order; priority?: number; + ranges?: Track.Range[]; } export function infoDefaults(info?: Partial): Track.Info; } diff --git a/js/net/src/lite/subscribe.ts b/js/net/src/lite/subscribe.ts index 33093a9d229..bf0c6202826 100644 --- a/js/net/src/lite/subscribe.ts +++ b/js/net/src/lite/subscribe.ts @@ -1,5 +1,12 @@ import type * as Epoch from "../epoch.ts"; -import { fromTransport, ProtocolViolation, StreamCode, StreamError, TimestampMismatch, toStreamCode } from "../error.ts"; +import { + fromTransport, + ProtocolViolation, + StreamCode, + StreamError, + TimestampMismatch, + toStreamCode, +} from "../error.ts"; import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import { type Timescale, Timestamp } from "../time.ts"; diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index e58f73979e7..95e3533b00b 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -949,7 +949,15 @@ export class Subscriber { try { if (valid?.aborted) retire(); state.cancel.signal.throwIfAborted(); - entry = this.#registerSubscribe(id, msg, ranges, producer, entry.compress, entry.timescale, entry.scale); + entry = this.#registerSubscribe( + id, + msg, + ranges, + producer, + entry.compress, + entry.timescale, + entry.scale, + ); stream = await this.#sendSubscribe(state, msg, new Deadline()); current = { id, msg }; } catch (err) { @@ -957,7 +965,9 @@ export class Subscriber { this.#subscribes.delete(id); state.stream?.abort(e); producer.abort(e); - console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${producer.name} error=${reason(e)}`); + console.warn( + `subscribe error: id=${id} broadcast=${broadcast} track=${producer.name} error=${reason(e)}`, + ); return; } finally { valid?.removeEventListener("abort", retire); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index d93b32c609e..725e8131f3f 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -636,7 +636,9 @@ test("a time range reads the groups containing its times, settling its end as gr test("a datagram is judged against a time range by its own timestamp", () => { const producer = new TrackProducer("test"); producer.accept({ timescale: Timescale.MILLI }); - const reader = producer.subscribe({ ranges: [{ start: { absolute: 0 }, end: { time: Timestamp.fromMillis(3500) } }] }); + const reader = producer.subscribe({ + ranges: [{ start: { absolute: 0 }, end: { time: Timestamp.fromMillis(3500) } }], + }); producer.insertDatagram(0, Timestamp.fromMillis(3600), enc.encode("late")); producer.insertDatagram(1, Timestamp.fromMillis(3400), enc.encode("in")); const recv = hooks.tryRecvDatagram(reader); @@ -644,12 +646,17 @@ test("a datagram is judged against a time range by its own timestamp", () => { expect(hooks.tryRecvDatagram(reader)).toEqual({ kind: "idle" }); }); -test("an untimed track refuses a time anchor", () => { - const producer = new TrackProducer("test"); - producer.accept({}); - const reader = producer.subscribe({ ranges: [{ start: { time: Timestamp.fromMillis(0) } }] }); - producer.createGroup(0).finish(); +test("an untimed track or a moq-transport copy refuses a time anchor", () => { + const ranges = [{ start: { time: Timestamp.fromMillis(0) } }]; + const untimed = new TrackProducer("test"); + untimed.accept({}); + const reader = untimed.subscribe({ ranges }); + untimed.createGroup(0).finish(); expect(() => reader.tryRecvGroup()).toThrow(TimestampMismatch); + + const copy = new TrackProducer("test"); + copy.accept({ timescale: Timescale.MILLI, ietf: IetfVersion.DRAFT_18 }); + expect(() => copy.subscribe({ ranges }).tryRecvGroup()).toThrow("moq-transport"); }); // A resume floor above the live edge must not hide the latest group from a reader that diff --git a/js/net/src/track.ts b/js/net/src/track.ts index d82e3b1c408..4791fdf81e2 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -243,7 +243,7 @@ export interface Groups { * below that one is accounted for; until then reads stop at the first held group that may still * start, so at most that one group past the end is read before its first frame shows where it * starts. A datagram is judged by its own timestamp. A time anchor on an untimed track is refused - * with {@link TimestampMismatch}. + * with {@link TimestampMismatch}, and on a moq-transport copy, whose timestamps may rewind. * * Older moq-lite versions carry only absolute anchors, the live start, and the oldest start, * refusing any other. @@ -2249,6 +2249,10 @@ export class Subscriber { } const info = this.#state.info.peek(); if (info && info.timescale == null && hasTime(ranges)) return new TimestampMismatch(); + // Its timestamps may rewind, so its group starts are never checked or indexed. + if (info?.ietf !== undefined && hasTime(ranges)) { + return new Error("a time anchor needs timestamps that never rewind, which moq-transport does not promise"); + } if (this.#resume || this.#window.settled) return undefined; // The live feed had no group when the ranges resolved, so the newest one then was the first. const latest = this.#window.pending ? this.#state.first : this.#latestGroup(); diff --git a/js/net/src/window.test.ts b/js/net/src/window.test.ts index 7836e733e0b..5f728ce6a74 100644 --- a/js/net/src/window.test.ts +++ b/js/net/src/window.test.ts @@ -165,7 +165,13 @@ const covered = (window: Window, upto: number) => Array.from({ length: upto }, (_, sequence) => sequence).filter((sequence) => window.contains(sequence)); test("a time start is the group containing it, and an end stops past it once a later group starts after it", () => { - const groups = new Groups([[2, started(2000)], [3, started(3000)], [4, started(4000)], [5, started(5000)], [6, "pending"]]); + const groups = new Groups([ + [2, started(2000)], + [3, started(3000)], + [4, started(4000)], + [5, started(5000)], + [6, "pending"], + ]); let window = new Window([{ start: time(3500), end: time(4500) }], 6, groups); expect(covered(window, 8)).toEqual([3, 4]); expect(window.settled).toBe(true); @@ -195,7 +201,10 @@ test("a time start is the group containing it, and an end stops past it once a l }); test("a time end settles once nothing below its end can arrive", () => { - const groups = new Groups([[3, started(3000)], [6, started(6000)]]); + const groups = new Groups([ + [3, started(3000)], + [6, started(6000)], + ]); const window = new Window([{ start: time(3000), end: time(4000) }], 6, groups); // 4 and 5 may still arrive inside the end: only 4 is read, on speculation, and never 6. expect(window.settled).toBe(false); @@ -213,14 +222,22 @@ test("a time end settles once nothing below its end can arrive", () => { expect(covered(window, 8)).toEqual([3, 4, 5]); // A group that never starts proves nothing, so it never holds the end open. - const never = new Groups([[3, started(3000)], [4, "never"], [5, started(5000)]]); + const never = new Groups([ + [3, started(3000)], + [4, "never"], + [5, started(5000)], + ]); const settled = new Window([{ start: time(3000), end: time(4000) }], 5, never); expect(settled.settled).toBe(true); expect(covered(settled, 8)).toEqual([3, 4]); }); test("an unsettled time end reads one group on speculation", () => { - const groups = new Groups([[3, "pending"], [4, "pending"], [5, "pending"]]); + const groups = new Groups([ + [3, "pending"], + [4, "pending"], + [5, "pending"], + ]); const window = new Window([{ start: { absolute: 3 }, end: time(3500) }], 5, groups); expect(covered(window, 8)).toEqual([3]); @@ -246,7 +263,11 @@ test("an unsettled time end reads one group on speculation", () => { expect(covered(window, 9)).toEqual([3, 4, 5, 6]); // Nothing more can arrive or start: uncapped. - const complete = new Groups([[3, started(3000)], [4, "pending"], [5, "pending"]]); + const complete = new Groups([ + [3, started(3000)], + [4, "pending"], + [5, "pending"], + ]); complete.done = true; expect(covered(new Window([{ start: time(3000), end: time(3500) }], 5, complete), 7)).toEqual([3, 4, 5, 6]); }); @@ -278,7 +299,10 @@ test("a datagram is judged by its own timestamp, one range at a time", () => { expect(waiting.admitsDatagram(3, Timestamp.fromMillis(3600))).toBe(false); // Group 1 arrived only as a datagram: the end settles before 2 by sequence, but 1 presents past it. - const groups = new Groups([[0, started(0)], [2, started(10_000)]]); + const groups = new Groups([ + [0, started(0)], + [2, started(10_000)], + ]); groups.gones.push({ start: 1, end: 2 }); const settled = new Window([{ start: time(0), end: time(5000) }], undefined, groups); expect(settled.resolved()).toEqual([{ start: 0, end: 2 }]); @@ -300,7 +324,10 @@ test("a time anchor needs moq-lite-07, is resolved by the copy, and clamps to it expect(joinsLive([{ start: time(1) }])).toBe(false); // A time can't be placed against the others without the hop's groups. expect(union([{ start: time(5) }, {}])).toEqual([{}]); - expect(union([{ start: time(5) }, { start: { relative: 2 } }])).toEqual([{ start: { relative: 2 } }, { start: time(5) }]); + expect(union([{ start: time(5) }, { start: { relative: 2 } }])).toEqual([ + { start: { relative: 2 } }, + { start: time(5) }, + ]); // Resolved anchors become their groups; an unresolved time end stays so the cap applies apart. expect(clamped([{ start: time(1), end: time(5) }], 2, 9, [{ start: 3, end: 7 }])).toEqual([ { start: { absolute: 3 }, end: { absolute: 7 } }, diff --git a/js/net/src/window.ts b/js/net/src/window.ts index 9d879a29493..83abf672ee0 100644 --- a/js/net/src/window.ts +++ b/js/net/src/window.ts @@ -27,7 +27,9 @@ function anchorOf(field: string, anchor: Anchor): Anchor { const value = keys.length === 1 ? (anchor as Record)[keys[0]] : undefined; if (keys[0] === "time" && value instanceof Timestamp) return { time: value }; if ((keys[0] !== "absolute" && keys[0] !== "relative") || !Number.isSafeInteger(value) || (value as number) < 0) { - throw new TypeError(`${field} must be {absolute} or {relative}, a non-negative safe integer, or {time}, a Timestamp`); + throw new TypeError( + `${field} must be {absolute} or {relative}, a non-negative safe integer, or {time}, a Timestamp`, + ); } return keys[0] === "absolute" ? { absolute: value as number } : { relative: value as number }; } @@ -93,7 +95,10 @@ function anchorKey(anchor: Anchor | undefined, none: [number, number]): [number, * back the farthest), and times; then by end, open last. */ function compare(a: Range, b: Range): number { - const [x, y] = [anchorKey(a.start, [1, Number.POSITIVE_INFINITY]), anchorKey(b.start, [1, Number.POSITIVE_INFINITY])]; + const [x, y] = [ + anchorKey(a.start, [1, Number.POSITIVE_INFINITY]), + anchorKey(b.start, [1, Number.POSITIVE_INFINITY]), + ]; const [p, q] = [anchorKey(a.end, [3, 0]), anchorKey(b.end, [3, 0])]; return x[0] - y[0] || x[1] - y[1] || p[0] - q[0] || p[1] - q[1]; } @@ -676,7 +681,7 @@ export class Window { this.#spans = []; this.#pending = false; this.#waiting = []; - this.ranges.forEach((_, index) => this.#span(index, latest, holdings)); + for (let index = 0; index < this.ranges.length; index++) this.#span(index, latest, holdings); this.#rebuild(); } @@ -755,7 +760,10 @@ function startOf( * Where a range stops, exclusive, or undefined while it waits on a latest group. A time end's * walk starts once its start is known. */ -function endOf(anchor: Anchor | undefined, latest: number | undefined): { before: number } | "open" | { pts: Timestamp } | undefined { +function endOf( + anchor: Anchor | undefined, + latest: number | undefined, +): { before: number } | "open" | { pts: Timestamp } | undefined { if (anchor === undefined) return "open"; const end = absolute(anchor); if (end !== undefined) return { before: end }; diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index e674216c750..7d3698dcc13 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -101,7 +101,7 @@ every sequence delivered or dropped. - [Linear fill path](/quest/m1/subscribe-ranges/fill-linear.md) - filling stays flat as range count and span grow, so a subscription's range count cannot multiply per-group cost - [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - `fetch_group` becomes a one-range subscription that ends once its range is accounted for - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) - the lite-07 wire, Rust publisher and subscriber, and the draft -- [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/net` time anchors, reopened demand, and the on-demand request surface +- [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/net`'s on-demand request surface and `fetchGroup` as a range - [Remove FETCH from lite-07](/quest/m1/subscribe-ranges/lite07-fetch.md) - FETCH removed from lite-07 in Rust, JS, and the draft - [Delete the old start bookkeeping](/quest/m1/subscribe-ranges/start-bookkeeping.md) - what each lite cursor is owed and dropped lives in the model, and `TrackRun` only translates it per version diff --git a/quest/m1/subscribe-ranges/js.md b/quest/m1/subscribe-ranges/js.md index 02ae2f6f779..e51e1695722 100644 --- a/quest/m1/subscribe-ranges/js.md +++ b/quest/m1/subscribe-ranges/js.md @@ -1,31 +1,24 @@ -# [L] JS ranges +# [M] JS ranges ## Goal -`@moq/net` matches Rust: `Subscription` ranges take `Time(pts)` anchors too, -`fetchGroup` becomes a one-range subscription or is removed, and a JavaScript -publisher serves requested ranges that are no longer in its live cache. +`@moq/net` matches Rust: `fetchGroup` becomes a one-range subscription or is +removed, and a JavaScript publisher serves requested ranges that are no longer +in its live cache. ## Plan Landed in [moq-dev/moq#6949](https://github.com/moq-dev/moq/pull/6949): -`Subscription.ranges` of `absolute` and `relative` anchors in place of -`groups`, a per-reader window (`js/net/src/window.ts`), the union aggregate, -lite-07 SUBSCRIBE carrying the list (older wires the covering span, refusing -other relative anchors per reader), `maxDelay: null`, and the lite-07 publisher -FINning a covered bounded subscription without SUBSCRIBE_END. +`Subscription.ranges` of `absolute`, `relative`, and `time` anchors in place of +`groups`, a per-reader window (`js/net/src/window.ts`) with the Rust time-end +walk, the union aggregate, lite-07 SUBSCRIBE carrying the list with times in +the track's timescale (older wires the covering span, refusing other relative +and time anchors per reader), `maxDelay: null`, the lite-07 publisher FINning a +covered bounded subscription without SUBSCRIBE_END, and the subscriber opening +a fresh SUBSCRIBE on the same track when that FIN leaves the demand uncovered. What remains: -- **Time anchors.** Mirror Rust's `Anchor::Time(pts)`: resolved from the - cache's group starts (the time-end settle walk in Rust's `window.rs`), - refused on an untimed track with `TimestampMismatch`, sent upstream on - lite-07, and a datagram judged by its own timestamp. Today a JS publisher - decoding a time anchor serves from the oldest group or through the live edge. -- **Reopen uncovered demand.** When a lite-07 FIN leaves the demand uncovered - because an update moved the ranges, the JS subscriber fails the track - (`#settleTail`). Reopen that demand on the same track instead, as the Rust - copy does. - **On-demand request surface.** Decided in the 2026-10-05 audit: the producer half of [JavaScript FETCH](/quest/m1/js-fetch.md) folds in here, so `@moq/net`'s on-demand request surface takes range requests from the start From 53cc20773cb71a75e312eec17696af806820eee0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 19:16:35 -0700 Subject: [PATCH 08/22] fix(net): a time start waits for a group to start in Rust too; JS review fixes A Rust time start resolved at the latest group while no group had started, so a copy that showed the route's answer (an empty edge group) before the groups its publisher resolved the time to, or a producer that opened groups before their first frames, skipped them. It now waits, reading nothing of its range, as JS does, and reaches back to the oldest group once nothing more can start. JS holdings now count as complete only once the producer is gone, as Rust's sealed or aborted state does, since a group below a reached end may still start. A window that settles in place bumps a revision so the drop scan looks again at drops it skipped past the old cap. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 41 ++++ js/net/src/track.ts | 29 ++- rs/moq-net/src/model/subscription.rs | 17 +- rs/moq-net/src/model/subscription/window.rs | 201 +++++++++++++++----- rs/moq-net/src/model/track.rs | 43 +++++ 5 files changed, 263 insertions(+), 68 deletions(-) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 725e8131f3f..f0514001643 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -646,6 +646,47 @@ test("a datagram is judged against a time range by its own timestamp", () => { expect(hooks.tryRecvDatagram(reader)).toEqual({ kind: "idle" }); }); +// A reached end leaves a held group that may still start, so a time start still waits on it. +test("a time start waits on groups below a reached end that have yet to start", () => { + for (const order of ["ascending", "descending"] as const) { + const producer = new TrackProducer("test"); + producer.accept({ timescale: Timescale.MILLI }); + const groups = [0, 1, 2].map((sequence) => producer.createGroup(sequence)); + producer.finishAt({ group: 3, frame: 0 }); + const reader = producer.subscribe({ + maxDelay: null, + order, + ranges: [{ start: { time: Timestamp.fromMillis(1500) } }], + }); + expect(reader.tryRecvGroup()).toBeUndefined(); + for (const [sequence, group] of groups.entries()) { + group.writeFrame(textFrame(`${sequence}`, Timestamp.fromMillis(sequence * 1000))); + } + const got: number[] = []; + for (let group = reader.tryRecvGroup(); group; group = reader.tryRecvGroup()) got.push(group.sequence); + expect(got.sort()).toEqual([1, 2]); + } +}); + +// A drop outside a time end's cap is reported once the end settles past it. +test("a drop a time end settles over is reported", () => { + const producer = new TrackProducer("test"); + producer.accept({ timescale: Timescale.MILLI }); + const zero = producer.createGroup(0); + const reader = producer.subscribe({ + maxDelay: null, + order: "ascending", + ranges: [{ start: { absolute: 0 }, end: { time: Timestamp.fromMillis(500) } }, { start: { absolute: 10 } }], + }); + expect(hooks.tryRecvGroup(reader, true)).toMatchObject({ kind: "group", group: { sequence: 0 } }); + // Past the cap while group 0 has yet to start. + hooks.dropTrackGroups(producer, 1, 2, DROPPED); + expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); + zero.writeFrame(textFrame("0", Timestamp.fromMillis(0))); + producer.createGroup(2).writeFrame(textFrame("2", Timestamp.fromMillis(2000))); + expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 1, end: 2, error: DROPPED }); +}); + test("an untimed track or a moq-transport copy refuses a time anchor", () => { const ranges = [{ start: { time: Timestamp.fromMillis(0) } }]; const untimed = new TrackProducer("test"); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 4791fdf81e2..1e37db1cf1e 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -239,10 +239,10 @@ export interface Groups { * * A time anchor resolves from where the groups the hop holds start. A start before every group * held reaches back to the oldest one, and one resolved before any group has started waits for - * one that has, reading everything meanwhile as a waiting relative anchor does. An end settles once a group starting after it is known and every sequence - * below that one is accounted for; until then reads stop at the first held group that may still - * start, so at most that one group past the end is read before its first frame shows where it - * starts. A datagram is judged by its own timestamp. A time anchor on an untimed track is refused + * one that has, reading nothing of its range meanwhile. An end settles once a group starting + * after it is known and every sequence below that one is accounted for; until then reads stop + * at the first held group that may still start, so at most that one group past the end is read + * before its first frame shows where it starts. A datagram is judged by its own timestamp. A time anchor on an untimed track is refused * with {@link TimestampMismatch}, and on a moq-transport copy, whose timestamps may rewind. * * Older moq-lite versions carry only absolute anchors, the live start, and the oldest start, @@ -1861,6 +1861,8 @@ export class Subscriber { // This reader's own ranges resolved to the groups it reads, whatever another subscriber // widens the aggregate to. Assigned in the constructor. #window!: Window; + // Bumped whenever the window moves, so the drop scan knows to look again. + #revision = 0; // The hull of what this reader reads now: its local bounds narrowed to the window's floor // and end. A group inside it is read only if the window contains it too. #cursor = new Signal<{ start: number; end?: number }>({ start: 0 }); @@ -1876,7 +1878,7 @@ export class Subscriber { // the track's drops last folded in, and every sequence already reported. Drops are // persistent track state, so they are tracked here rather than removed for everyone. #pending: DropRun[] = []; - #dropsSeen?: { drops: readonly DropRun[]; start: number; end?: number; window: Window }; + #dropsSeen?: { drops: readonly DropRun[]; start: number; end?: number; revision: number }; #reported: Run[] = []; // The group the frame-level helpers are currently draining, acquired through the // sequence cursor so frame reads and {@link Ordered.nextGroup} share one floor. @@ -2198,6 +2200,12 @@ export class Subscriber { // Resolve this reader's ranges again, as when they are replaced. #resolve(): void { this.#window = new Window(this.#state.update.peek()?.ranges ?? [{}], this.#latestGroup(), this.#holdings()); + this.#moved(); + } + + // The window moved: narrow the bounds to it. + #moved(): void { + this.#revision++; this.#bound(); } @@ -2235,7 +2243,8 @@ export class Subscriber { return { sequence: group.sequence, held: hooks.groupStarted(group) ? "never" : "pending" }; }, gone: (sequence) => goneAt(producer, sequence), - complete: () => state.closed.peek() !== undefined || this.#complete(), + // Only once the producer is gone: a group held below a reached end may still start. + complete: () => state.closed.peek() !== undefined, }; } @@ -2256,7 +2265,7 @@ export class Subscriber { if (this.#resume || this.#window.settled) return undefined; // The live feed had no group when the ranges resolved, so the newest one then was the first. const latest = this.#window.pending ? this.#state.first : this.#latestGroup(); - if (this.#window.settle(latest, this.#holdings())) this.#bound(); + if (this.#window.settle(latest, this.#holdings())) this.#moved(); return undefined; } @@ -2781,10 +2790,10 @@ export class Subscriber { // Rescanned only after it changed, so an idle poll costs nothing per drop held. // Out-of-window runs were skipped, so a moved window rescans too. const drops = this.#state.drops.peek(); - const window = this.#window; + const revision = this.#revision; const seen = this.#dropsSeen; - if (!seen || seen.drops !== drops || seen.start !== start || seen.end !== end || seen.window !== window) { - this.#dropsSeen = { drops, start, end, window }; + if (!seen || seen.drops !== drops || seen.start !== start || seen.end !== end || seen.revision !== revision) { + this.#dropsSeen = { drops, start, end, revision }; for (const run of drops) this.#pending = insertDrop(this.#pending, run.start, run.end, run.error); } for (let run = this.#pending.shift(); run; run = this.#pending.shift()) { diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 96b2bf9970b..d6751d4c3a6 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -136,14 +136,15 @@ pub enum Anchor { /// /// Resolved from the starts of the groups the serving hop holds. When none starts at /// or before it, a start reaches back to the oldest group, and while no group has - /// started at all it waits like `Relative(0)`. An end settles once a group starting - /// after it is known and every sequence below that one is accounted for (started, - /// dropped, or never to start). Until then reads stop at the first held group that - /// may still start, so at the live edge at most that one group past the end is read - /// before its first frame shows where it starts. A datagram skips all of that: it is - /// judged on its own, its timestamp against the range's times and its sequence against - /// the range's absolute groups and the relative ones resolved so far. An untimed track - /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). + /// started at all it waits for one that has, reading nothing of its range meanwhile. + /// An end settles once a group starting after it is known and every sequence below + /// that one is accounted for (started, dropped, or never to start). Until then reads + /// stop at the first held group that may still start, so at the live edge at most that + /// one group past the end is read before its first frame shows where it starts. A + /// datagram skips all of that: it is judged on its own, its timestamp against the + /// range's times and its sequence against the range's absolute groups and the relative + /// ones resolved so far. An untimed track refuses it with + /// [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index fc4b32df39e..9ed3554f998 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -122,6 +122,9 @@ pub(in crate::model) struct Window { missable: RangeInclusiveSet, /// A relative anchor waits on a first group, so nothing is filtered yet. pending: bool, + /// The ranges, by index, whose time start waits on a group starting, so they read + /// nothing yet. + waiting: Vec, /// Nothing is left to resolve: see [`Self::is_settled`]. Kept so a read checks it once /// per group, not once per range. settled: bool, @@ -331,6 +334,7 @@ impl Window { groups: RangeInclusiveSet::new(), missable: RangeInclusiveSet::new(), pending: false, + waiting: Vec::new(), settled: false, }; window.place(latest, holdings)?; @@ -348,7 +352,7 @@ impl Window { } /// Resolve what the groups now held settle: relative anchors once `latest` exists, and - /// time ends as groups start. Returns whether the groups in range moved. + /// time starts and ends as groups start. Returns whether the groups in range moved. pub fn settle(&mut self, latest: Option, holdings: &impl Holdings) -> bool { if self.pending { if latest.is_none() { @@ -359,6 +363,9 @@ impl Window { return !self.pending; } let mut moved = false; + for index in std::mem::take(&mut self.waiting) { + moved |= self.span(index, latest, holdings); + } for span in &mut self.spans { let End::Time(time) = &mut span.end else { continue }; let limit = time.limit; @@ -384,9 +391,12 @@ impl Window { }) } - /// The lowest sequence whose start could still move an unsettled time end, `None` - /// without one or once the last sequence is in range. + /// The lowest sequence whose start could still move an unsettled time start or end, + /// `None` without one or once the last sequence is in range. pub fn watch_starts(&self) -> Option { + if !self.waiting.is_empty() { + return Some(0); + } self.spans .iter() .filter_map(|span| match span.end { @@ -432,26 +442,26 @@ impl Window { }) } - /// The lowest group in range, `None` while a relative anchor waits. Nothing in range - /// floors nowhere: group 0. + /// The lowest group in range, `None` while an anchor waits. Nothing in range floors + /// nowhere: group 0. pub fn floor(&self) -> Option { - match self.pending { + match self.pending || !self.waiting.is_empty() { true => None, false => Some(self.groups.iter().next().map_or(0, |run| *run.start())), } } /// The lowest group the ranges name, `None` while one reaches back to the oldest group - /// or a relative anchor waits on a first group. + /// or an anchor waits. pub fn start(&self) -> Option { - if self.pending || self.spans.iter().any(|span| !span.floored) { + if self.pending || !self.waiting.is_empty() || self.spans.iter().any(|span| !span.floored) { return None; } self.spans.iter().map(|span| span.start).min() } - /// Each range resolved, in the order given: `None` for one that is empty or still - /// waits on a first group. An unsettled time end is reported open, never at its cap. + /// Each range resolved, in the order given: `None` for one that is empty or whose start + /// still waits. An unsettled time end is reported open, never at its cap. pub fn resolved(&self) -> Vec> { let mut resolved = vec![None; self.ranges.len()]; for span in &self.spans { @@ -466,10 +476,10 @@ impl Window { resolved } - /// The exclusive end past every group in range, `None` when one stays open. An - /// unsettled time end counts as open: its cap only holds reads back meanwhile. + /// The exclusive end past every group in range, `None` when one stays open or an anchor + /// waits. An unsettled time end counts as open: its cap only holds reads back meanwhile. pub fn end(&self) -> Option { - if self.pending || self.spans.iter().any(|span| matches!(span.end, End::Time(_))) { + if self.pending || !self.waiting.is_empty() || self.spans.iter().any(|span| matches!(span.end, End::Time(_))) { return None; } match self.groups.iter().next_back() { @@ -545,37 +555,60 @@ impl Window { } self.spans.clear(); self.pending = false; - for (index, range) in self.ranges.iter().enumerate().filter(|(_, range)| !range.is_empty()) { - let (Some((start, floored)), Some(end)) = (start(range.start, latest, holdings), end(range.end, latest)) - else { - self.pending = true; - continue; - }; - let end = match end { - End::Time(mut time) => { - time = TimeEnd::new(time.pts, start); - time.advance(start, holdings).map_or(End::Time(time), End::Before) - } - end => end, - }; - let reach = match range.start { - Anchor::Time(_) if !floored => holdings.next_start(0).map_or(start, |(oldest, _)| oldest), - _ => start, - }; - self.spans.push(Span { - index, - start, - end, - floored, - reach, - }); + self.waiting.clear(); + for index in 0..self.ranges.len() { + self.span(index, latest, holdings); } self.rebuild(); Ok(()) } + /// Resolve range `index` into a span, or note what it waits on. Returns whether it + /// resolved. + fn span(&mut self, index: usize, latest: Option, holdings: &impl Holdings) -> bool { + let range = self.ranges[index]; + if range.is_empty() { + return false; + } + let (start, floored) = match start(range.start, latest, holdings) { + Start::At(start, floored) => (start, floored), + Start::Waiting => { + self.waiting.push(index); + return false; + } + Start::Pending => { + self.pending = true; + return false; + } + }; + let Some(end) = end(range.end, latest) else { + self.pending = true; + return false; + }; + let end = match end { + End::Time(mut time) => { + time = TimeEnd::new(time.pts, start); + time.advance(start, holdings).map_or(End::Time(time), End::Before) + } + end => end, + }; + let reach = match range.start { + Anchor::Time(_) if !floored => holdings.next_start(0).map_or(start, |(oldest, _)| oldest), + _ => start, + }; + self.spans.push(Span { + index, + start, + end, + floored, + reach, + }); + true + } + fn rebuild(&mut self) { - self.settled = !self.pending && !self.spans.iter().any(|span| matches!(span.end, End::Time(_))); + self.settled = + !self.pending && self.waiting.is_empty() && !self.spans.iter().any(|span| matches!(span.end, End::Time(_))); self.groups = RangeInclusiveSet::new(); self.missable = RangeInclusiveSet::new(); for span in &self.spans { @@ -594,20 +627,36 @@ impl Window { } } -/// The first group a range starts at, and whether it names that group rather than -/// reaching back to the oldest one. `None` while it waits on a latest group. -fn start(anchor: Anchor, latest: Option, holdings: &impl Holdings) -> Option<(u64, bool)> { +/// Where a range starts. +enum Start { + /// At this group, and whether it names that group rather than reaching back to the + /// oldest one. + At(u64, bool), + /// A time start no group has started for yet, so it reads nothing. + Waiting, + /// Waiting on a latest group, reading everything meanwhile. + Pending, +} + +fn start(anchor: Anchor, latest: Option, holdings: &impl Holdings) -> Start { match anchor { - Anchor::Absolute(start) => Some((start, true)), + Anchor::Absolute(start) => Start::At(start, true), // Nothing sits below group 0, so the farthest offset needs no latest group. - Anchor::Relative(u64::MAX) => Some((0, false)), - Anchor::Relative(back) => Some((latest?.saturating_sub(back), true)), + Anchor::Relative(u64::MAX) => Start::At(0, false), + Anchor::Relative(back) => latest.map_or(Start::Pending, |latest| Start::At(latest.saturating_sub(back), true)), Anchor::Time(pts) => match holdings.containing(pts) { - Containing::Group(group) => Some((group, true)), + Containing::Group(group) => Start::At(group, true), // The group holding it is older than any held, so reach back to the oldest. - Containing::Before => Some((0, false)), - // Nothing has started yet: like the live edge, the latest group. - Containing::Unknown => Some((latest?, true)), + Containing::Before => Start::At(0, false), + // Before any group, as for a relative anchor, which a hop that holds none (such as + // a reader following route copies) leaves to the copies to resolve. + Containing::Unknown if latest.is_none() => Start::Pending, + // Nothing can start any more, so nothing held reaches past it. + Containing::Unknown if holdings.complete() => Start::At(0, false), + // Nothing has started yet, so wait for a group that has, rather than guess the + // latest: groups arrive before the frames that show where they start, so a copy + // can see the live edge before the groups its publisher resolved the time to. + Containing::Unknown => Start::Waiting, }, } } @@ -832,10 +881,14 @@ mod tests { let window = Window::new(&[range(time(1000), time(2500))], Some(6), &groups).unwrap(); assert_eq!(covered(&window, 8), vec![0, 1, 2]); - // Nothing started yet: a time start waits like the live edge. + // Nothing started yet: a time start waits on a first group, then on one starting. let mut window = Window::new(&[range(time(1000), None)], None, &Groups::default()).unwrap(); assert!(window.is_pending()); - assert!(window.settle(Some(4), &Groups::new([(4, Held::Pending)]))); + let mut held = Groups::new([(4, Held::Pending)]); + assert!(window.settle(Some(4), &held)); + assert!(!window.contains(4), "4 has not started"); + held.held.insert(4, Held::Started(at(1000))); + assert!(window.settle(Some(4), &held)); assert_eq!(window.floor(), Some(4)); } @@ -1034,6 +1087,54 @@ mod tests { assert!(!window.admits_datagram(1, Some(at(7000)))); } + /// A time start no held group has started for yet reads nothing of its range, rather + /// than guess the latest group, and resolves once one starts. A track that can start + /// nothing more reaches back to the oldest group. + #[test] + fn a_time_start_waits_for_a_group_to_start() { + let pending = (2..10).map(|sequence| (sequence, Held::Pending)); + let mut groups = Groups::new(pending.clone()); + let ranges = [ + range(time(2500), None), + range(Anchor::Absolute(20), Anchor::Absolute(21)), + ]; + let mut window = Window::new(&ranges, Some(9), &groups).unwrap(); + assert!(!window.is_pending() && !window.is_settled()); + assert_eq!(covered(&window, 22), vec![20], "only the other range reads"); + assert_eq!((window.floor(), window.start(), window.end()), (None, None, None)); + assert_eq!( + window.resolved(), + vec![ + None, + Some(Resolved { + start: 20, + end: Some(21) + }) + ] + ); + assert_eq!(window.watch_starts(), Some(0), "any group starting may resolve it"); + assert!(!window.settle(Some(9), &groups), "nothing started"); + + groups.held.insert(3, Held::Started(at(3000))); + assert!(window.settle(Some(9), &groups)); + assert_eq!( + window.resolved()[0], + Some(Resolved { start: 0, end: None }), + "3 starts past it" + ); + groups.held.insert(2, Held::Started(at(2000))); + let window = Window::new(&ranges, Some(9), &groups).unwrap(); + assert_eq!(window.resolved()[0], Some(Resolved { start: 2, end: None })); + assert!(window.is_settled()); + + let groups = Groups { + complete: true, + ..Groups::new(pending) + }; + let window = Window::new(&ranges, Some(9), &groups).unwrap(); + assert_eq!(window.resolved()[0], Some(Resolved { start: 0, end: None })); + } + #[test] fn a_waiting_window_judges_a_datagram_against_both_times() { let window = Window::new(&[range(time(3000), time(3500))], None, &Groups::default()).unwrap(); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index e83e895427c..55c124b9ffb 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -19154,6 +19154,49 @@ mod test { assert_eq!(drain(&mut seek), vec![1, 2]); } + /// A time start resolves once a group starts, not at the latest group: a copy can show + /// the route's answer (here an empty group) before the groups its publisher resolved the + /// time to start, and a producer can open groups before their first frames. + #[test] + #[cfg(feature = "async")] + fn a_time_start_waits_for_a_group_to_start() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let since = [Range { + start: Anchor::Time(at(2500)), + end: None, + }]; + for (copy, order) in [ + (false, Order::Ascending), + (true, Order::Ascending), + (true, Order::Descending), + ] { + let mut producer = track_producer("test", Info::timed()); + if copy { + producer.set_idle(); + producer.set_live(Some(Position::group(9)), Some(2)); + } + let mut groups: Vec<_> = (2..9) + .map(|sequence| producer.create_group(group::Info { sequence }).unwrap()) + .collect(); + producer + .create_group(group::Info { sequence: 9 }) + .unwrap() + .finish() + .unwrap(); + + let mut subscriber = producer.subscribe(replay().with_ranges(since).with_order(order)); + assert_eq!(drain(&mut subscriber), Vec::::new(), "copy: {copy}, {order:?}"); + for (group, sequence) in groups.iter_mut().zip(2..) { + group + .write_frame(at(sequence * 1000), bytes::Bytes::from_static(b"x")) + .unwrap(); + } + let mut read = drain(&mut subscriber); + read.sort_unstable(); + assert_eq!(read, (2..10).collect::>(), "copy: {copy}, {order:?}"); + } + } + /// A time end stays open across a group that has yet to start: it may start before the /// end, so a later group starting after it settles nothing. #[test] From 54f62641c44fbac742f368805ff273b7434de6dc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Oct 2026 09:15:12 -0700 Subject: [PATCH 09/22] fix(net): keep groups between ranges for the ordered cursor; a waiting time start keeps its relative end Codex: an ordered reader closed a group between its ranges once it passed the one before, so widening the ranges never delivered it. Keep it until the cursor passes it, as an unsettled window already did. Review: a range whose time start waited re-resolved its relative end against whatever group arrived meanwhile. It now counts back from the latest group it was asked against, in Rust and JS. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 22 +++++++++++++++ js/net/src/track.ts | 11 +++----- js/net/src/window.test.ts | 10 +++++++ js/net/src/window.ts | 14 ++++++---- rs/moq-net/src/model/subscription/window.rs | 30 ++++++++++++++++++--- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index fdb2bd6ef1d..198e60a1a57 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -585,6 +585,28 @@ test("each reader reads only its own ranges, whatever the union asks for", () => expect(hooks.tryRecvGroup(sparse, false)).toEqual({ kind: "done" }); }); +test("an ordered reader keeps a group between its ranges until the cursor passes it", () => { + const producer = new TrackProducer("test"); + const track = producer + .subscribe({ + maxDelay: null, + ranges: [ + { start: { absolute: 0 }, end: { absolute: 1 } }, + { start: { absolute: 3 }, end: { absolute: 4 } }, + ], + }) + .ordered(); + for (const sequence of [0, 1]) producer.createGroup(sequence).finish(); + expect(track.tryNextGroup()?.sequence).toBe(0); + expect(track.tryNextGroup()).toBeUndefined(); + + track.update({ maxDelay: null, ranges: [{ start: { absolute: 0 }, end: { absolute: 4 } }] }); + expect(track.tryNextGroup()?.sequence).toBe(1); + track.close(); + producer.finish(); + producer.close(); +}); + test("a relative range counts back from the latest group, or waits for the first", () => { const producer = new TrackProducer("test"); for (let sequence = 0; sequence < 5; sequence++) producer.createGroup(sequence).finish(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 12d65cd866c..b284ea61264 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -2988,15 +2988,10 @@ export class Subscriber { for (let index = 0; ; ) { const group = groups[index]; if (!group || (cursor.end !== undefined && group.sequence >= cursor.end)) break; + // Out of range for now, but kept until the cursor passes it: an anchor still + // resolving, or an update widening the ranges, may take it in. if (!this.#window.contains(group.sequence)) { - // Kept while an anchor still resolves, which may take it in. - if (!this.#window.settled) { - index++; - continue; - } - // Out of range for now: an update taking it in offers it again from the cache. - groups.splice(index, 1); - group.close(); + index++; continue; } groups.splice(index, 1); diff --git a/js/net/src/window.test.ts b/js/net/src/window.test.ts index 5f728ce6a74..58acccd9512 100644 --- a/js/net/src/window.test.ts +++ b/js/net/src/window.test.ts @@ -200,6 +200,16 @@ test("a time start is the group containing it, and an end stops past it once a l expect(new Window([{ start: time(1000) }], 4, complete).floor()).toBe(0); }); +test("a waiting time start keeps the relative end it was asked with", () => { + const groups = new Groups(Array.from({ length: 9 }, (_, index): [number, Held] => [index + 2, "pending"])); + const window = new Window([{ start: time(2500), end: { relative: 0 } }], 9, groups); + expect(window.settle(10, groups)).toBe(false); + + groups.held.set(2, started(2000)); + expect(window.settle(10, groups)).toBe(true); + expect(window.resolved()).toEqual([{ start: 2, end: 10 }]); +}); + test("a time end settles once nothing below its end can arrive", () => { const groups = new Groups([ [3, started(3000)], diff --git a/js/net/src/window.ts b/js/net/src/window.ts index 83abf672ee0..99a600af905 100644 --- a/js/net/src/window.ts +++ b/js/net/src/window.ts @@ -511,8 +511,11 @@ export class Window { #runs: Run[] = []; /** A relative anchor waits on a first group, so nothing is filtered yet. */ #pending = false; - /** The ranges, by index, whose time start waits on a group starting, so they read nothing yet. */ - #waiting: number[] = []; + /** + * The ranges, by index, whose time start waits on a group starting, so they read nothing yet, + * with the latest group their relative end counts back from. + */ + #waiting: { index: number; latest: number | undefined }[] = []; /** Nothing is left to resolve as groups arrive and start. */ #settled = false; @@ -545,8 +548,9 @@ export class Window { let moved = false; const waiting = this.#waiting; this.#waiting = []; - for (const index of waiting) { - // Its end resolved against `latest` now, as a relative start resolves once one exists. + // A relative end keeps counting back from the latest group it was asked against, not + // one that arrived while the start waited. + for (const { index, latest } of waiting) { if (this.#span(index, latest, holdings)) moved = true; } for (const span of this.#spans) { @@ -691,7 +695,7 @@ export class Window { if (isEmpty(range)) return false; const start = startOf(range.start, latest, holdings); if (start === "waiting") { - this.#waiting.push(index); + this.#waiting.push({ index, latest }); return false; } const until = endOf(range.end, latest); diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 35e79630d9f..ec1e2d08ddb 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -123,8 +123,8 @@ pub(in crate::model) struct Window { /// A relative anchor waits on a first group, so nothing is filtered yet. pending: bool, /// The ranges, by index, whose time start waits on a group starting, so they read - /// nothing yet. - waiting: Vec, + /// nothing yet, with the latest group their relative end counts back from. + waiting: Vec<(usize, Option)>, /// Nothing is left to resolve: see [`Self::is_settled`]. Kept so a read checks it once /// per group, not once per range. settled: bool, @@ -363,7 +363,9 @@ impl Window { return !self.pending; } let mut moved = false; - for index in std::mem::take(&mut self.waiting) { + // A relative end keeps counting back from the latest group it was asked against, not + // one that arrived while the start waited. + for (index, latest) in std::mem::take(&mut self.waiting) { moved |= self.span(index, latest, holdings); } for span in &mut self.spans { @@ -587,7 +589,7 @@ impl Window { let (start, floored) = match start(range.start, latest, holdings) { Start::At(start, floored) => (start, floored), Start::Waiting => { - self.waiting.push(index); + self.waiting.push((index, latest)); return false; } Start::Pending => { @@ -1149,6 +1151,26 @@ mod tests { assert_eq!(window.resolved()[0], Some(Resolved { start: 0, end: None })); } + /// A relative end counts back from the latest group it was asked against, however many + /// arrive while its time start waits. + #[test] + fn a_waiting_time_start_keeps_its_relative_end() { + let mut groups = Groups::new((2..11).map(|sequence| (sequence, Held::Pending))); + let ranges = [range(time(2500), Anchor::Relative(0))]; + let mut window = Window::new(&ranges, Some(9), &groups).unwrap(); + assert!(!window.settle(Some(10), &groups), "nothing started"); + + groups.held.insert(2, Held::Started(at(2000))); + assert!(window.settle(Some(10), &groups)); + assert_eq!( + window.resolved()[0], + Some(Resolved { + start: 2, + end: Some(10) + }) + ); + } + #[test] fn a_waiting_window_judges_a_datagram_against_both_times() { let window = Window::new(&[range(time(3000), time(3500))], None, &Groups::default()).unwrap(); From ab8e85d2b5fa03d44332dd86a9152faa16e957ac Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Oct 2026 09:48:03 -0700 Subject: [PATCH 10/22] fix(net): resolve waiting relative ends at the first observed group Keep the first available latest group while a time start waits, including when the subscription began on an empty track. Exercise both initially empty and populated tracks in the shared window tests. Co-Authored-By: GPT-6.1 Sol --- js/net/src/window.test.ts | 21 ++++++--- js/net/src/window.ts | 12 ++++-- rs/moq-net/src/model/subscription/window.rs | 47 ++++++++++++--------- 3 files changed, 49 insertions(+), 31 deletions(-) diff --git a/js/net/src/window.test.ts b/js/net/src/window.test.ts index 58acccd9512..160523dc286 100644 --- a/js/net/src/window.test.ts +++ b/js/net/src/window.test.ts @@ -201,13 +201,20 @@ test("a time start is the group containing it, and an end stops past it once a l }); test("a waiting time start keeps the relative end it was asked with", () => { - const groups = new Groups(Array.from({ length: 9 }, (_, index): [number, Held] => [index + 2, "pending"])); - const window = new Window([{ start: time(2500), end: { relative: 0 } }], 9, groups); - expect(window.settle(10, groups)).toBe(false); - - groups.held.set(2, started(2000)); - expect(window.settle(10, groups)).toBe(true); - expect(window.resolved()).toEqual([{ start: 2, end: 10 }]); + for (const latest of [undefined, 9]) { + const groups = new Groups(); + const window = new Window([{ start: time(2500), end: { relative: 0 } }], latest, groups); + expect(window.pending).toBe(latest === undefined); + for (let sequence = 2; sequence <= 9; sequence++) groups.held.set(sequence, "pending"); + window.settle(9, groups); + expect(window.pending).toBe(false); + groups.held.set(10, "pending"); + expect(window.settle(10, groups)).toBe(false); + + groups.held.set(2, started(2000)); + expect(window.settle(10, groups)).toBe(true); + expect(window.resolved()).toEqual([{ start: 2, end: 10 }]); + } }); test("a time end settles once nothing below its end can arrive", () => { diff --git a/js/net/src/window.ts b/js/net/src/window.ts index 99a600af905..276606fb7ea 100644 --- a/js/net/src/window.ts +++ b/js/net/src/window.ts @@ -548,8 +548,8 @@ export class Window { let moved = false; const waiting = this.#waiting; this.#waiting = []; - // A relative end keeps counting back from the latest group it was asked against, not - // one that arrived while the start waited. + // A relative end counts back from the latest group when asked, or the first observed + // group on an empty track, even while the time start waits. for (const { index, latest } of waiting) { if (this.#span(index, latest, holdings)) moved = true; } @@ -693,13 +693,17 @@ export class Window { #span(index: number, latest: number | undefined, holdings: Holdings): boolean { const range = this.ranges[index]; if (isEmpty(range)) return false; + const until = endOf(range.end, latest); + if (until === undefined) { + this.#pending = true; + return false; + } const start = startOf(range.start, latest, holdings); if (start === "waiting") { this.#waiting.push({ index, latest }); return false; } - const until = endOf(range.end, latest); - if (start === undefined || until === undefined) { + if (start === undefined) { this.#pending = true; return false; } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index ec1e2d08ddb..332b87cb113 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -363,8 +363,8 @@ impl Window { return !self.pending; } let mut moved = false; - // A relative end keeps counting back from the latest group it was asked against, not - // one that arrived while the start waited. + // A relative end counts back from the latest group when asked, or the first observed + // group on an empty track, even while the time start waits. for (index, latest) in std::mem::take(&mut self.waiting) { moved |= self.span(index, latest, holdings); } @@ -586,6 +586,10 @@ impl Window { if range.is_empty() { return false; } + let Some(end) = end(range.end, latest) else { + self.pending = true; + return false; + }; let (start, floored) = match start(range.start, latest, holdings) { Start::At(start, floored) => (start, floored), Start::Waiting => { @@ -597,10 +601,6 @@ impl Window { return false; } }; - let Some(end) = end(range.end, latest) else { - self.pending = true; - return false; - }; let end = match end { End::Time(mut time) => { time = TimeEnd::new(time.pts, start); @@ -1155,20 +1155,27 @@ mod tests { /// arrive while its time start waits. #[test] fn a_waiting_time_start_keeps_its_relative_end() { - let mut groups = Groups::new((2..11).map(|sequence| (sequence, Held::Pending))); - let ranges = [range(time(2500), Anchor::Relative(0))]; - let mut window = Window::new(&ranges, Some(9), &groups).unwrap(); - assert!(!window.settle(Some(10), &groups), "nothing started"); - - groups.held.insert(2, Held::Started(at(2000))); - assert!(window.settle(Some(10), &groups)); - assert_eq!( - window.resolved()[0], - Some(Resolved { - start: 2, - end: Some(10) - }) - ); + for latest in [None, Some(9)] { + let mut groups = Groups::default(); + let ranges = [range(time(2500), Anchor::Relative(0))]; + let mut window = Window::new(&ranges, latest, &groups).unwrap(); + assert_eq!(window.is_pending(), latest.is_none()); + groups.held.extend((2..10).map(|sequence| (sequence, Held::Pending))); + window.settle(Some(9), &groups); + assert!(!window.is_pending()); + groups.held.insert(10, Held::Pending); + assert!(!window.settle(Some(10), &groups), "nothing started"); + + groups.held.insert(2, Held::Started(at(2000))); + assert!(window.settle(Some(10), &groups)); + assert_eq!( + window.resolved()[0], + Some(Resolved { + start: 2, + end: Some(10) + }) + ); + } } #[test] From 278149ab206ade87669da930193fff5dc629faa9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 21:25:12 -0700 Subject: [PATCH 11/22] feat(net)!: keep a covered lite-07 copy, so its readers park A lite-07 publisher FINs a bounded subscription once it covered the ranges. JavaScript released the copy there, so its readers ended, while Rust keeps the copy and its readers park. Keep it as Rust does: the cache stays warm for the same ranges, and a demand the received groups no longer cover subscribes again on the same track. The copy goes once nobody wants it or it closes. Decided 2026-10-31 (maintainer): readers park in both languages; a read that should end once its range is served belongs to the request surface and `fetchGroup`, which the JS ranges quest still holds. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 5 ++- js/net/src/integration.test.ts | 61 +++++++++++++++++++++++++++++- js/net/src/lite/subscriber.test.ts | 20 +++++++--- js/net/src/lite/subscriber.ts | 39 ++++++++++++++----- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 556e372490a..32112ac6316 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -295,7 +295,10 @@ Ranges are both a request and a filter. A subscriber reads only the groups its own ranges cover, resolved again whenever it replaces them, even when another subscriber of the same track asked for more. Widening them, including lowering the start, also serves the cached groups they newly cover, never one already -served. A group they cover below the live feed that the cache lacks (evicted +served. A reader that has read every group its ranges cover waits rather than +ends, since the track goes on: a moq-lite-07 publisher ends a bounded +subscription once it covered it, and the copy stays cached for the same ranges +until a wider demand asks again. A group they cover below the live feed that the cache lacks (evicted before the subscriber first looked for it, skipped, or below where the feed starts) is asked of the track's dynamic handler as a fill, which a relay serves by fetching upstream one group at a time, skipping diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 42156b1d4ac..edb972ab5c6 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -466,10 +466,69 @@ test("integration: lite-07 serves sparse and relative ranges", async () => { ], }) .ordered(); + // Covering the ranges ends the subscription upstream, not the reader, which parks. const got: number[] = []; - for (let group = await reader.nextGroup(); group; group = await reader.nextGroup()) got.push(group.sequence); + for (let i = 0; i < 4; i++) got.push((await reader.nextGroup())?.sequence ?? -1); expect(got).toEqual([1, 2, 7, 8]); + const parked = new Promise((resolve) => setTimeout(() => resolve("parked"), 50)); + expect(await Promise.race([reader.nextGroup(), parked])).toBe("parked"); + reader.close(); + } finally { + remote.close(); + broadcast.close(); + client.abort(); + server.abort(); + } +}); + +// A lite-07 publisher ends a bounded subscription once it covered the range, releasing its +// demand, and the subscriber keeps the copy: the reader parks rather than ends, the same range +// reads again from the cache, and a wider one subscribes again for the rest. Mirrors the Rust +// `a_covered_bounded_subscription_ends_on_lite07`; decided 2026-10-31 (maintainer). +test("integration: lite-07 keeps a covered bounded subscription's copy", async () => { + const pair = createMockTransportPair(Lite.ALPN_07_WIP); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + + const broadcast = publish(origin, Path.from("test")); + const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); + for (const sequence of [0, 1, 2, 4, 5]) producer.createGroup(sequence).finish(); + + const remote = wireOf(client).consume(Path.from("test")); + const bounded = (end: number) => + remote + .track("video") + .subscribe({ + maxDelay: Milli(60_000), + order: "ascending", + ranges: [{ start: { absolute: 1 }, end: { absolute: end } }], + }) + .ordered(); + const read = async (reader: ReturnType, count: number) => { + const got: number[] = []; + for (let i = 0; i < count; i++) got.push((await reader.nextGroup())?.sequence ?? -1); + return got; + }; + try { + const reader = bounded(3); + expect(await read(reader, 2)).toEqual([1, 2]); + // The publisher's demand ends with the covered subscription. + while (producer.subscription.peek() !== undefined) await producer.subscription.changed(); + const parked = new Promise((resolve) => setTimeout(() => resolve("parked"), 50)); + expect(await Promise.race([reader.nextGroup(), parked])).toBe("parked"); + + const again = bounded(3); + expect(await read(again, 2)).toEqual([1, 2]); + expect(producer.subscription.peek()).toBeUndefined(); + + const wider = bounded(6); + expect(await read(wider, 4)).toEqual([1, 2, 4, 5]); reader.close(); + again.close(); + wider.close(); } finally { remote.close(); broadcast.close(); diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index 73e053cc24b..6d59c68c465 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -2271,9 +2271,10 @@ test("an empty START remains resolved when its range widens", async () => { }); // A lite-07 publisher FINs a bounded subscription without SUBSCRIBE_END once it covered the range, -// which ends the subscription cleanly rather than violating the protocol. Covering a range is -// not ending the track, so no end is declared. -test("a lite-07 publisher FIN ends a covered bounded subscription", async () => { +// which is no protocol violation. Covering a range is not ending the track, so the copy stays for +// its readers, as the Rust copy does: they park, and widening the range asks again on the same +// track. Decided 2026-10-31 (maintainer). +test("a lite-07 publisher FIN keeps a covered bounded subscription's copy", async () => { const version = Version.DRAFT_07; const { quic, streams, opened, closes } = fakeSession(); const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); @@ -2297,9 +2298,18 @@ test("a lite-07 publisher FIN ends a covered bounded subscription", async () => ); for (const chunk of chunks) stream.inbound.enqueue(chunk); stream.inbound.close(); - expect(await track.closed).toBeNull(); - await expect(track.finished()).rejects.toThrow("track closed before its end was known"); await drainReactions(); + expect(track.closed.peek()).toBeUndefined(); + expect(streams).toHaveLength(2); + + track.update({ ranges: [{ start: { absolute: 0 }, end: { absolute: 3 } }] }); + const reopened = await opened(2); + await reopened.reading; + const reader = new Reader(undefined, new Uint8Array(Buffer.concat(reopened.written)), version); + expect(await reader.u53()).toBe(StreamId.Subscribe); + const msg = await Subscribe.decode(reader, version); + expect(msg.ranges).toEqual([{ start: { absolute: 0 }, end: { absolute: 3 } }]); + expect(track.closed.peek()).toBeUndefined(); expect(closes).toEqual([]); } finally { track.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 42af7e84e6e..e391f4fdd90 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1102,21 +1102,25 @@ export class Subscriber { break; } - // A publisher that covered the range before reading an update widening it sent fewer - // groups than the demand asks for, so the caller asks again for the rest. Otherwise - // only a received SUBSCRIBE_END, or before lite-05 a FIN, declares the track's end: - // a range covered or a subscription nobody wants anymore is a released copy, not - // finished content. - const reopen = short && reason_ !== idle && hooks.trackReleased(producer).peek() === undefined; - if (!reopen) { - if (tailSettled && entry.finished) producer.finish(); - hooks.releaseTrack(producer); - } // A settled lite07 tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. if (tailSettled && waitsForSubscriberFin(this.version)) stream.writer.close(); else stream.close(); console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${producer.name}`); + + // A publisher that covered the range before reading an update widening it sent fewer + // groups than the demand asks for, so the caller asks again for the rest. A lite-07 + // FIN without SUBSCRIBE_END that covered the demand leaves the copy to its readers + // until the demand moves past it. Otherwise only a received SUBSCRIBE_END, or before + // lite-05 a FIN, declares the track's end: a subscription nobody wants anymore is a + // released copy, not finished content. + const live = reason_ !== idle && hooks.trackReleased(producer).peek() === undefined; + const covered = live && tailSettled && entry.end === undefined && hasRanges(this.version); + const reopen = live && (short || (covered && (await this.#parkCovered(entry)))); + if (!reopen) { + if (tailSettled && entry.finished) producer.finish(); + hooks.releaseTrack(producer); + } return reopen; } catch (err) { const e = await sessionCause(this.#quic, err); @@ -1131,6 +1135,21 @@ export class Subscriber { } } + // Keep a copy whose lite-07 publisher covered its bounded ranges, as the Rust copy does: its + // readers park rather than end, the cache answers the same ranges, and an update can still + // widen them. Resolves true once the demand asks for groups it lacks, to ask again on the + // same track, and false once nobody wants the track or it closed. + async #parkCovered(entry: SubscribeEntry): Promise { + const producer = entry.track; + const demand = producer.demand(); + const released = hooks.trackReleased(producer); + for (;;) { + if (released.peek() !== undefined || isAbandoned(demand)) return false; + if (!this.#demandCovered(entry)) return true; + await race([Signal.race(producer.subscription, demand.wanted, hooks.demandReleased(demand)), released]); + } + } + // Determine the track's immutable properties, accept the request (so the // application's track.Subscriber resolves and incoming groups have a producer to // write into), register it, then open the subscribe stream. `state.stream` and From 19548e794ca8fef0d16b9000adc3fef2ecc1fee2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 21:25:13 -0700 Subject: [PATCH 12/22] fix(net): cap the arrival grace at the cache expiry `grace` passed any finite budget through, so the largest delay a wire carries (an unbounded `maxDelay`, or an IETF subscription without a max age) waited near-forever for a gap that never fills. Cap every budget at CACHE_EXPIRY, as its doc promises and as Rust bounds the delay by the publisher's retention. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ledger.test.ts | 10 +++++++++- js/net/src/ledger.ts | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/js/net/src/ledger.test.ts b/js/net/src/ledger.test.ts index f2bcfc00c3b..e693154fc2b 100644 --- a/js/net/src/ledger.test.ts +++ b/js/net/src/ledger.test.ts @@ -1,9 +1,17 @@ import { expect, test } from "bun:test"; import { Instant, Milli, Nano } from "@moq/clock"; -import { Ledger } from "./ledger.ts"; +import { CACHE_EXPIRY, GRACE, grace, Ledger } from "./ledger.ts"; const at = (ms: number) => Instant(Nano.fromMilli(Milli(ms))); +test("grace waits out the budget, capped at the cache expiry", () => { + expect(grace(0)).toBe(GRACE); + expect(grace(500)).toBe(Milli(500)); + // The largest delay a wire carries is no limit, as an unbounded one is. + expect(grace(Number.MAX_SAFE_INTEGER)).toBe(CACHE_EXPIRY); + expect(grace(Number.POSITIVE_INFINITY)).toBe(CACHE_EXPIRY); +}); + test("overlapping and adjacent ranges merge without changing surviving gap ages", () => { const ledger = new Ledger(); ledger.account(3, 4, at(0)); diff --git a/js/net/src/ledger.ts b/js/net/src/ledger.ts index f439957c227..677831338ae 100644 --- a/js/net/src/ledger.ts +++ b/js/net/src/ledger.ts @@ -23,12 +23,12 @@ export const CACHE_EXPIRY = Milli(30_000); /** * The grace for a subscription with a `maxDelay` budget in milliseconds: how long it was willing - * to wait for a late group anyway, {@link GRACE} without one, and {@link CACHE_EXPIRY} for an - * unbounded one. + * to wait for a late group anyway, {@link GRACE} without one, and at most {@link CACHE_EXPIRY}, + * which an unbounded one or the largest delay a wire carries reaches. */ export function grace(budget: number): Milli { if (budget <= 0) return GRACE; - return Number.isFinite(budget) ? Milli(budget) : CACHE_EXPIRY; + return budget < CACHE_EXPIRY ? Milli(budget) : CACHE_EXPIRY; } // A run of accounted sequences `[start, end)`, and when the gap below it opened, which is From 9c4899949695f4d90c8007838f263f59bf3288cb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 21:25:21 -0700 Subject: [PATCH 13/22] fix(net): keep an unsettled time window pending at a reached end A reader whose time anchor still waits on a held group to start reported the track done once the live edge reached a declared end: nothing local caps it, so the read path took the reached end as completion and an asynchronous reader stopped before the group's frames could resolve its window. Wait while a held group may still start inside the window, until it starts or the track closes, which `Holdings.complete` already requires, on both the arrival and the ordered cursor. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 16 ++++++++++------ js/net/src/track.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 0236f22f630..d731065e707 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -696,18 +696,22 @@ test("a time start waits on groups below a reached end that have yet to start", producer.accept({ timescale: Timescale.MILLI }); const groups = [0, 1, 2].map((sequence) => producer.createGroup(sequence)); producer.finishAt({ group: 3, frame: 0 }); - const reader = producer.subscribe({ - maxDelay: null, - order, - ranges: [{ start: { time: Timestamp.fromMillis(1500) } }], - }); - expect(reader.tryRecvGroup()).toBeUndefined(); + const subscription = { maxDelay: null, order, ranges: [{ start: { time: Timestamp.fromMillis(1500) } }] }; + const reader = producer.subscribe(subscription); + const sequenced = producer.subscribe(subscription); + const ordered = sequenced.ordered(); + // Idle rather than done: the groups may still start inside the window. + expect(hooks.tryRecvGroup(reader, false)).toEqual({ kind: "idle" }); + expect(hooks.tryNextGroup(sequenced)).toEqual({ kind: "idle" }); for (const [sequence, group] of groups.entries()) { group.writeFrame(textFrame(`${sequence}`, Timestamp.fromMillis(sequence * 1000))); } const got: number[] = []; for (let group = reader.tryRecvGroup(); group; group = reader.tryRecvGroup()) got.push(group.sequence); expect(got.sort()).toEqual([1, 2]); + const inOrder: number[] = []; + for (let group = ordered.tryNextGroup(); group; group = ordered.tryNextGroup()) inOrder.push(group.sequence); + expect(inOrder).toEqual([1, 2]); } }); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 8c5b8b1150e..1a8827b0610 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -2309,6 +2309,14 @@ export class Subscriber { return cap !== undefined && (groups.at(-1)?.sequence ?? -1) >= cap; } + // Whether a held group from `from` on may still start, and so settle a time anchor the window + // waits on, which a reached end does not decide: until it starts or the track closes, as + // {@link Holdings.complete} says, the window may still take it in. + #starting(groups: readonly GroupConsumer[], from = 0): boolean { + if (this.#window.settled || this.#state.closed.peek() !== undefined) return false; + return groups.some((group) => group.sequence >= from && !hooks.groupStarted(group)); + } + static { makeSubscriber = (name, state) => new Subscriber(name, state); hooks.subscriberEnd = (subscriber) => @@ -2780,7 +2788,7 @@ export class Subscriber { const closed = this.#state.closed.peek(); if (closed instanceof Error) return { kind: "error", error: closed }; - if (closed === undefined && !this.#complete()) return { kind: "idle" }; + if (closed === undefined && (!this.#complete() || this.#starting(groups))) return { kind: "idle" }; // A group beyond the cap outlives a clean close: it becomes deliverable if // the cap rises, so the track isn't over while any are held. return this.#parked(groups) ? { kind: "boundary" } : { kind: "done" }; @@ -3042,6 +3050,7 @@ export class Subscriber { const end = this.#state.end.peek(); if ( !this.#parked(groups) && + !this.#starting(groups, start) && (closed !== undefined || this.#complete() || (end !== undefined && start >= finalSequence(end))) ) { return { kind: "done" }; From 33cf39e1d499f9ac03c5383580e8d494017b7376 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 21:25:35 -0700 Subject: [PATCH 14/22] chore(quest): record the reader-completion decision and per-range starts Co-Authored-By: Claude Opus 5.5 --- quest/m1/subscribe-ranges/js.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/quest/m1/subscribe-ranges/js.md b/quest/m1/subscribe-ranges/js.md index 91d34f31bd8..62eaae919de 100644 --- a/quest/m1/subscribe-ranges/js.md +++ b/quest/m1/subscribe-ranges/js.md @@ -13,9 +13,11 @@ Landed in [moq-dev/moq#6949](https://github.com/moq-dev/moq/pull/6949): `groups`, a per-reader window (`js/net/src/window.ts`) with the Rust time-end walk, the union aggregate, lite-07 SUBSCRIBE carrying the list with times in the track's timescale (older wires the covering span, refusing other relative -and time anchors per reader), `maxDelay: null`, the lite-07 publisher FINning a -covered bounded subscription without SUBSCRIBE_END, and the subscriber opening -a fresh SUBSCRIBE on the same track when that FIN leaves the demand uncovered. +and time anchors per reader), `maxDelay: null`, the lite-07 publisher resolving +relative and time anchors and naming each range's start in SUBSCRIBE_START, +FINning a covered bounded subscription without SUBSCRIBE_END, and the +subscriber keeping that copy and opening a fresh SUBSCRIBE on the same track +once the demand moves past what it covered. Decided 2026-10-31 (maintainer): a range-bounded reader parks once its ranges are covered, in JavaScript as in Rust. A lite-07 copy outlives a covered FIN, From 7cafe16a3a12ba27f903085e08da33e1955061e4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 31 Oct 2026 00:44:17 -0700 Subject: [PATCH 15/22] fix(net): give up a reopened lite-07 setup once its readers leave Reopening a covered copy for a wider demand awaited the new SUBSCRIBE's open and write with a deadline it never waited on, so a peer that stopped taking request bytes held the stream and its entry after every reader left. Wait under the request deadline as the first setup does, give the setup up once nobody wants the track, resetting a stream that opens late, and release the copy rather than fail it. Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/subscriber.test.ts | 46 +++++++++++++++++++++++++++++- js/net/src/lite/subscriber.ts | 23 +++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index 6d59c68c465..d64a143895a 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -21,7 +21,7 @@ import { hooks } from "../internal.ts"; import { GRACE } from "../ledger.ts"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; -import type { Producer } from "../track.ts"; +import type { Producer, Subscriber as TrackSubscriber } from "../track.ts"; import { type AnnounceBroadcast, AnnounceInit, AnnounceOk, encodeAnnounceBroadcast } from "./announce.ts"; import { Datagram as DatagramMessage } from "./datagram.ts"; import { Group } from "./group.ts"; @@ -2318,6 +2318,50 @@ test("a lite-07 publisher FIN keeps a covered bounded subscription's copy", asyn } }); +// A widening reopens a covered copy, and its readers leaving while the publisher never takes the +// new SUBSCRIBE gives the setup up: its stream is reset and the copy released, not held forever. +test("a reopened lite-07 subscription ends once its readers leave", async () => { + const version = Version.DRAFT_07; + const { quic, streams, opened } = fakeSession([], [2]); + const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); + const broadcast = subscriber.consume(Path.from("room")); + const track = broadcast.track("video").subscribe({ ranges: [{ start: { absolute: 0 }, end: { absolute: 2 } }] }); + let next: TrackSubscriber | undefined; + try { + await opened(0); + await answerTrackInfo(streams[0], version); + const stream = await opened(1); + await stream.reading; + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ write: (chunk) => void chunks.push(new Uint8Array(chunk)) }), + version, + ); + await encodeSubscribeResponse(writer, { start: new SubscribeStart([0]) }, version); + await encodeSubscribeResponse( + writer, + { drop: new SubscribeDrop({ start: 0, end: 1, error: StreamCode.NotFound }) }, + version, + ); + for (const chunk of chunks) stream.inbound.enqueue(chunk); + stream.inbound.close(); + await drainReactions(); + + track.update({ ranges: [{ start: { absolute: 0 }, end: { absolute: 3 } }] }); + await (await opened(2)).writing; + track.close(); + await drainReactions(); + // A reader arriving now asks afresh, rather than join a copy held by the stalled setup. + next = broadcast.track("video").subscribe(); + await opened(3); + } finally { + next?.close(); + track.close(); + broadcast.close(); + subscriber.close(); + } +}); + // Before lite-05 the publisher FINs the subscribe stream only once the track is over, so that FIN // finishes the track once the tail settles, rather than releasing it for another route. test.each( diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index e391f4fdd90..4c8bbdca432 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -966,6 +966,12 @@ export class Subscriber { const id = this.#subscribeNext++; const msg = this.#subscribeMessage(id, broadcast, consumed, producer.name, subscription, ranges); console.debug(`subscribe reopen: id=${id} broadcast=${broadcast} track=${producer.name}`); + // Bounded like the first setup, and given up once nobody wants the track, so a peer + // that stops reading holds neither the stream nor its entry. + const left = new AbortController(); + const leave = () => left.abort(new StreamError(StreamCode.Cancel, { message: "cancel" })); + void abandoned(producer.demand()).then(leave, leave); + let sent: Promise | undefined; valid?.addEventListener("abort", retire); try { if (valid?.aborted) retire(); @@ -979,12 +985,23 @@ export class Subscriber { entry.timescale, entry.scale, ); - stream = await this.#sendSubscribe(state, msg, new Deadline()); + const deadline = new Deadline(); + sent = this.#sendSubscribe(state, msg, deadline); + stream = await deadline.wait(untilAborted(untilAborted(sent, valid), left.signal)); current = { id, msg }; } catch (err) { - const e = state.revoked ?? (await sessionCause(this.#quic, err)); + const e = + state.revoked ?? (left.signal.aborted ? left.signal.reason : await sessionCause(this.#quic, err)); + state.cancel.abort(e); this.#subscribes.delete(id); - state.stream?.abort(e); + // A stream that opens late is reset too, as the first setup's are. + const close = () => state.stream?.abort(e); + close(); + sent?.then(close, close); + if (left.signal.aborted && !state.revoked) { + hooks.releaseTrack(producer); + return; + } producer.abort(e); console.warn( `subscribe error: id=${id} broadcast=${broadcast} track=${producer.name} error=${reason(e)}`, From 7e4745c49b00c4b6f973ae400d4c984c2a416e77 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 31 Oct 2026 00:45:13 -0700 Subject: [PATCH 16/22] fix(net): watch a reopened setup's demand only while it runs The abandonment watch for a reopened lite-07 setup outlived a setup that succeeded, so a reader widening a bounded range again and again kept one watcher per reopen until the track closed. Register it on the demand signals for the setup alone and dispose it once the setup settles. Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/subscriber.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 4c8bbdca432..8a34bcbd64a 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -968,9 +968,15 @@ export class Subscriber { console.debug(`subscribe reopen: id=${id} broadcast=${broadcast} track=${producer.name}`); // Bounded like the first setup, and given up once nobody wants the track, so a peer // that stops reading holds neither the stream nor its entry. + // Watched only while the setup runs, so a reopen leaves no watcher behind. const left = new AbortController(); - const leave = () => left.abort(new StreamError(StreamCode.Cancel, { message: "cancel" })); - void abandoned(producer.demand()).then(leave, leave); + const demand = producer.demand(); + let unwatch: Dispose | undefined; + const watch = () => { + if (isAbandoned(demand)) left.abort(new StreamError(StreamCode.Cancel, { message: "cancel" })); + else unwatch = Signal.race(demand.wanted, hooks.demandReleased(demand)).changed(watch); + }; + watch(); let sent: Promise | undefined; valid?.addEventListener("abort", retire); try { @@ -1008,6 +1014,7 @@ export class Subscriber { ); return; } finally { + unwatch?.(); valid?.removeEventListener("abort", retire); } } From be319df33e052488f20a201c71c078bb70750bb6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 31 Oct 2026 22:24:25 -0700 Subject: [PATCH 17/22] fix(net): routed readers serve ranges the serving copy resolves; a time start pends before any group Address CodeRabbit on moq-dev/moq#6949: a reader following route copies now takes its serving runs and end from the serving copy, a time start with no latest group pends as in Rust, and the moq-lite doc names every update that reopens a covered copy. Port main's errored-end test to ranges. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 2 +- js/net/src/internal.ts | 7 +++++++ js/net/src/resume.test.ts | 21 +++++++++++++++++++++ js/net/src/resume.ts | 13 +++++++++++++ js/net/src/track.test.ts | 2 +- js/net/src/track.ts | 14 +++++++++++--- js/net/src/window.test.ts | 10 ++++++---- js/net/src/window.ts | 15 +++++++++------ 8 files changed, 69 insertions(+), 15 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 51e6a81ad7c..01729870ba9 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -305,7 +305,7 @@ the start, also serves the cached groups they newly cover, never one already served. A reader that has read every group its ranges cover waits rather than ends, since the track goes on: a moq-lite-07 publisher ends a bounded subscription once it covered it, and the copy stays cached for the same ranges -until a wider demand asks again. A group they cover below the live feed that the cache lacks (evicted +until an update asks for groups it did not cover, wider or shifted. A group they cover below the live feed that the cache lacks (evicted before the subscriber first looked for it, skipped, or below where the feed starts) is asked of the track's dynamic handler as a fill, which a relay serves by fetching upstream one group at a time, skipping diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index c3564cd0f8f..14b33866591 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -332,6 +332,13 @@ export interface ResumeSource { end(): Position | undefined; /** Where each range starts on the serving copy once delivery starts at `from`, undefined without one. */ starts(from: number): (number | undefined)[] | undefined; + /** The runs of `[start, end)` the serving copy reads, undefined without one. */ + runs(start: number, end: number): Run[] | undefined; + /** + * The exclusive end past every group the serving copy reads: undefined while one of its + * ranges stays open or without a serving copy. + */ + servingEnd(): number | undefined; /** The error the logical track's end carries, if it does not end cleanly. */ endError(): Error | undefined; /** Resolve with the logical track's exclusive end once known. */ diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 44e9aa035f8..49d9170aa48 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -287,6 +287,27 @@ test("a routed source start follows the serving copy and its subscriber floor", } }); +test("a routed reader serves its relative ranges as the serving copy resolves them", async () => { + const a = new BroadcastProducer(); + const ta = copy(a); + for (let sequence = 0; sequence < 5; sequence++) ta.write(sequence); + const front = new Front(); + front.serve(a.consume()); + const sub = front.consumer + .track("seq") + .subscribe({ maxDelay: null, ranges: [{ start: { relative: 2 }, end: { relative: 1 } }] }); + try { + await settle(); + // The copy holds the groups the anchors count back from, so the ranges end there. + expect(hooks.servingEnd(sub)).toBe(4); + expect(hooks.servingRuns(sub, 0, 10)).toEqual([{ start: 2, end: 4 }]); + } finally { + sub.close(); + front.consumer.close(); + a.close(); + } +}); + test("a fork continues its open group across a route failure after its parent closes", async () => { const a = new BroadcastProducer(); const b = new BroadcastProducer(); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 38090610ac2..3e6fcded908 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -1123,6 +1123,19 @@ class Reader implements ResumeSource { return serving && hooks.servingStarts(serving.sub, from); } + // The reader's ranges resolve on the copies, which hold the groups their anchors name. + runs(start: number, end: number): Run[] | undefined { + this.sync(); + const serving = this.#serving(); + return serving && hooks.servingRuns(serving.sub, start, end); + } + + servingEnd(): number | undefined { + this.sync(); + const serving = this.#serving(); + return serving && hooks.servingEnd(serving.sub); + } + /** The error the logical track's end carries, if it does not end cleanly. */ endError(): Error | undefined { this.sync(); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index d9b1775a83f..8dcef743a5f 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -4180,7 +4180,7 @@ test("an errored end keeps its history", async () => { producer.close(); expect(producer.closed.peek()).toBe(failed); - const replay = { groups: { start: { included: 0 } }, maxDelay: Milli(30_000) }; + const replay = { ranges: [{ start: { absolute: 0 } }], maxDelay: Milli(30_000) }; const arrival = producer.subscribe(replay); expect((await arrival.recvGroup())?.sequence).toBe(0); expect((await arrival.recvGroup())?.sequence).toBe(1); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 5f44eaaffdc..a28adefd804 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -2356,12 +2356,20 @@ export class Subscriber { }; hooks.tryRecvGroup = (subscriber, dispositions) => subscriber.#tryRecvGroup(dispositions); hooks.tryDrop = (subscriber) => subscriber.#tryDrop(); + // A reader following route copies leaves its ranges to the serving copy, which resolves + // their anchors against the groups it holds, then narrows them to its own local bounds. hooks.servingRuns = (subscriber, start, end) => { const cursor = subscriber.#cursor.peek(); - return subscriber.#window.runs(Math.max(start, cursor.start), Math.min(end, cursor.end ?? end)); + const from = Math.max(start, cursor.start); + const to = Math.min(end, cursor.end ?? end); + return subscriber.#resume?.runs(from, to) ?? subscriber.#window.runs(from, to); + }; + hooks.servingEnd = (subscriber) => { + const cursor = subscriber.#cursor.peek(); + if (!subscriber.#resume) return subscriber.#window.end() === undefined ? undefined : cursor.end; + const end = subscriber.#resume.servingEnd(); + return end === undefined ? undefined : Math.min(end, cursor.end ?? end); }; - hooks.servingEnd = (subscriber) => - subscriber.#window.end() === undefined ? undefined : subscriber.#cursor.peek().end; hooks.servingStarts = (subscriber, from) => { const resumed = subscriber.#resume?.starts(from); if (resumed) return resumed; diff --git a/js/net/src/window.test.ts b/js/net/src/window.test.ts index 160523dc286..6bf429e9a16 100644 --- a/js/net/src/window.test.ts +++ b/js/net/src/window.test.ts @@ -187,11 +187,13 @@ test("a time start is the group containing it, and an end stops past it once a l // Before every start held: reach back to the oldest. expect(covered(new Window([{ start: time(1000), end: time(2500) }], 6, groups), 8)).toEqual([0, 1, 2]); - // Nothing started yet: a time start waits for a group that has, reading none meanwhile, since a - // group arrives before the frame that shows where it starts. + // Nothing started yet: a time start waits on a first group, as a relative one does, then on + // one starting, reading none meanwhile, since a group arrives before the frame that shows + // where it starts. window = new Window([{ start: time(1000) }], undefined, new Groups()); - expect([window.contains(3), window.floor(), window.settled]).toEqual([false, undefined, false]); - expect(window.settle(4, new Groups([[4, "pending"]]))).toBe(false); + expect([window.pending, window.floor(), window.settled]).toEqual([true, undefined, false]); + expect(window.settle(4, new Groups([[4, "pending"]]))).toBe(true); + expect([window.pending, window.contains(4), window.settled]).toEqual([false, false, false]); expect(window.settle(4, new Groups([[4, started(500)]]))).toBe(true); expect([window.floor(), window.settled]).toEqual([4, true]); // Nothing can start any more: it reaches back to the oldest. diff --git a/js/net/src/window.ts b/js/net/src/window.ts index 2885234a8e3..698246780db 100644 --- a/js/net/src/window.ts +++ b/js/net/src/window.ts @@ -761,14 +761,17 @@ function startOf( const pts = time(anchor); if (pts !== undefined) { const containing = holdings.containing(pts); - // The group holding it is older than any held, so reach back to the oldest, as when - // nothing can start any more. - if (containing === "before" || (containing === "unknown" && holdings.complete())) { - return { group: 0, floored: false }; - } + if (containing !== "before" && containing !== "unknown") return { group: containing.group, floored: true }; + // The group holding it is older than any held, so reach back to the oldest. + if (containing === "before") return { group: 0, floored: false }; + // Before any group, as for a relative anchor, which a hop that holds none (such as a + // reader following route copies) leaves to the copies to resolve. + if (latest === undefined) return undefined; + // Nothing can start any more, so nothing held reaches past it. + if (holdings.complete()) return { group: 0, floored: false }; // Nothing has started yet, so wait for a group that has. A copy may hear the live edge // before the groups its publisher resolved the time to, which the edge would skip. - return containing === "unknown" ? "waiting" : { group: containing.group, floored: true }; + return "waiting"; } const back = relative(anchor); if (back === undefined) return { group: 0, floored: false }; From 839d0a16dd3b11d9fac9a46ba237753350ca8381 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 08:56:10 -0800 Subject: [PATCH 18/22] fix(net): read a lite range's timescale from the track info after main dropped liteTimescale Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/publisher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 9b3625b4cc7..33fec63510c 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -877,7 +877,7 @@ export class Publisher { try { // A time anchor counts in the timescale this peer was told, so it waits for the info. const timescale = hasWireTime(msg.ranges) - ? liteTimescale(await untilAborted(consumer.info(), left.signal)) + ? ((await untilAborted(consumer.info(), left.signal)).timescale ?? undefined) : undefined; ranges = requestedRanges(msg, timescale); } catch (err: unknown) { @@ -978,7 +978,7 @@ export class Publisher { priority: update.priority, order: update.order, maxDelay: servingMaxDelay(this.version, update.maxDelay), - ranges: requestedRanges(update, liteTimescale(info)), + ranges: requestedRanges(update, info.timescale ?? undefined), }); }, }); From 871591ac5b7906ebb6c50cddab9604a79a030dcd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 09:09:38 -0800 Subject: [PATCH 19/22] fix(net): wake waiting reads when accepting a track refuses their time anchor Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 12 ++++++++++++ js/net/src/track.ts | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 48a374b80b3..7a2cb14de3b 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -748,6 +748,18 @@ test("an untimed track or a moq-transport copy refuses a time anchor", () => { expect(() => copy.subscribe({ ranges }).tryRecvGroup()).toThrow("moq-transport"); }); +// Waiting reads see the refusal once the track is accepted, with no group or datagram arriving. +test("accepting an untimed track refuses a waiting read's time anchor", async () => { + const producer = new TrackProducer("test"); + const reader = producer.subscribe({ ranges: [{ start: { time: Timestamp.fromMillis(0) } }] }); + const group = reader.recvGroup(); + const datagram = reader.recvDatagram(); + producer.accept({}); + const [grouped, received] = await Promise.allSettled([group, datagram]); + expect(grouped).toMatchObject({ status: "rejected", reason: expect.any(TimestampMismatch) }); + expect(received).toMatchObject({ status: "rejected", reason: expect.any(TimestampMismatch) }); +}); + // A resume floor above the live edge must not hide the latest group from a reader that // joins after it: on a quiet track that group may be the only one for a long time. test("an unfloored subscriber clears a resume floor above the live edge", () => { diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 30439f65841..1531fb4eb53 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -2921,6 +2921,8 @@ export class Subscriber { this.#state.startChanged.changed(fn), // A group starting may settle a time end, or move the cap it holds reads back with. ...(this.#window.settled ? [] : [this.#state.timelineChanged.changed(fn)]), + // Accepting the track may refuse a time anchor, with no group arriving to say so. + this.#state.info.changed(fn), this.#state.dropsAnchors.changed(fn), this.#cursor.changed(fn), // A widened budget re-offers the groups it skipped as stale. @@ -2985,6 +2987,9 @@ export class Subscriber { else await Signal.race( this.#state.datagrams, + // Either may refuse the ranges, with no datagram arriving to say so. + this.#state.info, + this.#state.dropsAnchors, this.#state.closed, this.#state.end, this.#state.received, From 1278eb91c1fe6560edff7ab24833adc84128080a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 09:15:35 -0800 Subject: [PATCH 20/22] fix(moq-net): keep window spans in range order once a waiting start resolves Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 37 +++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 38cb5f50865..ec8fd83394e 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -642,13 +642,19 @@ impl Window { Anchor::Time(_) if !floored => holdings.next_start(0).map_or(start, |(oldest, _)| oldest), _ => start, }; - self.spans.push(Span { - index, - start, - end, - floored, - reach, - }); + // Kept in range order, which `admits_datagram` pairs them by, though a waiting start + // resolves after the ranges past it. + let at = self.spans.partition_point(|span| span.index < index); + self.spans.insert( + at, + Span { + index, + start, + end, + floored, + reach, + }, + ); true } @@ -1236,6 +1242,23 @@ mod tests { assert!(!window.admits_datagram(9, Some(at(2900)))); } + /// A waiting time start that resolves after a later range still bounds datagrams by its + /// own relative end. + #[test] + fn a_resolved_waiting_start_judges_datagrams_by_its_own_end() { + let mut groups = Groups::new((2..10).map(|sequence| (sequence, Held::Pending))); + let ranges = [ + range(time(2500), Anchor::Relative(0)), + range(Anchor::Absolute(20), Anchor::Absolute(21)), + ]; + let mut window = Window::new(&ranges, Some(9), &groups).unwrap(); + groups.held.insert(2, Held::Started(at(2000))); + assert!(window.settle(Some(9), &groups)); + assert_eq!(window.resolved()[0], Some(Resolved { start: 2, end: Some(10) })); + assert!(window.admits_datagram(9, Some(at(3000)))); + assert!(!window.admits_datagram(15, Some(at(3000))), "past the relative end"); + } + /// A datagram is in range only when one range admits its sequence and time together, /// not when each passes a different range. #[test] From 9fbcbdef0754d8ef4f319ca9bc30ec2d4d996012 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 09:18:28 -0800 Subject: [PATCH 21/22] style(moq-net): format the span-order test Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index ec8fd83394e..ebe708b7999 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -1254,7 +1254,13 @@ mod tests { let mut window = Window::new(&ranges, Some(9), &groups).unwrap(); groups.held.insert(2, Held::Started(at(2000))); assert!(window.settle(Some(9), &groups)); - assert_eq!(window.resolved()[0], Some(Resolved { start: 2, end: Some(10) })); + assert_eq!( + window.resolved()[0], + Some(Resolved { + start: 2, + end: Some(10) + }) + ); assert!(window.admits_datagram(9, Some(at(3000)))); assert!(!window.admits_datagram(15, Some(at(3000))), "past the relative end"); } From 4d738d0349d0a52e19bc286358a3a29082000c47 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 22:00:21 -0800 Subject: [PATCH 22/22] fix(net): answer an empty lite-07 window, and hold a time lookup's track A lite-07 bounded window that resolves empty has no group to answer it, so the publisher sent nothing and the subscriber waited for good. Answer it with SUBSCRIBE_START at once, and end it as covered. Waiting on `consumer.info()` for a time anchor's timescale opened an on-demand track's request that was released before the subscription took it, which then served a second producer. Subscribe first, asking for nothing, and set the ranges once its info lands. Also formats the window walk the merge added. Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/publisher.test.ts | 37 ++++++++++++ js/net/src/lite/publisher.ts | 63 +++++++++++++++------ rs/moq-net/src/model/subscription/window.rs | 5 +- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 57ba749dc58..ed80f6d2fec 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -889,6 +889,43 @@ test("lite-07 SUBSCRIBE_START names each resolved range start", async () => { } }); +// A bounded window that resolves empty has no group to answer it, so the publisher answers it +// at once and ends the subscription as covered, as a local read of it ends at once. +test("lite-07 answers and ends a bounded window that resolves empty", async () => { + const version = Version.DRAFT_07; + const sub = await servedSubscription({ + version, + prefill: [0, 1, 2, 3, 4, 5], + maxAge: Milli(3_600_000), + ranges: [{ start: { relative: 0 }, end: { absolute: 3 } }], + }); + try { + const resp = await decodeSubscribeResponse(sub.client.reader, version); + if (!("start" in resp)) throw new Error("expected SUBSCRIBE_START"); + expect(resp.start.starts).toEqual([undefined]); + expect(await decodeSubscribeResponseMaybe(sub.client.reader, version)).toBeUndefined(); + } finally { + await sub.close(); + } +}); + +// A time anchor waits for the track's timescale. On an on-demand track that lookup is the +// subscription's own request, so the track it answers is the one served. +test("lite-07 serves a time range from the on-demand track its lookup resolved", async () => { + const sub = await servedSubscription({ + version: Version.DRAFT_07, + pending: true, + ranges: [{ start: { time: 0 } }], + }); + try { + await sub.accept(); + sub.serve(0); + expect(await sub.servedSequence()).toBe(0); + } finally { + await sub.close(); + } +}); + // A relay can ingest back-to-back groups micro-reordered (the upstream leg sends // newest-first). The older group is cached and in demand, so serving must still // deliver it; a sequence cursor would skip it permanently. diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 638cdb34787..bf59ef19506 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -896,31 +896,43 @@ export class Publisher { } const consumer = front.track(msg.track); - let ranges: track.Range[]; + const subscription = (ranges: track.Range[]): track.Subscription => ({ + priority: msg.priority, + order: msg.order, + delay: { + max: servingMaxDelay(this.version, msg.maxDelay), + min: msg.minDelay === undefined ? null : Milli(msg.minDelay), + }, + ranges, + }); + // A time anchor counts in the timescale this peer was told, so it waits for the info. + // Meanwhile the subscription asks for nothing but holds the track it resolves, so an + // on-demand track's request answers it, rather than one released once its info lands. + const timed = hasWireTime(msg.ranges); + let ranges: track.Range[] = []; try { - // A time anchor counts in the timescale this peer was told, so it waits for the info. - const timescale = hasWireTime(msg.ranges) - ? ((await untilAborted(consumer.info(), left.signal)).timescale ?? undefined) - : undefined; - ranges = requestedRanges(msg, timescale); + if (!timed) ranges = requestedRanges(msg, undefined); } catch (err: unknown) { stream.writer.reset(error(err)); return; } - if (watch.revoked()) return; // Parked past a bounded window's end: the peer reads this stream's end as the track's. - const track = hooks.parked( - consumer.subscribe({ - priority: msg.priority, - order: msg.order, - delay: { - max: servingMaxDelay(this.version, msg.maxDelay), - min: msg.minDelay === undefined ? null : Milli(msg.minDelay), - }, - ranges, - }), - ); + const track = hooks.parked(consumer.subscribe(subscription(ranges))); serving(track); + if (timed) { + try { + const info = await untilAborted(track.info(), left.signal); + track.update(subscription(requestedRanges(msg, info.timescale ?? undefined))); + } catch (err: unknown) { + track.close(); + stream.writer.reset(error(err)); + return; + } + } + if (watch.revoked()) { + track.close(); + return; + } const latest = hooks.latestSequence(track); let floor = cursorFloor(this.version, msg.startGroup, latest); // The ranges cap what is served; the floor only pins where an older wire starts. @@ -1413,6 +1425,21 @@ export class Publisher { continue; } } + // A lite-07 window whose bounded ranges all resolved empty owes nothing: say so at + // once, and the subscription ends as covered below. No group would ever answer it. + if (emitRange && !startSent && hasRanges(this.version)) { + const end = hooks.servingEnd(track); + if (end !== undefined && hooks.servingRuns(track, 0, end).length === 0) { + startSent = true; + startSequence = end; + const start = new SubscribeStart(rangeStarts(this.version, track, end), largest); + if ( + !(await controls.response(encodeSubscribeResponse(stream, { start }, this.version))) + ) + return false; + continue; + } + } // A declared end goes out while lower groups are still being produced. if (!endSent && hooks.subscriberEnd(track) !== undefined && declareEarly()) { if (!(await sendEnd())) return false; diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 44ecdc04f01..1cbc2fa50b9 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -678,7 +678,10 @@ impl Window { End::Open => None, End::Time(end) => { self.walks.push(Walk { - ends: BTreeSet::from([Ending { pts: end, span: position }]), + ends: BTreeSet::from([Ending { + pts: end, + span: position, + }]), trail: Trail::new(start, reach), low: start, low_reach: reach,