diff --git a/bench/browser/page/transport.ts b/bench/browser/page/transport.ts index 0c408caf1e1..e161d039198 100644 --- a/bench/browser/page/transport.ts +++ b/bench/browser/page/transport.ts @@ -92,7 +92,7 @@ async function consume(config: Transport): Promise { if (!broadcast) throw new Error("consume before prepare"); const groups = Math.ceil(config.frames / config.group); const start = performance.now(); - const subscriber = broadcast.track("data").subscribe({ maxDelay: Milli(600_000), order: "ascending" }); + const subscriber = broadcast.track("data").subscribe({ delay: { max: Milli(600_000) }, order: "ascending" }); cleanup.push(() => subscriber.close()); let frames = 0; let bytes = 0; diff --git a/dart/moq/lib/media.dart b/dart/moq/lib/media.dart index aa03b240d80..674d24c0a79 100644 --- a/dart/moq/lib/media.dart +++ b/dart/moq/lib/media.dart @@ -75,7 +75,7 @@ typedef ContainerInit = MoqContainerInit; /// A width and height pair, in pixels. typedef Dimensions = MoqDimensions; -/// How far behind the live edge a decoder plays: `min` is the playout delay and where a cold join starts, and `max`, null for no limit, is how far a stalled group may fall behind before it is skipped. `min` stays on the consumer; a `min` past `max` starts at `max`. +/// How far behind the live edge a decoder plays: `min` is the playout delay and where a cold join starts, and `max`, null for no limit, is how far a stalled group may fall behind before it is skipped. The subscription carries both; a `min` past `max` starts at `max`. typedef Delay = MoqDelay; /// Where an audio or video decoder starts on a track that already holds groups: `latest` for a live player. diff --git a/doc/concept/identity.md b/doc/concept/identity.md index 750aac06c9c..c663bc6d85f 100644 --- a/doc/concept/identity.md +++ b/doc/concept/identity.md @@ -225,7 +225,7 @@ Within one stream, recovery is about which groups are still worth having. - **Publisher max age bounds retention; reader max delay bounds interest.** A track's `max_age` (`maxAge` in JavaScript, `--max-age` on `moq import`) is the longest any hop keeps a non-latest group for late joiners, fetches, and - recovery; a relay may evict sooner. A reader's `max_delay` (`maxDelay`) is how long a non-latest group + recovery; a relay may evict sooner. A reader's `delay.max` is how long a non-latest group stays worth delivering to that reader. It can tighten retention, never relax it. Both age a group from its successor's arrival and never expire the newest group. Not every wire carries a reader's max delay on every request; see diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 98fdcca2542..2de07142f37 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -116,7 +116,7 @@ choices. They follow the product model. `DUPLICATE_SUBSCRIPTION`. - **Subscriber max age is negotiated.** The [Timestamp extension](/draft/moq-timestamp) - carries a subscriber's `max_delay` in `SUBSCRIBE` and `REQUEST_UPDATE` only + carries a subscriber's `delay.max` in `SUBSCRIBE` and `REQUEST_UPDATE` only when the peer advertises support. Superseded groups expire against media time and the hop's clock; the newest group, including a quiet catalog, survives any budget. Relays forward their most tolerant subscriber's budget. Without the diff --git a/doc/lib/js/json.md b/doc/lib/js/json.md index d870f37ff4c..1fd5044a997 100644 --- a/doc/lib/js/json.md +++ b/doc/lib/js/json.md @@ -35,9 +35,9 @@ for await (const { value, at } of consumer) { ``` `next()` buffers every state it has not yielded and skips a group once the -subscription's `maxDelay` proves it stale. The budget counts media time and +subscription's `delay.max` proves it stale. The budget counts media time and wall time, or wall time alone for an untimed track. The default subscription's -zero keeps only the newest group, so subscribe with a `maxDelay` covering how +zero keeps only the newest group, so subscribe with a `delay.max` covering how far the playhead trails the live edge. Use `latest()` to skip straight to the head. A stream rides one group with no record-count or cumulative byte limit. The diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 35dcdc8a56d..038d93ef9f1 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -50,8 +50,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` 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`. -- **Subscription defaults.** `Track.Subscription.default()` returns the default subscription, whose zero `maxDelay` skips a superseded group at once; calling `subscribe()` with no options means the same. An omitted `maxDelay` means the same zero; `null` sets no subscriber limit. +- **A subscription's `delay` is a `Track.Delay { min, max }`, as in Rust.** Staleness is `delay.max`: it counts from a group's successor, by media time and by wall clock, whichever reaches it first. `delay.min` is the target latency, carried on moq-lite 07; a peer that cannot carry it leaves it `null` (not stated). Passing the old `maxAge` or `maxDelay` key throws a `TypeError` naming `delay.max`. Publisher retention is the separate `Track.Info.maxAge`. +- **Subscription defaults.** `Track.Subscription.default()` returns the default subscription, whose zero `delay.max` skips a superseded group at once; calling `subscribe()` with no options means the same. An omitted `delay` field means the same zero; `max: null` sets no subscriber limit. - **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. `moq-lite-05` and newer wait for the subscriber to receive finished tails; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index a14e533d6ac..8273fa2b15f 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -71,7 +71,7 @@ with a warning, since every viewer would refuse it. ## Encoding Video encodes while enabled, so its recent groups wait in the track's cache for -a late subscription's `maxDelay` or a fetch. Audio keeps raw PCM while nobody +a late subscription's `delay.max` or a fetch. Audio keeps raw PCM while nobody listens, then encodes that history ahead of live audio when a subscriber arrives. Both retain up to `maxAge`; raw audio without a declared retention keeps at most 30 seconds (about 11.5 MB for 48 kHz stereo f32). Capture stays open. diff --git a/doc/lib/rs/moq-json.md b/doc/lib/rs/moq-json.md index d32ad3df195..dfb51393929 100644 --- a/doc/lib/rs/moq-json.md +++ b/doc/lib/rs/moq-json.md @@ -26,7 +26,7 @@ same optional capture time when opened, including when they publish on drop. Consumers return `Timed`: read `.value` for the decoded value and `.at` for its optional frame timestamp, with the track's exact scale preserved. Snapshot -`next()` yields states in order inside the subscription's `max_delay` budget; +`next()` yields states in order inside the subscription's `delay.max` budget; set that budget when following a playhead behind live. Use `latest()` to skip to the newest buffered state. Skipped states are gone, and a later `next()` continues after them. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 54dfc2d713e..0ed50e2dfe7 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -22,7 +22,7 @@ What you use it for, beyond what the concept page already describes: - **Discovery follows what you watch.** A client session asks the relay for announcements only under the prefixes its subscribe origin's readers watch, with `announced()`, `request`, or `follow`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A broadcast resolved through a discovered route keeps its path watched until dropped. `request` owns discovery of its path, so no separate watch is needed. A server session, or a client fed into an origin handle marked `with_link(origin::Link::Peer)` or `Link::Upstream`, asks for everything it may, so a relay discovers everything. An HTTP gateway (the moq-hls `Server`, the moq-rtc WHEP router) watches its whole scope while it lives, so the paths its callers name never open a request each. moq-lite-03/04 sessions, whose announcements mark no initial set, still ask for everything. - **One track per name.** Concurrent subscriptions share one request and producer. The application owns sequence numbers: the broadcast keeps nothing of an ended track, so a producer that replaces it starts at 0. A name always means the same content, so the replacement continues past its predecessor's groups with `create_group` at an explicit sequence. Readers of an aborted track resume onto the replacement when they read through an origin request, whose route always names an instance; otherwise their subscription ends with the error. - **End content by finishing tracks.** A broadcast has no finished state. When the input ends, finish each track; the broadcast stays announced and keeps serving them until you close it or drop its last producer. How long to keep it up for late readers is yours to decide: `moq_mux::Linger` holds a hang broadcast for a fixed window. -- **Demand says whether anyone is watching.** `demand()` on a track, group, or broadcast says whether a subscriber is attached. First-party capture does not wait for it: video encodes while enabled and audio keeps raw PCM to encode when someone listens, so a late subscription's `max_delay` or a FETCH finds the recent past. Demand gates bandwidth reservations, transcode rungs, and on-demand video where a publisher opts in, which saves the encode but leaves nothing from before the first subscriber. Holding a broadcast or track consumer is not demand: a track consumer requests nothing until it queries, subscribes, or fetches, and those count until dropped. A shared fetch stays up until its last reader leaves. Demand rolls up: a frame being read keeps its group wanted, a group a subscription or fetch handed out keeps its track wanted until read to its end, and any wanted track keeps its broadcast wanted. +- **Demand says whether anyone is watching.** `demand()` on a track, group, or broadcast says whether a subscriber is attached. First-party capture does not wait for it: video encodes while enabled and audio keeps raw PCM to encode when someone listens, so a late subscription's `delay.max` or a FETCH finds the recent past. Demand gates bandwidth reservations, transcode rungs, and on-demand video where a publisher opts in, which saves the encode but leaves nothing from before the first subscriber. Holding a broadcast or track consumer is not demand: a track consumer requests nothing until it queries, subscribes, or fetches, and those count until dropped. A shared fetch stays up until its last reader leaves. Demand rolls up: a frame being read keeps its group wanted, a group a subscription or fetch handed out keeps its track wanted until read to its end, and any wanted track keeps its broadcast wanted. - **Use the model without a runtime.** Disable default features on `moq-net` to use its `poll_*` APIs and origin driver with caller-supplied time. The default `async` feature adds async helpers, `time::run`, and both wire sessions. - **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio. `moq-tokio` hides that. A custom transport implements `moq_net::transport::poll`, and `moq_net::transport::memory::pair` links two sessions in one process with no sockets, for tests or in-process links. - **A session bounds what its peer can hold.** Each request holds a stream, so the transport's stream credit bounds subscriptions and FETCHes. `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds the announcements per session. The default suits a relay mesh, so lower it for untrusted peers. Past it, the session closes with `TOO_MANY_REQUESTS`. Peer-declared lengths are capped before they are buffered. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 563690ed7eb..51e9aeb675c 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1245,6 +1245,7 @@ SUBSCRIBE Message { Subscriber Priority (8) Group Order (8) Subscriber Max Age (i) + Subscriber Min Age (i) Range Count (i) Range (..) ... } @@ -1285,6 +1286,12 @@ A value of `0` means the subscriber wants only the latest group in live delivery This is a delivery-time preference, not a retention rule: the publisher MAY still hold these groups for FETCH or future subscriptions (see `Publisher Max Age` in [TRACK_INFO](#track-info)). See the [Expiration](#expiration) section for more information. +**Subscriber Min Age**: +The subscriber's target latency behind the live edge, in milliseconds plus one: the playout or jitter-buffer delay it holds. +A value of `0` means the subscriber did not state one, which is distinct from a stated zero (`1`). +It is a hint that asks for nothing: a publisher MAY use it, for example to start a cold join that far back, and MAY ignore it. +A relay forwarding an aggregate of several subscriptions SHOULD carry the smallest stated value, and not stated when none is, so a subscription that did not state one never pulls the aggregate to zero. + **Range Count**: The number of Ranges that follow. A value of 0 is a PROTOCOL_VIOLATION, since a subscription that asks for nothing has no use. @@ -1327,6 +1334,7 @@ SUBSCRIBE_UPDATE Message { Subscriber Priority (8) Group Order (8) Subscriber Max Age (i) + Subscriber Min Age (i) Range Count (i) Range (..) ... Wanted Group (i) @@ -1882,6 +1890,7 @@ The `Message Length` describes the payload size on the wire. - Replaced `Group Start`, `Group End`, `Frame Start`, and `Frame End` in SUBSCRIBE and SUBSCRIBE_UPDATE with a non-empty list of Ranges. Each end is an Anchor: none, an absolute Position (exclusive as an end), a count of Groups back from the latest, or a presentation time. - Added `Group Order` to SUBSCRIBE and SUBSCRIBE_UPDATE: descending (0x0, the previous fixed order) or ascending (0x1). +- Added an optional `Subscriber Min Age` to SUBSCRIBE and SUBSCRIBE_UPDATE, the subscriber's target latency, encoded as milliseconds plus one with zero meaning not stated. A relay forwards the smallest stated value. - SUBSCRIBE may start at any frame, including when the subscriber no longer holds the prefix. - A SUBSCRIBE_UPDATE that lowers a `Start` covers the groups below the old start that the publisher still holds, not only those that arrive later. A group already sent is not sent again. - Replaced SUBSCRIBE_OK `Group` with a `Range Start` per Range: the Group where that Range's delivery starts, including at a Relative or Time Anchor, from which its Groups are owed. diff --git a/go/wrapper/media/media.go b/go/wrapper/media/media.go index 08a82cd39f1..ef704bf3cfd 100644 --- a/go/wrapper/media/media.go +++ b/go/wrapper/media/media.go @@ -25,7 +25,7 @@ type ( Catalog = ffi.MoqCatalog // Delay is how far behind the live edge a decoder plays: Min is the playout delay and where a // cold join starts, and Max, nil for no limit, is how far a stalled group may fall behind before - // it is skipped. Min stays on the consumer; a Min past Max starts at Max. + // it is skipped. The subscription carries both; a Min past Max starts at Max. Delay = ffi.MoqDelay // Dimensions is a width and height in pixels. Dimensions = ffi.MoqDimensions diff --git a/js/e2ee/bench/pump.ts b/js/e2ee/bench/pump.ts index 92d8ac957a5..02d5974af98 100644 --- a/js/e2ee/bench/pump.ts +++ b/js/e2ee/bench/pump.ts @@ -29,7 +29,7 @@ for (const bytes of [160, 1_160, 16_384, 262_144]) { }); const consumer = credential .consume(generation.epoch) - .consume(net.subscribe({ maxDelay: Milli(60_000) }), "video"); + .consume(net.subscribe({ delay: { max: Milli(60_000) } }), "video"); const producer = await generation.produce(net, "video"); const sealStart = performance.now(); diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index e91c968cea5..5235431636a 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -40,7 +40,7 @@ function netTrack(name: string, timed = true): NetTrack.Producer { } function subscribe(net: NetTrack.Producer): NetTrack.Subscriber { - return net.subscribe({ maxDelay: Milli(60_000) }); + return net.subscribe({ delay: { max: Milli(60_000) } }); } interface Pair { @@ -236,7 +236,7 @@ test("a broadcast speaks semantic names and puts physical names on the wire", as const consumer = await new Protected.Consumer( net.consume(), generation.credential.consume(generation.epoch), - ).subscribe("video", { maxDelay: Milli(60_000) }); + ).subscribe("video", { delay: { max: Milli(60_000) } }); const group = video.appendGroup(); await group.writeFrame(ms(1), text("frame")); group.finish(); diff --git a/js/e2ee/src/transport.test.ts b/js/e2ee/src/transport.test.ts index fe68486d6f1..99569ecaf04 100644 --- a/js/e2ee/src/transport.test.ts +++ b/js/e2ee/src/transport.test.ts @@ -78,7 +78,7 @@ for (const [name, protocol, routed, timed] of transports) { expect(Epoch.of(remote)).toBe(routed ? generation.epoch : undefined); const epoch = Epoch.of(remote) ?? generation.epoch; const consumer = await new Protected.Consumer(remote, cred.consume(epoch)).subscribe("video", { - maxDelay: Milli(60_000), + delay: { max: Milli(60_000) }, }); const received = await consumer.recvGroup(); @@ -135,7 +135,7 @@ for (const [name, protocol] of [ const consumer = await new Protected.Consumer( remote, cred.consume(Epoch.of(remote) ?? generation.epoch), - ).subscribe("audio", { maxDelay: Milli(60_000) }); + ).subscribe("audio", { delay: { max: Milli(60_000) } }); expect(decoder.decode((await (await consumer.recvGroup())?.readFrame())?.plaintext)).toBe("init"); // Sequences continue past the group, preserved hop to hop by insertDatagram. diff --git a/js/hang/MIGRATE.md b/js/hang/MIGRATE.md index c54c268de42..084cba04e55 100644 --- a/js/hang/MIGRATE.md +++ b/js/hang/MIGRATE.md @@ -4,9 +4,9 @@ How to update your code for each breaking change, newest first. ## Unreleased -### `Container.Consumer` takes a `delay` ([#7311](https://github.com/moq-dev/moq/pull/7311)) +### `Container.Consumer` takes a `delay` ([#7311](https://github.com/moq-dev/moq/pull/7311), [#7597](https://github.com/moq-dev/moq/pull/7597)) -`ConsumerProps.maxDelay` is `delay: GetterInit`, where `Delay` is `{ min?: Milli; max?: Milli | null }` and an omitted field is zero. +`ConsumerProps.maxDelay` is `delay: GetterInit`, where `@moq/net`'s `Track.Delay` is `{ min?: Milli | null; max?: Milli | null }` and an omitted field is zero. The consumer reads a `null` `min` as zero. Replace `maxDelay: d` with `delay: { max: d }`, or derive it from a signal. `max: null` is no limit. `min` is the playout delay: the consumer starts at the group covering the live edge less `min` plus any `warmup`, waiting for it when the newest group arrives first. Before the first read, a backlog that already landed starts at the first group whose first frame is within `max`, plus the warmup, of the newest content, keeping the group covering the live edge less `min` and the warmup. diff --git a/js/hang/api/container.api.md b/js/hang/api/container.api.md index f3f01b44471..11e93744217 100644 --- a/js/hang/api/container.api.md +++ b/js/hang/api/container.api.md @@ -26,7 +26,7 @@ export class Consumer { } export interface ConsumerProps { - delay?: Signals.GetterInit; + delay?: Signals.GetterInit; format: Format; maxBuffer?: number; predecessor?: Consumer; @@ -35,11 +35,6 @@ export interface ConsumerProps { export const DEFAULT_MAX_BUFFER: number; -export interface Delay { - max?: Milli | null; - min?: Milli; -} - export interface Format { decode(payload: Uint8Array, timestamp: Net.Time.Timestamp | undefined): Frame[]; end?(frame: Frame): Micro | undefined; diff --git a/js/hang/bench/slow-group.ts b/js/hang/bench/slow-group.ts index 9fb8b7e6fbf..90306512768 100644 --- a/js/hang/bench/slow-group.ts +++ b/js/hang/bench/slow-group.ts @@ -3,14 +3,14 @@ import { Milli } from "@moq/clock"; import { Time, Track, Varint } from "@moq/net"; import { Signal } from "@moq/signals"; -import { Consumer, type Delay } from "../src/container/consumer.ts"; +import { Consumer } from "../src/container/consumer.ts"; import { Format } from "../src/container/legacy.ts"; console.log("groups,warnings,bytes,catchup_ms"); for (const count of [1, 16, 256, 1024]) { const track = new Track.Producer("tone"); - const delay = new Signal({ max: Milli(30_000) }); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const delay = new Signal({ max: Milli(30_000) }); + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay, }); diff --git a/js/hang/src/container/consumer.backpressure.test.ts b/js/hang/src/container/consumer.backpressure.test.ts index bfc5bfb7855..78f5621e237 100644 --- a/js/hang/src/container/consumer.backpressure.test.ts +++ b/js/hang/src/container/consumer.backpressure.test.ts @@ -55,7 +55,7 @@ test("Consumer holds frames sharing a timestamp within the max buffer", async () const maxBuffer = 4096; const format = new Counting("data"); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format, delay: { max: Milli(5_000) }, maxBuffer, @@ -90,7 +90,7 @@ test("Consumer holds many groups within the max buffer and reaches the live edge const maxBuffer = 8192; const format = new Counting("data"); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format, delay: { max: Milli(100) }, maxBuffer, @@ -125,7 +125,7 @@ test("Consumer skips a stalled group once group overhead alone fills the max buf const warn = spyOn(console, "warn").mockImplementation(() => {}); const format = new Counting("data"); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format, delay: { max: Milli(5_000) }, // Two groups' overhead, so nothing past the stalled head would decode without its successor leading. @@ -152,7 +152,7 @@ test("Consumer reaches a readable group past a stalled successor once group over const warn = spyOn(console, "warn").mockImplementation(() => {}); const format = new Counting("data"); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format, delay: { max: Milli.zero }, // Three groups' overhead: the readable group cannot decode until the stalled ones are moved past. @@ -181,7 +181,7 @@ test("Consumer reaches a readable group past a stalled successor once group over test("Consumer admits reads completing together one at a time against the max buffer", async () => { const format = new Counting("data"); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format, delay: { max: Milli(5_000) }, maxBuffer: 4096, @@ -216,7 +216,7 @@ class Header extends Format { test("Consumer charges a small view of a large wire frame the wire frame", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Header("data"), delay: { max: Milli(5_000) }, maxBuffer: 16 * 1024, @@ -252,7 +252,7 @@ test("Consumer stamps frames the max buffer held back with when they landed, not const clock = new Manual(); const installed = clock.install(); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(5_000) }, // One group's overhead and one frame: the second frame waits in the transport until the first is read. @@ -281,7 +281,7 @@ test("Consumer expires a hole behind a full max buffer from when the group past const clock = new Manual(); const installed = clock.install(); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(500) }, // Two groups' overhead: group 3 waits in the transport until groups 0 and 1 are read. @@ -314,7 +314,7 @@ for (const maxBuffer of [0, 256]) { test(`Consumer moves past a stalled group with a ${maxBuffer} byte max buffer`, async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(5_000) }, // No more than one group's overhead: the successor is pulled anyway, so the head can be skipped. @@ -341,7 +341,7 @@ for (const maxBuffer of [0, 256]) { test("Consumer moves past a stalled backlog group below the cursor once the max buffer is full", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(5_000) }, // Two groups' overhead: the backlog group and the active one leave no room for the newer group. @@ -369,7 +369,7 @@ test("Consumer moves past a stalled backlog group below the cursor once the max test("Consumer reads what a promoted head already holds before skipping it under pressure", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(5_000) }, maxBuffer: 1024, @@ -414,7 +414,7 @@ async function within(promise: Promise): Promise { test("Consumer raises a malformed frame in a head promoted under pressure", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(5_000) }, maxBuffer: 1024, @@ -447,7 +447,7 @@ test("Consumer raises a malformed frame in a head promoted under pressure", asyn test("Consumer raises a malformed group an age skip passes after the track finished", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(500) }, }); @@ -477,7 +477,7 @@ test("Consumer raises a malformed group an age skip passes after the track finis test("Consumer reads a finished marker group's end before skipping under pressure", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(5_000) }, maxBuffer: 1024, @@ -513,7 +513,7 @@ test("Consumer reads a finished marker group's end before skipping under pressur test("Consumer reads a group whose frame fails to read as cut short while the reader waits", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("test"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(5_000) }, }); diff --git a/js/hang/src/container/consumer.holes.test.ts b/js/hang/src/container/consumer.holes.test.ts index b1f9de2114f..ddf7ce278e1 100644 --- a/js/hang/src/container/consumer.holes.test.ts +++ b/js/hang/src/container/consumer.holes.test.ts @@ -10,7 +10,7 @@ test.each([0, 100])("a sequence hole before a paused data marker expires at %sms const clock = new Manual(); const installed = clock.install(); const track = new Track.Producer("data"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(budget) }, }); @@ -55,7 +55,7 @@ test("a filled hole cancels its timer and the next hole gets a fresh budget", as const clock = new Manual(); const installed = clock.install(); const track = new Track.Producer("data"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(100) }, }); @@ -106,7 +106,7 @@ test("the holes a stalled source left behind share one budget", async () => { const clock = new Manual(); const installed = clock.install(); const track = new Track.Producer("data"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(100) }, }); @@ -147,7 +147,7 @@ test("a hole is late from the earliest arrival past it", async () => { const clock = new Manual(); const installed = clock.install(); const track = new Track.Producer("data"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(100) }, }); @@ -205,7 +205,7 @@ test("a hole in a drained backlog is late from the earliest arrival past it, not await clock.advance(Nano.fromMilli(Milli(100))); // Joined now, the backlog drains in sequence order: group 3 before group 5, which landed first. - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(500) }, }); diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index 34c6092fb57..5974b00ebe3 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -55,7 +55,7 @@ async function drain(consumer: Consumer): Promise<[number, number | undefined][] // subscription's own max delay already bounds how far back one can be. test("out-of-order groups are delivered rather than dropped", async () => { const track = new Track.Producer("test").accept({ timescale: Time.Timescale.MILLI, maxAge: Milli(30_000) }); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(5000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(5000) } }), { format: new LegacyFormat("data"), delay: { max: 5000 as Milli }, }); @@ -84,7 +84,7 @@ test("out-of-order groups are delivered rather than dropped", async () => { // instant silently truncates its tail, so removal must wait for the group to finish. test("a below-cursor group still downloading is not truncated", async () => { const track = new Track.Producer("test").accept({ timescale: Time.Timescale.MILLI, maxAge: Milli(30_000) }); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(5000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(5000) } }), { format: new LegacyFormat("data"), delay: { max: 5000 as Milli }, }); @@ -123,7 +123,7 @@ test("delayed older groups survive a floor established by two newer groups", asy clock.jump(Nano.fromMilli(Milli(200))); const installed = clock.install(); const track = new Track.Producer("test").accept({ timescale: Time.Timescale.MILLI, maxAge: Milli(30_000) }); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(5000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(5000) } }), { format: new LegacyFormat("data"), delay: { max: Milli(5000) }, }); diff --git a/js/hang/src/container/consumer.rewind.test.ts b/js/hang/src/container/consumer.rewind.test.ts index 251048703dd..88da5753dbd 100644 --- a/js/hang/src/container/consumer.rewind.test.ts +++ b/js/hang/src/container/consumer.rewind.test.ts @@ -15,7 +15,7 @@ afterEach(() => installed.close()); function setup() { const track = new Track.Producer("rewind"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("video"), delay: { max: Milli(30_000) }, }); diff --git a/js/hang/src/container/consumer.skips.test.ts b/js/hang/src/container/consumer.skips.test.ts index a7316087d1b..94820a9e44b 100644 --- a/js/hang/src/container/consumer.skips.test.ts +++ b/js/hang/src/container/consumer.skips.test.ts @@ -2,15 +2,15 @@ import { expect, spyOn, test } from "bun:test"; import { Milli } from "@moq/clock"; import { Time, Track, Varint } from "@moq/net"; import { Signal } from "@moq/signals"; -import { Consumer, type Delay } from "./consumer.ts"; +import { Consumer } from "./consumer.ts"; import { Format } from "./legacy.ts"; for (const count of [1, 16, 256]) { test(`Consumer summarizes ${count} skipped groups once per catch-up`, async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("tone"); - const delay = new Signal({ max: Milli(30_000) }); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const delay = new Signal({ max: Milli(30_000) }); + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay, }); @@ -59,7 +59,7 @@ for (const open of [true, false]) { test(`a buffered backlog starts within the budget (sixth group ${open ? "open" : "finished"})`, async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("backlog"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(1500) }, }); @@ -99,7 +99,7 @@ for (const open of [true, false]) { test("a backlog start waits on a successor without frames", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); const track = new Track.Producer("backlog"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("data"), delay: { max: Milli(1500) }, }); diff --git a/js/hang/src/container/consumer.startup.test.ts b/js/hang/src/container/consumer.startup.test.ts index a40396c2a77..3ee96232bae 100644 --- a/js/hang/src/container/consumer.startup.test.ts +++ b/js/hang/src/container/consumer.startup.test.ts @@ -10,7 +10,7 @@ function setup(maxDelay = 100) { // Drain the in-memory readers and their continuations without moving time. const flush = () => clock.advance(Nano.zero); const track = new Track.Producer("video"); - const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: Milli(30_000) } }), { format: new Format("video"), delay: { max: Milli(maxDelay) }, }); diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 932bd1ec773..4f70c5119f9 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -81,7 +81,7 @@ function settle(ms = 20): Promise { // These tests write every group up front and only then read, so they ask for history // rather than the live edge. function replay(track: Track.Producer): Track.Subscriber { - return track.subscribe({ maxDelay: Milli(30_000) }); + return track.subscribe({ delay: { max: Milli(30_000) } }); } // --- LegacyFormat --- @@ -161,7 +161,7 @@ test("Legacy Producer accepts open-GOP leading pictures above the previous group test("Legacy Producer writes a duration marker at the next keyframe", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxDelay: Milli(30_000) }); + const subscriber = track.subscribe({ delay: { max: Milli(30_000) } }); const producer = new LegacyProducer(track, new LegacyFormat("video")); producer.encode(new Uint8Array([0xde, 0xad]), 0 as Micro, true); producer.encode(new Uint8Array([0xbe, 0xef]), 10_000 as Micro, false); @@ -186,7 +186,7 @@ test("Legacy Producer writes a duration marker at the next keyframe", async () = test("Legacy Producer omits a reordered group's presentation endpoint marker", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxDelay: Milli(30_000) }); + const subscriber = track.subscribe({ delay: { max: Milli(30_000) } }); const producer = new LegacyProducer(track, new LegacyFormat("video")); for (const [index, timestamp] of [0, 120_000, 40_000, 80_000].entries()) { producer.encode(new Uint8Array([1]), timestamp as Micro, index === 0); @@ -212,7 +212,7 @@ test("Legacy Producer omits a reordered group's presentation endpoint marker", a test("Legacy Producer estimates the tail from the current cadence", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxDelay: Milli(30_000) }); + const subscriber = track.subscribe({ delay: { max: Milli(30_000) } }); const producer = new LegacyProducer(track, new LegacyFormat("video")); for (const [index, timestamp] of [0, 16_000, 32_000, 65_000, 98_000].entries()) { producer.encode(new Uint8Array([1]), timestamp as Micro, index === 0); @@ -232,7 +232,7 @@ test("Legacy Producer estimates the tail from the current cadence", async () => test("Legacy Producer rejects a backwards discontinuity without closing the group", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxDelay: Milli(30_000) }); + const subscriber = track.subscribe({ delay: { max: Milli(30_000) } }); const producer = new LegacyProducer(track, new LegacyFormat("video")); producer.encode(new Uint8Array([1]), 20_000 as Micro, true); expect(() => producer.discontinuity(10_000 as Micro)).toThrow(); @@ -251,7 +251,7 @@ test("Legacy Producer rejects a backwards discontinuity without closing the grou test("Legacy Producer keeps the cadence after rejecting a backwards discontinuity", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxDelay: Milli(30_000) }); + const subscriber = track.subscribe({ delay: { max: Milli(30_000) } }); const producer = new LegacyProducer(track, new LegacyFormat("video")); producer.encode(new Uint8Array([1]), 20_000 as Micro, true); producer.encode(new Uint8Array([2]), 30_000 as Micro, false); @@ -421,7 +421,7 @@ test("Legacy Producer discontinuity keeps pre-pause media from reading as live", producer.encode(new Uint8Array([1]), 5_000_000 as Micro, true); producer.close(); - const subscriber = track.subscribe({ maxDelay: Milli(1_000) }); + const subscriber = track.subscribe({ delay: { max: Milli(1_000) } }); expect((await readGroups(subscriber, 2)).map(([sequence]) => sequence)).toEqual([1, 2]); }); diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts index 2836e9c254a..d29782d52c0 100644 --- a/js/hang/src/container/consumer.ts +++ b/js/hang/src/container/consumer.ts @@ -5,38 +5,27 @@ import type { Format } from "./format"; import { Jitter } from "./jitter"; import type { BufferedRanges, Frame, ReceivedFrame } from "./types"; -/** How far behind the live edge a {@link Consumer} plays, in milliseconds. Mirrors Rust's `Delay`. */ -export interface Delay { - /** - * The target latency: the playout or jitter-buffer delay a player holds behind the live edge - * (default: 0). - * - * The start is the group covering the live edge less `min`, plus any {@link ConsumerProps.warmup}, - * waiting for it when the newest group arrives first. Consumer-only: it never reaches the - * subscription. A `min` past `max` starts at `max`, since the publisher serves nothing older. - */ - min?: Milli; - /** - * How stale a group may get before it is skipped (default: 0), or `null` for no limit. - * - * Measured as the span from the oldest buffered frame to the newest, so it bounds how long a - * late group is waited for. A missing sequence also expires this much monotonic time after - * the first group past it arrived, even when the next group is a marker on a paused track. - * The local half of the subscription's `maxDelay`; both measure the same budget, one on the wire - * and one as frames are read. It bounds a join's lateness too: a backlog that landed before the - * first read starts at the first group whose first frame is within it, plus any warmup, of the - * newest content, though the start keeps the group covering the live edge less `min` and the warmup. - */ - max?: Milli | null; -} - /** Options for constructing a {@link Consumer}. */ export interface ConsumerProps { /** The container format used to decode each MoQ frame. */ format: Format; - /** How far behind the live edge to play (default: zero for both bounds). See {@link Delay}. */ + /** + * How far behind the live edge to play (default: zero for both bounds). See `Moq.Track.Delay`. + * + * The start is the group covering the live edge less `min`, plus any {@link ConsumerProps.warmup}, waiting + * for it when the newest group arrives first; an unstated (`null`) `min` reads as zero. A `min` + * past `max` starts at `max`, since the publisher serves nothing older. `max` is measured as + * the span from the oldest buffered frame to the newest, so it bounds how long a late group is + * waited for. A missing sequence also expires this much monotonic time after the first group + * past it arrived, even when the next group is a marker on a paused track. It is the local + * half of the subscription's `delay.max`; both measure the same budget, one on the wire and one + * as frames are read. It bounds a join's lateness too: a backlog that landed before the first + * read starts at the first group whose first frame is within it, plus any warmup, of the + * newest content, though the start keeps the group covering the live edge less `min` and the + * warmup. + */ // Read-only: a Getter (e.g. another component's output) is accepted directly. - delay?: GetterInit; + delay?: GetterInit; /** * Cap the bytes of decoded frames held ahead of the reader, counting the whole wire payload they * share and each frame's and group's own overhead (default: {@link DEFAULT_MAX_BUFFER}). Mirrors @@ -57,7 +46,7 @@ export interface ConsumerProps { * * Put the max delay plus the warmup on the subscription, since the publisher skips what falls * outside that budget. The start is no later than the group covering the live edge less - * {@link Delay.min} and the warmup: one that starts at or before that point. Both default to zero, + * `delay.min` and the warmup: one that starts at or before that point. Both default to zero, * which the newest group covers, so the start waits for nothing. Otherwise groups can arrive * newest first, so when none that arrived covers that point the start waits for an earlier one, * until one covers it, group 0 arrived, the track finishes, the max buffer fills, or the newest @@ -160,7 +149,7 @@ function continues(prev: Group, next: Group | undefined): next is Group { } // The max delay in microseconds, infinite when unlimited. -function maxMicros(delay: Delay): number { +function maxMicros(delay: Moq.Track.Delay): number { return delay.max === null ? Number.POSITIVE_INFINITY : Micro.fromMilli(delay.max ?? Milli.zero); } @@ -168,7 +157,7 @@ function maxMicros(delay: Delay): number { export class Consumer { #track: Moq.Track.Subscriber; #format: Format; - #delay: Getter; + #delay: Getter; #maxBuffer: number; // How much further than the min delay the start reaches, and the age skip tolerates while warming. #warmup: Milli; @@ -918,7 +907,7 @@ export class Consumer { // How far behind the live edge the start reaches, in microseconds: the min delay plus the warmup. // The publisher serves nothing older than the max delay, so a min delay past it reaches no further. - #reach(delay: Delay): number { + #reach(delay: Moq.Track.Delay): number { return Math.min(Micro.fromMilli(delay.min ?? Milli.zero), maxMicros(delay)) + Micro.fromMilli(this.#warmup); } diff --git a/js/hang/src/container/consumer.warmup.test.ts b/js/hang/src/container/consumer.warmup.test.ts index 6270d9696c7..7920b4c6206 100644 --- a/js/hang/src/container/consumer.warmup.test.ts +++ b/js/hang/src/container/consumer.warmup.test.ts @@ -14,7 +14,7 @@ function setup(delay = BUDGET, warmup = 0, min = delay, max: number | null = del const flush = () => clock.advance(Nano.zero); const track = new Track.Producer("video"); // A null max delay sets no subscriber limit; an omitted one is zero. - const consumer = new Consumer(track.subscribe({ maxDelay: max === null ? null : Milli(max + warmup) }), { + const consumer = new Consumer(track.subscribe({ delay: { max: max === null ? null : Milli(max + warmup) } }), { format: new Format("video"), delay: { min: Milli(min), max: max === null ? null : Milli(max) }, warmup: Milli(warmup), diff --git a/js/hang/src/container/index.ts b/js/hang/src/container/index.ts index 8c4ce6fcb2d..14d2edf2cc7 100644 --- a/js/hang/src/container/index.ts +++ b/js/hang/src/container/index.ts @@ -7,7 +7,7 @@ /** Re-export of {@link https://jsr.io/@moq/loc | @moq/loc}, the LOC container. */ export * as Loc from "@moq/loc"; -export { Consumer, type ConsumerProps, DEFAULT_MAX_BUFFER, type Delay, type Read } from "./consumer"; +export { Consumer, type ConsumerProps, DEFAULT_MAX_BUFFER, type Read } from "./consumer"; export type { Format } from "./format"; /** The legacy varint container. */ export * as Legacy from "./legacy"; diff --git a/js/json/src/snapshot/consumer.ts b/js/json/src/snapshot/consumer.ts index 7ca39e0fb02..6914da66761 100644 --- a/js/json/src/snapshot/consumer.ts +++ b/js/json/src/snapshot/consumer.ts @@ -37,9 +37,9 @@ export class Consumer { * Get the next state in order, or `undefined` once the track ends. * * Yields one state per frame and finishes a group before starting the next, buffering every - * state the reader has not reached yet. A group the subscription's `maxDelay` proves too old + * state the reader has not reached yet. A group the subscription's `delay.max` proves too old * is skipped, counting media time and wall time, or wall time alone for an untimed track. The - * default subscription's zero keeps only the newest group, so a playhead reader sets `maxDelay` + * default subscription's zero keeps only the newest group, so a playhead reader sets `delay.max` * to how far it trails the live edge. Use {@link latest} to skip straight to the head. A group lost to a * gap resyncs from the next one. */ diff --git a/js/json/src/snapshot/snapshot.test.ts b/js/json/src/snapshot/snapshot.test.ts index 5e9a129e8f2..935cfd5a0a0 100644 --- a/js/json/src/snapshot/snapshot.test.ts +++ b/js/json/src/snapshot/snapshot.test.ts @@ -52,8 +52,8 @@ test("a cut makes the next update a snapshot group", async () => { // The ratio would have kept every update in one group; the cut rolled it anyway. The replacement // opens with a full snapshot, so it is one frame rather than a delta appended to the first group. - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([2, 1]); - expect((await drain(track.subscribe({ maxDelay: REPLAY_LATENCY }))).at(-1)).toEqual({ a: 1, b: 3 }); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([2, 1]); + expect((await drain(track.subscribe({ delay: { max: REPLAY_LATENCY } }))).at(-1)).toEqual({ a: 1, b: 3 }); }); test("a cut republishes an unchanged value", async () => { @@ -67,7 +67,7 @@ test("a cut republishes an unchanged value", async () => { producer.update({ value: { a: 1 } }); producer.finish(); - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([1, 1]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([1, 1]); }); test("a cut opens no replacement group", async () => { @@ -78,7 +78,7 @@ test("a cut opens no replacement group", async () => { producer.finish(); // Cutting closes the open group and stops there: no empty group for a consumer to advance into. - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([1]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([1]); }); test("a cut is idempotent", async () => { @@ -96,7 +96,7 @@ test("a cut is idempotent", async () => { producer.update({ value: { a: 2 } }); producer.finish(); - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([1, 1]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([1, 1]); }); test("a cut is inert without deltas", async () => { @@ -109,7 +109,7 @@ test("a cut is inert without deltas", async () => { producer.update({ value: { a: 2 } }); producer.finish(); - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([1, 1]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([1, 1]); }); test("deltas off: a snapshot group per change", async () => { @@ -122,8 +122,8 @@ test("deltas off: a snapshot group per change", async () => { // Two changes => two single-frame snapshot groups. `next()` reads both in order, while `latest()` // collapses the backlog to the newest value: older groups only hold superseded state (mirrors the // Rust consumer). The layout itself is asserted via structure() below. - expect(await drain(track.subscribe({ maxDelay: REPLAY_LATENCY }))).toEqual([{ a: 1 }, { a: 2 }]); - expect(await drainLatest(track.subscribe({ maxDelay: REPLAY_LATENCY }))).toEqual([{ a: 2 }]); + expect(await drain(track.subscribe({ delay: { max: REPLAY_LATENCY } }))).toEqual([{ a: 1 }, { a: 2 }]); + expect(await drainLatest(track.subscribe({ delay: { max: REPLAY_LATENCY } }))).toEqual([{ a: 2 }]); }); // `latest()` holding a group must jump when a newer snapshot group rolls: the @@ -133,7 +133,7 @@ test("deltas off: a snapshot group per change", async () => { test("latest abandons a held group when a newer snapshot group rolls", async () => { const track = new Track.Producer("test"); const producer = new Producer({ track }); - const consumer = new Consumer({ track: track.subscribe({ maxDelay: REPLAY_LATENCY }) }); + const consumer = new Consumer({ track: track.subscribe({ delay: { max: REPLAY_LATENCY } }) }); producer.update({ value: { a: 1 } }); expect((await consumer.latest())?.value).toEqual({ a: 1 }); @@ -153,7 +153,7 @@ test("latest abandons a held group when a newer snapshot group rolls", async () test("latest stays inside the reader's range", async () => { const track = new Track.Producer("test"); const producer = new Producer({ track, deltas: false }); - const subscriber = track.subscribe({ maxDelay: REPLAY_LATENCY }).withRange({ end: { excluded: 2 } }); + const subscriber = track.subscribe({ delay: { max: REPLAY_LATENCY } }).withRange({ end: { excluded: 2 } }); const consumer = new Consumer({ track: subscriber }); for (let n = 0; n < 4; n++) producer.update({ value: { a: n } }); @@ -166,7 +166,7 @@ test("latest stays inside the reader's range", async () => { test("next reads a held group out before a newer snapshot group", async () => { const track = new Track.Producer("test"); const producer = new Producer({ track }); - const consumer = new Consumer({ track: track.subscribe({ maxDelay: REPLAY_LATENCY }) }); + const consumer = new Consumer({ track: track.subscribe({ delay: { max: REPLAY_LATENCY } }) }); producer.update({ value: { a: 1 } }); expect((await consumer.next())?.value).toEqual({ a: 1 }); @@ -189,8 +189,8 @@ test("next skips the groups the max delay abandons", async () => { for (let n = 1; n <= 3; n++) producer.update({ value: { n }, at: Time.Timestamp.fromMillis(n * 1_000) }); producer.finish(); - expect(await drain(track.subscribe({ maxDelay: REPLAY_LATENCY }))).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); - expect(await drain(track.subscribe({ maxDelay: Milli(500) }))).toEqual([{ n: 2 }, { n: 3 }]); + expect(await drain(track.subscribe({ delay: { max: REPLAY_LATENCY } }))).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); + expect(await drain(track.subscribe({ delay: { max: Milli(500) } }))).toEqual([{ n: 2 }, { n: 3 }]); // The default of zero keeps only the newest group. expect(await drain(track.subscribe())).toEqual([{ n: 3 }]); }); @@ -335,7 +335,7 @@ test("deltas never roll without a joiner", async () => { producer.finish(); // The deltas outweigh the snapshot ten times over, but nobody joined to ask for a fresh group. - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([11]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([11]); }); test("a joiner over the ratio gets a fresh snapshot", async () => { @@ -353,7 +353,7 @@ test("a joiner over the ratio gets a fresh snapshot", async () => { producer.finish(); joiner.close(); - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([...layout]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([...layout]); expect(await drainLatest(track.subscribe())).toEqual([{ n: 3 }]); } }); @@ -368,7 +368,7 @@ test("a refresh reuses the newest timestamp", async () => { producer.finish(); joiner.close(); - const ordered = track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered(); + const ordered = track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered(); const timestamps: (number | undefined)[] = []; for (let group = await ordered.nextGroup(); group; group = await ordered.nextGroup()) { const frame = await group.readFrame(); @@ -391,7 +391,7 @@ test("a refresh after an older update starts at the newest timestamp", async () producer.finish(); joiner.close(); - const ordered = track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered(); + const ordered = track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered(); const restated: [number | undefined, unknown][] = []; for (let group = await ordered.nextGroup(); group; group = await ordered.nextGroup()) { const frame = await group.readFrame(); @@ -423,8 +423,8 @@ test("a refresh after a rejected update restates the last written value", async producer.finish(); joiner.close(); - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([4, 2]); - expect(await drain(track.subscribe({ maxDelay: REPLAY_LATENCY }))).toEqual([ + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([4, 2]); + expect(await drain(track.subscribe({ delay: { max: REPLAY_LATENCY } }))).toEqual([ { n: 0 }, { n: 1 }, { n: 2 }, @@ -444,7 +444,7 @@ test("no ratio never refreshes", async () => { producer.finish(); joiner.close(); - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([4]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([4]); }); test("a compressed refresh opens a fresh group", async () => { @@ -460,7 +460,7 @@ test("a compressed refresh opens a fresh group", async () => { producer.finish(); joiner.close(); - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([2, 2]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([2, 2]); const consumer = new Consumer({ track: track.subscribe() }); const values: Value[] = []; for await (const { value } of consumer) values.push(value); @@ -506,7 +506,7 @@ test("frame cap rolls snapshot", async () => { } producer.finish(); - expect(await structure(track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered())).toEqual([256, 1]); + expect(await structure(track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered())).toEqual([256, 1]); }); test("a rejected update leaves the previous value readable", async () => { @@ -542,13 +542,13 @@ test("a delta that would overflow the snapshot rolls a new one instead", async ( producer.finish(); // Two groups, a self-contained snapshot each, rather than one whose frame 0 was evicted. - const subscriber = track.subscribe({ maxDelay: REPLAY_LATENCY }).ordered(); + const subscriber = track.subscribe({ delay: { max: REPLAY_LATENCY } }).ordered(); expect((await subscriber.nextGroup())?.sequence).toBe(0); expect((await subscriber.nextGroup())?.sequence).toBe(1); expect(await subscriber.nextGroup()).toBeUndefined(); // The newest value is readable with no earlier frame to apply it to. - const values = await drain(track.subscribe({ maxDelay: REPLAY_LATENCY })); + const values = await drain(track.subscribe({ delay: { max: REPLAY_LATENCY } })); expect(values[values.length - 1]).toEqual({ v: "y".repeat(size) }); }); @@ -647,7 +647,7 @@ test("each state reads back with its frame's timestamp", async () => { } producer.finish(); - const subscription = { maxDelay: REPLAY_LATENCY }; + const subscription = { delay: { max: REPLAY_LATENCY } }; const consumer = new Consumer({ track: track.subscribe(subscription) }); const states: [unknown, number | undefined][] = []; for await (const { value, at } of consumer) states.push([value.n, at?.as(Time.Timescale.MILLI)]); diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index bed2bbc4a7d..d2ae6a81fc4 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -99,7 +99,7 @@ test("a second group is reported while the first is still open", async () => { // Ask for a replay window, so the first group is delivered rather than skipped by the // subscriber's default max delay budget once a newer group exists. - const consumer = new Consumer({ track: track.subscribe({ maxDelay: Milli(30_000) }) }); + const consumer = new Consumer({ track: track.subscribe({ delay: { max: Milli(30_000) } }) }); expect((await consumer.next())?.value).toEqual({ n: 0 }); await expect(consumer.next()).rejects.toBeInstanceOf(Rolled); diff --git a/js/json/src/window/window.test.ts b/js/json/src/window/window.test.ts index d2642567309..f62f5a6faf5 100644 --- a/js/json/src/window/window.test.ts +++ b/js/json/src/window/window.test.ts @@ -212,7 +212,7 @@ test("compressed round-trip across rolls", async () => { // The frame count and first-frame timestamp of each group in a finished track. async function groups(track: Track.Producer): Promise<[number, number | undefined][]> { - const ordered = track.subscribe({ maxDelay: Milli(30_000) }).ordered(); + const ordered = track.subscribe({ delay: { max: Milli(30_000) } }).ordered(); const out: [number, number | undefined][] = []; for (let group = await ordered.nextGroup(); group; group = await ordered.nextGroup()) { const first = await group.readFrame(); diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 678db5aed59..b9d95ca28e4 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,10 +4,17 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Subscription.maxDelay` is `delay: Track.Delay` ([#7597](https://github.com/moq-dev/moq/pull/7597)) + +`Track.Subscription` carries a `delay: Track.Delay` (`{ min?: Milli | null; max?: Milli | null }`) in place of `maxDelay`, which is now refused at runtime. +Replace `maxDelay: d` with `delay: { max: d }`. `max` keeps `maxDelay`'s meaning, and an omitted field is zero. +`min` is the subscriber's target latency; `null` means not stated, which only a peer on moq-lite before 07 or moq-transport sends. moq-lite-07 SUBSCRIBE and SUBSCRIBE\_UPDATE carry it, and a track's aggregate takes the smallest stated one. +`FetchGroupOptions.maxDelay` is unchanged. + ### Ordered subscriptions wait for owed groups -An ordered reader on a subscribed track now waits at a missing group while the feed may still deliver it, up to `maxDelay` by media time or wall clock. Local tracks still skip gaps, and the zero default waits for nothing. -With `maxDelay: null`, there is no subscriber deadline. Use a finite budget for bounded waiting: a moq-transport SUBSCRIBE signals no gaps, so an unlimited wait can last until the track ends. Publisher retention still bounds the budget. +An ordered reader on a subscribed track now waits at a missing group while the feed may still deliver it, up to `delay.max` by media time or wall clock. Local tracks still skip gaps, and the zero default waits for nothing. +With `delay: { max: null }`, there is no subscriber deadline. Use a finite budget for bounded waiting: a moq-transport SUBSCRIBE signals no gaps, so an unlimited wait can last until the track ends. Publisher retention still bounds the budget. ### `maxDelay: null` sets no subscriber limit ([#7403](https://github.com/moq-dev/moq/pull/7403)) diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 3fb0ff01e8c..d92f8e493e1 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -593,6 +593,10 @@ export namespace Track { sequence: number; timestamp?: Time.Timestamp; } + export interface Delay { + max?: Milli | null; + min?: Milli | null; + } export class Demand { get closed(): Signals.GetPromise; readonly name: string; @@ -688,8 +692,8 @@ export namespace Track { default(): Track.Subscription; }; export interface Subscription { + delay?: Track.Delay; groups?: Track.Groups; - maxDelay?: Milli | null; order?: Track.Order; priority?: number; } diff --git a/js/net/bench/drift.ts b/js/net/bench/drift.ts index 8224eb80a67..bac240adb0d 100644 --- a/js/net/bench/drift.ts +++ b/js/net/bench/drift.ts @@ -14,7 +14,7 @@ export async function run(): Promise { try { // The same reordered-PTS verdict runs here in Chromium before timing any work. const check = new Track.Producer("reordered").accept({ timescale: Time.Timescale.MILLI }); - const reader = check.subscribe({ maxDelay: Milli(80) }); + const reader = check.subscribe({ delay: { max: Milli(80) } }); try { for (const timestamps of [[0], [100], [150, 200, 160]]) { const group = check.appendGroup(); @@ -57,9 +57,12 @@ export async function run(): Promise { } const subscribers = Array.from({ length: readers }, () => track.subscribe({ - maxDelay: Milli( - (retained + Math.ceil((iterations + warmup) / offsets.length) + 1) * 100, - ), + delay: { + max: Milli( + (retained + Math.ceil((iterations + warmup) / offsets.length) + 1) * + 100, + ), + }, }), ); // Drain replay through the normal ordered cursor while retaining its timeline. diff --git a/js/net/bench/replay.ts b/js/net/bench/replay.ts index 9fcf10cd75f..b79a787699d 100644 --- a/js/net/bench/replay.ts +++ b/js/net/bench/replay.ts @@ -10,7 +10,7 @@ const PAYLOAD = 8; const APPENDS = 64; const REPS = 5; // Every retained group, however old: replay is the work being measured. -const SUBSCRIPTION = { groups: { start: { included: 0 } }, maxDelay: null }; +const SUBSCRIPTION = { groups: { start: { included: 0 } }, delay: { max: null } }; // The arrival order of sequences 0..count-1: ascending, descending, or a seeded shuffle. function arrivals(count: number, order: Order): number[] { diff --git a/js/net/bench/resume-history.ts b/js/net/bench/resume-history.ts index c933c546e63..e154687603a 100644 --- a/js/net/bench/resume-history.ts +++ b/js/net/bench/resume-history.ts @@ -115,7 +115,7 @@ async function read(front: Front, tracks: string[], access: Access, payloads: We await track.info(); continue; } - const subscriber = access === "subscribe" ? track.subscribe({ maxDelay: MAX_DELAY }) : undefined; + const subscriber = access === "subscribe" ? track.subscribe({ delay: { max: MAX_DELAY } }) : undefined; const group = subscriber ? await subscriber.recvGroup() : await track.fetchGroup(0); const frame = await group?.readFrame(); group?.close(); diff --git a/js/net/bench/resume.ts b/js/net/bench/resume.ts index e76a48044e7..b764403c5ba 100644 --- a/js/net/bench/resume.ts +++ b/js/net/bench/resume.ts @@ -150,7 +150,7 @@ async function measureFlaps(readers: number, flaps: number): Promise { }); front.serve(handles[0]); const subscribers = Array.from({ length: readers }, () => - front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }), + front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }), ); const held = subscribers.map((subscriber) => { const group = subscriber.tryRecvGroup(); @@ -247,7 +247,7 @@ async function measureDatagrams(readers: number, retained: number): Promise - front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }), + front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }), ); const deliver = (sequence: number) => { producer.insertDatagram(sequence, undefined, payload); diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 7e4dee4b976..5ef066610a3 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -57,7 +57,7 @@ async function measure( const held = hold.count; const window = Milli(held > 0 ? 3_600_000 : retained); const producer = new Producer("bench").accept({ timescale: Timescale.MILLI, maxAge: window }); - const subscribers = Array.from({ length: subscriberCount }, () => producer.subscribe({ maxDelay: window })); + const subscribers = Array.from({ length: subscriberCount }, () => producer.subscribe({ delay: { max: window } })); let sequence = 0; // Inserted by sequence, the way the wire hands a subscribed track its groups. const publish = (close: boolean) => { @@ -116,7 +116,7 @@ async function measure( // Ending the track releases the newest group too; idle cleanup reclaims the cache. clock += CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8 + 1; const idleStart = now(); - const replay = producer.subscribe({ maxDelay: window }); + const replay = producer.subscribe({ delay: { max: window } }); if (replay.tryRecvGroup()) throw new Error("idle cache entries were retained"); const idle = (now() - idleStart) * 1000; replay.close(); diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 182696e672a..e62532d6d9b 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -318,19 +318,19 @@ test("a request exposes the aggregate subscription options", async () => { consumer.track("video").subscribe({ priority: 3, - maxDelay: Milli(100), + delay: { max: Milli(100) }, groups: { start: { included: 10 }, end: { excluded: 20 } }, }); consumer.track("video").subscribe({ priority: 7, - maxDelay: Milli(250), + delay: { max: Milli(250) }, groups: { start: { included: 0 }, end: { excluded: 30 } }, }); const request = await pendingRequest(consumer); expect(request?.subscription).toEqual({ priority: 7, - maxDelay: Milli(250), + delay: { min: Milli.zero, max: Milli(250) }, order: "descending", groups: { start: { included: 0 }, end: { excluded: 30 } }, }); @@ -442,11 +442,11 @@ test("two subscribers to one inserted track each get a full copy", async () => { const a = broadcast .track("video") - .subscribe({ maxDelay: Milli(5000) }) + .subscribe({ delay: { max: Milli(5000) } }) .ordered(); const b = broadcast .track("video") - .subscribe({ maxDelay: Milli(5000) }) + .subscribe({ delay: { max: Milli(5000) } }) .ordered(); producer.writeFrame(textFrame("hello")); @@ -478,7 +478,7 @@ test("a read throws Evicted at a shed front, then resyncs to the next group", as const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const sub = broadcast .track("video") - .subscribe({ maxDelay: Milli(5000) }) + .subscribe({ delay: { max: Milli(5000) } }) .ordered(); // Group 0 sheds its front without aborting the writer. @@ -507,7 +507,7 @@ test("a stalled consumer does not pin evicted groups", async () => { // A subscriber that never reads. Its sink must not grow without bound. No max delay, // so only eviction (not a skip of a superseded group) can drop the old group. - const stalled = broadcast.track("video").subscribe({ maxDelay: null }); + const stalled = broadcast.track("video").subscribe({ delay: { max: null } }); producer.writeFrame(textFrame("old")); @@ -559,7 +559,7 @@ test("a finished track is still served from its cache", async () => { const late = broadcast .consume() .track("track1") - .subscribe({ maxDelay: Milli(5000) }) + .subscribe({ delay: { max: Milli(5000) } }) .ordered(); expect(await late.readString()).toBe("last"); expect(await late.nextGroup()).toBeUndefined(); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 4ba30dac47e..abbc5e6547b 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -1120,7 +1120,7 @@ export class Consumer { } /** - * Settles with a `StreamError` of code `Old` once the subscription's `maxDelay` gives up + * Settles with a `StreamError` of code `Old` once the subscription's `delay.max` gives up * on this group, even after every frame was read or the group closed: a publisher still * writing or draining it races its writes against this, and against {@link closed} * settling with an error, and abandons the stream once either does. Peek it synchronously diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 729690a6214..86fb980a622 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -3440,7 +3440,7 @@ test("REQUEST_UPDATE applies priority and preserves it when omitted", async () = await RequestOk.decode(client.reader, fx.version); 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()?.delay?.max).toBe(before?.delay?.max); expect(track.subscription.peek()?.groups).toEqual(before?.groups); await client.writer.write(new Uint8Array([0x02, 0, 2, 4, 0])); expect(await client.reader.u53()).toBe(RequestOk.id); @@ -4436,19 +4436,19 @@ test("subscriber max age and REQUEST_UPDATE preserve omitted preferences", async }), ); try { - expect(track.subscription.peek()?.maxDelay).toBe(Milli(250)); + expect(track.subscription.peek()?.delay?.max).toBe(Milli(250)); const parameters = new Parameters(); parameters.maxAge = 900; await client.writer.u53(SubscribeUpdate.id); await new SubscribeUpdate({ requestId: 2n, 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()?.maxDelay).toBe(Milli(900)); + expect(track.subscription.peek()?.delay?.max).toBe(Milli(900)); await client.writer.u53(SubscribeUpdate.id); await new SubscribeUpdate({ requestId: 4n }).encode(client.writer, fx.version); expect(await client.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(client.reader, fx.version); - expect(track.subscription.peek()?.maxDelay).toBe(Milli(900)); + expect(track.subscription.peek()?.delay?.max).toBe(Milli(900)); } finally { client.close(); track.finish(); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 4a8c665052d..fe3482f8413 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -501,7 +501,8 @@ export class Publisher { priority: fromWire(msg.subscriberPriority), order: msg.groupOrder, // An absent budget preserves everything retained by the producer. - maxDelay: Milli(msg.maxAge ?? Varint.MAX_U53), + // moq-transport has no field for a minimum. + delay: { max: Milli(msg.maxAge ?? Varint.MAX_U53), min: null }, groups: fixed.start ? rangeGroups(fixed) : undefined, }); }; @@ -707,7 +708,8 @@ export class Publisher { track.update({ priority, order, - maxDelay: Milli(msg.maxAge ?? Varint.MAX_U53), + // moq-transport has no field for a minimum. + delay: { max: Milli(msg.maxAge ?? Varint.MAX_U53), min: null }, groups: rangeGroups(range), }); const startGroup = range.start ? Number(range.start.group) : hooks.latestSequence(track); @@ -721,7 +723,7 @@ export class Publisher { // asking the broadcast would mint a second producer nobody has accepted. cache = fill && fill.kind !== "empty" - ? track.fork({ priority, order, maxDelay: Milli(Varint.MAX_U53) }) + ? track.fork({ priority, order, delay: { max: Milli(Varint.MAX_U53), min: null } }) : undefined; const ranked = new Priority(track, info.priority); @@ -834,8 +836,11 @@ export class Publisher { // update() replaces every option, so carry the rest over as Rust does. if (params.subscriberPriority !== undefined) track.update({ ...track.subscription.peek(), priority: fromWire(params.subscriberPriority) }); - if (params.maxAge !== undefined) - track.update({ ...track.subscription.peek(), maxDelay: Milli(params.maxAge) }); + const maxAge = params.maxAge; + if (maxAge !== undefined) { + const current = track.subscription.peek(); + track.update({ ...current, delay: { ...current?.delay, max: Milli(maxAge) } }); + } // A fill holds one cache view. At most this decoded update waits behind // it; request parsing then backpressures any further fill requests. if (params.fillParameters) await filling; @@ -904,7 +909,7 @@ export class Publisher { ? track.fork({ priority: fromWire(msg.subscriberPriority), order: msg.groupOrder, - maxDelay: Milli(Varint.MAX_U53), + delay: { max: Milli(Varint.MAX_U53), min: null }, }) : undefined; try { @@ -2362,7 +2367,9 @@ export class Publisher { const broadcast = await this.#resolve(named.trackNamespace, signal); const priority = fromWire(msg.subscriberPriority); - const cache = broadcast.track(named.trackName).subscribe({ priority, maxDelay: Milli(Varint.MAX_U53) }); + const cache = broadcast + .track(named.trackName) + .subscribe({ priority, delay: { max: Milli(Varint.MAX_U53), min: null } }); let answer: FetchAnswer | undefined; try { const info = await untilAborted(cache.info(), signal); diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 915c04936a3..2cfaee4e23e 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -1237,7 +1237,7 @@ test.each([ [VERSION, true, [3, 4]], ])("draft %s with FIRST_OBJECT=%s: a header-only stream delivers %p", async (version, firstObject, expected) => { // A budget, so the empty group outlives its successor's arrival. - const { subscriber, track } = await subscribeTrack({ version, subscription: { maxDelay: Milli(60_000) } }); + const { subscriber, track } = await subscribeTrack({ version, subscription: { delay: { max: Milli(60_000) } } }); await subscriber.handleGroup( new GroupMessage({ @@ -1735,7 +1735,7 @@ for (const negotiated of [false, true]) { const track = subscriber .consume(Path.from("room")) .track("video") - .subscribe({ maxDelay: Milli(250) }); + .subscribe({ delay: { max: Milli(250) } }); const peer = await nextStream(pair.client); if (!peer) throw new Error("no subscribe stream"); expect(await peer.reader.u53()).toBe(Subscribe.id); @@ -1745,7 +1745,7 @@ for (const negotiated of [false, true]) { await new SubscribeOk({ trackAlias: ALIAS }).encode(peer.writer, VERSION); await track.info(); // The aggregate can change before SUBSCRIBE_OK: the first update must catch it. - const tolerant = track.fork({ maxDelay: Milli(900), priority: 9 }); + const tolerant = track.fork({ delay: { max: Milli(900) }, priority: 9 }); expect(await peer.reader.u53()).toBe(SubscribeUpdate.id); const update = await SubscribeUpdate.decode(peer.reader, VERSION); expect(update.parameters.maxAge).toBe(negotiated ? 900 : undefined); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 4951c0815e8..352f6da0483 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1210,7 +1210,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 ? maxDelayOf(request.subscription) : undefined, // Fixed for the subscription's life: no REQUEST_UPDATE parameter carries it. groupOrder: request.subscription.order, // Joins at the current group, from its first object: the subscription delivers the diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index f4a96209176..22aacd1b6c6 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -61,7 +61,7 @@ async function subscribed(largest?: number) { const reader = subscriber .consume(Path.from("room")) .track("video") - .subscribe({ maxDelay: Milli(60_000) }); + .subscribe({ delay: { max: Milli(60_000) } }); const peer = await Stream.accept(pair.client, VERSION); if (!peer) throw new Error("the subscriber never opened a subscribe stream"); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index cc9a3fd6047..a7fa963a5f6 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -207,13 +207,14 @@ test("integration: lite subscription options and updates reach the publisher", a // A floor of group 1, a concrete group on every draft. const subscriber = remote.track("video").subscribe({ priority: 3, - maxDelay: Milli(250), + delay: { max: Milli(250) }, groups: { start: { included: 1 }, end: { excluded: 9 } }, }); const producer = await accepted; expect(producer.subscription.peek()).toEqual({ priority: 3, - maxDelay: Milli(250), + // The negotiated lite-06 cannot state a minimum. + delay: { min: null, max: Milli(250) }, order: "descending", groups: { start: { included: 1 }, end: { excluded: 9 } }, }); @@ -221,12 +222,12 @@ test("integration: lite subscription options and updates reach the publisher", a const updated = producer.subscription.changed(); subscriber.update({ priority: 8, - maxDelay: Milli(500), + delay: { max: Milli(500) }, groups: { start: { included: 2 }, end: { excluded: 12 } }, }); expect(await updated).toEqual({ priority: 8, - maxDelay: Milli(500), + delay: { min: null, max: Milli(500) }, order: "descending", groups: { start: { included: 2 }, end: { excluded: 12 } }, }); @@ -256,14 +257,14 @@ test.each([Ietf.ALPN.DRAFT_22])("integration: %s carries the subscriber max age })(); const remote = wireOf(client).consume(Path.from("test")); - const subscriber = remote.track("video").subscribe({ maxDelay: Milli(250) }); + const subscriber = remote.track("video").subscribe({ delay: { max: Milli(250) } }); const producer = await accepted; // Without the negotiated parameter the publisher keeps its whole backlog instead. - expect(producer.subscription.peek()?.maxDelay).toBe(Milli(250)); + expect(producer.subscription.peek()?.delay?.max).toBe(Milli(250)); const updated = producer.subscription.changed(); - subscriber.update({ maxDelay: Milli(500) }); - expect((await updated)?.maxDelay).toBe(Milli(500)); + subscriber.update({ delay: { max: Milli(500) } }); + expect((await updated)?.delay?.max).toBe(Milli(500)); subscriber.close(); remote.close(); @@ -273,7 +274,7 @@ test.each([Ietf.ALPN.DRAFT_22])("integration: %s carries the subscriber max age server.abort(); }); -test("integration: lite carries a fractional maxDelay as a whole millisecond", async () => { +test("integration: lite carries a fractional delay as a whole millisecond", async () => { const pair = createMockTransportPair(Lite.ALPN_05); const origin = new OriginProducer(); const [client, server] = await Promise.all([ @@ -304,7 +305,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({ delay: { max: Milli(38.75) }, groups: { start: { included: 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. @@ -313,11 +316,11 @@ test("integration: lite carries a fractional maxDelay as a whole millisecond", a }); const producer = await Promise.race([accepted, failed]); - expect(producer.subscription.peek()?.maxDelay).toBe(Milli(39)); + expect(producer.subscription.peek()?.delay?.max).toBe(Milli(39)); const updated = producer.subscription.changed(); - subscriber.update({ maxDelay: Milli(500.25), groups: { start: { included: 1 } } }); - expect((await Promise.race([updated, failed]))?.maxDelay).toBe(Milli(501)); + subscriber.update({ delay: { max: Milli(500.25) }, groups: { start: { included: 1 } } }); + expect((await Promise.race([updated, failed]))?.delay?.max).toBe(Milli(501)); subscriber.close(); remote.close(); @@ -353,7 +356,7 @@ test("integration: lite applies initial and updated group bounds", async () => { const subscriber = remote .track("video") .subscribe({ - maxDelay: Milli(REPLAY_LATENCY_MS), + delay: { max: Milli(REPLAY_LATENCY_MS) }, groups: { start: { included: INITIAL_START_GROUP }, end: { excluded: INITIAL_END_GROUP } }, }) .ordered(); @@ -365,7 +368,7 @@ test("integration: lite applies initial and updated group bounds", async () => { expect(await Promise.race([pending, sleepMs(PENDING_ASSERT_MS).then(() => "pending")])).toBe("pending"); subscriber.update({ - maxDelay: Milli(REPLAY_LATENCY_MS), + delay: { max: Milli(REPLAY_LATENCY_MS) }, groups: { start: { included: UPDATED_GROUP }, end: { excluded: UPDATED_END_GROUP } }, }); expect( @@ -405,7 +408,7 @@ test("integration: lite refuses an empty requested range on open and on update", try { // Bounds that meet cannot go on the wire: the nearest encoding inverts the range. const empty = video.subscribe({ - maxDelay: Milli(REPLAY_LATENCY_MS), + delay: { max: Milli(REPLAY_LATENCY_MS) }, groups: { start: { included: 2 }, end: { excluded: 2 } }, }); await expect( @@ -416,13 +419,13 @@ test("integration: lite refuses an empty requested range on open and on update", // A live subscription whose demand later collapses to nothing fails the same way. const live = video .subscribe({ - maxDelay: Milli(REPLAY_LATENCY_MS), + delay: { max: Milli(REPLAY_LATENCY_MS) }, groups: { start: { included: 1 }, end: { excluded: 2 } }, }) .ordered(); expect((await live.nextGroup())?.sequence).toBe(1); live.update({ - maxDelay: Milli(REPLAY_LATENCY_MS), + delay: { max: Milli(REPLAY_LATENCY_MS) }, groups: { start: { included: 3 }, end: { excluded: 3 } }, }); await expect( diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index ad0d76b4ec7..44563205c21 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -17,17 +17,25 @@ import type * as Instance from "./instance.ts"; import type { Request as OriginRequest } from "./origin.ts"; import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; -import type { Demand, Groups, Info, Position, Producer, Request, Subscriber, Subscription } from "./track.ts"; +import type { Delay, Demand, Groups, Info, Position, Producer, Request, Subscriber, Subscription } from "./track.ts"; import * as Varint from "./varint.ts"; import type { Advertised, Advertisements } from "./wire.ts"; -/** What a null maxDelay goes out as: the largest value every peer can read, as Rust sends for `None`. */ +/** What a null {@link Delay.max} goes out as: the largest value every peer can read, as Rust sends for `None`. */ export const NO_MAX_DELAY = Milli(Varint.MAX_U53); -/** A subscription's budget as the wire carries it: null for no limit, omitted for zero. */ -export function maxDelayOf(subscription: Pick): Milli { - if (subscription.maxDelay === null) return NO_MAX_DELAY; - return subscription.maxDelay ?? Milli.zero; +/** A subscription's {@link Delay.max} as the wire carries it: null for no limit, omitted for zero. */ +export function maxDelayOf(subscription: Pick): Milli { + const max = subscription.delay?.max; + if (max === null) return NO_MAX_DELAY; + return max ?? Milli.zero; +} + +/** A subscription's {@link Delay.min} as the wire carries it: undefined when not stated, omitted for zero. */ +export function minDelayOf(subscription: Pick): Milli | undefined { + const min = subscription.delay?.min; + if (min === null) return undefined; + return min ?? Milli.zero; } /** Normalize public group bounds into an inclusive start and exclusive end. */ diff --git a/js/net/src/ledger.ts b/js/net/src/ledger.ts index a145a2ba556..3590f22c318 100644 --- a/js/net/src/ledger.ts +++ b/js/net/src/ledger.ts @@ -15,7 +15,7 @@ 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. */ +/** The grace for a subscription with `delay.max`: 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; } diff --git a/js/net/src/lite/compression.test.ts b/js/net/src/lite/compression.test.ts index 8acd2b32145..21ee318c979 100644 --- a/js/net/src/lite/compression.test.ts +++ b/js/net/src/lite/compression.test.ts @@ -42,7 +42,7 @@ for (const downstream of [ALPN_06, ALPN_07_WIP]) { if (!active) throw new Error("broadcast did not resolve"); const remote = active.track("data"); expect((await remote.info()).compress).toBe(downstream === ALPN_07_WIP); - const reader = remote.subscribe({ maxDelay: Milli(1000) }); + const reader = remote.subscribe({ delay: { max: Milli(1000) } }); const group = track.appendGroup(); const payloads = [new Uint8Array(4096).fill(7), new Uint8Array(), new Uint8Array(4096).fill(7)]; for (const payload of payloads) group.writeFrame({ payload }); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index c789994f4bd..23426bf536d 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -860,7 +860,8 @@ for (const kind of [2, 3]) { test(`lite-07 refuses anchor ${kind} at ${end ? "end" : "start"} in ${update ? "UPDATE" : "SUBSCRIBE"}`, async () => { const version = Version.DRAFT_07; const anchor = end ? [0, kind, 0] : [kind, 0, 0]; - const bytes = new Uint8Array([8, 128, 0, 0, 1, ...anchor, 0]); + // Priority, order, max delay, an unstated min delay, one range, then the wanted group. + const bytes = new Uint8Array([9, 128, 0, 0, 0, 1, ...anchor, 0]); const decoded = await SubscribeUpdate.decode(new Reader(undefined, bytes, version), version); const sub = await servedSubscription({ version, ranges: update ? undefined : decoded.ranges }); try { @@ -1666,7 +1667,8 @@ test("lite draft-06: scheduling updates apply while SUBSCRIBE_START is blocked", expect(sub.track.subscription.peek()).toEqual({ priority: 9, - maxDelay: TEST_MAX_DELAY_MS, + // Lite-06 cannot state a minimum. + delay: { min: null, max: TEST_MAX_DELAY_MS }, order: "descending", groups: { start: undefined, end: { excluded: 6 } }, }); @@ -2495,7 +2497,7 @@ test("a version without the latency field serves a non-dropping budget", async ( try { // These drafts decode the absent field as zero. The publisher must not turn // that into a live-edge request the peer never made. - expect(sub.track.subscription.peek()?.maxDelay).toBe(Milli(Number.MAX_SAFE_INTEGER)); + expect(sub.track.subscription.peek()?.delay?.max).toBe(Milli(Number.MAX_SAFE_INTEGER)); } finally { await sub.close(); } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index d7a98f5c1dc..56ba1626d6f 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -908,7 +908,10 @@ export class Publisher { const track = front.track(msg.track).subscribe({ priority: msg.priority, order: msg.order, - maxDelay: Milli(servingMaxDelay(this.version, msg.maxDelay)), + delay: { + max: Milli(servingMaxDelay(this.version, msg.maxDelay)), + min: msg.minDelay === undefined ? null : Milli(msg.minDelay), + }, groups: { start: msg.startGroup === undefined ? undefined : { included: msg.startGroup }, end: endGroup === undefined ? undefined : { excluded: endGroup }, @@ -1018,7 +1021,10 @@ export class Publisher { track.update({ priority: update.priority, order: update.order, - maxDelay: Milli(servingMaxDelay(this.version, update.maxDelay)), + delay: { + max: Milli(servingMaxDelay(this.version, update.maxDelay)), + min: update.minDelay === undefined ? null : Milli(update.minDelay), + }, groups: { start: update.startGroup === undefined ? undefined : { included: update.startGroup }, end: end === undefined ? undefined : { excluded: end }, diff --git a/js/net/src/lite/subscribe.test.ts b/js/net/src/lite/subscribe.test.ts index e68e4722827..2fcaa9c010c 100644 --- a/js/net/src/lite/subscribe.test.ts +++ b/js/net/src/lite/subscribe.test.ts @@ -193,9 +193,9 @@ test("lite-07 carries the span as its only range, exclusive at the end", async ( }); test("a lite-07 list decodes as the span covering it", async () => { - // Priority, order, and max delay as SubscribeUpdate writes them, then the ranges and - // an absent wanted group. - const head = (await encodeMessage(Version.DRAFT_07, new SubscribeUpdate({ priority: 0 }))).slice(1, 4); + // Priority, order, max delay, and an unstated min delay as SubscribeUpdate writes them, + // then the ranges and an absent wanted group. + const head = (await encodeMessage(Version.DRAFT_07, new SubscribeUpdate({ priority: 0 }))).slice(1, 5); const update = (ranges: number[]) => { const body = [...head, ...ranges, 0]; return SubscribeUpdate.decode( @@ -389,6 +389,22 @@ test.each([Version.DRAFT_03, Version.DRAFT_05, Version.DRAFT_06])( }, ); +test("min delay survives lite-07 and decodes as not stated on legacy wires", async () => { + for (const version of [Version.DRAFT_03, Version.DRAFT_06, Version.DRAFT_07]) { + for (const minDelay of [undefined, 0, 250, Number.MAX_SAFE_INTEGER]) { + const expected = version === Version.DRAFT_07 ? minDelay : undefined; + const props = { id: 1n, broadcast: Path.from("room"), track: "video", priority: 3, minDelay }; + const subscribe = await encodeMessage(version, new Subscribe(props)); + const got = await Subscribe.decode(new Reader(undefined, subscribe, version), version); + expect(got.minDelay).toBe(expected); + + const update = await encodeMessage(version, new SubscribeUpdate({ priority: 3, minDelay })); + const decoded = await SubscribeUpdate.decode(new Reader(undefined, update, version), version); + expect(decoded.minDelay).toBe(expected); + } + } +}); + test("wanted group survives lite-07 and disappears on legacy wires", async () => { for (const version of [Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07]) { for (const wantedGroup of [undefined, 0, 3, Number.MAX_SAFE_INTEGER]) { diff --git a/js/net/src/lite/subscribe.ts b/js/net/src/lite/subscribe.ts index ef33a90dde7..199e7578c9c 100644 --- a/js/net/src/lite/subscribe.ts +++ b/js/net/src/lite/subscribe.ts @@ -11,6 +11,7 @@ import { hasEndError, hasFrameBounds, hasGroupOrder, + hasMinDelay, hasOrder, hasRanges, hasRefresh, @@ -329,6 +330,21 @@ export function emptyRange({ startGroup, endGroup }: { startGroup?: number; endG return endGroup !== undefined && (startGroup ?? 0) >= endGroup; } +/** Encode the `Subscriber Min Age`: milliseconds plus one, zero meaning not stated. Dropped + * on a wire without the field. */ +async function encodeMinDelay(w: Writer, version: Version, minDelay: number | undefined) { + if (!hasMinDelay(version)) return; + await w.u62(minDelay === undefined ? 0n : BigInt(minDelay) + 1n); +} + +/** Decode the `Subscriber Min Age`, as not stated on a wire without the field. */ +async function decodeMinDelay(r: Reader, version: Version): Promise { + if (!hasMinDelay(version)) return undefined; + const value = await r.u62(); + if (value > BigInt(Number.MAX_SAFE_INTEGER) + 1n) throw new RangeError("min delay exceeds safe integer"); + return value === 0n ? undefined : Number(value - 1n); +} + export class SubscribeUpdate { /** A join requesting this group or later, judged against the local refresh ratio. */ wantedGroup?: number; @@ -337,6 +353,11 @@ export class SubscribeUpdate { order: Order; /** Subscriber max delay in milliseconds; zero skips once a newer group is available. */ maxDelay: number; + /** + * Subscriber min delay (`Subscriber Min Age`) in milliseconds, or undefined when not stated. + * Lite-07+; earlier wires decode it as not stated and drop it on encode. + */ + minDelay?: number; startGroup?: number; endGroup?: number; /** See {@link Subscribe.startFrame}. */ @@ -352,6 +373,7 @@ export class SubscribeUpdate { wantedGroup?: number; order?: Order; maxDelay?: number; + minDelay?: number; startGroup?: number; endGroup?: number; startFrame?: number; @@ -364,6 +386,7 @@ export class SubscribeUpdate { this.priority = props.priority; this.order = props.order ?? "descending"; this.maxDelay = props.maxDelay ?? 0; + this.minDelay = props.minDelay; this.startGroup = props.startGroup; this.endGroup = props.endGroup; this.startFrame = props.startFrame ?? 0; @@ -381,6 +404,7 @@ export class SubscribeUpdate { await w.u8(toWire(this.priority)); await encodeOrder(w, version, this.order); await w.u53(this.maxDelay); + await encodeMinDelay(w, version, this.minDelay); await encodeSpan(w, version, this); if (hasRefresh(version)) await w.u62(this.wantedGroup === undefined ? 0n : BigInt(this.wantedGroup) + 1n); @@ -397,6 +421,7 @@ export class SubscribeUpdate { const priority = fromWire(await r.u8()); const order = await decodeOrder(r, version); const maxDelay = await r.u53(); + const minDelay = await decodeMinDelay(r, version); const span = await decodeSpan(r, version); const wanted = hasRefresh(version) ? await r.u62() : 0n; if (wanted > BigInt(Number.MAX_SAFE_INTEGER) + 1n) @@ -406,6 +431,7 @@ export class SubscribeUpdate { priority, order, maxDelay, + minDelay, ...span, }); } @@ -441,6 +467,11 @@ export class Subscribe { order: Order; /** Subscriber max delay in milliseconds; zero skips once a newer group is available. */ maxDelay: number; + /** + * Subscriber min delay (`Subscriber Min Age`) in milliseconds, or undefined when not stated. + * Lite-07+; earlier wires decode it as not stated and drop it on encode. + */ + minDelay?: number; startGroup?: number; endGroup?: number; @@ -472,6 +503,7 @@ export class Subscribe { priority: number; order?: Order; maxDelay?: number; + minDelay?: number; startGroup?: number; endGroup?: number; startFrame?: number; @@ -485,6 +517,7 @@ export class Subscribe { this.priority = props.priority; this.order = props.order ?? "descending"; this.maxDelay = props.maxDelay ?? 0; + this.minDelay = props.minDelay; this.startGroup = props.startGroup; this.endGroup = props.endGroup; this.startFrame = props.startFrame ?? 0; @@ -506,6 +539,7 @@ export class Subscribe { default: await encodeOrder(w, version, this.order); await w.u53(this.maxDelay); + await encodeMinDelay(w, version, this.minDelay); await encodeSpan(w, version, this); break; } @@ -525,6 +559,7 @@ export class Subscribe { default: { const order = await decodeOrder(r, version); const maxDelay = await r.u53(); + const minDelay = await decodeMinDelay(r, version); const span = await decodeSpan(r, version); return new Subscribe({ id, @@ -534,6 +569,7 @@ export class Subscribe { priority, order, maxDelay, + minDelay, ...span, }); } diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index 814a162ff58..c1c0acba951 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1293,7 +1293,7 @@ test.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])( const track = subscriber .consume(Path.from("room")) .track("video") - .subscribe({ priority: 3, maxDelay: Milli(10_000) }); + .subscribe({ priority: 3, delay: { max: Milli(10_000) } }); try { await opened(0); await streams[0].reading; @@ -2374,7 +2374,7 @@ test.each([Version.DRAFT_05, Version.DRAFT_06])("version %p passes received time const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); const broadcast = subscriber.consume(Path.from("room")); // Old enough to keep every group, however far behind the newest start. - const track = broadcast.track("video").subscribe({ maxDelay: Milli(60_000) }); + const track = broadcast.track("video").subscribe({ delay: { max: Milli(60_000) } }); try { await opened(0); await answerTrackInfo(streams[0], version, 1000); @@ -2410,7 +2410,7 @@ test("a lite-07 rewind is refused", async () => { const { quic, streams, opened } = fakeSession(); const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); const broadcast = subscriber.consume(Path.from("room")); - const track = broadcast.track("video").subscribe({ maxDelay: Milli(60_000) }); + const track = broadcast.track("video").subscribe({ delay: { max: Milli(60_000) } }); try { await opened(0); await answerTrackInfo(streams[0], version, 1000); @@ -2635,7 +2635,7 @@ test.each([ const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); const broadcast = subscriber.consume(Path.from("room")); const track = broadcast.track("video").subscribe({ - maxDelay: Milli.zero, + delay: { max: Milli.zero }, groups: { start: { included: 0 }, end: { excluded: 1 } }, }); try { @@ -2651,7 +2651,7 @@ test.each([ }), ) : undefined; - track.update({ maxDelay: Milli.zero, groups }); + track.update({ delay: { max: Milli.zero }, groups }); await refused; await drainReactions(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 79ce26f850b..77a699ffb6a 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -22,7 +22,16 @@ import { import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import * as Instance from "../instance.ts"; -import { groupBounds, hiddenBelow, hooks, maxDelayOf, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; +import { + groupBounds, + hiddenBelow, + hooks, + maxDelayOf, + minDelayOf, + scopeCaptures, + scopeHead, + scopeOverlaps, +} from "../internal.ts"; import type { Lister } from "../listing.ts"; import * as Path from "../path.ts"; import { receiveFrame } from "../payload.ts"; @@ -70,6 +79,7 @@ import { hasDatagrams, hasFetchMaxDelay, hasMediaTime, + hasMinDelay, hasOrder, hasProbeRtt, hasRanges, @@ -1007,6 +1017,7 @@ export class Subscriber { priority: subscription.priority ?? 0, order: subscription.order, maxDelay: maxDelayOf(subscription), + minDelay: minDelayOf(subscription), startGroup: subscription.groups?.start === undefined ? undefined : bounds.start, endGroup: inclusiveGroupEnd(bounds.end), }); @@ -1739,7 +1750,7 @@ export class Subscriber { let lastSent: track.Subscription = { priority: msg.priority, order: msg.order, - maxDelay: Milli(msg.maxDelay), + delay: { max: Milli(msg.maxDelay), min: msg.minDelay === undefined ? null : Milli(msg.minDelay) }, groups: { start: msg.startGroup === undefined ? undefined : { included: msg.startGroup }, end: msg.endGroup === undefined ? undefined : { excluded: exclusiveGroupEnd(msg.endGroup) ?? 0 }, @@ -1783,12 +1794,13 @@ export class Subscriber { } // Round-trip the other Subscribe parameters so the publisher doesn't - // interpret SUBSCRIBE_UPDATE as a reset of ordered/maxDelay/etc. + // interpret SUBSCRIBE_UPDATE as a reset of ordered/delay/etc. const update = new SubscribeUpdate({ wantedGroup: refresh ?? sentWanted, priority: current.priority ?? 0, order: current.order, maxDelay: maxDelayOf(current), + minDelay: minDelayOf(current), startGroup: current.groups?.start === undefined ? undefined : bounds.start, endGroup: inclusiveGroupEnd(bounds.end), }); @@ -1820,7 +1832,9 @@ export class Subscriber { return ( (a.priority ?? 0) === (b.priority ?? 0) && sameOrder && - (a.maxDelay ?? 0) === (b.maxDelay ?? 0) && + maxDelayOf(a) === maxDelayOf(b) && + // A wire without `Subscriber Min Age` cannot carry a change to it. + (!hasMinDelay(this.version) || minDelayOf(a) === minDelayOf(b)) && sameStart && ag.start === bg.start && ag.end === bg.end diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index b0dba5db99b..0a1771e91f1 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -57,7 +57,10 @@ function groupStream(subscriber: Subscriber, sequence: number) { async function subscribed(version: Version, maxDelay = GRACE, groups?: Groups) { 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({ delay: { max: maxDelay }, groups }); const info = await Stream.accept(pair.server, version); if (!info) throw new Error("the subscriber never asked for TRACK_INFO"); @@ -187,7 +190,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const sub = await subscribed(version); subscriber = sub.subscriber; const { reader, respond, fin } = sub; - const ordered = reader.fork({ maxDelay: GRACE }).ordered(); + const ordered = reader.fork({ delay: { max: GRACE } }).ordered(); await respond({ start: new SubscribeStart([0]) }); const group = groupStream(subscriber, 1); group.write("1.0"); @@ -359,7 +362,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({ delay: { max: maxDelay }, groups: { start: { included: 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/lite/track_stream.test.ts b/js/net/src/lite/track_stream.test.ts index b1e6cf03ca7..150bd6614bc 100644 --- a/js/net/src/lite/track_stream.test.ts +++ b/js/net/src/lite/track_stream.test.ts @@ -39,7 +39,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const reader = subscriber .consume(Path.from("room")) .track("video") - .subscribe({ maxDelay: Milli(100) }); + .subscribe({ delay: { max: Milli(100) } }); const track = await Stream.accept(pair.server, version); if (!track) throw new Error("the subscriber never asked for TRACK_INFO"); diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index 0be4b772fc6..0ce6dac4db9 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -274,6 +274,23 @@ export function hasOrder(version: Version): boolean { } } +/** Whether SUBSCRIBE and SUBSCRIBE_UPDATE carry the optional `Subscriber Min Age`. Added in + * lite-07; an earlier subscription decodes it as not stated. */ +export function hasMinDelay(version: Version): boolean { + // Explicitly list older versions so future versions keep the lite-07+ behavior. + switch (version) { + case Version.DRAFT_01: + case Version.DRAFT_02: + case Version.DRAFT_03: + case Version.DRAFT_04: + case Version.DRAFT_05: + case Version.DRAFT_06: + return false; + default: + return true; + } +} + /** Whether SUBSCRIBE and SUBSCRIBE_UPDATE carry a list of ranges. Added in lite-07; an * earlier subscription carries one span in its group and frame bounds. */ export function hasRanges(version: Version): boolean { diff --git a/js/net/src/lite/widened_max_age.test.ts b/js/net/src/lite/widened_max_age.test.ts index 259511181ee..e12296d4eea 100644 --- a/js/net/src/lite/widened_max_age.test.ts +++ b/js/net/src/lite/widened_max_age.test.ts @@ -45,10 +45,10 @@ test("a widened maxDelay re-offers what the publisher skipped below the start", ]); const remote = wireOf(client).consume(Path.from("room")); // Group 5 ends at 6s, a full 3s behind the 9s edge. - const reader = remote.track("video").subscribe({ maxDelay: Milli(2500) }); + const reader = remote.track("video").subscribe({ delay: { max: Milli(2500) } }); expect(await received(reader, 4)).toEqual([6, 7, 8, 9]); - reader.update({ maxDelay: Milli(6500) }); + reader.update({ delay: { max: Milli(6500) } }); expect(await received(reader, 4)).toEqual([2, 3, 4, 5]); // Ending the track flushes anything else the publisher would send: nothing, not even a repeat. producer.finish(); diff --git a/js/net/src/order.test.ts b/js/net/src/order.test.ts index 8d31bac0e6e..6c494a7eb29 100644 --- a/js/net/src/order.test.ts +++ b/js/net/src/order.test.ts @@ -127,14 +127,14 @@ async function backlog(protocol: string, order: Order, update?: Order) { const remote = wireOf(client).consume(Path.from("test")); const subscriber = remote.track("video").subscribe({ - maxDelay: MAX_DELAY, + delay: { max: MAX_DELAY }, order, groups: { start: { included: 0 } }, }); await receive(subscriber, groups.length); if (update !== undefined) { - subscriber.update({ maxDelay: MAX_DELAY, order: update, groups: { start: { included: 0 } } }); + subscriber.update({ delay: { max: MAX_DELAY }, order: update, groups: { start: { included: 0 } } }); await aggregate(video, (upstream) => upstream === update); } @@ -179,7 +179,7 @@ test.each(["ascending", "descending"] as const)("ietf serves GROUP_ORDER %s", as const video = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = wireOf(client).consume(Path.from("test")); - const subscriber = remote.track("video").subscribe({ maxDelay: MAX_DELAY, order }); + const subscriber = remote.track("video").subscribe({ delay: { max: MAX_DELAY }, order }); await aggregate(video, (upstream) => upstream === order); const groups: GroupProducer[] = []; @@ -247,7 +247,7 @@ test("ietf keeps a closing group stream ranked until it settles", async () => { const video = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = wireOf(client).consume(Path.from("test")); - const subscriber = remote.track("video").subscribe({ maxDelay: MAX_DELAY, order: "descending" }); + const subscriber = remote.track("video").subscribe({ delay: { max: MAX_DELAY }, order: "descending" }); await aggregate(video, (upstream) => upstream === "descending"); const groups: GroupProducer[] = []; @@ -288,11 +288,13 @@ test("a shared track aggregates descending and serves each subscriber in its own const video = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const ascendingRemote = wireOf(ascending.client).consume(Path.from("test")); - const ascendingTrack = ascendingRemote.track("video").subscribe({ maxDelay: MAX_DELAY, order: "ascending" }); + const ascendingTrack = ascendingRemote.track("video").subscribe({ delay: { max: MAX_DELAY }, order: "ascending" }); await aggregate(video, (order) => order === "ascending"); const descendingRemote = wireOf(descending.client).consume(Path.from("test")); - const descendingTrack = descendingRemote.track("video").subscribe({ maxDelay: MAX_DELAY, order: "descending" }); + const descendingTrack = descendingRemote + .track("video") + .subscribe({ delay: { max: MAX_DELAY }, order: "descending" }); await aggregate(video, (order) => order === "descending"); // One at a time, so each session opens its streams in sequence order. diff --git a/js/net/src/resume.model.test.ts b/js/net/src/resume.model.test.ts index 1827e2ab59b..dbc1d598f38 100644 --- a/js/net/src/resume.model.test.ts +++ b/js/net/src/resume.model.test.ts @@ -310,7 +310,7 @@ function groups(range: Range): track.Groups { } function subscription(budget: Budget, range: Range): track.Subscription { - return { maxDelay: budget === "hour" ? HOUR : SHORT, groups: range ? groups(range) : undefined }; + return { delay: { max: budget === "hour" ? HOUR : SHORT }, groups: range ? groups(range) : undefined }; } function max(values: Iterable): number | undefined { diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index c59317dbf95..7a418c23a3f 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -114,7 +114,7 @@ test("returning to a route reuses its subscription and held group", async () => const open = tracks[0].write(0, 1, false); const mirrored = spyOn(open, "mirror"); front.serve(handles[0]); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); try { await clock.advance(Nano.zero); const held = sub.tryRecvGroup(); @@ -160,7 +160,7 @@ test("a returning route backfills below the reader's newest", async () => { const open = ta.write(0, 1, false); const mirrored = spyOn(open, "mirror"); front.serve(handles[0]); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); try { await clock.advance(Nano.zero); const held = sub.tryRecvGroup(); @@ -205,7 +205,7 @@ test("route flaps keep one backfill per distinct route", async () => { const handles = sources.map((source) => source.consume()); const tracks = sources.map(copy); front.serve(handles[0]); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); const subscribed = tracks.map((track) => spyOn(track.producer, "subscribe")); try { await clock.advance(Nano.zero); @@ -240,7 +240,7 @@ test("a group below the replacement's resume point is backfilled", async () => { const handles = sources.map((source) => source.consume()); const [ta, tb] = sources.map(copy); front.serve(handles[0]); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); try { await clock.advance(Nano.zero); ta.write(5); @@ -330,7 +330,7 @@ test.each(["conclude", "park"] as const)("a fork made after the front's %s repla ta.write(1); expect(drain(parent)).toEqual([1]); front[end](); - const fork = parent.fork({ maxDelay: Milli(10_000) }); + const fork = parent.fork({ delay: { max: Milli(10_000) } }); try { expect(drain(fork)).toEqual([0, 1]); } finally { @@ -355,7 +355,7 @@ test("a resumed fork has its own preferences and replay cursor", async () => { ta.write(0); ta.write(1); expect(drain(parent)).toEqual([1]); - const fork = parent.fork({ priority: 7, maxDelay: Milli(10_000) }); + const fork = parent.fork({ priority: 7, delay: { max: Milli(10_000) } }); try { expect(drain(fork)).toEqual([0, 1]); fork.update({ priority: 9 }); @@ -420,7 +420,7 @@ test("a lagging same-instance replica resumes from one publisher's bytes exactly function replica(lagging: boolean) { const broadcast = new BroadcastProducer(); const output = broadcast.createTrack("seq"); - const input = source.subscribe({ maxDelay: Milli(60_000) }); + const input = source.subscribe({ delay: { max: Milli(60_000) } }); const release = Promise.withResolvers(); let stopped = false; const done = (async () => { @@ -470,14 +470,14 @@ test("a lagging same-instance replica resumes from one publisher's bytes exactly const disposeA = serve(origin, a.broadcast, 5n); const request = origin.request(path); const standby = b.broadcast.consume(); - const warm = standby.track("seq").subscribe({ maxDelay: Milli(60_000) }); + const warm = standby.track("seq").subscribe({ delay: { max: Milli(60_000) } }); let sub: track.Subscriber | undefined; let disposeB: (() => void) | undefined; try { await clock.advance(Nano(0)); const active = request.active.peek(); if (!active) throw new Error("expected the incumbent route"); - sub = active.track("seq").subscribe({ maxDelay: Milli(60_000) }); + sub = active.track("seq").subscribe({ delay: { max: Milli(60_000) } }); const first = source.appendGroup(); for (let frame = 0; frame < 4; frame++) first.writeFrame({ payload: enc.encode(`0.${frame}`) }); first.finish(); @@ -554,7 +554,7 @@ test("a subscription reads on across a same-instance route swap, each group once const { a, b, active, request, swap, close } = await setup(); const ta = copy(a); const tb = copy(b); - const sub = active.track("seq").subscribe({ maxDelay: 10_000 as track.Subscription["maxDelay"] }); + const sub = active.track("seq").subscribe({ delay: { max: 10_000 as track.Delay["max"] } }); ta.write(0); expect(drain(sub)).toEqual([0]); @@ -818,7 +818,7 @@ test("a replacement delivers unseen datagrams below the newest sequence", async const { a, b, swap, active, close } = await setup(); const ta = copy(a); const tb = copy(b); - const sub = active.track("seq").subscribe({ maxDelay: Milli(100) }); + const sub = active.track("seq").subscribe({ delay: { max: Milli(100) } }); ta.producer.insertDatagram(10, undefined, enc.encode("10")); expect((await sub.recvDatagram())?.sequence).toBe(10); await swap(); @@ -843,7 +843,7 @@ test.each([0, 100])("datagram identities stay within the reader's %p ms budget", const a = new BroadcastProducer(); const ta = copy(a); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(budget) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(budget) } }); const send = (sequence: number) => ta.producer.insertDatagram(sequence, undefined, enc.encode("d")); const recv = () => hooks.tryRecvDatagram(sub); send(10); @@ -875,7 +875,7 @@ test("datagram identities are capped by the replay buffer", () => { const ta = copy(a); front.serve(a.consume()); // Superseded identities never expire within an hour, so only the cap bounds them. - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); const send = (sequence: number) => ta.producer.insertDatagram(sequence, undefined, enc.encode("d")); const recv = () => hooks.tryRecvDatagram(sub).kind; for (let sequence = 0; sequence <= MAX_DATAGRAMS + 1; sequence++) { @@ -1023,7 +1023,7 @@ test("an ordered reader takes a replaced copy's buffered group before the new co front.serve(a.consume()); const ordered = front.consumer .track("seq") - .subscribe({ maxDelay: 10_000 as track.Subscription["maxDelay"] }) + .subscribe({ delay: { max: 10_000 as track.Delay["max"] } }) .ordered(); ta.write(0); expect((await ordered.nextGroup())?.sequence).toBe(0); @@ -1056,7 +1056,7 @@ for (const resolution of ["arrival", "drop", "other copy", "budget"] as const) { front.serve(a.consume()); const ordered = front.consumer .track("seq") - .subscribe({ maxDelay: Milli(1000) }) + .subscribe({ delay: { max: Milli(1000) } }) .ordered(); try { ta.write(4); @@ -1158,7 +1158,7 @@ for (const fails of [true, false]) { const tb = copy(b); const stalled = ta.write(0, 1, false); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(60_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(60_000) } }); const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.0"); @@ -1197,7 +1197,7 @@ test("a resumed reader reports a drop once and still serves a late group", async const a = new BroadcastProducer(); const ta = copy(a); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(60_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(60_000) } }); ta.write(0); expect(recvDisposition(sub)).toBe(0); // A route failing is no verdict: another route may still deliver the group. @@ -1247,7 +1247,7 @@ test("a resumed reader reports a group its serving copy skipped past the budget" const a = new BroadcastProducer(); const ta = copy(a); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(1_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(1_000) } }); expect(recvDisposition(sub)).toBe("idle"); ta.write(0); ta.write(1); @@ -1274,7 +1274,7 @@ test("a drop waits while a copy the serving one replaced may still deliver it", const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(1_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(1_000) } }); ta.write(0); expect(recvDisposition(sub)).toBe(0); front.serve(b.consume()); @@ -1343,7 +1343,7 @@ test("a front serving another front reports the inner serving route's drop", asy const ta = copy(a); inner.serve(a.consume()); outer.serve(inner.consumer); - const sub = outer.consumer.track("seq").subscribe({ maxDelay: Milli(60_000) }); + const sub = outer.consumer.track("seq").subscribe({ delay: { max: Milli(60_000) } }); ta.write(0); expect(recvDisposition(sub)).toBe(0); hooks.dropTrackGroups(ta.producer, 1, 3, new StreamError(StreamCode(70), { message: "cut" })); @@ -1469,7 +1469,7 @@ test("a failed group whose replacement cannot fetch ends with the failure, or co const tb = copy(b); front.serve(a.consume()); // A budget wide enough that the newer group never gives group 0 up first. - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); const stalled = ta.write(0, 1, false); const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); @@ -1522,7 +1522,7 @@ test("a datagram read keeps a replaced copy a group reader still needs", async ( const ta = copy(a); copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: 10_000 as track.Subscription["maxDelay"] }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: 10_000 as track.Delay["max"] } }); ta.write(0); expect(drain(sub)).toEqual([0]); ta.write(1); @@ -2053,7 +2053,7 @@ test("an arrival read takes a replaced copy's buffered group before the new copy const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: 10_000 as track.Subscription["maxDelay"] }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: 10_000 as track.Delay["max"] } }); ta.write(0); expect(drain(sub)).toEqual([0]); // Both buffered when the read starts: the lower one comes first. @@ -2181,7 +2181,7 @@ test.each(["lands", "dropped", "expired"])( const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: SECOND }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: SECOND } }); try { ta.write(0); expect(drain(sub)).toEqual([0]); @@ -2221,7 +2221,7 @@ test("a group read whole still expires once the reader lets go of its copy", asy const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: SECOND }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: SECOND } }); try { ta.write(0); const group = sub.tryRecvGroup(); @@ -2272,7 +2272,7 @@ test("an ordered reader keeps no copy for a group it passed", async () => { const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: SECOND }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: SECOND } }); const next = () => { const recv = hooks.tryNextGroup(sub); if (recv.kind !== "group") return undefined; @@ -2306,7 +2306,7 @@ test.each([true, false])( const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); const view = ordered ? sub.ordered() : undefined; const next = () => (view ? view.nextGroup() : sub.recvGroup()); ta.write(0); @@ -2348,7 +2348,7 @@ test.each([true, false])("a replaced copy backs a group before its first arrival const tb = copy(b); const tc = copy(c); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); const view = ordered ? sub.ordered() : undefined; const next = () => (view ? view.nextGroup() : sub.recvGroup()); ta.write(0); @@ -2391,7 +2391,7 @@ test("a clean end waits for a group the replaced copy still carries", async () = const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: SECOND }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: SECOND } }); try { ta.write(0); ta.write(2); @@ -2426,7 +2426,7 @@ test.each([false, true])( const ta = copy(a); copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(10_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(10_000) } }); expect(hooks.tryRecvGroup(sub, false).kind).toBe("idle"); expect(hooks.tryRecvDatagram(sub).kind).toBe("idle"); ta.write(0); @@ -2448,7 +2448,7 @@ test.each([Milli(100), Milli(3_600_000)])( const front = new Front(); const sources: BroadcastProducer[] = []; const copies: ReturnType[] = []; - const sub = front.consumer.track("seq").subscribe({ maxDelay }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: maxDelay } }); try { for (let sequence = 0; sequence < 20; sequence++) { const source = new BroadcastProducer(); @@ -2722,7 +2722,7 @@ test.each(["lands", "expired"])( const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: SECOND }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: SECOND } }); try { ta.write(0); ta.write(2); @@ -2773,7 +2773,7 @@ test.each([ const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); const inOrder = ordered ? sub.ordered() : undefined; const read = () => { const sequences: number[] = []; @@ -2850,7 +2850,7 @@ test("a clean end keeps the serving copy while a group read from a replaced one const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); try { ta.write(0); expect(drain(sub)).toEqual([0]); @@ -2890,7 +2890,7 @@ test("a retained copy declaring another end after a clean end fails the reads", const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: SECOND }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: SECOND } }); try { ta.write(0); ta.write(2); @@ -2927,7 +2927,7 @@ test("a clean end keeps the serving copy while a group handed out before the swa const ta = copy(a); const tb = copy(b); front.serve(a.consume()); - const sub = front.consumer.track("seq").subscribe({ maxDelay: Milli(3_600_000) }); + const sub = front.consumer.track("seq").subscribe({ delay: { max: Milli(3_600_000) } }); try { // Group 0 is handed out while the old route is the only one. const stalled = ta.write(0, 1, false); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 48380707ac7..87dd8e8f3c4 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -60,6 +60,7 @@ import { insertDrop, insertRun, MAX_DATAGRAMS, + maxDelayOf, type Recv, type RecvDatagram, type Replacement, @@ -973,7 +974,8 @@ 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; + const subscription = this.#shell.subscription.peek(); + return subscription ? maxDelayOf(subscription) : Milli.zero; } /** @@ -1484,7 +1486,8 @@ class Recover implements GroupRecover { // That route failing too waits for the next one, and a refusal is a verdict only for // a copy that failed, once the serving subscription will not deliver the group either. if (outcome && failed && !routeFailed(outcome.error) && !(serving.sub.closed.peek() instanceof Error)) { - const maxDelay = serving.sub.subscription.peek()?.maxDelay ?? Milli.zero; + const subscription = serving.sub.subscription.peek(); + const maxDelay = subscription ? maxDelayOf(subscription) : Milli.zero; const land = { start: this.sequence, end: this.sequence + 1, floor: true, grace: grace(maxDelay) }; if (generation === current.generation || !hooks.mayLand(serving.sub, land)) throw outcome.error; landing = new Promise((resolve) => { diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts index 26dd949b12f..5d8e479f7a8 100644 --- a/js/net/src/tail.test.ts +++ b/js/net/src/tail.test.ts @@ -31,7 +31,10 @@ async function session(protocol: string) { broadcastPublication.announce(); const video = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = wireOf(client).consume(Path.from("test")); - const reader = remote.track("video").subscribe({ maxDelay: MAX_DELAY }).ordered(); + const reader = remote + .track("video") + .subscribe({ delay: { max: MAX_DELAY } }) + .ordered(); return { video, diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 0be32df7ab4..5cc81da4918 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -86,7 +86,7 @@ test("ordered reads wait only for subscription-owed groups", () => { try { for (const received of [false, true]) { const producer = received ? receivedTrack() : new TrackProducer("test"); - const reader = producer.subscribe({ maxDelay: Milli(1000) }).ordered(); + const reader = producer.subscribe({ delay: { max: Milli(1000) } }).ordered(); putGroup(producer, 0); putGroup(producer, 2); expect(drainOrdered(reader)).toEqual(received ? [0] : [0, 2]); @@ -104,7 +104,7 @@ test("ordered reads wait only for subscription-owed groups", () => { test("an ordered owed hole wakes exactly at its wall budget", async () => { const clock = manualTime(0); const producer = receivedTrack(); - const reader = producer.subscribe({ maxDelay: Milli(50) }).ordered(); + const reader = producer.subscribe({ delay: { max: Milli(50) } }).ordered(); try { putGroup(producer, 0); expect(drainOrdered(reader)).toEqual([0]); @@ -134,9 +134,9 @@ test("an ordered owed hole wakes exactly at its wall budget", async () => { test("an ordered read skipping a stale group stops waiting on holes below it", () => { const clock = manualTime(0); const producer = receivedTrack(); - const reader = producer.subscribe({ maxDelay: Milli(50) }).ordered(); + const reader = producer.subscribe({ delay: { max: Milli(50) } }).ordered(); // A wider subscriber keeps the ledger's gaps open past this reader's budget. - const wide = producer.subscribe({ maxDelay: Milli(1000) }); + const wide = producer.subscribe({ delay: { max: Milli(1000) } }); try { putGroup(producer, 0); expect(drainOrdered(reader)).toEqual([0]); @@ -161,7 +161,7 @@ test("an ordered read skipping a stale group stops waiting on holes below it", ( test("an ordered owed hole follows media time and a lower successor", async () => { const clock = manualTime(0); const producer = receivedTrack({ timescale: Timescale.MILLI }); - const reader = producer.subscribe({ maxDelay: Milli(1000) }).ordered(); + const reader = producer.subscribe({ delay: { max: Milli(1000) } }).ordered(); try { putGroup(producer, 0, 0); expect(drainOrdered(reader)).toEqual([0]); @@ -197,7 +197,7 @@ test("an ordered owed hole follows media time and a lower successor", async () = test("an ordered owed hole wakes when its ledger gap folds", async () => { const clock = manualTime(0); const producer = receivedTrack(); - const reader = producer.subscribe({ maxDelay: Milli(50) }).ordered(); + const reader = producer.subscribe({ delay: { max: Milli(50) } }).ordered(); try { putGroup(producer, 0); expect(drainOrdered(reader)).toEqual([0]); @@ -227,7 +227,7 @@ test("an ordered owed hole wakes when its ledger gap folds", async () => { test("ordered reads pass accounted sequences and honor the feed and reader floors", () => { const clock = manualTime(0); const producer = receivedTrack(); - const reader = producer.subscribe({ maxDelay: null }).ordered(); + const reader = producer.subscribe({ delay: { max: null } }).ordered(); try { putGroup(producer, 0); producer.appendDatagram(undefined, enc.encode("d")); @@ -259,7 +259,7 @@ test("ordered owed holes follow zero, narrowed, unlimited, and publisher budgets try { for (const maxDelay of [Milli.zero, Milli(1000), null]) { const producer = receivedTrack(); - const reader = producer.subscribe({ maxDelay }).ordered(); + const reader = producer.subscribe({ delay: { max: maxDelay } }).ordered(); putGroup(producer, 0); expect(drainOrdered(reader)).toEqual([0]); putGroup(producer, 2); @@ -271,7 +271,7 @@ test("ordered owed holes follow zero, narrowed, unlimited, and publisher budgets expect(drainOrdered(reader)).toEqual([1, 2]); } else if (maxDelay > 0) { const pending = reader.nextGroup(); - reader.update({ maxDelay: Milli.zero }); + reader.update({ delay: { max: Milli.zero } }); expect((await pending)?.sequence).toBe(2); } reader.close(); @@ -279,7 +279,7 @@ test("ordered owed holes follow zero, narrowed, unlimited, and publisher budgets producer.close(); } const producer = receivedTrack({ maxAge: Milli(50) }); - const reader = producer.subscribe({ maxDelay: null }).ordered(); + const reader = producer.subscribe({ delay: { max: null } }).ordered(); putGroup(producer, 0); putGroup(producer, 2); expect(drainOrdered(reader)).toEqual([0]); @@ -299,7 +299,7 @@ test("ordered owed waits wake on a datagram, a drop, a declared start, or track try { for (const resolve of ["datagram", "drop", "start", "close"] as const) { const producer = receivedTrack(); - const reader = producer.subscribe({ maxDelay: null }).ordered(); + const reader = producer.subscribe({ delay: { max: null } }).ordered(); putGroup(producer, 1); // With no declared start the first arrival is the floor. expect(drainOrdered(reader)).toEqual([1]); @@ -331,7 +331,7 @@ test("ordered owed waits wake on a datagram, a drop, a declared start, or track } const producer = receivedTrack(); hooks.startTrack(producer, { start: 0 }); - const reader = producer.subscribe({ maxDelay: null }).ordered(); + const reader = producer.subscribe({ delay: { max: null } }).ordered(); putGroup(producer, 1); expect(drainOrdered(reader)).toEqual([]); putGroup(producer, 0); @@ -548,7 +548,7 @@ for (const timed of [true, false]) { const producer = new TrackProducer("test").accept(timed ? { timescale: Timescale.MICRO } : {}); const timestamp = timed ? Timestamp.fromMillis(180_000) : undefined; producer.appendDatagram(timestamp, enc.encode("before")); - const subscriber = producer.subscribe({ maxDelay: Milli(50) }); + const subscriber = producer.subscribe({ delay: { max: Milli(50) } }); const sequence = producer.appendDatagram(timestamp, enc.encode("after")); producer.finish(); producer.close(); @@ -627,7 +627,7 @@ test("insertDatagram preserves an explicit sequence", async () => { test("recvDatagram leaves the ordered group cursor alone", async () => { const producer = new TrackProducer("test"); const datagrams = producer.subscribe(); - const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const track = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); producer.insertDatagram(5, Timestamp.fromMillis(5), enc.encode("x")); expect((await datagrams.recvDatagram())?.sequence).toBe(5); @@ -701,12 +701,12 @@ 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({ maxDelay: Milli.zero, groups: { start: { included: 0 } } }); + const track = producer.subscribe({ delay: { max: Milli.zero }, groups: { start: { included: 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), + delay: { min: Milli(0), max: Milli(0) }, order: "descending", groups: { start: { included: 0 } }, }); @@ -715,28 +715,29 @@ test("subscriber options and updates are forwarded to the producer's aggregate", const next = producer.subscription.changed(); track.update({ priority: 7, - maxDelay: Milli(250), + delay: { max: Milli(250) }, order: "ascending", groups: { start: { included: 2 }, end: { excluded: 9 } }, }); expect(await next).toEqual({ priority: 7, - maxDelay: Milli(250), + delay: { min: Milli(0), max: Milli(250) }, order: "ascending", groups: { start: { included: 2 }, end: { excluded: 9 } }, }); }); -test("subscriber maxAge is refused before defaults or updates take effect", () => { +test("subscriber maxAge and maxDelay are refused before defaults or updates take effect", () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(250) }); + const track = producer.subscribe({ delay: { max: Milli(250) } }); const original = producer.subscription.peek(); for (const maxAge of [Milli(500), undefined]) { - expect(() => producer.subscribe({ maxAge } as Subscription)).toThrow("maxDelay"); - const options = { maxDelay: Milli(100), maxAge }; + expect(() => producer.subscribe({ maxAge } as Subscription)).toThrow("delay.max"); + const options = { maxDelay: Milli(100), maxAge } as Subscription; expect(() => producer.subscribe(options)).toThrow(TypeError); - expect(() => track.update(options)).toThrow("maxDelay"); + expect(() => track.update(options)).toThrow("delay.max"); + expect(() => producer.subscribe({ maxDelay: Milli(100) } as Subscription)).toThrow("delay.max"); expect(producer.subscription.peek()).toEqual(original); } @@ -756,36 +757,40 @@ test("an unknown subscriber order is refused", () => { producer.close(); }); -test("a fractional maxDelay is rounded up before the wire sees it", async () => { +test("a fractional delay is rounded up before the wire sees it", async () => { const producer = new TrackProducer("test"); // Subscribers derive this from measurements (a jitter estimate scaled off RTT), so a // fractional millisecond is expected. The wire encodes it as a varint, which throws on a // non-integer, and rounding down would shorten a budget the subscriber asked for. - const track = producer.subscribe({ maxDelay: Milli(38.75) }); - expect(producer.subscription.peek()?.maxDelay).toBe(Milli(39)); + const track = producer.subscribe({ delay: { max: Milli(38.75) } }); + expect(producer.subscription.peek()?.delay?.max).toBe(Milli(39)); const next = producer.subscription.changed(); - track.update({ maxDelay: Milli(500.25) }); - expect((await next)?.maxDelay).toBe(Milli(501)); + track.update({ delay: { max: Milli(500.25) } }); + expect((await next)?.delay?.max).toBe(Milli(501)); + const min = producer.subscription.changed(); + track.update({ delay: { min: Milli(99.5) } }); + expect((await min)?.delay?.min).toBe(Milli(100)); // The publisher half of the budget lands on the wire through TRACK_INFO, with the same hazard. producer.accept({ maxAge: Milli(1000.5) }); expect((await producer.info()).maxAge).toBe(Milli(1001)); }); -test("a maxDelay or maxAge that is not a duration is refused, not rounded into one", () => { +test("a delay or maxAge that is not a duration is refused, not rounded into one", () => { const producer = new TrackProducer("test"); // The wire carries an unsigned varint, so rounding these would encode a budget the // caller never asked for: -0.5 would ceil to zero and silently take the live edge. - expect(() => producer.subscribe({ maxDelay: -0.5 as Milli })).toThrow(RangeError); - expect(() => producer.subscribe({ maxDelay: -100 as Milli })).toThrow(RangeError); - expect(() => producer.subscribe({ maxDelay: Number.NaN as Milli })).toThrow(RangeError); - expect(() => producer.subscribe({ maxDelay: Number.POSITIVE_INFINITY as Milli })).toThrow(RangeError); + expect(() => producer.subscribe({ delay: { max: -0.5 as Milli } })).toThrow(RangeError); + expect(() => producer.subscribe({ delay: { max: -100 as Milli } })).toThrow(RangeError); + expect(() => producer.subscribe({ delay: { max: Number.NaN as Milli } })).toThrow(RangeError); + expect(() => producer.subscribe({ delay: { min: -1 as Milli } })).toThrow(RangeError); + expect(() => producer.subscribe({ delay: { max: Number.POSITIVE_INFINITY as Milli } })).toThrow(RangeError); expect(() => producer.accept({ maxAge: -0.5 as Milli })).toThrow(RangeError); expect(() => producer.accept({ maxAge: Milli(Number.MAX_SAFE_INTEGER + 1) })).toThrow(RangeError); - expect(() => producer.subscribe({ maxDelay: Milli(Number.MAX_SAFE_INTEGER + 1) })).toThrow(RangeError); + expect(() => producer.subscribe({ delay: { max: Milli(Number.MAX_SAFE_INTEGER + 1) } })).toThrow(RangeError); }); test("priority -128..=127 is accepted and anything outside is refused", () => { @@ -811,26 +816,33 @@ test("multiple subscriber options aggregate like Rust", async () => { const producer = new TrackProducer("test"); const bounded = producer.subscribe({ priority: 2, - maxDelay: Milli(100), + delay: { min: Milli(400), max: Milli(100) }, order: "ascending", groups: { start: { included: 10 }, end: { excluded: 20 } }, }); - const live = producer.subscribe({ priority: 7, maxDelay: Milli(250), groups: { start: { included: 5 } } }); + const live = producer.subscribe({ + priority: 7, + delay: { min: Milli(50), max: Milli(250) }, + groups: { start: { included: 5 } }, + }); + // A minimum that went unstated, as a legacy peer's does, never pulls the smallest to zero. + const legacy = producer.subscribe({ delay: { min: null, max: Milli(0) }, groups: { start: { included: 5 } } }); // One descending subscriber makes the aggregate descending, so a live reader is never held // behind another's backlog. expect(producer.subscription.peek()).toEqual({ priority: 7, - maxDelay: Milli(250), + delay: { min: Milli(50), max: Milli(250) }, order: "descending", groups: { start: { included: 5 } }, }); + legacy.close(); const narrowed = producer.subscription.changed(); live.close(); expect(await narrowed).toEqual({ priority: 2, - maxDelay: Milli(100), + delay: { min: Milli(400), max: Milli(100) }, order: "ascending", groups: { start: { included: 10 }, end: { excluded: 20 } }, }); @@ -840,6 +852,13 @@ test("multiple subscriber options aggregate like Rust", async () => { expect(await none).toBeUndefined(); }); +test("only unstated minimums aggregate to an unstated one", () => { + const producer = new TrackProducer("test"); + producer.subscribe({ delay: { min: null } }); + producer.subscribe({ delay: { min: null, max: Milli(250) } }); + expect(producer.subscription.peek()?.delay).toEqual({ min: null, max: Milli(250) }); +}); + // 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", () => { @@ -853,14 +872,14 @@ test("the producer aggregate is not clamped to the track's maxAge", () => { // The publisher applies its own, so a subscriber asking upstream before the info lands // asks for the same demand after it. const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(10_000) }); + const track = producer.subscribe({ delay: { max: Milli(10_000) } }); const before = producer.subscription.peek(); producer.accept({ timescale: Timescale.MILLI, maxAge: Milli(2_000) }); expect(producer.subscription.peek()).toEqual(before); - expect(producer.subscription.peek()?.maxDelay).toBe(Milli(10_000)); - expect(track.subscription.peek()?.maxDelay).toBe(Milli(10_000)); + expect(producer.subscription.peek()?.delay?.max).toBe(Milli(10_000)); + expect(track.subscription.peek()?.delay?.max).toBe(Milli(10_000)); // A late group is waited for no longer than the publisher keeps it. expect(hooks.tailGrace(producer)).toBe(Milli(2_000)); }); @@ -885,7 +904,7 @@ test("nextGroup skips late arrivals", async () => { test("nextGroup returns buffered groups in sequence", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const track = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); producer.createGroup(3); producer.createGroup(5); @@ -898,7 +917,7 @@ test.each([false, true])("a reordered tail keeps the group PTS drift verdict (B- const clock = manualTime(0); try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); - const subscriber = producer.subscribe({ maxDelay: Milli(80) }); + const subscriber = producer.subscribe({ delay: { max: Milli(80) } }); for (const ms of [0, 100]) { const group = producer.appendGroup(); group.writeFrame({ payload: new Uint8Array(), timestamp: Timestamp.fromMillis(ms) }); @@ -924,7 +943,7 @@ test.each([false, true])("a reordered tail keeps the group PTS drift verdict (B- // its first timestamp would discard exactly the group being filled. test("a long group is not stale while its tail reaches the edge", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); - const sub = producer.subscribe({ maxDelay: Milli(500) }); + const sub = producer.subscribe({ delay: { max: Milli(500) } }); // Group 0 spans 0..2000ms; group 1 starts at 2000ms, where group 0 ends. const long = producer.appendGroup(); @@ -940,7 +959,7 @@ test("a long group is not stale while its tail reaches the edge", async () => { test("a long group is stale once its successor falls behind", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); - const sub = producer.subscribe({ maxDelay: Milli(500) }); + const sub = producer.subscribe({ delay: { max: Milli(500) } }); const long = producer.appendGroup(); for (const ms of [0, 500, 1000]) { @@ -961,7 +980,7 @@ test("a long group is stale once its successor falls behind", async () => { // track using both channels needs one subscription, not two. test("the ordered handle carries datagrams", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const track = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); producer.insertDatagram(5, Timestamp.fromMillis(5), enc.encode("x")); producer.createGroup(3); @@ -986,7 +1005,7 @@ test("the latency budget skips a buffered timeline on either cursor", async () = expect((await ordered.nextGroup())?.sequence).toBe(2); // The budget is the only gate: one that spans the backlog bursts it in order. - const replay = producer.subscribe({ maxDelay: Milli(30_000) }).ordered(); + const replay = producer.subscribe({ delay: { max: Milli(30_000) } }).ordered(); expect((await replay.nextGroup())?.sequence).toBe(0); expect((await replay.nextGroup())?.sequence).toBe(1); expect((await replay.nextGroup())?.sequence).toBe(2); @@ -998,7 +1017,7 @@ test("the latency budget skips a buffered timeline on either cursor", async () = test("a frameless successor leaves the next started group as the bound", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); // Any budget, so only media time judges: the wall clock barely moves here. - const track = producer.subscribe({ maxDelay: Milli(500) }); + const track = producer.subscribe({ delay: { max: Milli(500) } }); producer.writeFrame({ payload: enc.encode("0"), timestamp: Timestamp.fromMillis(0) }); producer.appendGroup(); // seq 1 stalls before its first frame @@ -1013,7 +1032,7 @@ test("a frameless successor leaves the next started group as the bound", async ( // arrived last. test("a late-arriving successor bounds a group's reach", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); - const track = producer.subscribe({ maxDelay: Milli(500) }); + const track = producer.subscribe({ delay: { max: Milli(500) } }); for (const [sequence, ms] of [ [0, 0], @@ -1035,7 +1054,7 @@ test("a late-arriving successor bounds a group's reach", async () => { // backlog inside it is drained in full, and what is past it is skipped. test("ordered frame reads follow the budget", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); - const ordered = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const ordered = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); const live = producer.subscribe().ordered(); for (const timestamp of [0, 1000, 2000]) { @@ -1055,7 +1074,7 @@ test("ordered frame reads follow the budget", async () => { for (const take of ["nextGroup", "tryNextGroup"] as const) { test(`${take} abandons the frame helpers' group when it passes it`, async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const track = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); const zero = producer.createGroup(0); zero.writeFrame(textFrame("0.0")); @@ -1085,7 +1104,7 @@ for (const take of ["nextGroup", "tryNextGroup"] as const) { // after a higher one was read is skipped, never fed to the caller backwards. test("ordered frame reads skip a late lower-sequence group", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const track = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); const five = producer.createGroup(5); five.writeFrame(textFrame("five", Timestamp.fromMillis(5))); @@ -1117,8 +1136,12 @@ test("zero latency takes the latest group when ages are equal", async () => { } }); -test("an omitted maxDelay is the default zero, and null sets no limit", async () => { - expect(Subscription.default()).toEqual({ priority: 0, maxDelay: Milli.zero, order: "descending" }); +test("an omitted delay is the default zero, and a null max sets no limit", async () => { + expect(Subscription.default()).toEqual({ + priority: 0, + delay: { min: Milli.zero, max: Milli.zero }, + order: "descending", + }); const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); const explicit = producer.subscribe(Subscription.default()); @@ -1132,12 +1155,12 @@ test("an omitted maxDelay is the default zero, and null sets no limit", async () expect((await explicit.recvGroup())?.sequence).toBe(2); expect((await implicit.recvGroup())?.sequence).toBe(2); expect((await bare.recvGroup())?.sequence).toBe(2); - expect(producer.subscription.peek()?.maxDelay).toBe(Milli.zero); + expect(producer.subscription.peek()?.delay?.max).toBe(Milli.zero); // An explicit null leaves the publisher's retention as the only bound. - const unlimited = producer.subscribe({ maxDelay: null }); + const unlimited = producer.subscribe({ delay: { max: null } }); for (const sequence of [0, 1, 2]) expect((await unlimited.recvGroup())?.sequence).toBe(sequence); - expect(producer.subscription.peek()?.maxDelay).toBe(Milli(Number.MAX_SAFE_INTEGER)); + expect(producer.subscription.peek()?.delay?.max).toBe(Milli(Number.MAX_SAFE_INTEGER)); expect(hooks.tailGrace(producer)).toBe(Milli(5000)); producer.finish(); producer.close(); @@ -1145,7 +1168,7 @@ test("an omitted maxDelay is the default zero, and null sets no limit", async () test("latency budget admits groups within its presentation-time window", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); - const track = producer.subscribe({ maxDelay: Milli(1500) }); + const track = producer.subscribe({ delay: { max: Milli(1500) } }); for (const timestamp of [0, 1000, 2000]) { producer.writeFrame({ payload: enc.encode(`${timestamp}`), timestamp: Timestamp.fromMillis(timestamp) }); @@ -1169,7 +1192,7 @@ test("a late lower group within the budget is delivered", async () => { group.finish(); } - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const track = producer.subscribe({ delay: { max: Milli(5000) } }); expect((await track.recvGroup())?.sequence).toBe(5); expect((await track.recvGroup())?.sequence).toBe(6); expect((await track.recvGroup())?.sequence).toBe(7); @@ -1195,13 +1218,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({ groups: { start: { included: 3 } }, delay: { max: 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({ maxDelay: Milli.zero, groups: { start: { included: 1 } } }); + const named = producer.subscribe({ delay: { max: Milli.zero }, groups: { start: { included: 1 } } }); expect((await named.recvGroup())?.sequence).toBe(4); }); @@ -1218,13 +1241,13 @@ test("the ordered cursor sheds a stale backlog", async () => { // Only what is provably too old goes. Group 0 reaches 1s, a full 2s behind the 3s // edge; group 1 reaches 2s and could still present inside a 1.5s budget. - const bounded = producer.subscribe({ maxDelay: Milli(1500) }).ordered(); + const bounded = producer.subscribe({ delay: { max: Milli(1500) } }).ordered(); expect((await bounded.nextGroup())?.sequence).toBe(1); expect((await bounded.nextGroup())?.sequence).toBe(2); expect((await bounded.nextGroup())?.sequence).toBe(3); // A budget spanning the history still bursts it in full, gap-free. - const replay = producer.subscribe({ maxDelay: Milli(30_000) }).ordered(); + const replay = producer.subscribe({ delay: { max: Milli(30_000) } }).ordered(); for (const sequence of [0, 1, 2, 3]) { expect((await replay.nextGroup())?.sequence).toBe(sequence); } @@ -1232,7 +1255,7 @@ test("the ordered cursor sheds a stale backlog", async () => { test("the budget is clamped to the publisher's window", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(1500) }); - const track = producer.subscribe({ maxDelay: Milli(60_000) }); + const track = producer.subscribe({ delay: { max: Milli(60_000) } }); // Spaced so group 0's reach (2s) sits 2s behind the edge (4s): outside the // publisher's 1.5s window, inside the subscriber's requested minute. @@ -1329,7 +1352,7 @@ test("a budget is measured from the reader's position", async () => { // group's first frame instead makes the drift 2000ms, so a budget shorter than one // GOP would drop the tail of every GOP. const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(60_000) }); - const track = producer.subscribe({ maxDelay: Milli(1000) }); + const track = producer.subscribe({ delay: { max: Milli(1000) } }); const open = producer.appendGroup(); open.writeFrame({ payload: enc.encode("key"), timestamp: Timestamp.fromMillis(0) }); @@ -1378,7 +1401,7 @@ test("a handed-out group still expires while its first frame is stalled", async test("committing track info wakes a group newly outside the retention window", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(10_000) }); + const track = producer.subscribe({ delay: { max: Milli(10_000) } }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("old"), timestamp: Timestamp.fromMillis(0) }); @@ -1416,7 +1439,7 @@ test("a handed-out group reports its expiry to a writer racing it", async () => test("a frame still being written is judged against the budget", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(100) }); - const track = producer.subscribe({ maxDelay: Milli(100) }); + const track = producer.subscribe({ delay: { max: Milli(100) } }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("old"), timestamp: Timestamp.fromMillis(0) }); source.writeFrame({ payload: enc.encode("future"), timestamp: Timestamp.fromMillis(10_000) }); @@ -1439,7 +1462,7 @@ test("a fully read group still reports its expiry", async () => { const clock = manualTime(10_000); const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); try { - const track = producer.subscribe({ maxDelay: Milli(1000) }); + const track = producer.subscribe({ delay: { max: Milli(1000) } }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.fromMillis(0) }); source.finish(); @@ -1494,11 +1517,11 @@ test("a stalled track keeps only its newest group", () => { for (const millis of [0, 10, 20]) { producer.writeFrame({ payload: enc.encode(`${millis}`), timestamp: Timestamp.fromMillis(millis) }); } - expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([0, 1, 2]); + expect(drain(producer.subscribe({ delay: { max: Milli(1000) } }))).toEqual([0, 1, 2]); // Nothing is written again: media time stands still. clock.set(12_000); - expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([2]); + expect(drain(producer.subscribe({ delay: { max: Milli(1000) } }))).toEqual([2]); } finally { producer.finish(); producer.close(); @@ -1520,11 +1543,11 @@ test("frozen timestamps still age out by wall clock", () => { frame(); // Group 0's successor arrived 800ms ago, group 1's just now. - expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([0, 1, 2]); + expect(drain(producer.subscribe({ delay: { max: Milli(1000) } }))).toEqual([0, 1, 2]); clock.set(11_500); - expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([1, 2]); + expect(drain(producer.subscribe({ delay: { max: Milli(1000) } }))).toEqual([1, 2]); clock.set(12_500); - expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([2]); + expect(drain(producer.subscribe({ delay: { max: Milli(1000) } }))).toEqual([2]); } finally { producer.finish(); producer.close(); @@ -1543,7 +1566,7 @@ test("a startup burst ages out by media time", () => { } // Group N reaches N + 1 seconds against the 4s edge: groups 0 and 1 are 3s and 2s // behind, past 1.5s. Their successors arrived just now. - expect(drain(producer.subscribe({ maxDelay: Milli(1500) }))).toEqual([2, 3, 4]); + expect(drain(producer.subscribe({ delay: { max: Milli(1500) } }))).toEqual([2, 3, 4]); } finally { producer.finish(); producer.close(); @@ -1558,7 +1581,7 @@ test("a parked read wakes at its wall deadline", async () => { const clock = manualTime(10_000); const producer = new TrackProducer("test").accept({ timescale }); try { - const track = producer.subscribe({ maxDelay: Milli(1000) }); + const track = producer.subscribe({ delay: { max: Milli(1000) } }); const held = producer.appendGroup(); held.writeFrame({ payload: enc.encode("x"), timestamp: timescale && Timestamp.fromMillis(0) }); const group = track.tryRecvGroup(); @@ -1596,7 +1619,7 @@ test("an evicted successor with unread frames still starts the wall clock", asyn const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); try { const budget = 2 * CACHE_WINDOW_MS; - const track = producer.subscribe({ maxDelay: Milli(budget) }); + const track = producer.subscribe({ delay: { max: Milli(budget) } }); const write = () => { const group = producer.appendGroup(); group.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); @@ -1638,7 +1661,7 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); try { const budget = 2 * CACHE_WINDOW_MS; - const track = producer.subscribe({ maxDelay: Milli(budget) }); + const track = producer.subscribe({ delay: { max: Milli(budget) } }); const held = producer.appendGroup(); held.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); held.finish(); @@ -1684,18 +1707,18 @@ test("a run of empty groups ages out by wall clock", () => { const clock = manualTime(10_000); const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); try { - const track = producer.subscribe({ maxDelay: Milli(1000) }); + const track = producer.subscribe({ delay: { max: Milli(1000) } }); producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); for (let sequence = 1; sequence <= RUN; sequence++) { const group = producer.appendGroup(); if (sequence % 2 === 0) group.finish(); } producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(10) }); - expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toHaveLength(RUN + 2); + expect(drain(producer.subscribe({ delay: { max: Milli(1000) } }))).toHaveLength(RUN + 2); clock.set(11_000); expect(drain(track)).toEqual([RUN + 1]); - expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([RUN + 1]); + expect(drain(producer.subscribe({ delay: { max: Milli(1000) } }))).toEqual([RUN + 1]); } finally { producer.finish(); producer.close(); @@ -1714,7 +1737,7 @@ test("a live track retains its newest closed group through idle expiry", async ( group.finish(); } await clock.advance(10_000 + CACHE_WINDOW_MS + 200); - const replay = producer.subscribe({ maxDelay: Milli(100) }); + const replay = producer.subscribe({ delay: { max: Milli(100) } }); expect(replay.tryRecvGroup()?.sequence).toBe(2); expect(replay.tryRecvGroup()).toBeUndefined(); replay.close(); @@ -1733,7 +1756,7 @@ test("retention eviction surfaces as a gap for a handed-out group", async () => const clock = manualTime(10_000); try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(100) }); - const track = producer.subscribe({ maxDelay: Milli(100) }); + const track = producer.subscribe({ delay: { max: Milli(100) } }); const source = producer.appendGroup(); source.writeFrame(textFrame("first")); source.writeFrame(textFrame("tail")); @@ -1756,7 +1779,7 @@ test("idle expiry reclaims an ended track's held mirror", async () => { const clock = manualTime(10_000); try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(100) }); - const track = producer.subscribe({ maxDelay: Milli(100) }); + const track = producer.subscribe({ delay: { max: Milli(100) } }); const source = producer.appendGroup(); source.writeFrame(textFrame("first")); source.writeFrame(textFrame("tail")); @@ -1769,7 +1792,7 @@ test("idle expiry reclaims an ended track's held mirror", async () => { producer.finish(); producer.close(); clock.set(10_000 + CACHE_WINDOW_MS + 200); - producer.subscribe({ maxDelay: Milli(100) }); + producer.subscribe({ delay: { max: Milli(100) } }); await expect(group.readFrame()).rejects.toBeInstanceOf(TooFarBehind); } finally { @@ -1781,7 +1804,7 @@ test("retention reclaims a group the publisher abandoned open", async () => { const clock = manualTime(10_000); try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(100) }); - const track = producer.subscribe({ maxDelay: Milli(100) }); + const track = producer.subscribe({ delay: { max: Milli(100) } }); const stalled = producer.appendGroup(); stalled.writeFrame(textFrame("first")); // A successor, so the stalled group is not the live edge the publisher is @@ -1793,7 +1816,7 @@ test("retention reclaims a group the publisher abandoned open", async () => { expect(await group.readString()).toBe("first"); clock.set(10_000 + CACHE_WINDOW_MS + 200); - producer.subscribe({ maxDelay: Milli(100) }); + producer.subscribe({ delay: { max: Milli(100) } }); // A gap, not a clean finish: the publisher never ended this group. await expect(group.readFrame()).rejects.toBeInstanceOf(TooFarBehind); @@ -1806,7 +1829,7 @@ test("an idle live edge ages out once a newer group arrives", async () => { const clock = manualTime(10_000); try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(100) }); - const track = producer.subscribe({ maxDelay: Milli(100) }); + const track = producer.subscribe({ delay: { max: Milli(100) } }); const edge = producer.appendGroup(); edge.writeFrame(textFrame("first")); @@ -1816,7 +1839,7 @@ test("an idle live edge ages out once a newer group arrives", async () => { // A prune while it is still the live edge keeps it, and finds nothing else to age out. clock.set(10_000 + CACHE_WINDOW_MS + 200); - producer.subscribe({ maxDelay: Milli(100) }); + producer.subscribe({ delay: { max: Milli(100) } }); // A successor ends the exemption, and the group is long past the window, so the // write evicts it rather than a later wakeup. @@ -1837,7 +1860,7 @@ test("an abandoned open group ages out with no further write", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); try { // A budget past the cache window, so the eviction rather than the budget ends it. - const track = producer.subscribe({ maxDelay: Milli(2 * CACHE_WINDOW_MS) }); + const track = producer.subscribe({ delay: { max: Milli(2 * CACHE_WINDOW_MS) } }); const stalled = producer.appendGroup(); stalled.writeFrame(textFrame("first")); producer.appendGroup(); @@ -1881,7 +1904,7 @@ test("retention pruning preserves clean EOF for a drained mirror", async () => { const clock = manualTime(10_000); try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(100) }); - const track = producer.subscribe({ maxDelay: Milli(100) }); + const track = producer.subscribe({ delay: { max: Milli(100) } }); const source = producer.appendGroup(); source.writeFrame(textFrame("only")); source.finish(); @@ -1894,7 +1917,7 @@ test("retention pruning preserves clean EOF for a drained mirror", async () => { producer.finish(); producer.close(); clock.set(10_000 + CACHE_WINDOW_MS + 200); - producer.subscribe({ maxDelay: Milli(100) }); + producer.subscribe({ delay: { max: Milli(100) } }); expect(await group.readFrame()).toBeUndefined(); expect(await group.closed).toBeNull(); @@ -1908,7 +1931,7 @@ test("system clock changes do not affect the latency budget", async () => { clock.stepWall(10_000); try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(100) }); - const track = producer.subscribe({ maxDelay: Milli(100) }); + const track = producer.subscribe({ delay: { max: Milli(100) } }); producer.writeFrame(textFrame("old")); clock.stepWall(20_000); @@ -1923,7 +1946,7 @@ test("system clock changes do not affect the latency budget", async () => { test("frame readiness cancels the losing latency waiter", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const track = producer.subscribe({ delay: { max: Milli(5000) } }); const source = producer.appendGroup(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1971,9 +1994,9 @@ test("an aborted live-edge anchor does not make older content stale", async () = test.each(["arrival", "ordered"] as const)("subscription bounds stay reader-local for %s reads", async (order) => { const producer = new TrackProducer("test"); - const everything = producer.subscribe({ maxDelay: Milli(5000) }); + const everything = producer.subscribe({ delay: { max: Milli(5000) } }); const subscriber = producer.subscribe({ - maxDelay: Milli(5000), + delay: { max: Milli(5000) }, groups: { start: { included: 1 }, end: { excluded: 3 } }, }); const ordered = order === "ordered" ? subscriber.ordered() : undefined; @@ -1985,11 +2008,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({ delay: { max: Milli(5000) }, groups: { start: { included: 3 }, end: { excluded: 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({ delay: { max: Milli(5000) }, groups: { end: { included: 4 } } }); // 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); @@ -2006,11 +2029,11 @@ test.each(["arrival", "ordered"] as const)("subscription bounds stay reader-loca test("subscription updates narrow the cursor before its first read", async () => { const producer = new TrackProducer("test"); - const everything = producer.subscribe({ maxDelay: Milli(5000) }); - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const everything = producer.subscribe({ delay: { max: Milli(5000) } }); + const track = producer.subscribe({ delay: { max: 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({ delay: { max: Milli(5000) }, groups: { start: { excluded: 2 }, end: { included: 3 } } }); expect((await track.recvGroup())?.sequence).toBe(3); const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); @@ -2026,8 +2049,8 @@ 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({ maxDelay: Milli.zero, groups: { end: { excluded: 2 } } }); + const everything = producer.subscribe({ delay: { max: Milli(5000) } }); + const track = producer.subscribe({ delay: { max: Milli.zero }, groups: { end: { excluded: 2 } } }); try { for (let sequence = 0; sequence < 5; sequence++) { producer.writeFrame({ @@ -2036,7 +2059,7 @@ test("a subscription cap chooses its own live edge", async () => { }); } expect((await track.recvGroup())?.sequence).toBe(1); - track.update({ maxDelay: Milli.zero, groups: { start: { included: 3 }, end: { excluded: 4 } } }); + track.update({ delay: { max: Milli.zero }, groups: { start: { included: 3 }, end: { excluded: 4 } } }); expect((await track.recvGroup())?.sequence).toBe(3); } finally { track.close(); @@ -2048,7 +2071,7 @@ 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 everything = producer.subscribe({ delay: { max: Milli(5000) } }); const track = producer.subscribe({ groups: { start: { included: 1 }, end: { excluded: 2 } } }); try { for (let sequence = 0; sequence < 4; sequence++) @@ -2072,8 +2095,8 @@ 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 } } }); + const everything = producer.subscribe({ delay: { max: Milli(5000) } }); + const empty = producer.subscribe({ delay: { max: Milli(5000) }, groups: { end: { excluded: 0 } } }); empty.withRange({ end: { excluded: 0 } }); for (let sequence = 0; sequence < 3; sequence++) producer.createGroup(sequence); @@ -2095,7 +2118,7 @@ for (const ordered of [false, true]) { for (const end of [0, 3, 4]) { test(`a clean close finishes a capped ${ordered ? "ordered" : "arrival"} reader at ${end}`, () => { const producer = new TrackProducer("test"); - const subscriber = producer.subscribe({ maxDelay: null }).withRange({ end: { excluded: end } }); + const subscriber = producer.subscribe({ delay: { max: null } }).withRange({ end: { excluded: end } }); if (ordered) subscriber.ordered(); const read = () => (ordered ? hooks.tryNextGroup(subscriber) : hooks.tryRecvGroup(subscriber, false)); for (const sequence of [0, 2, 4]) producer.createGroup(sequence).finish(); @@ -2136,7 +2159,7 @@ test("withRange caps the live edge used by the latency budget", async () => { // above the cap stays cached across a clean close until the cap admits it. test("withRange caps frame-level reads like the group cursor", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); - const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const track = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); track.withRange({ end: { excluded: 1 } }); producer.writeFrame({ payload: enc.encode("zero"), timestamp: Timestamp.fromMillis(0) }); @@ -2155,7 +2178,7 @@ test("withRange caps frame-level reads like the group cursor", async () => { test("local cursor bounds can skip, pause, and release buffered groups", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const track = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); for (let sequence = 0; sequence < 5; sequence++) producer.createGroup(sequence); @@ -2175,7 +2198,7 @@ test("local cursor bounds can skip, pause, and release buffered groups", async ( // deliver it; a sequence cursor would skip it permanently. test("recvGroup serves a late arrival after a newer group", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const track = producer.subscribe({ delay: { max: Milli(5000) } }); producer.createGroup(2); expect((await track.recvGroup())?.sequence).toBe(2); @@ -2194,7 +2217,7 @@ test("recvGroup serves a late arrival after a newer group", async () => { // Both cursors retain beyond-cap groups for widening, even after a clean close. test("withRange parks recvGroup beyond the cap and a raised cap re-offers", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const track = producer.subscribe({ delay: { max: Milli(5000) } }); for (let sequence = 0; sequence < 3; sequence++) producer.createGroup(sequence).finish(); @@ -2219,7 +2242,7 @@ test("withRange parks recvGroup beyond the cap and a raised cap re-offers", asyn // a relay can ingest a burst micro-reordered (newest first). test("recvGroup serves in-range groups that arrive behind a capped one", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const track = producer.subscribe({ delay: { max: Milli(5000) } }); track.withRange({ end: { excluded: 2 } }); @@ -2288,7 +2311,7 @@ test("createGroup rejects a duplicate live sequence", async () => { // leaving is what releases it: widening after close() finds nothing. test("closing the subscriber releases a group retained across a clean close", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const track = producer.subscribe({ delay: { max: Milli(5000) } }); track.withRange({ end: { excluded: 1 } }); producer.createGroup(1).finish(); @@ -2323,8 +2346,8 @@ test("closing the subscriber releases a recvGroup parked while the producer is l test("the ordered and arrival cursors are independent", async () => { const producer = new TrackProducer("test"); - const ordered = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); - const arrival = producer.subscribe({ maxDelay: Milli(5000) }); + const ordered = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); + const arrival = producer.subscribe({ delay: { max: Milli(5000) } }); producer.createGroup(5); expect((await ordered.nextGroup())?.sequence).toBe(5); @@ -2342,7 +2365,7 @@ test("the first group read commits the cursor and refuses the other order", asyn const producer = new TrackProducer("test"); // An arrival read commits to arrival order; ordered() is refused afterwards. - const arrival = producer.subscribe({ maxDelay: Milli(5000) }); + const arrival = producer.subscribe({ delay: { max: Milli(5000) } }); producer.createGroup(5); expect((await arrival.recvGroup())?.sequence).toBe(5); expect(() => arrival.ordered()).toThrow("arrival order"); @@ -2353,7 +2376,7 @@ test("the first group read commits the cursor and refuses the other order", asyn expect((await arrival.recvGroup())?.sequence).toBe(3); // The other direction: ordered() takes the subscription and arrival reads throw. - const inert = producer.subscribe({ maxDelay: Milli(5000) }); + const inert = producer.subscribe({ delay: { max: Milli(5000) } }); const ordered = inert.ordered(); expect(inert.recvGroup()).rejects.toThrow("sequence order"); expect(() => inert.ordered()).toThrow("sequence order"); @@ -2365,7 +2388,7 @@ test("the first group read commits the cursor and refuses the other order", asyn // the subscription to a group order. test("recvDatagram does not commit the group cursor", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const track = producer.subscribe({ delay: { max: Milli(5000) } }); producer.insertDatagram(0, Timestamp.fromMillis(0), enc.encode("x")); expect((await track.recvDatagram())?.sequence).toBe(0); @@ -2387,7 +2410,7 @@ test("nextGroup returns undefined when track closes", async () => { // A clean close ends a pending ordered read without erasing beyond-cap frames. test("a closed track still delivers a group parked above the cap once the cap is raised", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); + const track = producer.subscribe({ delay: { max: Milli(5000) } }).ordered(); for (let sequence = 0; sequence < 3; sequence++) { const group = producer.createGroup(sequence); @@ -2470,7 +2493,7 @@ test("readFrame does not livelock when a sole group finishes before the next arr // a group: a filter is applied down to the object. test("largest names the newest frame written", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); - const subscriber = producer.subscribe({ maxDelay: Milli(1_000) }); + const subscriber = producer.subscribe({ delay: { max: Milli(1_000) } }); // Nothing published yet, so the first read waits. expect(producer.largest()).toBeUndefined(); @@ -2694,7 +2717,7 @@ test("a live-edge reader starts on an empty largest group", async () => { first.finish(); const empty = producer.appendGroup(); - const reader = producer.subscribe({ maxDelay: Milli(30_000) }); + const reader = producer.subscribe({ delay: { max: Milli(30_000) } }); const edge = await reader.largest(); expect(edge).toEqual({ group: 1, frame: 0 }); reader.withRange({ start: { included: edge?.group ?? 0 } }); @@ -2732,7 +2755,7 @@ test("largest survives a mirror replay of a group with a shed front", async () = test("group ranges preserve progress and spell out inclusion", async () => { const producer = new TrackProducer("test"); const track = producer - .subscribe({ maxDelay: Milli(5000) }) + .subscribe({ delay: { max: Milli(5000) } }) .withRange({ start: { included: 1 }, end: { included: 1 } }); for (let sequence = 0; sequence < 4; sequence++) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(1); @@ -2748,7 +2771,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({ delay: { max: Milli(5000) }, groups: { start: { included: 10 } } }); for (const sequence of [5, 6, 10]) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(10); expect(track.tryRecvGroup()).toBeUndefined(); @@ -2766,7 +2789,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({ delay: { max: Milli(5000) }, groups: { start: { included: 1 } } }); const cut = producer.createGroup(0); const error = new StreamError(StreamCode(70), { message: "cut" }); cut.abort(error); @@ -2784,7 +2807,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({ delay: { max: Milli(5000) }, groups: { start: { included: 1 } } }); for (const sequence of [0, 1]) producer.createGroup(sequence).finish(); expect(track.tryRecvGroup()?.sequence).toBe(1); producer.finish(); @@ -2799,7 +2822,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({ delay: { max: Milli(5000) }, groups: { start: { included: 1 } } }); for (const sequence of [0, 1]) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(1); track.close(abort); @@ -2813,7 +2836,7 @@ for (const abort of [undefined, new Error("gone")]) { test("malformed group bounds do not partially advance the cursor", () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(5000) }); + const track = producer.subscribe({ delay: { max: Milli(5000) } }); producer.createGroup(0); expect(() => track.withRange({ start: { included: 3 }, end: { excluded: -1 } })).toThrow(); expect(track.tryRecvGroup()?.sequence).toBe(0); @@ -2840,7 +2863,7 @@ test("finishAt refuses an end at or below a produced sequence", () => { test("finishAt declares an end ahead of the live edge without ending the track", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxDelay: Milli(60_000) }); + const track = producer.subscribe({ delay: { max: Milli(60_000) } }); producer.createGroup(5); const first = await track.recvGroup(); expect(first?.sequence).toBe(5); @@ -2870,7 +2893,7 @@ test("finishAt declares an end ahead of the live edge without ending the track", for (const ordered of [false, true]) { test(`a ${ordered ? "sequence" : "arrival"} reader ends at the declared boundary before closure`, async () => { const producer = new TrackProducer("test"); - const subscriber = producer.subscribe({ maxDelay: Milli(60_000) }); + const subscriber = producer.subscribe({ delay: { max: Milli(60_000) } }); const track = ordered ? subscriber.ordered() : subscriber; const receive = () => ("nextGroup" in track ? track.nextGroup() : track.recvGroup()); try { @@ -2965,13 +2988,13 @@ test("an omitted publisher limit still ages idle groups out", async () => { source.finish(); clock.set(10_000 + CACHE_WINDOW_MS - 1_000); producer.appendGroup(); - const within = producer.subscribe({ maxDelay: Milli(100_000) }); + const within = producer.subscribe({ delay: { max: Milli(100_000) } }); expect(await (await within.recvGroup())?.readString()).toBe("old"); within.close(); // A scan runs at most once per slice of the window, so step past the next one. clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); - const after = producer.subscribe({ maxDelay: Milli(100_000) }); + const after = producer.subscribe({ delay: { max: Milli(100_000) } }); expect(after.tryRecvGroup()?.sequence).toBe(1); expect(after.tryRecvGroup()).toBeUndefined(); after.close(); @@ -2994,7 +3017,7 @@ test("a live track does not pin an aborted newest group", () => { reset.abort(new Error("reset")); clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); - const late = producer.subscribe({ maxDelay: Milli(100_000) }); + const late = producer.subscribe({ delay: { max: Milli(100_000) } }); expect(late.tryRecvGroup()).toBeUndefined(); late.close(); } finally { @@ -3008,7 +3031,7 @@ test("closing a track keeps an idle newest group for subscribers to drain", asyn const clock = manualTime(10_000); try { const producer = new TrackProducer("catalog").accept({ timescale: Timescale.MILLI }); - const subscriber = producer.subscribe({ maxDelay: Milli(100_000) }); + const subscriber = producer.subscribe({ delay: { max: Milli(100_000) } }); const snapshot = producer.appendGroup(); snapshot.writeFrame(textFrame("snapshot")); snapshot.finish(); @@ -3022,7 +3045,7 @@ test("closing a track keeps an idle newest group for subscribers to drain", asyn // The close restarted its window, which now runs out like any other. clock.set(10_000 + 2 * (CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8)); - const late = producer.subscribe({ maxDelay: Milli(100_000) }); + const late = producer.subscribe({ delay: { max: Milli(100_000) } }); expect(late.tryRecvGroup()).toBeUndefined(); late.close(); } finally { @@ -3071,8 +3094,8 @@ test("a late subscriber to a closed unlimited track is released once the cache a test("an abort keeps finished groups for a slow reader, then reports it", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(10_000) }); - const arrival = producer.subscribe({ maxDelay: Milli(10_000) }); - const ordered = producer.subscribe({ maxDelay: Milli(10_000) }).ordered(); + const arrival = producer.subscribe({ delay: { max: Milli(10_000) } }); + const ordered = producer.subscribe({ delay: { max: Milli(10_000) } }).ordered(); for (let i = 0; i < 2; i++) { const group = producer.appendGroup(); group.writeFrame(textFrame(`g${i}`)); @@ -3095,8 +3118,8 @@ test("an abort keeps finished groups for a slow reader, then reports it", async test("an abort after the declared end settles ends clean", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(10_000) }); - const arrival = producer.subscribe({ maxDelay: Milli(10_000) }); - const ordered = producer.subscribe({ maxDelay: Milli(10_000) }).ordered(); + const arrival = producer.subscribe({ delay: { max: Milli(10_000) } }); + const ordered = producer.subscribe({ delay: { max: Milli(10_000) } }).ordered(); for (let i = 0; i < 2; i++) { const group = producer.appendGroup(); group.writeFrame(textFrame(`g${i}`)); @@ -3206,7 +3229,7 @@ test("an ended track's buffered groups age out for a stale subscriber", async () const clock = manualTime(10_000); const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(30) }); try { - const stale = producer.subscribe({ maxDelay: Milli(30) }); + const stale = producer.subscribe({ delay: { max: Milli(30) } }); const group = producer.appendGroup(); group.writeFrame(textFrame("x")); group.finish(); @@ -3230,8 +3253,8 @@ test("an ended track's buffered groups age out for a stale subscriber", async () test("an abort leaves a group already taken readable, then reports the abort", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(10_000) }); - const arrival = producer.subscribe({ maxDelay: Milli(10_000) }); - const ordered = producer.subscribe({ maxDelay: Milli(10_000) }).ordered(); + const arrival = producer.subscribe({ delay: { max: Milli(10_000) } }); + const ordered = producer.subscribe({ delay: { max: Milli(10_000) } }).ordered(); producer.appendGroup().writeFrame(textFrame("held")); const held = [await arrival.recvGroup(), await ordered.nextGroup()]; const boom = new Error("boom"); @@ -3313,9 +3336,9 @@ test("unknown track metadata keeps presentation-time expiry until accepted", () } // No wall time passes. Until metadata is accepted, buffered timestamps still // prove that the two oldest groups are outside this reader's media budget. - expect(drain(producer.subscribe({ maxDelay: Milli(500) }))).toEqual([2, 3]); + expect(drain(producer.subscribe({ delay: { max: Milli(500) } }))).toEqual([2, 3]); producer.accept({ timescale: Timescale.MILLI }); - expect(drain(producer.subscribe({ maxDelay: Milli(500) }))).toEqual([2, 3]); + expect(drain(producer.subscribe({ delay: { max: Milli(500) } }))).toEqual([2, 3]); } finally { producer.finish(); producer.close(); @@ -3338,11 +3361,11 @@ test("an untimed subscriber starts at the oldest fresh group", () => { // Four seconds after group 4 arrived, groups 0 to 2 were superseded 4, 3, and 2 // seconds ago: past a 1.5s budget. Group 3 was superseded 1s ago. - expect(drain(producer.subscribe({ maxDelay: Milli(1500) }))).toEqual([3, 4]); + expect(drain(producer.subscribe({ delay: { max: Milli(1500) } }))).toEqual([3, 4]); // No budget takes the newest group alone, which is never stale. const live = producer.subscribe(); expect(drain(live)).toEqual([4]); - expect(drain(producer.subscribe({ maxDelay: Milli(60_000) }))).toEqual([0, 1, 2, 3, 4]); + expect(drain(producer.subscribe({ delay: { max: Milli(60_000) } }))).toEqual([0, 1, 2, 3, 4]); producer.writeFrame({ payload: enc.encode("5") }); expect(drain(live)).toEqual([5]); @@ -3356,7 +3379,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({ maxDelay: Milli.zero, groups: { end: { excluded: 3 } } }); + const subscriber = producer.subscribe({ delay: { max: Milli.zero }, groups: { end: { excluded: 3 } } }); subscriber.withRange({ end: { excluded: 3 } }); expect(drain(subscriber)).toEqual([2]); }); @@ -3375,7 +3398,7 @@ test("accept closes the track when something written before it disagrees", async test("a single-frame write that throws aborts its group", () => { const producer = new TrackProducer("test").accept({}); - const subscriber = producer.subscribe({ maxDelay: Milli(60_000) }); + const subscriber = producer.subscribe({ delay: { max: Milli(60_000) } }); // A timestamped frame is refused by an untimed track. expect(() => producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(1) })).toThrow( TimestampMismatch, @@ -3394,9 +3417,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({ delay: { max: Milli(60_000) }, groups: { start: { included: 2 } } }); expect(drain(resumed)).toEqual([2, 3, 4]); - const live = producer.subscribe({ maxDelay: Milli.zero, groups: { start: { included: 2 } } }); + const live = producer.subscribe({ delay: { max: Milli.zero }, groups: { start: { included: 2 } } }); expect(drain(live)).toEqual([4]); }); @@ -3405,7 +3428,7 @@ const DROPPED = new StreamError(StreamCode.NotFound, { message: "dropped" }); test("explicit and producer-cut drops survive outside the group cache", async () => { const producer = new TrackProducer("video").accept({}); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); hooks.dropTrackGroups(producer, 3, 4, DROPPED); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 3, end: 4, error: DROPPED }); @@ -3422,7 +3445,7 @@ test("explicit and producer-cut drops survive outside the group cache", async () test("a group ended by a cancel or cache policy is a hole, not a drop", async () => { const producer = new TrackProducer("video").accept({}); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); const holes = [ new StreamError(StreamCode.Cancel, { message: "cancel" }), new StreamError(StreamCode.Old, { message: "old" }), @@ -3444,7 +3467,7 @@ test("a group ended by a cancel or cache policy is a hole, not a drop", async () test("an upstream drop ends a group still arriving with its error", async () => { const producer = new TrackProducer("video").accept({}); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); const arriving = producer.createGroup(3); arriving.writeFrame({ payload: enc.encode("3") }); const finished = producer.createGroup(4); @@ -3469,7 +3492,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({ delay: { max: Milli(60_000) }, groups: { start: { included: 5 } } }); hooks.dropTrackGroups(producer, 2, 3, DROPPED); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); @@ -3483,7 +3506,7 @@ test("a drop below the window is reported once the window lowers to it", () => { test("a re-created cut group is served, not dropped", async () => { const producer = new TrackProducer("video").accept({}); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); const cut = producer.createGroup(4); cut.abort(new Error("cut")); await settle(); @@ -3500,7 +3523,7 @@ test("a re-created cut group is served, not dropped", async () => { test("a reported drop still allows a late group, handed out once", () => { const producer = new TrackProducer("video").accept({}); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); hooks.dropTrackGroups(producer, 2, 4, DROPPED); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 2, end: 4, error: DROPPED }); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); @@ -3516,7 +3539,7 @@ test("a reported drop still allows a late group, handed out once", () => { test("a wide drop is held and reported as one run", () => { const producer = new TrackProducer("video").accept({}); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); hooks.dropTrackGroups(producer, 2, 2 ** 40, DROPPED); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 2, end: 2 ** 40, error: DROPPED }); @@ -3539,7 +3562,7 @@ test("a wide drop is held and reported as one run", () => { test("adjacent drops with the same code merge into one run", () => { const producer = new TrackProducer("video").accept({}); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); // Each DROP decodes to its own error, so runs must merge by code, not identity. for (let sequence = 2; sequence < 5; sequence++) { hooks.dropTrackGroups( @@ -3652,7 +3675,7 @@ function drainGroups(subscriber: ReturnType): number test("a re-created group is handed out once", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(30_000) }); - const subscriber = producer.subscribe({ maxDelay: Milli(30_000) }); + const subscriber = producer.subscribe({ delay: { max: Milli(30_000) } }); stampGroup(producer, 0, 0); const aborted = producer.createGroup(1); expect(drainGroups(subscriber)).toEqual([0, 1]); @@ -3674,7 +3697,7 @@ test("a gap is forgotten once a group above it is past the budget", () => { ]; for (const { info, maxDelay } of readers) { const producer = new TrackProducer("test").accept(info); - const subscriber = producer.subscribe({ maxDelay }); + const subscriber = producer.subscribe({ delay: { max: maxDelay } }); // Group 3 never arrives. for (const sequence of [0, 1, 2, 4, 5, 6]) stampGroup(producer, sequence, sequence); expect(drainGroups(subscriber)).toEqual([0, 1, 2, 4, 5, 6]); @@ -3699,7 +3722,7 @@ test("an endless track with gaps remembers only those within the budget", () => try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(30_000) }); // Groups present 1ms apart, so the budget spans ten of them. - const subscriber = producer.subscribe({ maxDelay: Milli(10) }); + const subscriber = producer.subscribe({ delay: { max: Milli(10) } }); let most = 0; for (let sequence = 0; sequence < 3000; sequence++) { // Every third group never arrives. @@ -3823,7 +3846,7 @@ test("an evicted group still bounds, and a late group below it is unverified", a // At or below the watermark the lower neighbor may be gone, so the watermark only caps // the group and it is accepted without being trusted to time anything. - const track = producer.subscribe({ maxDelay: Milli(60_000) }); + const track = producer.subscribe({ delay: { max: Milli(60_000) } }); expect(() => stampGroup(producer, 1, 4000)).toThrow(TimestampMismatch); stampGroup(producer, 1, 500).finish(); edge.finish(); @@ -3853,7 +3876,7 @@ test("an untimed track takes groups in any order", () => { test("replay and backlogs deliver sparse out-of-order arrivals ascending without sorting", () => { const producer = new TrackProducer("test").accept({}); - const all = { groups: { start: { included: 0 } }, maxDelay: null }; + const all = { groups: { start: { included: 0 } }, delay: { max: null } }; const write = (sequence: number) => { const group = producer.createGroup(sequence); group.writeFrame({ payload: enc.encode(`${sequence}`) }); @@ -3888,7 +3911,7 @@ test("replay and backlogs deliver sparse out-of-order arrivals ascending without // pending, so lower groups may still arrive behind a higher one. function pendingTail(end: number, maxDelay = Milli(60_000)) { const producer = new TrackProducer("test"); - const reader = producer.subscribe({ maxDelay }); + const reader = producer.subscribe({ delay: { max: maxDelay } }); hooks.startTrack(producer, { start: 0 }); hooks.endTrack(producer, { group: end, frame: 0 }); const write = (sequence: number) => { @@ -3912,7 +3935,7 @@ async function settled(read: Promise): Promise { // The end arrives after a higher group did, with lower ones still owed: it must not settle. test("a received end behind a hole settles only once the hole fills", async () => { const producer = new TrackProducer("test"); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); hooks.startTrack(producer, { start: 0 }); const late = producer.createGroup(2); late.finish(); @@ -3964,7 +3987,7 @@ test("a pending tail ends readers at the boundary once the track closes", async test("without a declared start, the first arrival stands in for it", async () => { const producer = new TrackProducer("test"); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); hooks.endTrack(producer, { group: 3, frame: 0 }); const group = producer.createGroup(2); group.finish(); @@ -4005,7 +4028,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({ delay: { max: Milli(60_000) }, groups: { start: { included: 1 } } }); hooks.startTrack(producer, { start: 3, requested: 1 }); hooks.endTrack(producer, { group: 4, frame: 0 }); const group = producer.createGroup(3); @@ -4013,7 +4036,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({ delay: { max: Milli(60_000) }, groups: { start: { included: 0 } } }); expect((await lower.recvGroup())?.sequence).toBe(3); const next = lower.recvGroup(); expect(await settled(next)).toBe(false); @@ -4025,7 +4048,7 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" // The wire's end can land after the highest group, with lower groups still owed. test("an end after the highest group holds the reader at the hole", async () => { const producer = new TrackProducer("test"); - const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + const reader = producer.subscribe({ delay: { max: Milli(60_000) } }); hooks.startTrack(producer, { start: 0 }); const high = producer.createGroup(2); high.finish(); @@ -4109,7 +4132,7 @@ test("an evicted group still fills a pending tail", async () => { clock.set(CACHE_WINDOW_MS + 1000); write(2); // A late subscriber replays only what the cache still holds. - expect(producer.subscribe({ maxDelay: Milli(60_000) }).tryRecvGroup()?.sequence).toBe(2); + expect(producer.subscribe({ delay: { max: Milli(60_000) } }).tryRecvGroup()?.sequence).toBe(2); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); producer.finish(); @@ -4153,7 +4176,7 @@ for (const abort of [false, true]) { // already handed out. test("a widened maxDelay re-offers what it skipped as stale", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); - const subscriber = producer.subscribe({ maxDelay: Milli(1500) }); + const subscriber = producer.subscribe({ delay: { max: Milli(1500) } }); stampGroup(producer, 0, 0); expect(drainGroups(subscriber)).toEqual([0]); for (const [sequence, ms] of [ @@ -4167,7 +4190,7 @@ test("a widened maxDelay re-offers what it skipped as stale", () => { // Groups 1 and 2 reach 2s and 3s, at least 1.5s behind the 6s edge. expect(drainGroups(subscriber)).toEqual([3, 4]); - subscriber.update({ maxDelay: Milli(10_000) }); + subscriber.update({ delay: { max: Milli(10_000) } }); expect(drainGroups(subscriber)).toEqual([1, 2]); expect(drainGroups(subscriber)).toEqual([]); producer.close(); @@ -4176,7 +4199,7 @@ test("a widened maxDelay re-offers what it skipped as stale", () => { // A read already parked when the budget widens wakes for the re-offer. test("a widened maxDelay wakes a parked read", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); - const subscriber = producer.subscribe({ maxDelay: Milli(1500) }); + const subscriber = producer.subscribe({ delay: { max: Milli(1500) } }); stampGroup(producer, 0, 0); expect(drainGroups(subscriber)).toEqual([0]); for (const [sequence, ms] of [ @@ -4190,7 +4213,7 @@ test("a widened maxDelay wakes a parked read", async () => { expect(drainGroups(subscriber)).toEqual([2, 3]); const parked = subscriber.recvGroup(); - subscriber.update({ maxDelay: Milli(10_000) }); + subscriber.update({ delay: { max: Milli(10_000) } }); expect((await parked)?.sequence).toBe(1); producer.close(); }); @@ -4201,7 +4224,7 @@ test("an evicted stale skip lets the delivered gaps fold", () => { const time = manualTime(1_000); try { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(30_000) }); - const subscriber = producer.subscribe({ maxDelay: Milli(10) }); + const subscriber = producer.subscribe({ delay: { max: Milli(10) } }); stampGroup(producer, 0, 0); expect(drainGroups(subscriber)).toEqual([0]); // Group 1 reaches 5ms, past the 10ms budget behind the 30ms edge. @@ -4229,7 +4252,7 @@ test("an evicted stale skip lets the delivered gaps fold", () => { // A re-offer never hands out a group below a floor raised in the same update. test("a widened maxDelay re-offers nothing below a raised floor", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); - const subscriber = producer.subscribe({ maxDelay: Milli(1500) }); + const subscriber = producer.subscribe({ delay: { max: Milli(1500) } }); stampGroup(producer, 0, 0); expect(drainGroups(subscriber)).toEqual([0]); for (const [sequence, ms] of [ @@ -4242,7 +4265,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({ delay: { max: Milli(10_000) }, groups: { start: { included: 2 } } }); expect(drainGroups(subscriber)).toEqual([2]); producer.close(); }); @@ -4252,7 +4275,7 @@ test("a widened maxDelay re-offers nothing below a raised floor", () => { for (const reported of [false, true]) { test(`a widened maxDelay re-offers a stale group after its drop was ${reported ? "reported" : "pending"}`, () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); - const subscriber = producer.subscribe({ maxDelay: Milli(1500) }); + const subscriber = producer.subscribe({ delay: { max: Milli(1500) } }); expect(hooks.tryDrop(subscriber)).toBeUndefined(); stampGroup(producer, 0, 0); expect(drainGroups(subscriber)).toEqual([0]); @@ -4270,7 +4293,7 @@ for (const reported of [false, true]) { expect(hooks.tryDrop(subscriber)).toMatchObject({ start: 1, end: 2 }); expect(hooks.tryDrop(subscriber)).toBeUndefined(); } - subscriber.update({ maxDelay: Milli(10_000) }); + subscriber.update({ delay: { max: Milli(10_000) } }); expect(drainGroups(subscriber)).toEqual([1]); expect(hooks.tryDrop(subscriber)).toBeUndefined(); producer.close(); @@ -4281,7 +4304,7 @@ for (const reported of [false, true]) { // older one into the delivered floor: a further widening still re-offers it. test("a partial maxDelay widening keeps the older skip owed", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); - const subscriber = producer.subscribe({ maxDelay: Milli(1500) }); + const subscriber = producer.subscribe({ delay: { max: Milli(1500) } }); stampGroup(producer, 0, 0); expect(drainGroups(subscriber)).toEqual([0]); for (const [sequence, ms] of [ @@ -4302,9 +4325,9 @@ test("a partial maxDelay widening keeps the older skip owed", () => { expect(drainGroups(subscriber)).toEqual([5, 6]); // Group 4 reaches 6s, 3s behind the 9s edge; group 1 reaches 2s, 7s behind. - subscriber.update({ maxDelay: Milli(4000) }); + subscriber.update({ delay: { max: Milli(4000) } }); expect(drainGroups(subscriber)).toEqual([4]); - subscriber.update({ maxDelay: Milli(10_000) }); + subscriber.update({ delay: { max: Milli(10_000) } }); expect(drainGroups(subscriber)).toEqual([1]); producer.close(); }); @@ -4362,7 +4385,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 = { groups: { start: { included: 0 } }, delay: { max: 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 9cb6a95da89..3d5d56ae021 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -30,6 +30,7 @@ import { insertRun, type Land, MAX_DATAGRAMS, + maxDelayOf, NO_MAX_DELAY, type Recv, type RecvDatagram, @@ -59,7 +60,7 @@ function taken(read: () => Recv, ended: () => undefined): GroupConsumer | undefi } // 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. +// Media delay.max only bounds how far behind the live edge a reader may fall. const CACHE_WINDOW = Nano.fromMilli(Milli(30_000)); // The cache scans at most this many times per idle cache window. @@ -223,31 +224,49 @@ export interface Groups { end?: Bound; } +/** + * How far behind the live edge a subscriber plays, in milliseconds: where it starts, and how + * late it may fall. Mirrors the Rust `track::Delay`. Both default to zero, which starts at the + * newest group and skips a superseded one at once. Each value is rounded up to a whole + * millisecond, so one derived from a measurement is never shortened; a negative or non-finite + * value, or one past `Number.MAX_SAFE_INTEGER` after rounding, is refused. + */ +export interface Delay { + /** + * The target latency, the playout or jitter-buffer delay held behind the live edge. Omitted + * means `0`; `null` means not stated, which only a peer whose wire cannot carry it (moq-lite + * before 07, moq-transport) sends. A media consumer reads an unstated minimum as `0`. + * + * The aggregate across subscribers is the smallest stated one, so an unstated minimum never + * pulls it to zero, and it is forwarded upstream as is. A publisher may use it, such as to + * start a cold join that far back; nothing reads it there yet. + */ + min?: Milli | null; + /** + * How long 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, and never past the publisher's {@link Info.maxAge}. Omitted + * means `0`, the live edge only, timed or not. `null` sets no subscriber limit, so the + * publisher's {@link Info.maxAge} alone bounds it, which is what a reader reaching back for + * history wants; read in order, see {@link Ordered} for where that can block. + */ + max?: Milli | null; +} + /** * Per-subscription options, requested when a subscription opens and adjustable later via * {@link Subscriber.update}. Mirrors the Rust `Subscription`. - * The obsolete subscriber `maxAge` field is refused at runtime; use `maxDelay`. + * The obsolete `maxAge` and `maxDelay` fields are refused at runtime; use `delay`. */ export interface Subscription { /** Delivery priority relative to this session's other subscriptions (`-128..=127`, higher first). Defaults to `0`. */ priority?: number; - /** - * 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, and never past the publisher's - * {@link Info.maxAge}. Omitted (or `undefined`) means `0`, the live edge only, timed or - * not, as in {@link Subscription.default}. `null` sets no subscriber limit, so the - * publisher's {@link Info.maxAge} alone bounds it, which is what a reader reaching back - * for history wants; read in order, see {@link Ordered} for where that can block. - * 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 | null; + /** How far behind the live edge this subscriber plays; see {@link Delay}. Omitted is all zero. */ + delay?: Delay; /** * The groups this reader may receive, with an optional floor and cap. * - * Only {@link maxDelay} asks for data; these bounds limit which groups it may reach. + * Only {@link Delay.max} 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. @@ -272,7 +291,7 @@ export interface Subscription { * first, draining a backlog in sequence order). * * A transmission order only: it never decides whether a group is delivered, which is - * {@link Subscription.maxDelay}'s job, so an ascending subscriber that wants a backlog needs a + * {@link Delay.max}'s job, so an ascending subscriber that wants a backlog needs a * budget that covers it. */ export type Order = "ascending" | "descending"; @@ -281,24 +300,33 @@ export type Order = "ascending" | "descending"; export const Subscription: { /** * The default subscription, used when none is passed: priority `0`, descending order, and - * a `maxDelay` of zero that skips a superseded group at once. Mirrors the Rust + * a {@link Delay} of zero that skips a superseded group at once. Mirrors the Rust * `Subscription::default()`. Spread it to change one field and keep the rest: * `{ ...Subscription.default(), priority: 2 }`. */ default(): Subscription; } = { - default: () => ({ priority: DEFAULT_PRIORITY, maxDelay: Milli.zero, order: "descending" }), + default: () => ({ + priority: DEFAULT_PRIORITY, + delay: { min: Milli.zero, max: Milli.zero }, + order: "descending", + }), }; // Materialize the defaults at the model boundary so every layer observes a complete // subscription rather than interpreting an omitted field differently. function subscriptionDefaults(subscription: Subscription = Subscription.default()): Subscription { - if ("maxAge" in subscription) throw new TypeError("Subscriber maxAge is unsupported; use maxDelay"); + if ("maxAge" in subscription || "maxDelay" in subscription) { + throw new TypeError("Subscriber maxAge and maxDelay are unsupported; use delay.max"); + } const bounds = groupBounds(subscription.groups ?? {}); + const delay = subscription.delay ?? {}; return { priority: priorityValue(subscription.priority ?? DEFAULT_PRIORITY), - maxDelay: - subscription.maxDelay === null ? NO_MAX_DELAY : wireMillis("maxDelay", subscription.maxDelay ?? Milli.zero), + delay: { + min: delay.min === null ? null : wireMillis("delay.min", delay.min ?? Milli.zero), + max: delay.max === null ? NO_MAX_DELAY : wireMillis("delay.max", delay.max ?? Milli.zero), + }, order: orderOf(subscription.order), groups: { start: subscription.groups?.start === undefined ? undefined : { included: bounds.start }, @@ -314,6 +342,13 @@ function orderOf(order: Order | undefined): Order { return order; } +// The smaller of two minimums, ignoring one that went unstated (`null`). +function smallest(a: Milli | null | undefined, b: Milli | null | undefined): Milli | null { + if (a === null || a === undefined) return b ?? null; + if (b === null || b === undefined) return a; + return Milli(Math.min(a, b)); +} + // Aggregate the preferences of every live subscriber, matching Rust's Subscription::poll_combined. function combineSubscriptions(states: Iterable): Subscription | undefined { let combined: Subscription | undefined; @@ -326,7 +361,11 @@ 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.delay = { + max: Milli(Math.max(maxDelayOf(combined), maxDelayOf(subscription))), + // Only stated minimums count, so one that went unstated never pulls the smallest to zero. + min: smallest(combined.delay?.min, subscription.delay?.min), + }; // 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"; @@ -461,10 +500,10 @@ 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 + * {@link Delay.max} is what asks for data: delivery skips everything above * the floor that the budget convicts. Without options this is {@link Subscription.default}, * whose budget of zero delivers only the latest group; a larger one reaches back over what - * it can still use, and `maxDelay: null` reaches back as far as the publisher retains. + * it can still use, and `delay: { max: null }` reaches back as far as the publisher retains. */ subscribe(options?: Subscription): Subscriber { return this.#broadcast.subscribe(this.name, options); @@ -1024,10 +1063,10 @@ 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 + * {@link Delay.max} is what asks for data: delivery skips everything above * the floor that the budget convicts. Without options this is {@link Subscription.default}, * whose budget of zero delivers only the latest group; a larger one reaches back over what - * it can still use, and `maxDelay: null` reaches back as far as the publisher retains. + * it can still use, and `delay: { max: null }` reaches back as far as the publisher retains. */ subscribe(options: Subscription = Subscription.default()): Subscriber { const sink = new TrackState(options); @@ -1645,7 +1684,8 @@ export class Producer { // The grace the session's tail uses, from the max delay the track's subscribers ask for, // bounded by how long the publisher keeps a group. The demand asked upstream is not. #grace(): Milli { - const maxDelay = this.#state.update.peek()?.maxDelay ?? Milli.zero; + const update = this.#state.update.peek(); + const maxDelay = update ? maxDelayOf(update) : Milli.zero; const maxAge = this.#state.info.peek()?.maxAge; return grace(maxAge === undefined ? maxDelay : Milli(Math.min(maxDelay, maxAge))); } @@ -1925,7 +1965,8 @@ export class Subscriber { } } - const requested = this.#fetchBudget ?? this.#state.update.peek()?.maxDelay ?? 0; + const update = this.#state.update.peek(); + const requested = this.#fetchBudget ?? (update ? maxDelayOf(update) : 0); const retained = info?.maxAge; return { budget: retained === undefined ? requested : Math.min(requested, retained), @@ -2561,7 +2602,7 @@ export class Subscriber { * after a clean close, without blocking in-range groups that arrive behind it. * Once the track closes cleanly, a reader with nothing left in range finishes. * A group whose presentation time is further behind the live edge than this - * subscriber's `maxDelay` is skipped. The default subscription's zero takes the live edge. + * subscriber's `delay.max` is skipped. The default subscription's zero takes the live edge. * The budget remains attached after return, so a pending frame read rejects if a stalled * group becomes stale while newer data advances. * @@ -2943,7 +2984,7 @@ export class Subscriber { * * Groups are acquired through the same sequence cursor as {@link Ordered.nextGroup}, * so frames never run backwards: a late lower-sequence group is skipped, and so is - * one every frame of which `maxDelay` proves is too old. A group the budget abandons + * one every frame of which `delay.max` proves is too old. A group the budget abandons * mid-stall ends cleanly and the cursor resyncs from the next group; a gap inside a * group still surfaces as `Evicted`, {@link TooFarBehind}, or {@link GroupTooLarge}. */ @@ -3051,7 +3092,7 @@ export class Subscriber { * Every group this returns has a higher sequence than the last, so a late arrival is * skipped rather than delivered out of turn. * - * `maxDelay` applies as this cursor reads, exactly as it does on the arrival cursor: a + * `delay.max` applies as this cursor reads, exactly as it does on the arrival cursor: a * group is skipped once its reach, where its immediate successor begins, is that far * behind the newest frame on the track. Nothing weaker convicts it, since the reach is * the only proof that every frame it could still hold is past the budget, so a backlog @@ -3059,7 +3100,7 @@ export class Subscriber { * group already handed out too: a stalled group's pending frame read rejects once newer * content has pulled that far ahead. * - * A history or VOD read sets `maxDelay: null` (no limit). A subscribed track waits for a + * A history or VOD read sets `delay: { max: null }` (no limit). A subscribed track waits for a * missing group the feed still owes, within the budget, or until it resolves without one. * Local tracks skip missing groups. An unlimited or long budget * only avoids blocking where the feed signals its gaps: moq-lite, whose publisher sends @@ -3131,7 +3172,7 @@ export class Ordered { * Return the next group with a strictly-greater sequence number than the last returned. * * Late arrivals (sequence at or below the last returned) are silently skipped, as is a - * group whose every frame is further behind the live edge than `maxDelay` (the default + * group whose every frame is further behind the live edge than `delay.max` (the default * subscription's zero keeps only what nothing newer has superseded). Honors the bounds set by * {@link withRange}. */ @@ -3154,7 +3195,7 @@ export class Ordered { * Read the next frame across groups, in sequence order, with its group and frame numbers. * * Rides the same cursor as {@link nextGroup} and shares this handle's contract: a - * buffered backlog is drained in full up to the point `maxDelay` proves it useless. + * buffered backlog is drained in full up to the point `delay.max` proves it useless. * Treat the returned frame bytes as read-only; they are shared with other consumers. */ readFrame(): Promise<({ group: number; frame: number } & Frame) | undefined> { diff --git a/js/publish/bench/audio-page.ts b/js/publish/bench/audio-page.ts index e02ade99f47..113faf5ef54 100644 --- a/js/publish/bench/audio-page.ts +++ b/js/publish/bench/audio-page.ts @@ -95,7 +95,7 @@ async function measure(tracks: number, seconds: number, onDemand: boolean) { const net = broadcast.net.peek(); if (!net) throw new Error("missing broadcast"); for (const encoder of encoders) - subscribers.push(net.track(encoder.name).subscribe({ maxDelay: Milli(seconds * 1000) })); + subscribers.push(net.track(encoder.name).subscribe({ delay: { max: Milli(seconds * 1000) } })); await yieldNow(); await flush(); const catchup = performance.now() - joining; diff --git a/js/publish/bench/catalog.ts b/js/publish/bench/catalog.ts index 9025987c649..a3b47134380 100644 --- a/js/publish/bench/catalog.ts +++ b/js/publish/bench/catalog.ts @@ -55,7 +55,7 @@ async function measure(tracks: number, viewers: number, clock: Manual) { for (let index = 0; index < media.length; index++) { // Model the player's changed latency floor with real subscription handles. viewer.media[index].update({ - maxDelay: Milli(root?.video?.renditions[media[index].name].jitter ?? 0), + delay: { max: Milli(root?.video?.renditions[media[index].name].jitter ?? 0) }, }); updates++; } diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index a34e0407f55..ee27646d2cb 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -999,7 +999,7 @@ test.each([undefined, false, true])( await push(4); expect(encoder.out.stats.peek().frames).toBe(onDemand === false ? 2 : 0); - late = net.track("audio").subscribe({ maxDelay: Milli(10_000) }); + late = net.track("audio").subscribe({ delay: { max: Milli(10_000) } }); await settle(); expect(encoder.out.active.peek()).toBe(true); await push(3); @@ -1117,7 +1117,7 @@ test("default audio replays the last second after three seconds without a subscr expect(encoder.out.active.peek()).toBe(false); const net = broadcast.net.peek(); if (!net) throw new Error("expected network broadcast"); - late = net.track("audio").subscribe({ maxDelay: Milli(1000) }); + late = net.track("audio").subscribe({ delay: { max: Milli(1000) } }); await settle(); expect(encoder.out.stats.peek().frames).toBe(50 - LaggingAudioEncoder.LAG); for (let index = 150; index < 153; index++) { @@ -1168,7 +1168,7 @@ test("replayed raw audio raises no catalog delay", async () => { new Estimator().write(150 * 20_000, broadcast.order); const net = broadcast.net.peek(); if (!net) throw new Error("expected network broadcast"); - late = net.track("audio").subscribe({ maxDelay: Milli(1000) }); + late = net.track("audio").subscribe({ delay: { max: Milli(1000) } }); await settle(); for (let index = 150; index < 153; index++) { await feed.push({ timestamp: Micro(index * 20_000), channels: [new Float32Array(960)] }); diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index ac833acaf6c..b3684a0d4d1 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -1182,7 +1182,7 @@ test.each([false, true])( await recording.send(150); expect(recording.keys).toEqual(onDemand ? [] : [0, 100]); - late = net.track("video").subscribe({ maxDelay: Milli(10_000) }); + late = net.track("video").subscribe({ delay: { max: Milli(10_000) } }); await settle(); expect(encoder.out.active.peek()).toBe(true); await recording.send(200); diff --git a/js/sign/src/sign.test.ts b/js/sign/src/sign.test.ts index afd6048f776..bc5ecae0522 100644 --- a/js/sign/src/sign.test.ts +++ b/js/sign/src/sign.test.ts @@ -14,7 +14,7 @@ import * as Track from "./track.ts"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); const url = new URL("https://localhost:4443/test"); -const subscription = { maxDelay: Milli(60_000) }; +const subscription = { delay: { max: Milli(60_000) } }; async function session() { const pair = createMockTransportPair("moq-lite-07-wip"); diff --git a/js/watch/src/audio/decoder.ts b/js/watch/src/audio/decoder.ts index c69ad26b844..4dad4c81cb9 100644 --- a/js/watch/src/audio/decoder.ts +++ b/js/watch/src/audio/decoder.ts @@ -5,6 +5,7 @@ import * as Util from "@moq/hang/util"; import type * as Moq from "@moq/net"; import { type Computed, + Derived, Effect, type Getter, getter, @@ -168,7 +169,7 @@ export class Decoder { #subscribeMaxDelay = new Signal(Milli.zero); // The container consumer's delay: the playout delay to start at and the shared budget. Its warmup // reaches the start further back, to the media the decoder primes on. - #delay = new Signal({}); + #delay = new Signal({}); // The codec's frame duration: the catalog constant, refined by each frame's own duration. #frame = new Signal(undefined); @@ -533,7 +534,7 @@ export class Decoder { broadcast: active, track: Catalog.track(track, identity), priority: Catalog.PRIORITY.audio, - maxDelay: this.#subscribeMaxDelay, + delay: new Derived([this.sync.out.delay, this.#subscribeMaxDelay], (min, max) => ({ min, max })), }); if (!sub) { this.#retire(); diff --git a/js/watch/src/audio/playout.test.ts b/js/watch/src/audio/playout.test.ts index 1bba803197a..8cf3c963d1f 100644 --- a/js/watch/src/audio/playout.test.ts +++ b/js/watch/src/audio/playout.test.ts @@ -77,7 +77,7 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< const clock = new Manual(); const installed = clock.install(); const track = new Track.Producer("audio"); - const subscriber = track.subscribe({ maxDelay: budget(Milli.zero) }); + const subscriber = track.subscribe({ delay: { max: budget(Milli.zero) } }); const consumer = new Container.Consumer(subscriber, { format: new Container.Legacy.Format("audio"), delay: { max: AUTO_MAX_DELAY }, @@ -109,7 +109,7 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< const current = target({ measured: consumer.spread.peek(), frame: CHUNK_MS as Milli }); result.targets.push(current); - subscriber.update({ maxDelay: budget(current) }); + subscriber.update({ delay: { max: budget(current) } }); } } finally { track.finish(); diff --git a/js/watch/src/audio/replay.ts b/js/watch/src/audio/replay.ts index c3449156b6b..25c86916cf5 100644 --- a/js/watch/src/audio/replay.ts +++ b/js/watch/src/audio/replay.ts @@ -132,7 +132,7 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato const sync = new Sync({ delay: options.delay }); const track = new Track.Producer("audio"); - const consumer = new Container.Consumer(track.subscribe({ maxDelay: WIRE_MAX_DELAY }), { + const consumer = new Container.Consumer(track.subscribe({ delay: { max: WIRE_MAX_DELAY } }), { format: new Container.Legacy.Format("audio"), delay: new Derived([sync.out.delay, sync.out.maxDelay], (min, max) => ({ min, max })), }); diff --git a/js/watch/src/media.test.ts b/js/watch/src/media.test.ts index f4578845a9e..d83ceb80750 100644 --- a/js/watch/src/media.test.ts +++ b/js/watch/src/media.test.ts @@ -23,20 +23,20 @@ test("media max delay is present on the initial subscription and later updates", }, }), } as unknown as Moq.Broadcast.Consumer; - const maxDelay = new Signal(Milli(250)); + const delay = new Signal({ min: Milli(100), max: Milli(250) }); const effect = new Effect(); subscribeMedia(effect, { broadcast, track: "media", priority: 7, - maxDelay, + delay, }); - expect(initial).toEqual({ priority: 7, maxDelay: Milli(250) }); + expect(initial).toEqual({ priority: 7, delay: { min: Milli(100), max: Milli(250) } }); - maxDelay.set(Milli(500)); + delay.set({ min: Milli(100), max: Milli(500) }); await flush(); - expect(updates.at(-1)).toEqual({ priority: 7, maxDelay: Milli(500) }); + expect(updates.at(-1)).toEqual({ priority: 7, delay: { min: Milli(100), max: Milli(500) } }); effect.close(); }); @@ -73,7 +73,7 @@ test("media does not subscribe through a closed broadcast handle", () => { broadcast: handle, track: "video", priority: 0, - maxDelay: new Signal(Milli(0)), + delay: new Signal({}), }), ).toBeUndefined(); } finally { diff --git a/js/watch/src/media.ts b/js/watch/src/media.ts index e319c5db6d8..2b885644c2a 100644 --- a/js/watch/src/media.ts +++ b/js/watch/src/media.ts @@ -1,11 +1,10 @@ -import type { Milli } from "@moq/clock"; import type { Container } from "@moq/hang"; import type * as Moq from "@moq/net"; import { Error as NetError } from "@moq/net"; import type { Effect, Getter } from "@moq/signals"; /** - * Open a media subscription with its max delay on the initial request and every update. + * Open a media subscription with its delay on the initial request and every update. * * @internal */ @@ -15,16 +14,16 @@ export function subscribeMedia( broadcast: Moq.Broadcast.Consumer; track: string; priority: number; - maxDelay: Getter; + delay: Getter; }, ): Moq.Track.Subscriber | undefined { if (effect.get(props.broadcast.closed) !== undefined) return; - const subscription = () => ({ priority: props.priority, maxDelay: props.maxDelay.peek() }); + const subscription = () => ({ priority: props.priority, delay: props.delay.peek() }); const subscriber = props.broadcast.track(props.track).subscribe(subscription()); effect.cleanup(() => subscriber.close()); effect.run((inner) => { - subscriber.update({ priority: props.priority, maxDelay: inner.get(props.maxDelay) }); + subscriber.update({ priority: props.priority, delay: inner.get(props.delay) }); }); return subscriber; diff --git a/js/watch/src/retention.test.ts b/js/watch/src/retention.test.ts index b262f3fdde7..a8416295456 100644 --- a/js/watch/src/retention.test.ts +++ b/js/watch/src/retention.test.ts @@ -91,7 +91,7 @@ test("a long subscription through the player path leaves nothing behind", async broadcast: broadcast.consume(), track: "audio", priority: 0, - maxDelay: sync.out.maxDelay, + delay: new Derived([sync.out.delay, sync.out.maxDelay], (min, max) => ({ min, max })), }); if (!sub) throw new Error("no subscription"); const consumer = new Container.Consumer(sub, { diff --git a/js/watch/src/text/renderer.ts b/js/watch/src/text/renderer.ts index 3766fed311e..049caca2b23 100644 --- a/js/watch/src/text/renderer.ts +++ b/js/watch/src/text/renderer.ts @@ -2,7 +2,7 @@ import { Milli } from "@moq/clock"; import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; import { Error as NetError } from "@moq/net"; -import { Effect, type Getter, getter, type Inputs, type Readonlys } from "@moq/signals"; +import { Derived, Effect, type Getter, getter, type Inputs, type Readonlys } from "@moq/signals"; import type { VTTCue, VTTRegion } from "media-captions"; import { subscribeMedia } from "../media"; import type { Sync } from "../sync"; @@ -301,7 +301,7 @@ export class Renderer { broadcast: active, track: Catalog.track(track, config), priority: Catalog.PRIORITY.text, - maxDelay: this.sync.out.maxDelay, + delay: new Derived([this.sync.out.delay, this.sync.out.maxDelay], (min, max) => ({ min, max })), }); if (!sub) return; const store: CueStore = { cues: [], clears: [] }; diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index 2e2b3ae04f0..05f25f5e1fc 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -1477,7 +1477,7 @@ describe("Decoder with a warmup rendition", () => { const run = await refresh(fields); try { expect(run.playback.codecs).toBe(decoders); - const budgets = subscribe.mock.calls.map(([options]) => options?.maxDelay); + const budgets = subscribe.mock.calls.map(([options]) => options?.delay?.max); // The max-delay decoder tolerates one sweep more; js/net merges the two upstream. expect(budgets).toEqual(decoders === 1 ? [Milli(0)] : [Milli(0), Milli(WARMUP)]); } finally { diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index 4ff9dfd8a86..06ebe56418b 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -534,6 +534,11 @@ class DecoderTrack { // How far past the shared clock's budgets this decoder reaches. #warmup: Getter; + // The playout delay is where a cold join starts, and the shared max delay how late it may fall. + // The max-delay decoder adds the warmup to both, so it starts at the group before and keeps + // it, as Rust's does, rather than passing the consumer a `warmup` that would reach further. + // Its subscription and its consumer take the same delay. + #delay: Getter; // Whether this decoder drives the shared clock: only the min-delay one reports arrivals and // restarts the timeline. #drives: boolean; @@ -579,6 +584,10 @@ class DecoderTrack { this.tally = props.tally; this.key = props.key; this.#warmup = props.warmup ?? getter(Milli.zero); + this.#delay = new Derived([this.sync.out.delay, this.sync.out.maxDelay, this.#warmup], (min, max, warmup) => ({ + min: Milli(min + warmup), + max: Milli(max + warmup), + })); this.#drives = props.warmup === undefined; this.#refresh = props.refresh ?? getter(false); this.#predecessor = props.predecessor; @@ -614,16 +623,9 @@ class DecoderTrack { // Reorders groups and frames up to the shared max delay. A rendition switch is not a cold start: // the path's arrivals are what they were, so the estimate continues from whatever read before. #consume(effect: Effect, sub: Moq.Track.Subscriber, format: Container.Format): Container.Consumer { - // The playout delay is where a cold join starts, and the shared max delay how late it may fall. - // The max-delay decoder adds the warmup to both, so it starts at the group before and keeps - // it, as Rust's does, rather than passing the consumer a `warmup` that would reach further. - const delay = new Derived([this.sync.out.delay, this.sync.out.maxDelay, this.#warmup], (min, max, warmup) => ({ - min: Milli(min + warmup), - max: Milli(max + warmup), - })); const consumer = new Container.Consumer(sub, { format, - delay, + delay: this.#delay, predecessor: this.consumer ?? this.#predecessor, }); effect.cleanup(() => consumer.close()); @@ -637,8 +639,9 @@ class DecoderTrack { broadcast: this.broadcast, track: this.name, priority: Catalog.PRIORITY.video, - // js/net merges both decoders' subscriptions into one request at the larger max delay. - maxDelay: new Derived([this.sync.out.maxDelay, this.#warmup], (max, warmup) => Milli(max + warmup)), + // js/net merges both decoders' subscriptions into one request at the smaller stated min delay + // and the larger max delay. + delay: this.#delay, }); if (!sub) return; diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/media/Media.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/media/Media.kt index 38d759acc47..37037d8f006 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/media/Media.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/media/Media.kt @@ -91,7 +91,7 @@ typealias Dimensions = uniffi.moq.MoqDimensions /** * How far behind the live edge a decoder plays: `min` is the playout delay and where a cold join * starts, and `max`, null for no limit, is how far a stalled group may fall behind before it is - * skipped. `min` stays on the consumer; a `min` past `max` starts at `max`. + * skipped. The subscription carries both; a `min` past `max` starts at `max`. */ typealias Delay = uniffi.moq.MoqDelay diff --git a/quest/m1/README.md b/quest/m1/README.md index b69c47953f6..07a0d3d75f1 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -145,7 +145,7 @@ per-frame CPU growth since #5926. - [A job's exact route continues its claim's readers](/quest/m1/announce-takeover.md) - a job's route inherits its claim's instance, replacing the ANNOUNCE_TAKEOVER predecessor design - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - a lite-07 SUBSCRIBE asks for past and live ranges with absolute or relative ends, in either order, and replaces FETCH; merged ranges never starve a subscriber; relays fill misses by range, including over moq-transport - [Drop set](/quest/m1/drop-set.md) - each cursor judges missing groups against its own budget, so a narrow reader drops promptly and a widening re-offers groups already reported dropped on lite-07 -- [Subscriptions take a Delay { min, max }](/quest/m1/subscription-delay.md) - one Delay type across every subscription API, and omitted max means 0 in every binding +- [Binding subscriptions take a Delay { min, max }](/quest/m1/subscription-delay.md) - the bindings take the Rust and JS subscription Delay, and an omitted max means 0 in every one - [A step back smaller than the delay keeps the TS export](/quest/m1/ts-small-step-back.md) - audio restarts instead of regressing; video stays within its declared depth - [A replaced copy's grace wakes its reader](/quest/m1/grace-deadline.md) - the grace arms a deadline, so a clean end is on time - [moq-boy's status loop stops on an errored track](/quest/m1/moq-boy-status-loop.md) - only schema errors are caught diff --git a/quest/m1/lite07-finalize.md b/quest/m1/lite07-finalize.md index 5c1a2673c6f..3b78290acbc 100644 --- a/quest/m1/lite07-finalize.md +++ b/quest/m1/lite07-finalize.md @@ -114,7 +114,6 @@ timestamp bound in [#6168](https://github.com/moq-dev/moq/pull/6168). - [Lite-07 by start](/quest/m1/time-addressing/lite07.md) - lite-07 carries group starts, previous-start links, GROUP_END, and time bounds instead of sequence numbers - [@moq/net by start](/quest/m1/time-addressing/js.md) - JS speaks the same lite-07 wire - [A job's exact route continues its claim's readers](/quest/m1/announce-takeover.md) - the job's route inherits the claim's instance, with no new wire message -- [Subscriptions take a Delay { min, max }](/quest/m1/subscription-delay.md) - lite-07 SUBSCRIBE carries the delay's min as well as its max ## Related diff --git a/quest/m1/push-to-talk/README.md b/quest/m1/push-to-talk/README.md index ce53ae84400..f7bd47307cc 100644 --- a/quest/m1/push-to-talk/README.md +++ b/quest/m1/push-to-talk/README.md @@ -32,7 +32,7 @@ Decided 2026-10-28 (maintainer): - **Encoding no longer waits for demand**, in every first-party publisher (finished by [moq-dev/moq#7554](https://github.com/moq-dev/moq/pull/7554)). A listener who - subscribes after the unmute still gets the start within its `maxDelay`. This + subscribes after the unmute still gets the start within its `delay.max`. This replaces a publisher head buffer, which was considered and dropped as the same idea special-cased. diff --git a/quest/m1/subscribe-ranges/js.md b/quest/m1/subscribe-ranges/js.md index 1d0b7c687c4..c11c3f0db65 100644 --- a/quest/m1/subscribe-ranges/js.md +++ b/quest/m1/subscribe-ranges/js.md @@ -13,7 +13,7 @@ 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 +the ranges model, anchors, `delay: { max: 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)). @@ -26,9 +26,9 @@ 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. The publisher resolves no relative or time start, serving it from the oldest group its budget allows, and SUBSCRIBE_OK `Range Start` says so; resolve them. -Also mirror Rust's optional `Subscription::max_delay` +Also mirror Rust's optional `Delay::max` ([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 +`delay.max` 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. diff --git a/quest/m1/subscription-delay.md b/quest/m1/subscription-delay.md index f83f4370502..8e7db3c6d4b 100644 --- a/quest/m1/subscription-delay.md +++ b/quest/m1/subscription-delay.md @@ -1,25 +1,25 @@ -# [L] Subscriptions take a Delay { min, max } +# [M] Binding subscriptions take a Delay { min, max } ## Goal -Every subscription API takes the same `Delay { min, max }` the consumer APIs got in [moq-dev/moq#7311](https://github.com/moq-dev/moq/pull/7311), in place of a bare max delay. That covers moq-net `Subscription`, `@moq/net`'s `Subscription`, FFI `MoqSubscription`, and moq-c `moq_subscription`. The `Delay` type moves to moq-net / `@moq/net`, and an omitted max means 0 in every binding, including Dart, Go, and C. +Every binding's subscription takes the same `Delay { min, max }` that moq-net's `track::Subscription` and `@moq/net`'s `Track.Subscription` took in [moq-dev/moq#7597](https://github.com/moq-dev/moq/pull/7597), in place of a bare max delay. That covers FFI `MoqSubscription`, moq-c `moq_subscription`, and the Python, Swift, Kotlin, Go, Dart, and C++ wrappers. An omitted max means 0 in every binding, including Dart, Go, and C. ## Plan Decided 2026-10-30 (maintainer, follow-up planning): m1, because it breaks the public API and doing it before the cut avoids a second break. +[moq-dev/moq#7597](https://github.com/moq-dev/moq/pull/7597) landed the Rust and JS half: `track::Delay` / `Track.Delay`, `Subscription.delay`, the lite-07 `Subscriber Min Age`, and its aggregation. The bindings still expose `max_delay` and map it onto `delay.max`, stating a zero `min`. -- **Move:** move `Delay` from `moq_mux::container` / `Container.Delay` into `moq_net::track` / `Track.Delay`. Update moq-mux's callers directly and leave no re-export, since `AGENTS.md` forbids compatibility shims; the MIGRATE note names the new path. -- **Rename:** rename `max_delay` to `delay.max` throughout, about 1,050 Rust and 600 JS references. `max` travels on the wire as today. -- **`min` on the wire:** decided 2026-10-30 (maintainer): because `Delay` lives in moq-net, `min` goes on the lite-07 SUBSCRIBE (and SUBSCRIBE_UPDATE) too, following the rule that everything in moq-net survives the wire. Had `Delay` stayed in moq-mux, it would have stayed local. A relay forwards a subscriber's `min` upstream, and merged subscriptions take the smallest. Nothing reads it on the publisher side yet; later uses could include starting a cold join server-side, delay-tolerant batching, and priority under congestion. Versions that can't carry `min` (lite-01..06, moq-transport) follow the `carries_max_delay` precedent (`rs/moq-net/src/lite/subscribe.rs:113`): encoding to them drops `min`, and decoding from them yields "not stated", not 0. A relay forwards a not-stated `min` as not stated, and it doesn't count toward a merged subscription's smallest, so a legacy peer doesn't pull a merged `min` to 0. -- **Semantics:** they're settled in [moq-dev/moq#7403](https://github.com/moq-dev/moq/pull/7403): every default is 0, unlimited is explicit (Rust `None`, JS `null`, FFI null), and an omitted JS field means 0. +- **Representation:** decided 2026-11-02 (maintainer): the shared `Delay` has a nullable minimum. FFI `MoqDelay.min` becomes an explicit `null` (`Option`) that defaults to a stated zero, and C and Go get explicit absence flags so a zero-initialized value stays a stated zero. Media consumers read an unstated minimum as zero locally, and subscription metadata keeps it. +- **FFI:** `MoqSubscription.max_delay` becomes `delay: MoqDelay`, the same record the decoders take. `moq_subscription_defaults()` returns moq-net's defaults. - **Binding defaults:** this change also fixes the bindings where an omitted max is still unlimited. - Dart: an optional nullable parameter. - Go: a nil `MaxDelay` in a zero `Subscription{}`. - C: `max_delay_present` is false in a zeroed struct. - Each one's omitted or zeroed form becomes 0, and unlimited stays expressible. Dart defaults the parameter to `Duration.zero`, so an explicit `null` stays unlimited. Go's nil and C's unset `present` can't mean both, so each gains an explicit unlimited flag in the delay; the PR names it. The breaking binding changes get MIGRATE notes under `## Unreleased`. + Each one's omitted or zeroed form becomes 0, and unlimited stays expressible. Dart defaults the parameter to `Duration.zero`, so an explicit `null` stays unlimited. Go's nil and C's unset `present` can't mean both, so each gains an explicit unlimited flag in the delay, as `moq_delay.max_unlimited` already does for decoders; the PR names it. +- Update `doc/lib/{py,swift,kt,go,dart,c,cpp}` and add MIGRATE notes under `## Unreleased` for each breaking binding change. -Public API: breaking in every binding. Wire: lite-07 SUBSCRIBE and SUBSCRIBE_UPDATE gain `min`. +Public API: breaking in every binding. Wire: none. ## Related diff --git a/quest/m2/README.md b/quest/m2/README.md index 6bd93f2826b..06424e099b7 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -23,7 +23,7 @@ lite-07; bitrate enforcement is not part of this release. - [Sockets](/quest/m2/socket/README.md) - a generic dial/accept `Socket` in moq-net, Rust, JS, and Python, with tracks as streams; moq-boy and CARLA move onto it - [Announce credit](/quest/m2/announce-credit.md) - a subscriber bounds an Announce Stream's routes with credit at the wire, so a path flood never closes a session - [JS announced coalescing](/quest/m2/js-announced-coalesce.md) - `@moq/net`'s `Announced` keeps one pending event per path, like Rust -- [An audio track's final flush joins its open group](/quest/m2/audio-flush-group.md) - a zero-`max_delay` subscriber gets the tail, not `Old` +- [An audio track's final flush joins its open group](/quest/m2/audio-flush-group.md) - a zero-`delay.max` subscriber gets the tail, not `Old` - [Mesh libraries](/quest/m2/mesh/README.md) - an `Endpoint` reaches any enrolled node by endpoint ID over relays or a sparse direct overlay, with address hints and a `.status` track; the mesh binary and UI live in Kixel - [Byte streams](/quest/m2/stream/README.md) - TCP connections ride MoQ losslessly through relays, proxied per hop rather than as IP packets - [Release execution](/quest/m2/release/README.md) - publication, installed-artifact proof, cleanup, and downstream adoption after m1 diff --git a/quest/m2/audio-flush-group.md b/quest/m2/audio-flush-group.md index 979411006f5..57ce2ffadb1 100644 --- a/quest/m2/audio-flush-group.md +++ b/quest/m2/audio-flush-group.md @@ -4,7 +4,7 @@ `moq_audio::encode::Producer::finish` publishes the encoder's final flush in the track's open group instead of starting a new one, so a subscriber with the -default zero `max_delay` receives the tail of the audio rather than an `Old` +default zero `delay.max` receives the tail of the audio rather than an `Old` error. If upstream `main` already does this, record that and delete the quest. ## Plan @@ -13,9 +13,9 @@ From moq.pro's voice demo flake ([moq-dev/moq.pro#3318](https://github.com/moq-dev/moq.pro/pull/3318)): when the bot disconnected, `finish()` flushed the Opus encoder into a new group. Groups travel on separate streams, so the new group could reach a relay before the -previous group's end marker, and a zero-`max_delay` subscriber then saw the +previous group's end marker, and a zero-`delay.max` subscriber then saw the older group as superseded and got `Old`, losing about 0.8 s of audio in about 3% -of runs. moq.pro worked around it by subscribing with a 30 s `max_delay`. +of runs. moq.pro worked around it by subscribing with a 30 s max delay. Players set their own latency budget, so this isn't urgent. Check against `rs/moq-audio/src/encode/producer.rs` on `main` first: the group rules there (a discontinuity or reanchor opens a marker group) may already cover the diff --git a/quest/m2/quic-deadline.md b/quest/m2/quic-deadline.md index ed127ed9d58..d737070d76f 100644 --- a/quest/m2/quic-deadline.md +++ b/quest/m2/quic-deadline.md @@ -65,8 +65,7 @@ or ordering that suits only some rates. without one behaves as today. The same estimate of forward delay applies: drop one that can't arrive before its deadline. - moq-net: the datagram writer sets the deadline from the subscription's - max delay (`Delay::max` once [subscription delay](/quest/m1/subscription-delay.md) - renames it), through the same `transport::poll` seam as the stream + max delay (`Delay::max`), through the same `transport::poll` seam as the stream deadline; a backend without it sends as today. - Keep newest-first within a subscription; with stale datagrams expiring, it no longer starves the fresh ones. diff --git a/rs/hang/examples/subscribe.rs b/rs/hang/examples/subscribe.rs index b657aea85c6..9718326d08f 100644 --- a/rs/hang/examples/subscribe.rs +++ b/rs/hang/examples/subscribe.rs @@ -111,7 +111,7 @@ async fn play(consumer: moq_net::origin::Consumer, announce: moq_net::announce:: .subscribe( moq_net::track::Subscription::default() .with_priority(1) - .with_max_delay(latency), + .with_delay(moq_net::track::Delay::default().with_max(latency)), ) .await?; let mut ordered = diff --git a/rs/hang/src/container/frame.rs b/rs/hang/src/container/frame.rs index 560f1132947..00edd06c572 100644 --- a/rs/hang/src/container/frame.rs +++ b/rs/hang/src/container/frame.rs @@ -27,7 +27,7 @@ pub const TIMESCALE: Timescale = Timescale::MICRO; /// which is why every media track should start here rather than at `Info::default()`. It is a /// retention budget and a CEILING on what a subscriber may ask to wait for, so it never makes /// anyone play further behind live: a subscriber's own -/// [`Subscription::max_delay`](moq_net::track::Subscription::max_delay) defaults to +/// [`Delay::max`](moq_net::track::Delay::max) defaults to /// [`std::time::Duration::ZERO`](std::time::Duration::ZERO) (skip the moment a newer group arrives). /// /// `priority` is the publisher's tie-break priority, and should come from diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index a44bb4a59ae..8a1b6b98f54 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -2,7 +2,7 @@ use std::time::Duration; use bytes::Bytes; use kio::clock::{sleep, timeout}; -use moq_net::track::{Order, Position, Subscription}; +use moq_net::track::{Delay, Order, Position, Subscription}; use moq_net::{Timescale, Timestamp, broadcast, group, track}; use object_store::ObjectStoreExt; use tokio::task::JoinHandle; @@ -139,7 +139,7 @@ async fn subscribe(broadcast: &broadcast::Producer, track: &str) -> track::Order let subscription = Subscription::default() .with_range(0..) .with_order(Order::Ascending) - .with_max_delay(None); + .with_delay(Delay::default().with_max(None)); track.subscribe(subscription).await.unwrap().ordered() } @@ -305,7 +305,7 @@ async fn a_narrowed_read_skips_the_group_it_left() { let gate = mock.stall_gets("rec/video/"); let ascending = |groups| { Subscription::default() - .with_max_delay(None) + .with_delay(Delay::default().with_max(None)) .with_range(groups) .with_order(Order::Ascending) }; @@ -622,7 +622,7 @@ async fn groups_and_datagrams_keep_their_forms() { let subscription = Subscription::default() .with_range(0..) .with_order(Order::Ascending) - .with_max_delay(None); + .with_delay(Delay::default().with_max(None)); let mut reader = track.subscribe(subscription).await.unwrap(); archive @@ -748,7 +748,7 @@ async fn read_range(broadcast: &broadcast::Producer, track: &str, range: track:: let subscription = Subscription::default() .with_ranges([range]) .with_order(Order::Ascending) - .with_max_delay(None); + .with_delay(moq_net::track::Delay::default().with_max(None)); let mut reader = track.subscribe(subscription).await.unwrap().ordered(); let mut read = Vec::new(); while let Ok(group) = timeout(5 * REFRESH, reader.next_group()).await { @@ -810,7 +810,7 @@ async fn a_time_range_resolves_from_the_archive() { let subscription = Subscription::default() .with_ranges([range(at(7_002), None)]) .with_order(Order::Ascending) - .with_max_delay(None); + .with_delay(moq_net::track::Delay::default().with_max(None)); let mut reader = track.subscribe(subscription).await.unwrap().ordered(); for expected in 7..10 { assert_eq!(sequence(&mut reader).await, Some(expected)); @@ -950,7 +950,7 @@ async fn an_untimed_track_refuses_a_time_range() { start: at(1_000), end: None, }]) - .with_max_delay(None); + .with_delay(moq_net::track::Delay::default().with_max(None)); let result = match track.subscribe(subscription).await { Ok(subscriber) => timeout(Duration::from_secs(10), subscriber.ordered().next_group()) .await diff --git a/rs/moq-archive/src/producer/ordered.rs b/rs/moq-archive/src/producer/ordered.rs index 770ae8d036e..2838ce3a7fc 100644 --- a/rs/moq-archive/src/producer/ordered.rs +++ b/rs/moq-archive/src/producer/ordered.rs @@ -165,7 +165,7 @@ impl Ordered { #[cfg(test)] mod tests { - use moq_net::track::{Order, Range, Subscription}; + use moq_net::track::{Delay, Order, Range, Subscription}; use moq_net::{broadcast, track}; use super::*; @@ -188,7 +188,7 @@ mod tests { async fn ordered(broadcast: &broadcast::Producer, next: Option) -> Ordered { let subscription = Subscription::default() .with_order(Order::Ascending) - .with_max_delay(MAX_DELAY) + .with_delay(Delay::default().with_max(MAX_DELAY)) .with_ranges([Range::oldest()]); let subscriber = broadcast .consume() diff --git a/rs/moq-archive/src/producer/track.rs b/rs/moq-archive/src/producer/track.rs index ca1a153cfe2..6e4675f4d07 100644 --- a/rs/moq-archive/src/producer/track.rs +++ b/rs/moq-archive/src/producer/track.rs @@ -8,7 +8,7 @@ use std::time::Duration; use bytes::Bytes; use kio::clock::Instant; -use moq_net::track::{Anchor, Order, Position, Range, Subscription}; +use moq_net::track::{Anchor, Delay, Order, Position, Range, Subscription}; use moq_net::{StreamError, Timescale, group}; use object_store::path::Path; use object_store::{ObjectStore, ObjectStoreExt, PutMode, PutPayload}; @@ -171,7 +171,7 @@ impl Track { // backlog a smaller budget skipped is not sent again. The wait for a gap is narrowed below. let subscription = Subscription::default() .with_order(Order::Ascending) - .with_max_delay(config.max_delay) + .with_delay(Delay::default().with_max(config.max_delay)) .with_ranges([range]); let subscribed = match self.shared.broadcast.track(&self.name) { diff --git a/rs/moq-audio/MIGRATE.md b/rs/moq-audio/MIGRATE.md index 6d2eb4e3932..99511d6fab9 100644 --- a/rs/moq-audio/MIGRATE.md +++ b/rs/moq-audio/MIGRATE.md @@ -4,11 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased -### `decode::Options` takes a `Delay` and `estimate` ([#7311](https://github.com/moq-dev/moq/pull/7311)) +### `decode::Options` takes a `Delay` and `estimate` ([#7311](https://github.com/moq-dev/moq/pull/7311), [#7597](https://github.com/moq-dev/moq/pull/7597)) -`decode::Options::max_delay: Duration` and the playout `delay: Option` are one `delay: decode::Delay` (`moq_mux::container::Delay`) plus `estimate: bool`. +`decode::Options::max_delay: Duration` and the playout `delay: Option` are one `delay: decode::Delay` (`moq_net::track::Delay`) plus `estimate: bool`. `delay.max` is the old `max_delay`, and `delay.min` is the playout delay, where a cold join starts. -A fixed `delay: Some(d)` is `delay.min = d` with `estimate: false`. The default `None` (estimate from arrivals) is `estimate: true`, which now never goes below `delay.min`. +A fixed `delay: Some(d)` is `delay.min = Some(d)` with `estimate: false`. The default `None` (estimate from arrivals) is `estimate: true`, which now never goes below `delay.min`. ### `Level` is at the crate root diff --git a/rs/moq-audio/src/decode/conformance.rs b/rs/moq-audio/src/decode/conformance.rs index 541b0f00f18..8ffecb4c8c8 100644 --- a/rs/moq-audio/src/decode/conformance.rs +++ b/rs/moq-audio/src/decode/conformance.rs @@ -171,7 +171,10 @@ async fn every_backend_matches_independent_reference() { let (_source, broadcast, name, config) = import(vector); let track = broadcast.track(&name).unwrap(); let track = track - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); let wire = moq_mux::catalog::hang::Container::try_from(&config).unwrap(); diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index ec43d3ee511..6ebd41e014e 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -12,7 +12,7 @@ use super::decoder::{Config, Decoder}; use crate::jitter::{self, Target}; use crate::resample::{Remix, Resampler}; use crate::{Activity, Error, Format, Frame, Layout}; -pub use moq_mux::container::Delay; +pub use moq_net::track::Delay; /// One item of a [`Consumer`] read, in delivery order: decoded PCM, or a break in it. /// @@ -193,13 +193,18 @@ impl Consumer { let name = name.into(); let warmup = catalog.warmup.unwrap_or_default(); let track = broadcast.track(hang::catalog::Reference::track(catalog, &name))?; - // The whole budget from the start: the transport enforces it too, so a narrower one - // while joining would drop the groups queued behind a reader slow to take its first. + // The whole delay from the start, as the container keeps it: the transport enforces the + // max too, so a narrower one while joining would drop the groups queued behind a reader + // slow to take its first, and the publisher should never see a stated zero min first. let mut subscriber = track .subscribe( moq_net::track::Subscription::default() .with_priority(hang::catalog::PRIORITY.audio) - .with_max_delay(options.delay.max.map(|max| max.saturating_add(warmup))), + .with_delay( + options + .delay + .with_max(options.delay.max.map(|max| max.saturating_add(warmup))), + ), ) .await?; // Latest skips cached history; a warmup is primed by trimming the newest group's @@ -222,7 +227,7 @@ impl Consumer { .min(retention.unwrap_or(Duration::MAX)); // Holding more than the publisher keeps would wait on media it has already // discarded, so the caller asked for something impossible. - let delay = options.delay.min; + let delay = options.delay.min.unwrap_or_default(); if let Some(retention) = retention && delay > retention { @@ -241,7 +246,7 @@ impl Consumer { .with_delay(options.delay); Ok(Self { - target: Target::new(options.delay.min, options.estimate, catalog.jitter), + target: Target::new(delay, options.estimate, catalog.jitter), decoder, track, resampler: None, @@ -730,6 +735,44 @@ mod tests { use crate::encode::{Config as EncodeConfig, Encoder, Input, Options as EncodeOptions, Producer}; use crate::{Format, Layout}; + /// The first request already states the min delay, and the max plus the warmup: a latest + /// start waits for the live edge with its subscription open, before the container could + /// update it. + #[tokio::test] + async fn the_first_subscription_states_the_min_delay() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let mut track = broadcast + .create_track("audio", hang::container::track_info(hang::catalog::PRIORITY.audio)) + .unwrap(); + let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 1); + catalog.warmup = Some(Duration::from_millis(100)); + let delay = moq_net::track::Delay { + min: Some(Duration::from_millis(300)), + max: Some(Duration::from_millis(500)), + }; + let consume = broadcast.consume(); + let mut open = Box::pin(Consumer::new( + &consume, + &catalog, + "audio", + Options { + start: Start::Latest, + delay, + ..Default::default() + }, + )); + // The open waits for the live edge with its subscription open, so the first aggregate + // the publisher sees is the request itself. + let first = tokio::select! { + opened = &mut open => panic!("the open finished with no live edge (ok: {})", opened.is_ok()), + first = track.subscription_changed() => first.unwrap(), + }; + assert_eq!( + first.map(|sub| sub.delay), + Some(delay.with_max(Duration::from_millis(600))) + ); + } + #[tokio::test(start_paused = true)] async fn conversion_waits_for_decoded_samples() { for (rate, channels) in [(44_100, 1), (4_000_000_000, 8), (48_000, u32::MAX)] { @@ -955,7 +998,8 @@ mod tests { // A first reader warms the relay's copy with every group. { - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))); let mut warm = remote.track("audio").unwrap().subscribe(subscription).await.unwrap(); let mut groups = 0; while groups < 4 { @@ -1126,7 +1170,7 @@ mod tests { .await .unwrap(); assert_eq!( - track.subscription().unwrap().max_delay, + track.subscription().unwrap().delay.max, Some(Duration::from_millis(10_080)) ); @@ -2166,7 +2210,7 @@ mod tests { "audio", Options { delay: Delay { - min: case.delay.map_or(Duration::ZERO, duration), + min: Some(case.delay.map_or(Duration::ZERO, duration)), ..Delay::default() }, estimate: case.delay.is_none(), @@ -2284,7 +2328,7 @@ mod tests { let options = |delay| Options { delay: Delay { - min: std::time::Duration::from_millis(delay), + min: Some(std::time::Duration::from_millis(delay)), ..Delay::default() }, estimate: false, @@ -2486,7 +2530,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(observed.subscription().unwrap().max_delay, Some(max_delay)); + assert_eq!(observed.subscription().unwrap().delay.max, Some(max_delay)); let samples = [0.25f32, -0.5, 0.75, -1.0]; let payload: Vec = samples.iter().flat_map(|sample| sample.to_le_bytes()).collect(); diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index f160750205b..934ceadd8f4 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -2132,7 +2132,10 @@ mod tests { .consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RETAIN)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RETAIN)), + ) .await .unwrap(); wait_for(&mut self.publication, Status::Live).await; diff --git a/rs/moq-audio/src/encode/producer.rs b/rs/moq-audio/src/encode/producer.rs index 4c539e0cde2..e4b433a5d4d 100644 --- a/rs/moq-audio/src/encode/producer.rs +++ b/rs/moq-audio/src/encode/producer.rs @@ -791,7 +791,10 @@ mod tests { let track = consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); let mut reader = @@ -976,7 +979,10 @@ mod tests { let track = consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(60))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))), + ) .await .unwrap(); let mut reader = @@ -1209,7 +1215,10 @@ mod tests { consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(), moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio), @@ -1492,7 +1501,10 @@ mod tests { let mut track = consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); @@ -1534,7 +1546,10 @@ mod tests { let mut track = consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); for index in 0..packets * 2 { @@ -1643,7 +1658,10 @@ mod tests { let track = consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); let mut reader = moq_mux::container::Consumer::new( @@ -1694,7 +1712,10 @@ mod tests { let track = consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); let mut reader = diff --git a/rs/moq-bench/src/connection.rs b/rs/moq-bench/src/connection.rs index 3026ccdad9b..05ffb39a3f3 100644 --- a/rs/moq-bench/src/connection.rs +++ b/rs/moq-bench/src/connection.rs @@ -454,7 +454,7 @@ fn spawn_drain( async fn drain(broadcast: broadcast::Consumer, max_delay: Duration, stats: &Stats) -> anyhow::Result<()> { let _gauge = Gauge::inc(&stats.subscriptions); - let subscription = track::Subscription::default().with_max_delay(max_delay); + let subscription = track::Subscription::default().with_delay(track::Delay::default().with_max(max_delay)); let mut track = broadcast.track(TRACK)?.subscribe(subscription).await?; let mut gaps = GapTracker::new(stats); let mut learned_shape = false; @@ -824,7 +824,7 @@ mod tests { } fn replay() -> track::Subscription { - track::Subscription::default().with_max_delay(Duration::from_secs(30)) + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(30))) } /// A produced group must start with the JSON keyframe describing the rolled diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 74afa774a3c..e669383f31e 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -716,8 +716,8 @@ pub enum moq_demand { #[derive(Clone, Copy, Default)] pub struct moq_delay { /// The target latency in microseconds: the playout or jitter-buffer delay held behind - /// the live edge, and where a cold join starts. It stays on the consumer and never - /// reaches the subscription. A `min_us` past the max starts at the max, since the + /// the live edge, and where a cold join starts. The subscription carries it too, which + /// moq-lite-07 sends upstream. A `min_us` past the max starts at the max, since the /// publisher serves nothing older. pub min_us: u64, /// How far a group may fall behind the live edge before it is skipped, in @@ -728,10 +728,10 @@ pub struct moq_delay { pub max_unlimited: bool, } -impl From for moq_mux::container::Delay { +impl From for moq_net::track::Delay { fn from(delay: moq_delay) -> Self { Self { - min: std::time::Duration::from_micros(delay.min_us), + min: Some(std::time::Duration::from_micros(delay.min_us)), max: (!delay.max_unlimited).then(|| std::time::Duration::from_micros(delay.max_us)), } } @@ -741,7 +741,7 @@ impl From for moq_mux::container::Delay { /// /// # Safety /// - `delay` must be null or point to a valid [moq_delay]. -pub(crate) unsafe fn parse_delay(delay: *const moq_delay) -> moq_mux::container::Delay { +pub(crate) unsafe fn parse_delay(delay: *const moq_delay) -> moq_net::track::Delay { unsafe { delay.as_ref() }.copied().unwrap_or_default().into() } @@ -853,7 +853,8 @@ pub(crate) unsafe fn parse_subscription( .with_priority(subscription.priority) .with_order(order); let max_delay = std::time::Duration::from_micros(subscription.max_delay_us); - out = out.with_max_delay(subscription.max_delay_present.then_some(max_delay)); + out = + out.with_delay(moq_net::track::Delay::default().with_max(subscription.max_delay_present.then_some(max_delay))); if !subscription.ranges.is_null() { let ranges = unsafe { std::slice::from_raw_parts(subscription.ranges, subscription.ranges_len) }; let ranges = ranges @@ -1295,8 +1296,8 @@ pub extern "C" fn moq_subscription_defaults() -> moq_subscription { }; moq_subscription { priority: defaults.priority, - max_delay_us: defaults.max_delay.map(micros).unwrap_or_default(), - max_delay_present: defaults.max_delay.is_some(), + max_delay_us: defaults.delay.max.map(micros).unwrap_or_default(), + max_delay_present: defaults.delay.max.is_some(), ranges: std::ptr::null(), ranges_len: 0, order: order as u32, diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index c10faa71f06..f3d9a461a2d 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -494,7 +494,7 @@ impl Consume { &mut self, catalog: Id, index: usize, - delay: moq_mux::container::Delay, + delay: moq_net::track::Delay, on_read: OnStatus, ) -> Result where @@ -526,7 +526,9 @@ impl Consume { .subscribe( moq_net::track::Subscription::default() .with_priority(C::PRIORITY) - .with_max_delay(delay.max), + // The whole delay, as the container keeps it, so the first request + // already states the min. + .with_delay(delay), ) .await?; let track = moq_mux::container::Consumer::new(track, container).with_delay(delay); @@ -1158,7 +1160,10 @@ mod read_tests { .unwrap(); let format = || moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio); let consumer = moq_mux::container::Consumer::new( - track.subscribe(moq_net::track::Subscription::default().with_max_delay(std::time::Duration::ZERO)), + track.subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::ZERO)), + ), format(), ); let timestamp = |us| moq_net::Timestamp::from_micros(us).unwrap(); diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 052ec8199b8..74b1ecb8b6d 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -4169,7 +4169,7 @@ fn a_group_request_nobody_reads_is_withdrawn() { let ascending = |start, end| { moq_net::track::Subscription::default() - .with_max_delay(None) + .with_delay(moq_net::track::Delay::default().with_max(None)) .with_range(start..end) .with_order(moq_net::track::Order::Ascending) }; @@ -4235,7 +4235,7 @@ fn a_rejected_group_request_binds_only_its_readers() { } let subscribe = || { let subscription = moq_net::track::Subscription::default() - .with_max_delay(None) + .with_delay(moq_net::track::Delay::default().with_max(None)) .with_range(6..7); let mut state = State::lock(); let producer = state.publish.track_producer(Id::try_from(track).unwrap()).unwrap(); @@ -6341,7 +6341,7 @@ fn subscription_defaults_are_moq_net_defaults() { let zeroed: moq_subscription = unsafe { std::mem::zeroed() }; let zeroed = unsafe { crate::api::parse_subscription(&zeroed) }.unwrap(); - assert_eq!(zeroed.max_delay, None); + assert_eq!(zeroed.delay.max, None); } /// `moq_client_defaults` has to agree with what a zeroed struct actually dials, diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index 730c031a581..83345dae129 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -270,7 +270,7 @@ impl Media { // doubles as the staleness budget on the wire. let max_delay = self.args.video_max_delay(&snapshot.audio); // A decoder and its subscription, starting `lookback` groups before the live edge. - let open = async |delay: moq_mux::container::Delay, lookback: u64| { + let open = async |delay: moq_net::track::Delay, lookback: u64| { // The window's renderer refuses a GPU surface it cannot import, so // the decoder downloads those instead, all of them on a device // that imports none. @@ -282,7 +282,9 @@ impl Media { .subscribe( moq_net::track::Subscription::default() .with_priority(hang::catalog::PRIORITY.video) - .with_max_delay(delay.max), + // The whole delay from the first request, so a cold join never + // states a zero minimum before the container's update. + .with_delay(delay), ) .await?; // Start at the live edge without asking the shared publisher @@ -293,14 +295,13 @@ impl Media { subscriber = subscriber.with_range(largest.group.saturating_sub(lookback)..); } let format = catalog::hang::Container::try_from(config)?; - // The playout delay is the target latency; the floor above bounds how far - // back the start can wait, but the consumer still learns both bounds. + // The playout delay is the target latency, and the max how late a group may be. let track = moq_mux::container::Consumer::new(subscriber, format).with_delay(delay); Ok::<_, anyhow::Error>((track, decoder)) }; let opened = async { - let delay = moq_mux::container::Delay { - min: self.args.video_delay(), + let delay = moq_net::track::Delay { + min: Some(self.args.video_delay()), max: Some(max_delay), }; let (track, decoder) = open(delay, 0).await?; @@ -313,8 +314,8 @@ impl Media { } else { // The min-delay decoder's delay plus one sweep, as in // `moq_video::decode::Consumer`. - let delay = moq_mux::container::Delay { - min: delay.min.saturating_add(warmup), + let delay = moq_net::track::Delay { + min: delay.min.map(|min| min.saturating_add(warmup)), max: Some(max_delay.saturating_add(warmup)), }; let (track, decoder) = open(delay, 1).await?; @@ -403,7 +404,7 @@ impl Media { // still have reached, and would size the hole fill below to a playhead // that does not exist. decode.delay = moq_audio::decode::Delay { - min: self.args.fixed_delay().unwrap_or_default(), + min: Some(self.args.fixed_delay().unwrap_or_default()), max: Some(self.args.max_delay().max(AUDIO_BUFFER_MIN)), }; decode.estimate = self.args.fixed_delay().is_none(); @@ -913,7 +914,7 @@ mod tests { let mut producer = moq_mux::container::Producer::new(track, Container::Legacy(moq_mux::container::Kind::Audio)); let mut options = moq_audio::decode::Options::new(); options.delay = moq_audio::decode::Delay { - min: delay, + min: Some(delay), max: Some(delay), }; options.estimate = false; @@ -1012,14 +1013,16 @@ mod tests { .unwrap(); let subscriber = video .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let mut audio = moq_mux::container::Producer::new(audio, Container::Legacy(moq_mux::container::Kind::Audio)); let mut video = moq_mux::container::Producer::new(video, Container::Legacy(moq_mux::container::Kind::Video)); let mut options = moq_audio::decode::Options::new(); options.delay = moq_audio::decode::Delay { - min: delay, + min: Some(delay), max: Some(delay), }; options.estimate = false; @@ -1178,7 +1181,7 @@ mod tests { let mut producer = moq_mux::container::Producer::new(track, Container::Legacy(moq_mux::container::Kind::Audio)); let mut options = moq_audio::decode::Options::new(); options.delay = moq_audio::decode::Delay { - min: delay, + min: Some(delay), max: Some(delay), }; options.estimate = false; @@ -1264,7 +1267,7 @@ mod tests { // Joins at the end marker: the stopped encoder's media is already behind it. let mut options = moq_audio::decode::Options::new(); options.delay = moq_audio::decode::Delay { - min: delay, + min: Some(delay), max: Some(delay), }; options.estimate = false; @@ -1319,7 +1322,7 @@ mod tests { let mut producer = moq_mux::container::Producer::new(track, Container::Legacy(moq_mux::container::Kind::Audio)); let mut options = moq_audio::decode::Options::new(); options.delay = moq_audio::decode::Delay { - min: delay, + min: Some(delay), max: Some(delay), }; options.estimate = false; @@ -1400,7 +1403,10 @@ mod tests { // The network keeps every group; only the player's age budget evicts. let subscriber = video .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))), + ) .await .unwrap(); let mut video = moq_mux::container::Producer::new(video, Container::Legacy(moq_mux::container::Kind::Video)); @@ -2171,7 +2177,9 @@ mod tests { .consume() .track("video") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let track = moq_mux::container::Consumer::new(subscriber, Container::try_from(&catalog).unwrap()); diff --git a/rs/moq-cli/src/play/video.rs b/rs/moq-cli/src/play/video.rs index 2b8714dd449..3b5a29fe54e 100644 --- a/rs/moq-cli/src/play/video.rs +++ b/rs/moq-cli/src/play/video.rs @@ -761,7 +761,9 @@ mod tests { .unwrap(); let subscriber = track .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let format = moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data); @@ -786,7 +788,10 @@ mod tests { let subscribe = async |budget| { let subscriber = track .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(budget)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(budget)), + ) .await .unwrap(); moq_mux::container::Consumer::new( @@ -944,7 +949,9 @@ mod tests { .unwrap(); let subscriber = track .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let mut producer = moq_mux::container::Producer::new( @@ -1001,7 +1008,9 @@ mod tests { .unwrap(); let subscriber = published .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let format = moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video); @@ -1057,7 +1066,9 @@ mod tests { .unwrap(); let subscriber = track .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let mut producer = moq_mux::container::Producer::new( @@ -1099,7 +1110,9 @@ mod tests { .unwrap(); let subscriber = source .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let format = || moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video); @@ -1143,7 +1156,9 @@ mod tests { .unwrap(); let subscriber = source .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let format = || moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video); @@ -1205,7 +1220,10 @@ mod tests { // The network keeps every group; only the player's age budget evicts. let subscriber = source .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))), + ) .await .unwrap(); let format = || moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video); @@ -1253,7 +1271,9 @@ mod tests { .unwrap(); let subscriber = source .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let format = || moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video); @@ -1322,7 +1342,9 @@ mod tests { .unwrap(); let subscriber = track .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let format = moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video); @@ -1371,7 +1393,9 @@ mod tests { async fn join(track: &moq_net::track::Producer, budget: Duration, lookback: u64) -> Track { let mut subscriber = track .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(budget)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(budget)), + ) .await .unwrap(); let largest = subscriber.largest().await.unwrap(); @@ -1641,7 +1665,10 @@ mod tests { .unwrap(); let subscriber = track .consume() - .subscribe(moq_net::track::Subscription::default().with_max_delay(delay)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(delay)), + ) .await .unwrap(); let mut producer = moq_mux::container::Producer::new(track, format()); diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index f9f39342eed..7d3fa239934 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -1585,7 +1585,10 @@ pub(crate) mod tests { let mut track = consumer .track(&m2ts.track) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut output = Vec::new(); @@ -1756,8 +1759,13 @@ pub(crate) mod tests { groups })) }; - let recording = - read(start(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)).await); + let recording = read( + start( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) + .await, + ); // All at once: the track ends while the relay is still pulling the older groups. decoder.decode_chunk(&second).unwrap(); decoder.finish().unwrap(); @@ -1804,11 +1812,16 @@ pub(crate) mod tests { // the budget behind. An IETF subscription starts at the newest group, so only lite // has the groups behind it to read. if version.starts_with("moq-lite") { - let late = - read(start(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))).await) - .await - .unwrap() - .expect("the late read ends with the track"); + let late = read( + start( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))), + ) + .await, + ) + .await + .unwrap() + .expect("the late read ends with the track"); let whole: Vec = late .iter() .filter(|(_, frames)| frames.is_some()) diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 201d1745422..18fee02477b 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -337,7 +337,8 @@ fn consume(generation: &Producer) -> Consumer { } fn subscribe_all() -> moq_net::track::Subscription { - moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(60)) + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))) } fn net_track(name: &Name) -> moq_net::track::Producer { diff --git a/rs/moq-e2ee/tests/transport.rs b/rs/moq-e2ee/tests/transport.rs index ba8af2a28dc..a79dce71f36 100644 --- a/rs/moq-e2ee/tests/transport.rs +++ b/rs/moq-e2ee/tests/transport.rs @@ -69,7 +69,8 @@ async fn connect_protected(version: Version, track: &str) -> Fixture { let consumer = moq_e2ee::broadcast::Consumer::new(remote, cred.consume(epoch)) .subscribe( track, - moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(60)), + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))), ) .unwrap() .await diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 559e673f3e8..e987256914b 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -57,7 +57,7 @@ pub fn moq_subscription_defaults() -> MoqSubscription { let defaults = moq_net::track::Subscription::default(); MoqSubscription { priority: defaults.priority, - max_delay: defaults.max_delay, + max_delay: defaults.delay.max, ranges: None, order: None, } @@ -175,7 +175,7 @@ impl TryFrom for moq_net::track::Subscription { fn try_from(s: MoqSubscription) -> Result { let mut subscription = moq_net::track::Subscription::default() .with_priority(s.priority) - .with_max_delay(s.max_delay); + .with_delay(moq_net::track::Delay::default().with_max(s.max_delay)); if let Some(ranges) = s.ranges { subscription = subscription.with_ranges( ranges diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 17518c0346a..ef2d5da484c 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -21,22 +21,22 @@ pub struct MoqDimensions { pub height: u32, } -/// How far behind the live edge a decoder plays, the counterpart of `moq_mux::container::Delay`. +/// How far behind the live edge a decoder plays, the counterpart of `moq_net::track::Delay`. #[derive(Clone, Copy, Debug, uniffi::Record)] pub struct MoqDelay { /// The target latency: the playout or jitter-buffer delay held behind the live edge, and - /// where a cold join starts. It stays on the consumer and never reaches the subscription. A - /// `min` past `max` starts at `max`, since the publisher serves nothing older. + /// where a cold join starts. The subscription carries it too, which moq-lite-07 sends + /// upstream. A `min` past `max` starts at `max`, since the publisher serves nothing older. pub min: std::time::Duration, /// How far a group may fall behind the live edge before it is skipped, or null for no limit. /// It bounds lateness only, never the start. pub max: Option, } -impl From for moq_mux::container::Delay { +impl From for moq_net::track::Delay { fn from(delay: MoqDelay) -> Self { Self { - min: delay.min, + min: Some(delay.min), max: delay.max, } } @@ -1924,7 +1924,10 @@ mod timebase_tests { let track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(30))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(30))), + ) .await .unwrap(); readers.push(moq_mux::container::Consumer::new(track, container)); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 3f520127cb2..b3879aae23d 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -6474,7 +6474,7 @@ async fn server_new_validates_the_config() { fn subscription_defaults_are_moq_net_defaults() { let defaults = moq_net::track::Subscription::try_from(moq_subscription_defaults()).unwrap(); assert_eq!(defaults, moq_net::track::Subscription::default()); - assert_eq!(defaults.max_delay, Some(Duration::ZERO)); + assert_eq!(defaults.delay.max, Some(Duration::ZERO)); } /// A settings UI shows these, so each must be what an unset knob actually dials with. diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index 10930fbbd54..cdacbdf07a5 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -1621,7 +1621,10 @@ mod tests { let mut track = consumer .track("audio") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); for index in 0..17 { @@ -2012,7 +2015,10 @@ mod tests { .consume() .track("audiolevels") .expect("the opaque track is published") - .subscribe(moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(1))), + ) .await .expect("subscribe to the opaque track") .ordered(); diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 0a716c2f5ef..a05d8a9676f 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -1646,7 +1646,7 @@ impl Reader { subscriber = async { instance .track(&shape.track)? - .subscribe(moq_net::track::Subscription::default().with_max_delay(max_delay)) + .subscribe(moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(max_delay))) .await } => match subscriber { Ok(subscriber) => subscriber, @@ -2816,7 +2816,7 @@ mod session_tests { }); let request = await_request(&mut dynamic); - assert_eq!(request.subscription().unwrap().max_delay, Some(max_delay)); + assert_eq!(request.subscription().unwrap().delay.max, Some(max_delay)); let _ = shutdown.send(true); super::RUNTIME.block_on(session).unwrap().unwrap(); diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index f8cacd9029c..7485e37db75 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -1163,7 +1163,8 @@ impl Rendition { waiter: &kio::Waiter, ) -> Poll> { let track = std::task::ready!(self.media.poll_track(binding, waiter)); - let subscription = moq_net::track::Subscription::default().with_max_delay(FEED_MAX_DELAY); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(FEED_MAX_DELAY)); Poll::Ready(track.map(|(_, track)| track.subscribe(subscription).into_inner())) } diff --git a/rs/moq-hls/src/import.rs b/rs/moq-hls/src/import.rs index 608b1afd7e1..3a446f85c7a 100644 --- a/rs/moq-hls/src/import.rs +++ b/rs/moq-hls/src/import.rs @@ -2354,7 +2354,8 @@ mod tests { .clone() }; let before_name = name(&catalog.snapshot()); - let replay = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(3600)); + let replay = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(3600))); let mut old = consumer .track(&before_name) .unwrap() @@ -2520,7 +2521,8 @@ mod tests { /// Every frame timestamp `name` carries so far, in microseconds. async fn stamps(consumer: &moq_net::broadcast::Consumer, name: &str) -> Vec { - let replay = moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(3600)); + let replay = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(3600))); let mut track = consumer.track(name).unwrap().subscribe(replay).await.unwrap(); let wait = std::time::Duration::from_millis(100); let mut out = Vec::new(); diff --git a/rs/moq-json/src/snapshot/consumer.rs b/rs/moq-json/src/snapshot/consumer.rs index 72b20c52628..a59fc8deb74 100644 --- a/rs/moq-json/src/snapshot/consumer.rs +++ b/rs/moq-json/src/snapshot/consumer.rs @@ -187,7 +187,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = None; + subscription.delay.max = None; subscriber.update(subscription).unwrap(); let mut producer = crate::snapshot::Producer::new(track, crate::snapshot::Config::default()); let mut consumer = Consumer::::new(subscriber, Default::default()); diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 20536b583fe..625d2dfc96b 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -227,7 +227,7 @@ mod test { fn next_preserves_intermediate_states() { let (mut producer, mut track) = producer(cfg(true)); let mut subscription = track.subscription(); - subscription.max_delay = None; + subscription.delay.max = None; track.update(subscription).unwrap(); for n in [1, 2, 3] { producer diff --git a/rs/moq-json/src/stream/consumer.rs b/rs/moq-json/src/stream/consumer.rs index 67a354e0d69..4fac76ae20d 100644 --- a/rs/moq-json/src/stream/consumer.rs +++ b/rs/moq-json/src/stream/consumer.rs @@ -122,7 +122,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = None; + subscription.delay.max = None; subscriber.update(subscription).unwrap(); let config = crate::stream::Config::default(); let mut producer = crate::stream::Producer::new(track, config.clone()); diff --git a/rs/moq-json/src/stream/mod.rs b/rs/moq-json/src/stream/mod.rs index 21654d46769..ef98b0d434f 100644 --- a/rs/moq-json/src/stream/mod.rs +++ b/rs/moq-json/src/stream/mod.rs @@ -375,7 +375,8 @@ mod test { // Ask for a replay window, so the first group is delivered rather than skipped by the // subscriber's default max delay budget once a newer group exists. - let subscription = moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(30)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(30))); let subscriber = track.subscribe(subscription); // Both groups stay open, the way a publisher writing to two at once leaves them. diff --git a/rs/moq-json/src/window/mod.rs b/rs/moq-json/src/window/mod.rs index 6aca6243446..2116aecab48 100644 --- a/rs/moq-json/src/window/mod.rs +++ b/rs/moq-json/src/window/mod.rs @@ -969,7 +969,8 @@ mod test { .produce() .create_track("test", moq_net::track::Info::default()) .unwrap(); - let replay = moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(30)); + let replay = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(30))); let mut groups = track.subscribe(replay); let mut producer = Producer::::new(track, ProducerConfig::default()); diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index 782a409def2..d1522c7f1f0 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -10,12 +10,12 @@ After `container::fmp4::Import::decode` fails, later decodes, cuts, seeks and di Replace the importer to resume instead of feeding it another box or segment. `finish()` still ends the tracks with the accepted media and does not reset the error. HLS replaces a refused importer immediately; transient fetch failures still keep their retry budget. -### A `Delay` sets where a consumer starts ([#7311](https://github.com/moq-dev/moq/pull/7311)) +### A `Delay` sets where a consumer starts ([#7311](https://github.com/moq-dev/moq/pull/7311), [#7597](https://github.com/moq-dev/moq/pull/7597)) `container::Consumer::set_max_delay(Duration)` is `set_delay(Delay)`, and `with_delay(Delay)` is its builder. -`container::Delay { min, max }` defaults to zero for both: `max` (`None` for no limit) is the max delay that `set_max_delay` took, and the subscription still carries it. -`min` is the playout delay. An unfloored consumer starts at the group covering the live edge less `min` plus any `with_warmup`, waiting for it when the newest group arrives first. Before the first read, a backlog that already landed starts at the first group whose first frame is within the max delay, plus the warmup, of the newest content, keeping the group covering the live edge less `min` and the warmup. -Replace `consumer.set_max_delay(d)` with `consumer.set_delay(Delay { max: Some(d), ..Delay::default() })`, and set `min` to your playout delay. Call `with_warmup` before `with_delay`. +`moq_net::track::Delay { min, max }` defaults to zero for both: `max` (`None` for no limit) is the max delay that `set_max_delay` took. `set_delay` puts the whole delay on the subscription, and `Consumer::new` inherits it from there. +`min` is the playout delay, and a consumer reads an unstated (`None`) one as zero. An unfloored consumer starts at the group covering the live edge less `min` plus any `with_warmup`, waiting for it when the newest group arrives first. Before the first read, a backlog that already landed starts at the first group whose first frame is within the max delay, plus the warmup, of the newest content, keeping the group covering the live edge less `min` and the warmup. +Replace `consumer.set_max_delay(d)` with `consumer.set_delay(Delay::default().with_max(d))`, and set `min` to your playout delay. Call `with_warmup` before `with_delay`. ### Compression moves to the track diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 91a5d17df5d..f5699d5c8f4 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -2387,7 +2387,8 @@ mod test { "video0", "video0.hls", ] { - let all = moq_net::track::Subscription::default().with_max_delay(None); + let all = + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(None)); let track = consumer.track(name).unwrap().subscribe(all).await.unwrap(); readers.push((name, track.ordered())); } diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index a67e40d2390..045ca414f61 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -5,6 +5,7 @@ use std::task::{Poll, ready}; use kio::clock::Instant; use moq_net::Timestamp; +use moq_net::track::Delay; use super::{Container, Frame, Read, TimestampRewind}; @@ -62,7 +63,7 @@ pub(crate) enum Item { /// a group in arrival order, but across groups it advances by sequence number, skipping /// a stalled or missing group once everything it could still present (bounded by where /// the next group begins) falls a full budget behind the newest content, the same reach -/// [`moq_net::track::Subscription::max_delay`] measures. With the default max delay of zero, +/// [`Delay::max`] measures. With the default max delay of zero, /// the consumer skips aggressively: any group that has a newer alternative is dropped. /// With a non-zero max delay, slow groups are tolerated up to that budget before being /// skipped. A missing sequence gets the same tolerance: there is no way to tell a stream @@ -90,8 +91,8 @@ pub(crate) enum Item { /// group's first timestamp has nothing left worth waiting for. Containers without a /// duration report zero, which disables this check and falls back to the max delay budget. /// -/// Put the initial max delay on the [`moq_net::track::Subscription`] before -/// subscribing. [`new`](Self::new) inherits that budget, and +/// Put the initial [`Delay`] on the [`moq_net::track::Subscription`] before +/// subscribing. [`new`](Self::new) inherits it, reading an unstated [`Delay::min`] as zero, and /// [`with_delay`](Self::with_delay) or [`set_delay`](Self::set_delay) sets the whole [`Delay`]. /// /// ## Gaps and ends @@ -210,36 +211,6 @@ pub struct Consumer { /// about one group duration. const CONTIGUITY_TOLERANCE: std::time::Duration = std::time::Duration::from_millis(1); -/// How far behind the live edge a [`Consumer`] plays: where it starts, and how late it may fall. -/// -/// Both default to zero, which starts at the newest group and skips aggressively. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Delay { - /// The target latency: the playout or jitter-buffer delay a player holds behind the live edge. - /// - /// The start is the group covering the live edge less `min`, plus any - /// [`with_warmup`](Consumer::with_warmup), waiting for it when the newest group arrives first. - /// Consumer-only: it never reaches the subscription. A `min` past `max` starts at `max`, since - /// the publisher serves nothing older. - pub min: std::time::Duration, - /// How far a group may fall behind the live edge before it is skipped, or `None` for no limit. - /// - /// The subscription carries it as [`moq_net::track::Subscription::max_delay`]. It bounds a - /// join's lateness too: a backlog that landed before the first read starts at the first group - /// whose first frame is within it, plus any warmup, of the newest content, though the start - /// keeps the group covering the live edge less [`min`](Self::min) and the warmup. - pub max: Option, -} - -impl Default for Delay { - fn default() -> Self { - Self { - min: std::time::Duration::ZERO, - max: Some(std::time::Duration::ZERO), - } - } -} - /// The default [`Consumer::set_max_buffer`]: one group at the transport's size cap. pub const DEFAULT_MAX_BUFFER: usize = moq_net::group::MAX_CACHE_BYTES as usize; @@ -319,7 +290,8 @@ impl Consumer { // otherwise stall the catch-up by the excess. let start = track.floor(); let max_delay = subscription - .max_delay + .delay + .max .unwrap_or(std::time::Duration::MAX) .min(track.info().max_age.unwrap_or(std::time::Duration::MAX)); Self { @@ -334,7 +306,7 @@ impl Consumer { gated: false, startup: start.is_none(), newest: false, - min_delay: std::time::Duration::ZERO, + min_delay: subscription.delay.min.unwrap_or_default(), warmup: std::time::Duration::ZERO, anchor: None, target: None, @@ -388,7 +360,7 @@ impl Consumer { // Carve the warmup out of what was asked for, then clamp to retention as `new` does, so a // budget the track can keep is not cut by the clamp too. if let Some(track) = &self.track { - let requested = track.subscription().max_delay.unwrap_or(std::time::Duration::MAX); + let requested = track.subscription().delay.max.unwrap_or(std::time::Duration::MAX); self.max_delay = requested .saturating_sub(warmup) .min(track.info().max_age.unwrap_or(std::time::Duration::MAX)); @@ -1146,15 +1118,16 @@ impl Consumer { /// Set the [`Delay`], mid-stream or before the first read. /// /// [`Delay::max`] is clamped to the track's retention window like [`new`](Self::new). The - /// subscription keeps the requested value verbatim, matching - /// [`moq_net::track::Subscription::max_delay`], plus any [`with_warmup`](Self::with_warmup). - /// [`Delay::min`] stays here: it moves only a start still to come. + /// subscription keeps the requested delay verbatim, its max plus any + /// [`with_warmup`](Self::with_warmup). An unstated [`Delay::min`] reads as zero here, and moves + /// only a start still to come. A min past the max starts at the max, since the publisher serves + /// nothing older. pub fn set_delay(&mut self, delay: Delay) { // An ended reader has no subscription left to tune. let Some(track) = &mut self.track else { return; }; - self.min_delay = delay.min; + self.min_delay = delay.min.unwrap_or_default(); self.max_delay = delay .max .unwrap_or(std::time::Duration::MAX) @@ -1163,9 +1136,9 @@ impl Consumer { // tolerance set here has to reach it: otherwise moq-net skips the very groups // this consumer was told to wait for, before they ever get here. It keeps the // warmup too, as `with_warmup` asks of the subscription. - let max = delay.max.map(|max| max.saturating_add(self.warmup)); - if track.subscription().max_delay != max { - let subscription = track.subscription().with_max_delay(max); + let delay = delay.with_max(delay.max.map(|max| max.saturating_add(self.warmup))); + if track.subscription().delay != delay { + let subscription = track.subscription().with_delay(delay); let _ = track.update(subscription); } } @@ -1645,7 +1618,9 @@ mod tests { ..Delay::default() }); control - .update(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(30))) + .update( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(30))), + ) .unwrap(); consumer } @@ -1655,8 +1630,9 @@ mod tests { #[tokio::test] async fn read_single_group() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0)]); @@ -1706,8 +1682,9 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_join_on_a_marker_group_reads_its_end() { let mut track = track_producer("gaps", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let subscription = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let subscription = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); let mut reader = Consumer::new(subscription, Container::Legacy(crate::container::Kind::Audio)); write_marker_group(&mut track, 16, ts(1_000_000)); @@ -1728,7 +1705,7 @@ mod tests { let info = hang::container::track_info(hang::catalog::PRIORITY.audio).with_max_age(hour); let track = track_producer("data", info); let subscription = moq_net::track::Subscription::default() - .with_max_delay(hour) + .with_delay(Delay::default().with_max(hour)) .with_range(0..); let mut reader = Consumer::new( track.subscribe(subscription), @@ -1758,7 +1735,7 @@ mod tests { async fn a_hole_before_a_terminal_data_marker_is_a_gap() { let mut track = track_producer("data", hang::container::track_info(hang::catalog::PRIORITY.audio)); let subscription = moq_net::track::Subscription::default() - .with_max_delay(Duration::from_secs(10)) + .with_delay(Delay::default().with_max(Duration::from_secs(10))) .with_range(0..); let mut reader = Consumer::new( track.subscribe(subscription), @@ -1777,7 +1754,8 @@ mod tests { async fn a_sequence_hole_before_a_paused_data_marker_expires() { for budget in [Duration::ZERO, Duration::from_millis(100)] { let mut track = track_producer("data", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let subscriber = track.subscribe(moq_net::track::Subscription::default().with_max_delay(budget)); + let subscriber = + track.subscribe(moq_net::track::Subscription::default().with_delay(Delay::default().with_max(budget))); let mut reader = Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(10_000)]); assert!(matches!(reader.read().await.unwrap(), Some(Read::Frame { .. }))); @@ -1816,8 +1794,9 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_filled_sequence_hole_gives_the_next_hole_a_fresh_budget() { let mut track = track_producer("data", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let subscriber = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))); + let subscriber = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(100))), + ); let mut reader = Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0)]); assert!(matches!( @@ -1857,7 +1836,8 @@ mod tests { async fn holes_behind_a_stalled_source_share_one_budget() { let budget = Duration::from_millis(100); let mut track = track_producer("data", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let subscriber = track.subscribe(moq_net::track::Subscription::default().with_max_delay(budget)); + let subscriber = + track.subscribe(moq_net::track::Subscription::default().with_delay(Delay::default().with_max(budget))); let mut reader = Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0)]); assert!(matches!( @@ -1886,7 +1866,8 @@ mod tests { async fn a_hole_is_late_from_the_earliest_arrival_past_it() { let budget = Duration::from_millis(100); let mut track = track_producer("data", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let subscriber = track.subscribe(moq_net::track::Subscription::default().with_max_delay(budget)); + let subscriber = + track.subscribe(moq_net::track::Subscription::default().with_delay(Delay::default().with_max(budget))); let mut reader = Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0)]); assert!(matches!( @@ -1916,7 +1897,7 @@ mod tests { async fn a_malformed_marker_passed_by_a_skip_fails_the_reader() { let mut track = track_producer("data", hang::container::track_info(hang::catalog::PRIORITY.audio)); let subscription = moq_net::track::Subscription::default() - .with_max_delay(Duration::from_secs(10)) + .with_delay(Delay::default().with_max(Duration::from_secs(10))) .with_range(0..); let mut reader = Consumer::new( track.subscribe(subscription), @@ -1976,7 +1957,7 @@ mod tests { for marker_first in [true, false] { let mut track = track_producer("data", hang::container::track_info(hang::catalog::PRIORITY.audio)); let subscription = moq_net::track::Subscription::default() - .with_max_delay(Duration::from_secs(10)) + .with_delay(Delay::default().with_max(Duration::from_secs(10))) .with_range(0..); let mut reader = Consumer::new( track.subscribe(subscription), @@ -2040,8 +2021,9 @@ mod tests { #[tokio::test] async fn gaps_mark_holes_while_a_marker_reads_as_an_end() { let mut track = track_producer("gaps", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let subscription = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let subscription = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); let mut reader = Consumer::new(subscription, Container::Legacy(crate::container::Kind::Audio)); write_group(&mut track, 0, &[ts(0), ts(10_000)]); write_group(&mut track, 2, &[ts(1_000_000), ts(1_010_000)]); @@ -2068,8 +2050,9 @@ mod tests { #[tokio::test] async fn a_latency_skip_is_a_gap() { let mut track = track_producer("gaps", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let subscription = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let subscription = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); let mut reader = Consumer::new(subscription, Container::Legacy(crate::container::Kind::Audio)); reader.max_delay = Duration::ZERO; // Group 0 stays open, and is read before the newer group lands: the skip comes after the start. @@ -2095,8 +2078,9 @@ mod tests { #[tokio::test] async fn a_trailing_duration_marker_is_not_an_end() { let track = track_producer("gaps", hang::container::track_info(hang::catalog::PRIORITY.video)); - let subscription = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let subscription = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); let mut reader = Consumer::new(subscription, Container::Legacy(crate::container::Kind::Video)); let wire = Container::Legacy(crate::container::Kind::Video); for (sequence, start) in [(0, 0), (1, 33_000)] { @@ -2125,8 +2109,9 @@ mod tests { #[tokio::test] async fn an_audio_terminal_reads_its_end_before_the_final_packets() { let mut track = track_producer("gaps", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let subscription = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let subscription = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); let mut reader = Consumer::new(subscription, Container::Legacy(crate::container::Kind::Audio)); write_group(&mut track, 0, &[ts(0), ts(20_000)]); let mut group = track.create_group(moq_net::group::Info { sequence: 1 }).unwrap(); @@ -2150,8 +2135,9 @@ mod tests { #[tokio::test] async fn a_marker_group_declares_an_end() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(2))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Audio)); write_group(&mut track, 0, &[ts(0)]); @@ -2166,8 +2152,9 @@ mod tests { #[tokio::test] async fn only_a_marker_declares_an_end() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(2))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Audio)); write_group(&mut track, 0, &[ts(0)]); @@ -2186,8 +2173,9 @@ mod tests { #[tokio::test] async fn empty_groups_mean_nothing() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(2))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Audio)); write_group(&mut track, 0, &[ts(0)]); @@ -2210,8 +2198,9 @@ mod tests { write_group(&mut track, 0, &[ts(0)]); let _empty = track.create_group(moq_net::group::Info { sequence: 1 }).unwrap(); - let mut subscriber = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let mut subscriber = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let edge = match subscriber.poll_largest(&moq_net::kio::Waiter::noop()) { std::task::Poll::Ready(Some(edge)) => edge, _ => panic!("the empty group is the largest"), @@ -2229,8 +2218,9 @@ mod tests { #[tokio::test] async fn latency_skip_reports_a_gap() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.audio)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(2))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Audio)); // Keep transport filtering out of this test so it isolates the mux skip logic. consumer.max_delay = Duration::ZERO; @@ -2258,8 +2248,9 @@ mod tests { #[tokio::test] async fn read_multiple_frames_single_group() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0), ts(33_000), ts(66_000)]); @@ -2277,8 +2268,9 @@ mod tests { #[tokio::test] async fn read_multiple_groups_within_latency() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // 5 groups, 20ms spacing. Total span = 80ms, well within the 500ms max delay. @@ -2297,8 +2289,9 @@ mod tests { async fn latency_skip_delivers_recent_groups() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(100))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // Group 0: 5 frames, NOT finished (blocks consumer) @@ -2671,7 +2664,9 @@ mod tests { async fn a_marker_group_and_forward_jump_reads_one_end() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.audio)); let mut consumer = Consumer::new( - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(2))), + track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2))), + ), Container::Legacy(crate::container::Kind::Audio), ); write_group(&mut track, 0, &[ts(0)]); @@ -2766,8 +2761,9 @@ mod tests { #[tokio::test] async fn empty_payload_is_skipped() { let track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Video)); let mut group = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -2797,8 +2793,9 @@ mod tests { #[tokio::test] async fn leading_marker_preserves_the_first_media_keyframe() { let track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Video)); let mut group = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -2828,8 +2825,9 @@ mod tests { #[tokio::test] async fn consecutive_markers_do_not_stall() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Video)); let mut group = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -2860,8 +2858,9 @@ mod tests { #[tokio::test] async fn loc_empty_payload_is_skipped() { let track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Loc(crate::container::Kind::Video)); let mut group = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -2899,8 +2898,9 @@ mod tests { async fn groups_delivered_in_sequence_order() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let mut group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -2936,8 +2936,9 @@ mod tests { #[tokio::test] async fn adjacent_group_flushed_immediately() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0)]); @@ -2956,8 +2957,9 @@ mod tests { async fn empty_track_returns_none() { tokio::time::pause(); let track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); track.finish().unwrap(); @@ -2975,8 +2977,9 @@ mod tests { async fn track_closed_with_error() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0), ts(10_000)]); @@ -3004,8 +3007,9 @@ mod tests { #[tokio::test] async fn gap_in_group_sequence_recovery() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(100))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0), ts(20_000)]); @@ -3023,8 +3027,9 @@ mod tests { #[tokio::test] async fn gap_at_start_of_sequence() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(80))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(80))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 5, &[ts(0), ts(20_000)]); @@ -3057,7 +3062,9 @@ mod tests { let track = broadcast .track("video") .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(2))) + .subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2))), + ) .await .unwrap(); let format = Container::Legacy(crate::container::Kind::Data); @@ -3132,8 +3139,9 @@ mod tests { #[tokio::test] async fn evicted_group_with_gap_skips_to_live() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(100))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // Group 0: a frame the consumer reads, positioning it there. @@ -3186,8 +3194,9 @@ mod tests { // group 2 out of delivery before the consumer reaches it. tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(100))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // Read group 0 first, positioning the cursor there before the newer groups @@ -3222,8 +3231,9 @@ mod tests { async fn gap_keeps_late_arriving_group_within_max_delay() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // Group 2's stream beats group 1's, which hasn't arrived at all yet. @@ -3261,7 +3271,7 @@ mod tests { let consumer_track = track.subscribe( moq_net::track::Subscription::default() .with_range(0..) - .with_max_delay(Duration::from_millis(500)), + .with_delay(Delay::default().with_max(Duration::from_millis(500))), ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); @@ -3289,8 +3299,9 @@ mod tests { async fn startup_keeps_slow_earlier_stream_within_max_delay() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // Group 0's stream opens first but carries no frames yet; group 1's frame wins. @@ -3333,10 +3344,14 @@ mod tests { max: Duration, warmup: Duration, ) -> Consumer { - let consumer_track = track.subscribe(moq_net::track::Subscription::default().with_max_delay(max + warmup)); + let consumer_track = track + .subscribe(moq_net::track::Subscription::default().with_delay(Delay::default().with_max(max + warmup))); Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)) .with_warmup(warmup) - .with_delay(Delay { min, max: Some(max) }) + .with_delay(Delay { + min: Some(min), + max: Some(max), + }) } /// The live edge's stream wins the race, yet the start waits for a group covering the reach. @@ -3618,11 +3633,14 @@ mod tests { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); let consumer_track = track - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(Delay::default().with_max(Duration::from_millis(500))), + ) .with_range(5..); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)).with_delay(Delay { - min: Duration::from_millis(500), + min: Some(Duration::from_millis(500)), max: Some(Duration::from_millis(500)), }); assert!( @@ -3684,9 +3702,9 @@ mod tests { assert_eq!(frame.timestamp, ts(5_000_000)); } - /// The min delay stays on the consumer: the subscription carries the max delay plus the warmup. + /// The subscription carries the min delay as is and the max delay plus the warmup. #[tokio::test] - async fn the_min_delay_never_reaches_the_subscription() { + async fn the_subscription_carries_the_min_delay_and_the_warmed_max_delay() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); let consumer = reach_consumer( &mut track, @@ -3695,7 +3713,8 @@ mod tests { Duration::from_millis(300), ); let subscription = consumer.track.as_ref().unwrap().subscription(); - assert_eq!(subscription.max_delay, Some(Duration::from_millis(500))); + assert_eq!(subscription.delay.min, Some(Duration::from_secs(2))); + assert_eq!(subscription.delay.max, Some(Duration::from_millis(500))); assert_eq!(consumer.max_delay, Duration::from_millis(200)); } @@ -3705,13 +3724,14 @@ mod tests { async fn an_unlimited_max_delay_still_reaches_the_min_delay() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = track.subscribe(moq_net::track::Subscription::default().with_max_delay(None)); + let consumer_track = + track.subscribe(moq_net::track::Subscription::default().with_delay(Delay::default().with_max(None))); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)).with_delay(Delay { - min: Duration::from_millis(500), + min: Some(Duration::from_millis(500)), max: None, }); - assert_eq!(consumer.track.as_ref().unwrap().subscription().max_delay, None); + assert_eq!(consumer.track.as_ref().unwrap().subscription().delay.max, None); write_group(&mut track, 5, &[ts(5_000_000)]); assert!( @@ -3839,7 +3859,8 @@ mod tests { async fn a_decode_error_at_the_head_of_a_group_ends_the_reader() { tokio::time::pause(); let track = track_producer("test", timed()); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut consumer = Consumer::new(track.subscribe(subscription), FailingDecode); let mut group = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -3880,7 +3901,8 @@ mod tests { async fn an_error_releases_the_subscription_and_its_groups() { tokio::time::pause(); let track = track_producer("test", timed()); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut consumer = Consumer::new(track.subscribe(subscription), FailingDecode); let mut malformed = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -3902,7 +3924,8 @@ mod tests { async fn a_decode_error_mid_group_found_by_lookahead_ends_the_reader() { tokio::time::pause(); let track = track_producer("test", timed()); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut consumer = Consumer::new(track.subscribe(subscription), FailingDecode); // The current group stays open, so the reader looks ahead into the next one. @@ -3939,7 +3962,8 @@ mod tests { async fn a_decode_error_in_a_later_group_waits_for_current_frames() { tokio::time::pause(); let track = track_producer("test", timed()); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut consumer = Consumer::new(track.subscribe(subscription), FailingDecode); let mut current = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -3968,7 +3992,8 @@ mod tests { async fn a_decode_error_comes_before_a_later_rewind() { tokio::time::pause(); let track = track_producer("test", timed()); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut consumer = Consumer::new(track.subscribe(subscription), FailingDecode); let mut current = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4004,7 +4029,8 @@ mod tests { async fn a_startup_discard_returns_a_malformed_group() { tokio::time::pause(); let track = track_producer("test", timed()); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut consumer = Consumer::new(track.subscribe(subscription), FailingDecode).live(); let mut old = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4025,7 +4051,8 @@ mod tests { async fn an_aborted_later_group_keeps_its_fault_in_delivery_order() { tokio::time::pause(); let track = track_producer("test", timed()); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut consumer = Consumer::new(track.subscribe(subscription), FailingDecode); let mut current = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4059,7 +4086,8 @@ mod tests { async fn a_decode_error_before_a_reset_tail_ends_the_reader() { tokio::time::pause(); let track = track_producer("test", timed()); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut consumer = Consumer::new(track.subscribe(subscription), FailingDecode); let mut group = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4107,8 +4135,9 @@ mod tests { #[tokio::test] async fn frame_payload_preserved() { let track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let payload_bytes = vec![0x01, 0x02, 0x03, 0x04, 0x05]; @@ -4146,8 +4175,9 @@ mod tests { async fn no_infinite_loop_with_buffered_frames() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let mut group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4193,8 +4223,9 @@ mod tests { #[tokio::test] async fn large_timestamps() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(3700))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(3700))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let one_hour = 3_600_000_000u64; @@ -4210,8 +4241,9 @@ mod tests { #[tokio::test] async fn set_delay_changes_behavior() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0)]); @@ -4232,8 +4264,9 @@ mod tests { async fn max_timestamp_tracks_through_bframes() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(110))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(110))), + ); // max delay must exceed (group1_max - group0_min) = 100ms - 0ms = 100ms // to avoid the age skip and test B-frame timestamp tracking. let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); @@ -4285,8 +4318,9 @@ mod tests { async fn startup_selects_earliest_group() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(100))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 3, &[ts(0)]); @@ -4342,8 +4376,9 @@ mod tests { async fn startup_skips_groups_without_data() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let _group5 = track.create_group(moq_net::group::Info { sequence: 5 }).unwrap(); @@ -4375,8 +4410,9 @@ mod tests { async fn aborted_frameless_group_after_a_gap_ends_the_track() { tokio::time::pause(); let track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // Sequence 0 never arrives; sequence 1's stream opens but never carries a frame. @@ -4406,8 +4442,9 @@ mod tests { async fn finished_track_waits_for_an_open_head_group() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // Group 0's stream is open but its frames lose the race; the track boundary @@ -4449,7 +4486,7 @@ mod tests { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); let subscription = moq_net::track::Subscription::default() - .with_max_delay(Duration::from_secs(10)) + .with_delay(Delay::default().with_max(Duration::from_secs(10))) .with_ranges([ moq_net::track::Range::groups(0..1), moq_net::track::Range::groups(10..11), @@ -4484,7 +4521,7 @@ mod tests { write_group(&mut track, sequence, &[ts(sequence * 100_000)]); } let subscription = moq_net::track::Subscription::default() - .with_max_delay(Duration::from_secs(10)) + .with_delay(Delay::default().with_max(Duration::from_secs(10))) .with_ranges([moq_net::track::Range { start: moq_net::track::Anchor::Relative(2), end: None, @@ -4502,8 +4539,9 @@ mod tests { #[tokio::test] async fn startup_single_group_mid_stream() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 100, &[ts(3_000_000)]); @@ -4520,8 +4558,9 @@ mod tests { async fn startup_mid_stream_live_track_starts_at_the_served_group() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // The track is far along and stays live (never finished); only the current @@ -4540,8 +4579,9 @@ mod tests { async fn multiple_sequential_latency_skips() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(50))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(50))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let mut group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4577,8 +4617,9 @@ mod tests { async fn latency_skip_boundary_exact() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(100))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let mut group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4674,8 +4715,9 @@ mod tests { #[tokio::test] async fn group_error_skips_to_next() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4695,8 +4737,9 @@ mod tests { #[tokio::test] async fn finished_group_aborted_mid_read_drains_then_continues() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let mut group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4733,8 +4776,9 @@ mod tests { #[tokio::test] async fn oversized_group_skips_to_next() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let mut group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4761,8 +4805,9 @@ mod tests { async fn track_finishes_while_reading() { tokio::time::pause(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); write_group(&mut track, 0, &[ts(0)]); @@ -4791,8 +4836,9 @@ mod tests { #[tokio::test] async fn empty_group_advances() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); let mut group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -4812,8 +4858,9 @@ mod tests { tokio::time::pause(); let track = track_producer("video", hang::container::track_info(hang::catalog::PRIORITY.video)); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500))), + ); let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); // Write frames using Container::Legacy(crate::container::Kind::Data) encoding @@ -4857,8 +4904,9 @@ mod tests { // DurationWire is a test-only container that doesn't stamp moq_net frame // timestamps; leave the track untimed so model-layer validation matches. let track = track_producer("test", timed()); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); // The max delay dwarfs the gap, so only duration coverage can trigger the skip. let mut consumer = Consumer::new(consumer_track, DurationWire); @@ -4889,7 +4937,9 @@ mod tests { async fn a_nonsequential_contiguous_jump_has_no_gap() { let track = track_producer("test", timed()); let mut consumer = Consumer::new( - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))), + track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ), DurationWire, ); let mut group0 = track @@ -4915,8 +4965,9 @@ mod tests { tokio::time::pause(); // DurationWire is untimed at the moq_net frame layer. let track = track_producer("test", timed()); - let consumer_track = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); + let consumer_track = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))), + ); let mut consumer = Consumer::new(consumer_track, DurationWire); // Group 0: frame at ts=0 lasting only 10ms, far short of group 1 at 33ms. @@ -5068,8 +5119,9 @@ mod tests { tokio::time::pause(); const MAX_BUFFER: usize = 8192; let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let subscriber = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))); + let subscriber = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(100))), + ); let mut consumer = Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Data)); consumer.set_max_buffer(MAX_BUFFER); @@ -5092,8 +5144,9 @@ mod tests { const MAX_BUFFER: usize = 1024; let waiter = kio::Waiter::noop(); let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let subscriber = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(30))); + let subscriber = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(30))), + ); let mut consumer = Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Video)); consumer.set_max_buffer(MAX_BUFFER); @@ -5155,8 +5208,9 @@ mod tests { let waiter = kio::Waiter::noop(); let track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); - let subscriber = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(30))); + let subscriber = track.subscribe( + moq_net::track::Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(30))), + ); let mut consumer = Consumer::new(subscriber, Container::Loc(crate::container::Kind::Data)); let mut padded = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); @@ -5428,7 +5482,10 @@ mod tests { async fn received_input_waits_for_group_fin_without_consuming_payload() { let mut track = track_producer("finite", timed()); let mut consumer = Consumer::new( - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(500))), + track.subscribe( + moq_net::track::Subscription::default() + .with_delay(Delay::default().with_max(Duration::from_millis(500))), + ), Container::Legacy(crate::container::Kind::Data), ); write_group(&mut track, 0, &[ts(0)]); diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index 5099833b942..01d943413db 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -170,7 +170,10 @@ async fn import_emits_frames() { let track = consumer .track(&video_name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); let mut decoder = crate::container::Consumer::new( @@ -547,7 +550,10 @@ async fn config_churn_keeps_flat_state() { let replaced = consumer .track(&replaced) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); @@ -562,7 +568,10 @@ async fn config_churn_keeps_flat_state() { let live = consumer .track(snap.video.renditions.keys().next().unwrap()) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); drop(importer); @@ -609,7 +618,10 @@ async fn import_enhanced_av1() { let track = consumer .track(name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); let mut decoder = crate::container::Consumer::new( @@ -743,7 +755,10 @@ async fn import_reports_negative_pts_and_can_resume() { let track = consumer .track(name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); let mut decoder = crate::container::Consumer::new( @@ -783,7 +798,10 @@ async fn import_enhanced_hvc1_applies_composition_time() { let track = consumer .track(name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))), + ) .await .unwrap(); let mut decoder = crate::container::Consumer::new( diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 93cd3c8ec67..0641bda3c7a 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -2162,7 +2162,8 @@ mod tests { } fn replay(output: &Output<()>) -> moq_net::track::Subscriber { - let replay = moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(30)); + let replay = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(30))); match output { Output::Audio(output) => output.subscribe(replay), Output::Passthrough(output) => output.track.subscribe(replay), diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index f484bf59ed9..f2e0a1362e3 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -12,7 +12,7 @@ const RECORDING_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs( /// A replay window with that budget, for a test asserting every group is delivered. fn replay() -> moq_net::track::Subscription { - moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_DELAY) + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_DELAY)) } /// Drain every group currently buffered on the consumer without waiting for new ones. diff --git a/rs/moq-mux/src/container/mkv/import.rs b/rs/moq-mux/src/container/mkv/import.rs index 5b47fe873ee..b0274d5d5b0 100644 --- a/rs/moq-mux/src/container/mkv/import.rs +++ b/rs/moq-mux/src/container/mkv/import.rs @@ -2160,8 +2160,8 @@ mod tests { assert!(matches!(import.finish(), Err(crate::Error::Mkv(Error::Truncated)))); let snapshot = catalog.snapshot(); let (name, config) = snapshot.audio.renditions.iter().next().unwrap(); - let subscription = - moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(1)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(1))); let track = consumer.track(name).unwrap().subscribe(subscription).await.unwrap(); let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::try_from(config).unwrap()); diff --git a/rs/moq-mux/src/container/mkv/import_test.rs b/rs/moq-mux/src/container/mkv/import_test.rs index 1f3bbedd16a..de674d57f4b 100644 --- a/rs/moq-mux/src/container/mkv/import_test.rs +++ b/rs/moq-mux/src/container/mkv/import_test.rs @@ -1943,7 +1943,8 @@ async fn a_seek_mid_block_waits_for_the_block() { let snapshot = catalog.snapshot(); let name = snapshot.audio.renditions.keys().next().unwrap(); - let replay = moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(3600)); + let replay = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(3600))); let mut track = consumer.track(name).unwrap().subscribe(replay).await.unwrap(); let mut groups = Vec::new(); while let Some(mut group) = track.recv_group().await.unwrap() { diff --git a/rs/moq-mux/src/container/mod.rs b/rs/moq-mux/src/container/mod.rs index 23039d2a2b3..2de73f7a31f 100644 --- a/rs/moq-mux/src/container/mod.rs +++ b/rs/moq-mux/src/container/mod.rs @@ -31,7 +31,7 @@ pub mod mkv; pub mod mp4; pub mod ts; -pub use consumer::{Consumer, DEFAULT_MAX_BUFFER, Delay}; +pub use consumer::{Consumer, DEFAULT_MAX_BUFFER}; pub use group::GroupConsumer; pub use producer::Producer; pub(crate) use source::{ExportSource, Resume}; diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index 1187decb8a1..e3d4b33febf 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -1017,7 +1017,8 @@ mod tests { /// [`std::time::Duration::ZERO`](std::time::Duration::ZERO) budget collapses to the live /// edge: history has to be asked for. fn replay() -> moq_net::track::Subscription { - moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_DELAY) + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_DELAY)) } /// The media track's full retention window, so readers started after publishing @@ -1400,7 +1401,10 @@ mod tests { let discontinuity_max_age = std::time::Duration::from_secs(41 * 60); let info = hang::container::track_info(hang::catalog::PRIORITY.audio).with_max_age(discontinuity_max_age); let track = track_producer("test", info); - let consumer = track.subscribe(moq_net::track::Subscription::default().with_max_delay(discontinuity_max_age)); + let consumer = track.subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(discontinuity_max_age)), + ); let mut producer = Producer::new(track, Container::Legacy(crate::container::Kind::Audio)); producer.write(frame(0, true)).unwrap(); diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index ae2506b954f..60fd55b4b34 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -519,7 +519,8 @@ impl ExportSource { if sibling { self.instance = Some(broadcast.clone()); } - let mut subscription = moq_net::track::Subscription::default().with_max_delay(self.max_delay); + let mut subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(self.max_delay)); if let Some(after) = self.after { subscription = subscription.with_range((Bound::Excluded(after), Bound::Unbounded)); } @@ -1045,7 +1046,7 @@ mod tests { let observed = kio::wait(|waiter| { let _ = export.poll_read(waiter); match live.track.subscription() { - Some(subscription) => Poll::Ready(subscription.max_delay), + Some(subscription) => Poll::Ready(subscription.delay.max), None => Poll::Pending, } }) @@ -1300,7 +1301,10 @@ mod tests { .create_track("audio", hang::container::track_info(hang::catalog::PRIORITY.audio)) .unwrap(); let consumer = Consumer::new( - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))), + track.subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))), + ), HangContainer::Legacy(crate::container::Kind::Audio), ); export.state = SourceState::Active(Box::new(consumer)); diff --git a/rs/moq-mux/src/container/test_util.rs b/rs/moq-mux/src/container/test_util.rs index d1cd44333bc..381ea0367be 100644 --- a/rs/moq-mux/src/container/test_util.rs +++ b/rs/moq-mux/src/container/test_util.rs @@ -253,7 +253,8 @@ pub(crate) async fn published( let mut out = std::collections::BTreeMap::new(); for (name, container) in containers { - let replay = moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(3600)); + let replay = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(3600))); let track = consumer.track(&name).unwrap().subscribe(replay).await.unwrap(); let mut reader = crate::container::Consumer::new(track, container); let mut timestamps = Vec::new(); diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index b24dff53e10..bfa1f838447 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -1313,8 +1313,10 @@ impl SiTrack { Ok((broadcast, name)) => match broadcast.track(&name) { Ok(track) => SiState::Subscribing( track.subscribe( - self.source - .subscription(moq_net::track::Subscription::default().with_max_delay(self.max_age)), + self.source.subscription( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(self.max_age)), + ), ), ), Err(err) => { diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index a5eb7988744..d511e8587ca 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -1854,7 +1854,10 @@ async fn export_scte35_roundtrip() { let track = consumer2 .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut scte_reader = crate::container::Consumer::new(track, HangContainer::Legacy(crate::container::Kind::Data)); @@ -2067,7 +2070,10 @@ async fn export_pes_packet_roundtrip() { let track = consumer2 .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy(crate::container::Kind::Data)); @@ -2221,7 +2227,10 @@ async fn programs_without_video_roundtrip_opaque_tracks() { let track = consumer2 .track(&restored) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy(Kind::Data)); @@ -2265,7 +2274,10 @@ async fn read_frames(consumer: &moq_net::broadcast::Consumer, name: &str, kind: let track = consumer .track(name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy(kind)); @@ -3058,7 +3070,10 @@ async fn read_cues(consumer: &moq_net::broadcast::Consumer, name: &str) -> Vec<( let track = consumer .track(name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy(crate::container::Kind::Data)); @@ -3243,7 +3258,7 @@ async fn read_si_groups(consumer: &moq_net::broadcast::Consumer, name: &str) -> .subscribe( moq_net::track::Subscription::default() .with_range(0..) - .with_max_delay(Duration::from_secs(5)), + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(5))), ) .await .unwrap(); diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index e01d3790dbc..a43ed544b66 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -5333,7 +5333,10 @@ pub(super) mod test { let track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = Consumer::new(track, Container::Legacy(crate::container::Kind::Data)); @@ -6034,7 +6037,10 @@ pub(super) mod test { let track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new( @@ -7036,7 +7042,10 @@ pub(super) mod test { let track = consumer .track(hang::catalog::Catalog::DEFAULT_NAME) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new( @@ -7430,7 +7439,10 @@ pub(super) mod test { let track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = Consumer::new(track, Container::Legacy(crate::container::Kind::Data)); @@ -7723,7 +7735,8 @@ pub(super) mod test { // A generous max delay: the default of zero would shed every non-latest group, the // declared breaks among them, and the subscribe start is resolved from it too, so // it has to reach back past a 30 s leap to the first frame. - let subscription = moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(3600)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(3600))); let track = consumer.track(name).unwrap().subscribe(subscription).await.unwrap(); let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy(kind)); let mut frames = Vec::new(); @@ -8970,7 +8983,8 @@ pub(super) mod test { name: &str, kind: crate::container::Kind, ) -> Vec { - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(3600)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(3600))); let track = consumer.track(name).unwrap().subscribe(subscription).await.unwrap(); let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy(kind)); let mut frames = Vec::new(); @@ -9806,7 +9820,8 @@ pub(super) mod test { // Each group as published, its empty end marker included. let name = catalog.snapshot().video.renditions.keys().next().unwrap().clone(); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(3600)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(3600))); let mut track = consumer.track(&name).unwrap().subscribe(subscription).await.unwrap(); let mut groups = Vec::new(); while let Some(mut group) = track.recv_group().await.unwrap() { @@ -10938,7 +10953,10 @@ pub(super) mod test { let track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new( diff --git a/rs/moq-mux/src/container/ts/import_test.rs b/rs/moq-mux/src/container/ts/import_test.rs index 0a9b2b67d8d..01ceb5673d5 100644 --- a/rs/moq-mux/src/container/ts/import_test.rs +++ b/rs/moq-mux/src/container/ts/import_test.rs @@ -254,7 +254,10 @@ async fn import_opus_frames() { let track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new( @@ -533,7 +536,10 @@ async fn survives_midstream_join() { let track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new( @@ -588,7 +594,10 @@ async fn kyrion_dirtystart_extracts_real_cues() { let track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_AGE)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_AGE)), + ) .await .unwrap(); let mut reader = crate::container::Consumer::new( diff --git a/rs/moq-mux/src/container/ts/passthrough_test.rs b/rs/moq-mux/src/container/ts/passthrough_test.rs index c2170a35ec6..9ee5b64a428 100644 --- a/rs/moq-mux/src/container/ts/passthrough_test.rs +++ b/rs/moq-mux/src/container/ts/passthrough_test.rs @@ -88,7 +88,10 @@ async fn import_onto( let mut track = consumer .track(&name)? - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING_MAX_DELAY)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RECORDING_MAX_DELAY)), + ) .await?; let mut objects = Vec::new(); let mut markers = Vec::new(); diff --git a/rs/moq-mux/src/container/ts/pes_packet_test.rs b/rs/moq-mux/src/container/ts/pes_packet_test.rs index 858de183db5..a2cf0d12e37 100644 --- a/rs/moq-mux/src/container/ts/pes_packet_test.rs +++ b/rs/moq-mux/src/container/ts/pes_packet_test.rs @@ -238,7 +238,8 @@ async fn read_until( consumer: &moq_net::broadcast::Consumer, name: &str, ) -> (Vec, Result) { - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(3600)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(3600))); let track = consumer.track(name).unwrap().subscribe(subscription).await.unwrap(); let mut reader = crate::container::Consumer::new( track, diff --git a/rs/moq-mux/src/container/ts/recovery_test.rs b/rs/moq-mux/src/container/ts/recovery_test.rs index 8da9d02fc4b..9aa4c0d96be 100644 --- a/rs/moq-mux/src/container/ts/recovery_test.rs +++ b/rs/moq-mux/src/container/ts/recovery_test.rs @@ -498,7 +498,10 @@ async fn record(profile: Profile) -> Recording { let mut track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(300))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(300))), + ) .await .unwrap(); let mut groups = Vec::new(); diff --git a/rs/moq-mux/src/container/ts/release_test.rs b/rs/moq-mux/src/container/ts/release_test.rs index ceda6b254e0..ae7b665a661 100644 --- a/rs/moq-mux/src/container/ts/release_test.rs +++ b/rs/moq-mux/src/container/ts/release_test.rs @@ -46,7 +46,9 @@ async fn import(data: &[u8]) -> Imported { let mut track = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RECORDING)) + .subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(RECORDING)), + ) .await .unwrap(); let mut objects = Vec::new(); diff --git a/rs/moq-mux/src/container/ts/routes_test.rs b/rs/moq-mux/src/container/ts/routes_test.rs index 79883804195..9bb6e43f42f 100644 --- a/rs/moq-mux/src/container/ts/routes_test.rs +++ b/rs/moq-mux/src/container/ts/routes_test.rs @@ -114,7 +114,8 @@ impl Imported { /// Every frame on the track carrying `pid`. async fn frames(&self, pid: u16) -> Vec { let (name, _) = self.entry(pid).expect("a track on the PID"); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(3600)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(3600))); let track = self .consumer .track(&name) diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 017bc9fd7d0..0e8f58d7609 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -952,8 +952,10 @@ mod tests { .unwrap(); // Every group is written before anything reads, which the default // REAL_TIME budget would collapse to the live edge. - let subscriber = - track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(30))); + let subscriber = track.subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(30))), + ); let config = crate::codec::opus::Config::new(48_000, 2); let import = crate::codec::opus::Import::new(track, catalog.reserve(), config.into()).unwrap(); (import, subscriber) @@ -1369,7 +1371,8 @@ mod tests { assert_eq!((audio.sample_rate, audio.channel_count), (44_100, 2)); // Delay-tolerant, so the first group is still delivered once the second exists. - let replay = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(30)); + let replay = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(30))); let mut track = consumer.track("audio").unwrap().subscribe(Some(replay)).await.unwrap(); for i in 0..4u64 { if i == 2 { diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index 75d4e3cb95b..f7673a46ee6 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -565,7 +565,7 @@ mod tests { let broadcast = moq_net::broadcast::Info::new().produce(); let track = broadcast.create_track("audio", None).unwrap(); let preferences = moq_net::track::Subscription::default() - .with_max_delay(std::time::Duration::from_secs(3)) + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(3))) .with_range(5..=10); let first = track.subscribe(preferences.clone()); let held = source.control(first.control()).unwrap(); diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 17e25c9c834..9bcc0b2ec9e 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,23 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Subscription::max_delay` is `delay: track::Delay` ([#7597](https://github.com/moq-dev/moq/pull/7597)) + +`track::Subscription` carries a `track::Delay { min, max }` in place of `max_delay`, and `with_max_delay(d)` is `with_delay(Delay)`. +`Delay::max` is the old `max_delay`, unchanged. `Delay::min` is the subscriber's target latency, or `None` when a peer whose wire cannot carry it (moq-lite before 07, moq-transport) left it unstated. Both default to `Some(Duration::ZERO)`. +moq-lite-07 SUBSCRIBE and SUBSCRIBE\_UPDATE carry `min`, and a track's aggregate subscription takes the smallest stated one. + +```rust +// Before +Subscription::default().with_max_delay(d); +subscription.max_delay; +// After +Subscription::default().with_delay(Delay::default().with_max(d)); +subscription.delay.max; +``` + +`group::Fetch::max_delay` is unchanged. + ### Local readiness takes the next frame ([#7624](https://github.com/moq-dev/moq/pull/7624)) `group::Producer::poll_ready(waiter)` and `ready()` take the next frame's `&frame::Info`: `poll_ready(waiter, &info)` and `ready(&info).await`. @@ -25,7 +42,7 @@ When every one of a subscription's `ranges` has an end, `track::Subscriber::recv This holds for a track read from its own producer's cache. A track read through an origin's routes, such as a remote broadcast's, still waits past its ranges for now, so a reader there stops once it has the groups it asked for rather than waiting for `None`. A reader that wants to widen its ranges later keeps one open, such as `Range::groups(n..)`, and narrows its reads with `with_range`, whose cap never ends a read. A group another reader's fetch or fill cached is now handed to every subscriber whose ranges hold it, not only fetches. -A missing group the live feed may still deliver is waited for at most one second before it is asked of a fill or named dropped, when neither the readers' `max_delay` nor the track's max age bounds the wait. Before, a reader with `max_delay: None` on a track with no max age waited for it until the track ended. +A missing group the live feed may still deliver is waited for at most one second before it is asked of a fill or named dropped, when neither the readers' `delay.max` nor the track's max age bounds the wait. Before, a reader with `max_delay: None` on a track with no max age waited for it until the track ended. Over moq-transport, a subscription to a copy the session holds no upstream subscription for, such as one only fetched so far, whose ranges all end below the publisher's reported Largest Object, is served by FETCH without subscribing upstream. Each missing group is asked at once and a refusal is final, since nothing else would deliver it. ### `broadcast::Demand` reads like `track::Demand` diff --git a/rs/moq-net/benches/group.rs b/rs/moq-net/benches/group.rs index f7faa6c36df..d99a8b0c07b 100644 --- a/rs/moq-net/benches/group.rs +++ b/rs/moq-net/benches/group.rs @@ -249,7 +249,7 @@ fn filled_track(n: usize, payload: &Bytes) -> TrackCtx { /// Request the full cache window instead of the default live edge. fn replay() -> track::Subscription { track::Subscription::default() - .with_max_delay(Duration::MAX) + .with_delay(track::Delay::default().with_max(Duration::MAX)) .with_range(0..) } diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 50b32b55add..0e0b3eb8935 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -1683,7 +1683,8 @@ async fn resume_overlap(shape: ResumeShape, ordered: bool) -> ResumeOverlap { .expect("front refused"); let track = resolved.track("0").unwrap(); // A budget covering every group, so none is written off as late. - let subscription = track::Subscription::default().with_max_delay(Duration::from_secs(3600)); + let subscription = + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))); let mut subscribers = Vec::with_capacity(readers); for _ in 0..readers { let subscriber = kio::clock::timeout(COPY_WAIT, track.subscribe(subscription.clone())) diff --git a/rs/moq-net/benches/ranges.rs b/rs/moq-net/benches/ranges.rs index 5d0bf6346c8..799f6987719 100644 --- a/rs/moq-net/benches/ranges.rs +++ b/rs/moq-net/benches/ranges.rs @@ -85,7 +85,7 @@ impl TimeEnds { land(&track, 0, 0); // Each range starts and ends far past the live edge, at a time of its own. let subscription = track::Subscription::default() - .with_max_delay(BUDGET) + .with_delay(track::Delay::default().with_max(BUDGET)) .with_ranges((0..ranges).map(|i| track::Range { start: track::Anchor::Time(millis(LIVE + i * 2)), end: Some(track::Anchor::Time(millis(LIVE + i * 2 + 1))), @@ -121,13 +121,15 @@ impl TimeCap { let track = broadcast.create_track("bench", timed()).unwrap(); land(&track, 0, 0); // The time end, then disjoint groups far past anything the run lands. - let subscription = track::Subscription::default().with_max_delay(BUDGET).with_ranges( - std::iter::once(track::Range { - start: track::Anchor::Time(millis(0)), - end: Some(track::Anchor::Time(millis(LIVE))), - }) - .chain((1..ranges).map(|i| track::Range::groups(LIVE + i * 2..LIVE + i * 2 + 1))), - ); + let subscription = track::Subscription::default() + .with_delay(track::Delay::default().with_max(BUDGET)) + .with_ranges( + std::iter::once(track::Range { + start: track::Anchor::Time(millis(0)), + end: Some(track::Anchor::Time(millis(LIVE))), + }) + .chain((1..ranges).map(|i| track::Range::groups(LIVE + i * 2..LIVE + i * 2 + 1))), + ); let mut reader = track.subscribe(subscription); drain(&mut reader); Self { @@ -169,11 +171,13 @@ impl FillsOut { let start = ranges * 2; track.start_at(start).unwrap(); land(&track, start, start); - let subscription = track::Subscription::default().with_max_delay(BUDGET).with_ranges( - (0..ranges) - .map(|i| track::Range::groups(i * 2..i * 2 + 1)) - .chain([track::Range::groups(start..)]), - ); + let subscription = track::Subscription::default() + .with_delay(track::Delay::default().with_max(BUDGET)) + .with_ranges( + (0..ranges) + .map(|i| track::Range::groups(i * 2..i * 2 + 1)) + .chain([track::Range::groups(start..)]), + ); let mut reader = track.subscribe(subscription); drain(&mut reader); Self { @@ -212,10 +216,12 @@ impl Sparse { for i in 0..=ranges { land(&track, i * STRIDE, i); } - let subscription = track::Subscription::default().with_max_delay(BUDGET).with_ranges( - std::iter::once(track::Range::groups(0..1)) - .chain((1..=ranges).map(|i| track::Range::groups(i * STRIDE + 1..i * STRIDE + 2))), - ); + let subscription = track::Subscription::default() + .with_delay(track::Delay::default().with_max(BUDGET)) + .with_ranges( + std::iter::once(track::Range::groups(0..1)) + .chain((1..=ranges).map(|i| track::Range::groups(i * STRIDE + 1..i * STRIDE + 2))), + ); let mut reader = track.subscribe(subscription); assert_eq!(drain(&mut reader), 1, "group 0 is the one in range"); Self { @@ -259,14 +265,16 @@ impl TimeGaps { start: track::Anchor::Time(millis(ranges * 10)), end: Some(track::Anchor::Time(millis(LIVE))), }; - let subscription = track::Subscription::default().with_max_delay(BUDGET).with_ranges( - (0..ranges) - .map(|i| track::Range { - start: track::Anchor::Time(millis(i * 10 + 1)), - end: Some(track::Anchor::Time(millis(i * 10 + 5))), - }) - .chain([live]), - ); + let subscription = track::Subscription::default() + .with_delay(track::Delay::default().with_max(BUDGET)) + .with_ranges( + (0..ranges) + .map(|i| track::Range { + start: track::Anchor::Time(millis(i * 10 + 1)), + end: Some(track::Anchor::Time(millis(i * 10 + 5))), + }) + .chain([live]), + ); let mut reader = track.subscribe(subscription); let groups = usize::try_from(ranges).unwrap() + 1; assert_eq!(drain(&mut reader), groups, "each range's own group, and the live edge"); @@ -307,7 +315,7 @@ impl Datagrams { // Each range presents from a time of its own through group 0, which every datagram // after the first passes. let subscription = track::Subscription::default() - .with_max_delay(BUDGET) + .with_delay(track::Delay::default().with_max(BUDGET)) .with_ranges((0..ranges).map(|i| track::Range { start: track::Anchor::Time(millis(i)), end: Some(track::Anchor::Absolute(1)), diff --git a/rs/moq-net/benches/session/mod.rs b/rs/moq-net/benches/session/mod.rs index 46ea092f78f..d8554982cdc 100644 --- a/rs/moq-net/benches/session/mod.rs +++ b/rs/moq-net/benches/session/mod.rs @@ -187,7 +187,9 @@ impl Shape { /// What each viewer subscribes with: a burst is a backlog, which the default /// subscription writes off as late. fn subscription(&self) -> Option { - (self.burst > 1).then(|| track::Subscription::default().with_max_delay(Duration::from_secs(30))) + (self.burst > 1).then(|| { + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(30))) + }) } fn total(&self) -> usize { @@ -1229,7 +1231,11 @@ pub(crate) fn range_updates(c: &mut Criterion) { .watch( &path(0), &[TRACK], - Some(track::Subscription::default().with_range(span..).with_max_delay(None)), + Some( + track::Subscription::default() + .with_range(span..) + .with_delay(track::Delay::default().with_max(None)), + ), ) .await; viewers.push(viewer); @@ -1245,7 +1251,11 @@ pub(crate) fn range_updates(c: &mut Criterion) { let future = next + span; for viewer in &mut viewers { viewer.subscribers[0] - .update(track::Subscription::default().with_range(future..).with_max_delay(None)) + .update( + track::Subscription::default() + .with_range(future..) + .with_delay(track::Delay::default().with_max(None)), + ) .unwrap(); } kio::wait(|waiter| { @@ -1273,7 +1283,7 @@ pub(crate) fn range_updates(c: &mut Criterion) { .update( track::Subscription::default() .with_range(future - 1..) - .with_max_delay(None), + .with_delay(track::Delay::default().with_max(None)), ) .unwrap(); } diff --git a/rs/moq-net/benches/tail.rs b/rs/moq-net/benches/tail.rs index 9dec069e570..26d18a56e25 100644 --- a/rs/moq-net/benches/tail.rs +++ b/rs/moq-net/benches/tail.rs @@ -34,8 +34,10 @@ impl Held { .map(|i| { let mut track = broadcast.create_track(format!("bench{i}"), None).unwrap(); // A budget wide enough that no cached group is skipped as stale. - let mut reader = - track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let mut reader = track.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); track.start_at(0).unwrap(); let end = track::Position::group(groups as u64 + 1); moq_net::fuzz::received(&track); diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index e6c4da155e8..b31e74d69d3 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -443,7 +443,9 @@ impl Aged { let tracks = (0..tracks) .map(|i| { let track = broadcast.create_track(format!("bench{i}"), None).unwrap(); - let mut sub = track.subscribe(track::Subscription::default().with_max_delay(AGED_BUDGET)); + let mut sub = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(AGED_BUDGET)), + ); let mut held = track.append_group().unwrap(); held.write_frame(None, Bytes::from_static(b"x")).unwrap(); for _ in 1..groups.max(2) { @@ -930,13 +932,13 @@ fn bench_subscriber_preferences(c: &mut Criterion) { let i = i as u64; track::Subscription::default() .with_priority(((i % 255) as i16 - 128) as i8) - .with_max_delay(Duration::from_micros(i)) + .with_delay(track::Delay::default().with_max(Duration::from_micros(i))) .with_range(100 + i..200 + i) .with_order(track::Order::Ascending) }; let extreme = track::Subscription::default() .with_priority(i8::MAX) - .with_max_delay(Duration::from_secs(10)); + .with_delay(track::Delay::default().with_max(Duration::from_secs(10))); let mut group = c.benchmark_group("track_subscriber_preferences"); for steady in STEADY { for touched in CHURN { @@ -1054,7 +1056,7 @@ fn bench_range_serving(c: &mut Criterion) { group.finish().unwrap(); } let subscription = track::Subscription::default() - .with_max_delay(AGED_BUDGET) + .with_delay(track::Delay::default().with_max(AGED_BUDGET)) .with_ranges((0..ranges).map(|i| { let start = i * (span + 1); track::Range::groups(start..start + span) @@ -1102,7 +1104,8 @@ fn bench_datagram_subscriber_join(c: &mut Criterion) { (broadcast, track) }, |(broadcast, track)| { - let preferences = track::Subscription::default().with_max_delay(Duration::from_millis(5)); + let preferences = track::Subscription::default() + .with_delay(track::Delay::default().with_max(Duration::from_millis(5))); let viewers: Vec<_> = (0..join).map(|_| track.subscribe(preferences.clone())).collect(); (broadcast, track, viewers) }, @@ -1213,7 +1216,8 @@ fn bench_parked_read(c: &mut Criterion) { .checked_mul(MICROS_PER_GROUP) .unwrap(), ); - let mut sub = track.subscribe(track::Subscription::default().with_max_delay(max_delay)); + let mut sub = track + .subscribe(track::Subscription::default().with_delay(track::Delay::default().with_max(max_delay))); let mut micros = 0; let mut open = Vec::with_capacity(parked); let queue = Arc::new(std::sync::Mutex::new(Vec::new())); @@ -1286,7 +1290,9 @@ impl Sparse { fn new(info: track::Info, gaps: u64) -> Self { let broadcast = broadcast::Info::default().produce(); let track = broadcast.create_track("bench", info.clone()).unwrap(); - let sub = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let sub = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let mut sparse = Self { _broadcast: broadcast, track, @@ -1390,7 +1396,8 @@ impl Idle { let waiter = kio::Waiter::noop(); let (mut recv, mut next) = (Vec::new(), Vec::new()); for _ in 0..readers { - let reader = track.subscribe(track::Subscription::default().with_max_delay(AGED_BUDGET)); + let reader = track + .subscribe(track::Subscription::default().with_delay(track::Delay::default().with_max(AGED_BUDGET))); if ordered { let mut reader = reader.ordered(); assert!(matches!(reader.poll_next_group(&waiter), Poll::Ready(Ok(Some(_))))); @@ -1450,7 +1457,10 @@ impl Expiring { let mut sparse = Sparse::new(track::Info::default(), 0); sparse .sub - .update(track::Subscription::default().with_max_delay(Duration::from_millis(gaps))) + .update( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(Duration::from_millis(gaps))), + ) .unwrap(); let pool = sparse._broadcast.info().pool.clone(); // Supply time ahead of the host clock, so only these steps age the gaps even @@ -1516,7 +1526,9 @@ impl GapFlush { fn new(gaps: u64) -> Self { let broadcast = broadcast::Info::default().produce(); let track = broadcast.create_track("bench", timed()).unwrap(); - let mut sub = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let mut sub = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let mut hand_out = |sequence: u64, millis: u64| { let mut group = track.create_group(moq_net::group::Info { sequence }).unwrap(); group @@ -1631,7 +1643,7 @@ impl EmptyRun { Run::Fresh => (Duration::from_millis(HOUR), run as usize + 2), Run::Retired => (Duration::ZERO, 1), }; - let budget = track::Subscription::default().with_max_delay(budget); + let budget = track::Subscription::default().with_delay(track::Delay::default().with_max(budget)); Self { readers: (0..readers).map(|_| track.subscribe(budget.clone())).collect(), _broadcast: broadcast, @@ -1694,7 +1706,7 @@ fn bench_range_widening(c: &mut Criterion) { group.finish().unwrap(); } let narrow = track::Subscription::default() - .with_max_delay(AGED_BUDGET) + .with_delay(track::Delay::default().with_max(AGED_BUDGET)) .with_range(0..1); let mid = cached / 2; let wide = narrow @@ -1742,7 +1754,7 @@ impl BudgetWidening { fn new(readers: usize, cached: u64, runs: u64) -> Self { let broadcast = broadcast::Info::default().produce(); let track = broadcast.create_track("bench", timed()).unwrap(); - let narrow = track::Subscription::default().with_max_delay(Self::NARROW); + let narrow = track::Subscription::default().with_delay(track::Delay::default().with_max(Self::NARROW)); let mut reader = track.subscribe(narrow.clone()); let beside = (0..readers).map(|_| track.subscribe(narrow.clone())).collect(); let waiter = kio::Waiter::noop(); @@ -1780,7 +1792,7 @@ impl BudgetWidening { } fn widen(&mut self) { - let wide = track::Subscription::default().with_max_delay(AGED_BUDGET); + let wide = track::Subscription::default().with_delay(track::Delay::default().with_max(AGED_BUDGET)); self.reader.update(wide).unwrap(); let waiter = kio::Waiter::noop(); let mut offered = 0; @@ -1834,7 +1846,7 @@ impl TimeEnd { zero.finish().unwrap(); // Group 1 never arrives, and the budget outlasts the run, so the end never settles. let subscription = track::Subscription::default() - .with_max_delay(AGED_BUDGET) + .with_delay(track::Delay::default().with_max(AGED_BUDGET)) .with_ranges([track::Range { start: track::Anchor::Absolute(0), end: Some(track::Anchor::Time(Timestamp::from_secs(u32::MAX.into()).unwrap())), @@ -1921,7 +1933,7 @@ impl Fills { .chain([track::Range::groups(start..)]); let subscription = track::Subscription::default() .with_ranges(ranges) - .with_max_delay(AGED_BUDGET); + .with_delay(track::Delay::default().with_max(AGED_BUDGET)); let readers = (0..readers).map(|_| track.subscribe(subscription.clone())).collect(); Self { _broadcast: broadcast, @@ -2001,7 +2013,7 @@ impl RangeFills { .unwrap(); group.finish().unwrap(); let subscription = track::Subscription::default() - .with_max_delay(AGED_BUDGET) + .with_delay(track::Delay::default().with_max(AGED_BUDGET)) .with_ranges((0..ranges).map(|i| { let first = i * (span + 1); track::Range::groups(first..first + span) @@ -2104,7 +2116,7 @@ impl RefusedFill { let mut reader = track.subscribe( track::Subscription::default() .with_range(0..) - .with_max_delay(AGED_BUDGET), + .with_delay(track::Delay::default().with_max(AGED_BUDGET)), ); let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) else { panic!("the live group is cached"); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index d839a80ba88..41a27af2c79 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -651,6 +651,7 @@ impl LiteSample { track: "video".into(), priority: 2, max_delay: std::time::Duration::from_secs(10), + min_delay: None, ranges: vec![lite::Range::OLDEST], } .encode(w, version) @@ -856,6 +857,7 @@ impl Default for Messages { track: "video".into(), priority: 3, max_delay: Duration::from_millis(500), + min_delay: None, ranges: vec![lite::Range { start: Some(lite::Anchor::Absolute(crate::track::Position::group(1_000))), end: None, @@ -866,6 +868,7 @@ impl Default for Messages { order: crate::track::Order::Descending, priority: 4, max_delay: Duration::from_millis(500), + min_delay: None, ranges: vec![lite::Range { start: Some(lite::Anchor::Absolute(crate::track::Position::group(1_000))), end: Some(lite::Anchor::Absolute(crate::track::Position::group(2_001))), @@ -1033,6 +1036,7 @@ pub fn encode_subscribe(broadcast: &Path, track: &str, ietf: bool, out: &mut Vec track: track.into(), priority: 0, max_delay: std::time::Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::OLDEST], order: track::Order::Descending, } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 2db27696370..f42ce014e1e 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -31,8 +31,9 @@ use super::{Message, Version, cluster, error::request, peer, request_update}; fn serving_subscription(subscriber_priority: u8) -> Subscription { Subscription { priority: super::priority::from_wire(subscriber_priority), - // No Max Age is the publisher's retention alone, whatever it turns out to be. - max_delay: None, + // No Max Age is the publisher's retention alone, whatever it turns out to be, and + // moq-transport has no field for a minimum. + delay: crate::track::Delay { min: None, max: None }, // SUBSCRIBE_OK names the live edge, which a relay learns only from its upstream's // answer: never from its cache, nor from an edge reported before. feed: true, @@ -1545,7 +1546,7 @@ impl Publisher { let applied = track_serve.track.subscription(); msg.subscriber_priority = super::priority::to_wire(applied.priority); msg.filter = track_serve.filter; - msg.max_age = applied.max_delay.and_then(|delay| delay.as_millis().try_into().ok()); + msg.max_age = applied.delay.max.and_then(|delay| delay.as_millis().try_into().ok()); track_serve.close_groups(); self.pause_join(_join.as_ref()); drop(track_serve); @@ -2057,7 +2058,7 @@ impl Publisher { // which the track's refresh ratio judges. let mut subscription = serving_subscription(msg.subscriber_priority).with_order(msg.group_order.to_order()); if let Some(max_age) = msg.max_age { - subscription.max_delay = Some(Duration::from_millis(max_age)); + subscription.delay.max = Some(Duration::from_millis(max_age)); } // A range that resolves without the live edge has a fixed start, which is no join: // bound the subscription before subscribing so the ratio never judges it. From the @@ -2328,7 +2329,7 @@ impl Publisher { } if let Some(max_age) = update.max_age { let mut subscription = serve.track.subscription(); - subscription.max_delay = Some(Duration::from_millis(max_age)); + subscription.delay.max = Some(Duration::from_millis(max_age)); serve.track.update(subscription).map_err(failed)?; } if let Some(value) = update.new_group { @@ -4637,7 +4638,7 @@ impl TrackServe { if let Some(next) = range.fill_tail(fill, edge) { let options = group::Fetch { priority: self.track.subscription().priority, - max_delay: self.track.subscription().max_delay, + max_delay: self.track.subscription().delay.max, ..Default::default() }; // A stream still serving this group from the fill's edge keeps what it covers, so @@ -6288,8 +6289,10 @@ mod group_priority_test { "test", crate::track::Info::timed(), ); - let subscriber = - track.subscribe(track::Subscription::default().with_max_delay(std::time::Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(3600))), + ); let serve = TrackServe::new( crate::driver::Transport::new(session.clone()), subscriber, @@ -6338,7 +6341,7 @@ mod group_priority_test { let subscriber = track.subscribe( track::Subscription::default() .with_priority(3) - .with_max_delay(std::time::Duration::from_secs(3600)), + .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(3600))), ); let mut serve = TrackServe::new( crate::driver::Transport::new(session.clone()), @@ -6394,7 +6397,7 @@ mod group_priority_test { let subscriber = track.subscribe( track::Subscription::default() .with_priority(3) - .with_max_delay(std::time::Duration::from_secs(3600)), + .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(3600))), ); let mut serve = TrackServe::new( crate::driver::Transport::new(session.clone()), @@ -6435,8 +6438,10 @@ mod group_priority_test { "test", crate::track::Info::timed(), ); - let subscriber = - track.subscribe(track::Subscription::default().with_max_delay(std::time::Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(3600))), + ); let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); for millis in 0..FRAMES as u64 { group @@ -6529,8 +6534,10 @@ mod group_priority_test { crate::track::Info::timed(), ); // Nothing goes stale, so only the credit bounds what is held. - let subscriber = - track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let serve = TrackServe::new( crate::driver::Transport::new(session.clone()), subscriber, @@ -6575,7 +6582,9 @@ mod group_priority_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let mut serve = TrackServe::new( crate::driver::Transport::new(SinkSession::gated_open_uni(gate.consume())), subscriber, @@ -14596,7 +14605,7 @@ mod tests { let subscription = serving_subscription(128); assert_eq!( - subscription.max_delay, None, + subscription.delay.max, None, "no Max Age is the publisher's retention alone" ); let mut subscriber = producer.subscribe(subscription); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4c2111d80ae..de9a5194b45 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -12686,7 +12686,7 @@ mod stitch_tests { .subscribe(Some( track::Subscription::default() .with_order(crate::track::Order::Ascending) - .with_max_delay(None), + .with_delay(track::Delay::default().with_max(None)), )) .with_range(..); let mut fill = h.stream().await; @@ -14835,7 +14835,7 @@ mod stitch_tests { track::Subscription::default() .with_range(SEQUENCE..last + 1) .with_order(crate::track::Order::Ascending) - .with_max_delay(None), + .with_delay(track::Delay::default().with_max(None)), )) .await .unwrap(); @@ -14923,7 +14923,7 @@ mod stitch_tests { track::Subscription::default() .with_range(3..7) .with_order(crate::track::Order::Ascending) - .with_max_delay(None), + .with_delay(track::Delay::default().with_max(None)), )) .await .unwrap(); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 6ed08f37ac0..865488d068f 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -2314,7 +2314,10 @@ impl SubscribeServe { ) -> Self { let subscription = crate::track::Subscription { priority: msg.priority, - max_delay: serving_max_delay(shared.version, msg.max_delay), + delay: track::Delay { + min: msg.min_delay, + max: serving_max_delay(shared.version, msg.max_delay), + }, order: msg.order, ..bounds.groups() }; @@ -2573,11 +2576,12 @@ mod test { } // What run_subscribe hands the model for a peer that sent no budget at all. - let served = |version| { - producer.subscribe( - track::Subscription::default().with_max_delay(serving_max_delay(version, std::time::Duration::ZERO)), - ) - }; + let served = + |version| { + producer.subscribe(track::Subscription::default().with_delay( + track::Delay::default().with_max(serving_max_delay(version, std::time::Duration::ZERO)), + )) + }; let drain = |subscriber: &mut track::Subscriber| { let mut sequences = Vec::new(); while let Some(Ok(Some(group))) = subscriber.recv_group().now_or_never() { @@ -2599,14 +2603,18 @@ mod test { // Lite03-05 declare a budget, but their drafts define it as a staleness tolerance // and an absent start as the latest group, so they are pinned all the same. - let tolerant = - producer.subscribe(track::Subscription::default().with_max_delay(std::time::Duration::from_secs(5))); + let tolerant = producer.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(5))), + ); let mut tolerant = tolerant.with_range(cursor_floor(Version::Lite05, None, producer.latest_sequence())..); assert_eq!(drain(&mut tolerant), vec![2]); // On lite-06 the declared budget is what resolves the start, so it stands. - let declared = - producer.subscribe(track::Subscription::default().with_max_delay(std::time::Duration::from_secs(5))); + let declared = producer.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(5))), + ); let mut declared = declared.with_range(cursor_floor(Version::Lite06, None, producer.latest_sequence())..); assert_eq!(drain(&mut declared), vec![0, 1, 2]); } @@ -2657,7 +2665,7 @@ mod test { let mut subscriber = producer.subscribe( track::Subscription::default() .with_start(0) - .with_max_delay(std::time::Duration::from_secs(60)), + .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(60))), ); let write = |producer: &mut track::Producer, sequence: u64| { @@ -2784,7 +2792,9 @@ mod test { use futures::FutureExt; let mut producer = track_producer("test"); - let mut subscriber = producer.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(5))); + let mut subscriber = producer.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(5))), + ); producer .create_group(group::Info { sequence: 2 }) @@ -4231,7 +4241,10 @@ impl TrackRun { .track .update(crate::track::Subscription { priority: upd.priority, - max_delay: serving_max_delay(self.ctx.version, upd.max_delay), + delay: crate::track::Delay { + min: upd.min_delay, + max: serving_max_delay(self.ctx.version, upd.max_delay), + }, order: upd.order, ..groups }) @@ -5786,8 +5799,10 @@ mod serve_group_test { crate::track::Info::timed(), ); // Nothing goes stale, so only the credit bounds what is held. - let subscriber = - track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let (run, writer, _) = lite_run(Version::Lite07, session.clone(), subscriber); (track, run, writer) }) @@ -5823,7 +5838,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let (mut run, mut writer, log) = lite_run(version, SinkSession::gated_open_uni(gate.consume()), subscriber); let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -5860,7 +5877,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(1))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(1))), + ); let (mut run, mut writer, log) = lite_run(version, SinkSession::gated_open_uni(gate.consume()), subscriber); for sequence in 0..10 { write_group(&mut track, sequence, sequence * 1000); @@ -6165,6 +6184,7 @@ mod serve_group_test { wanted_group, priority: 0, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], order: Order::Descending, }; @@ -6201,7 +6221,9 @@ mod serve_group_test { crate::track::Info::timed(), ); // Old groups stay worth sending, so both are in flight at once. - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let session = SinkSession::new(Log::default()).with_groups().with_unacked_fin(); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); @@ -6227,6 +6249,7 @@ mod serve_group_test { order: Order::Ascending, priority: 7, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }); { @@ -6286,7 +6309,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let session = SinkSession::new(Log::default()) .with_groups() .with_datagrams(1200) @@ -6326,7 +6351,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let session = SinkSession::new(Log::default()).with_groups().with_datagrams(1200); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite07, session.clone(), subscriber); @@ -6337,6 +6364,7 @@ mod serve_group_test { order: Order::Descending, priority: 0, max_delay: Duration::from_secs(3600), + min_delay: None, ranges: vec![lite::Range::span(Some(5), 2, Some(7), Some(3))], }); @@ -6481,7 +6509,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let session = SinkSession::new(Log::default()).with_groups().with_datagrams(1200); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); @@ -6500,6 +6530,7 @@ mod serve_group_test { order: Order::Ascending, priority: 3, max_delay: Duration::from_secs(3600), + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }); let second = track @@ -6532,7 +6563,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ); let log = Log::default(); let (mut run, mut writer, _) = lite07_run(SinkSession::new(log.clone()).with_unacked_fin(), subscriber); @@ -6593,7 +6626,7 @@ mod serve_group_test { let at = |millis| Anchor::Time(Timestamp::from_millis(millis).unwrap()); let subscriber = track.subscribe( track::Subscription::default() - .with_max_delay(Duration::from_secs(3600)) + .with_delay(track::Delay::default().with_max(Duration::from_secs(3600))) .with_ranges([ Range { start: Anchor::Relative(1), @@ -6714,8 +6747,10 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = - track.subscribe(track::Subscription::default().with_max_delay(Duration::from_millis(1500))); + let subscriber = track.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(Duration::from_millis(1500))), + ); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 0, 0); while run.poll_step(&mut writer, &kio::Waiter::noop()).is_ready() {} @@ -6731,6 +6766,7 @@ mod serve_group_test { wanted_group: None, priority: 0, max_delay: Duration::from_secs(10), + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], order: Order::Descending, }); @@ -6870,7 +6906,7 @@ mod serve_group_test { ); let subscription = track::Subscription::default() .with_start(5) - .with_max_delay(Duration::from_secs(60)); + .with_delay(track::Delay::default().with_max(Duration::from_secs(60))); let subscriber = track.subscribe(subscription); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 5, 5); @@ -6886,6 +6922,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(60), + min_delay: None, ranges: vec![lite::Range::span(Some(0), 0, None, None)], }); while run.poll_step(&mut writer, &kio::Waiter::noop()).is_ready() {} @@ -6926,7 +6963,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(60))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))), + ); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 0, 0); while run.poll_step(&mut writer, &kio::Waiter::noop()).is_ready() {} @@ -6960,7 +6999,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(1))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(1))), + ); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 0, 0); write_group(&mut track, 2, 2); @@ -6990,7 +7031,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(60))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))), + ); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 0, 0); while run.poll_step(&mut writer, &kio::Waiter::noop()).is_ready() {} @@ -7024,7 +7067,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(60))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))), + ); let mut group = track.create_group(group::Info { sequence: u64::MAX }).unwrap(); group.start_at(1).unwrap(); group.write_frame(Timestamp::ZERO, b"x".as_slice()).unwrap(); @@ -7102,7 +7147,7 @@ mod serve_group_test { .subscribe( track::Subscription::default() .with_range(0..) - .with_max_delay(Duration::from_secs(60)), + .with_delay(track::Delay::default().with_max(Duration::from_secs(60))), ) .await .unwrap(), @@ -7263,7 +7308,7 @@ mod serve_group_test { ); let subscription = track::Subscription::default() .with_start(5) - .with_max_delay(Duration::from_secs(60)); + .with_delay(track::Delay::default().with_max(Duration::from_secs(60))); let subscriber = track.subscribe(subscription); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 2, 2); @@ -7281,6 +7326,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(60), + min_delay: None, ranges: vec![lite::Range::span(Some(0), 0, None, None)], }); { @@ -7306,7 +7352,7 @@ mod serve_group_test { ); let subscription = track::Subscription::default() .with_start(5) - .with_max_delay(Duration::from_secs(1)); + .with_delay(track::Delay::default().with_max(Duration::from_secs(1))); let subscriber = track.subscribe(subscription); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 5, 0); @@ -7321,6 +7367,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(1), + min_delay: None, ranges: vec![lite::Range::span(Some(0), 0, None, None)], }); { @@ -7352,7 +7399,7 @@ mod serve_group_test { ); let subscription = track::Subscription::default() .with_start(0) - .with_max_delay(Duration::from_secs(1)); + .with_delay(track::Delay::default().with_max(Duration::from_secs(1))); let subscriber = track.subscribe(subscription); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 0, 0); @@ -7366,6 +7413,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(1), + min_delay: None, ranges: vec![lite::Range::span(Some(start_group), 0, None, None)], }; run.update(update(5)); @@ -7397,7 +7445,7 @@ mod serve_group_test { ); let subscription = track::Subscription::default() .with_start(0) - .with_max_delay(Duration::from_secs(10)); + .with_delay(track::Delay::default().with_max(Duration::from_secs(10))); let subscriber = track.subscribe(subscription); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); write_group(&mut track, 0, 0); @@ -7410,6 +7458,7 @@ mod serve_group_test { order: crate::track::Order::Ascending, priority: 0, max_delay: Duration::from_secs(10), + min_delay: None, ranges: vec![lite::Range::span(Some(start_group), 0, None, None)], }; run.update(update(5)); @@ -7438,7 +7487,7 @@ mod serve_group_test { let subscriber = track.subscribe( track::Subscription::default() .with_start(0) - .with_max_delay(Duration::from_secs(60)), + .with_delay(track::Delay::default().with_max(Duration::from_secs(60))), ); let (mut run, mut writer, _log) = lite_run(Version::Lite05, SinkSession::new(Log::default()), subscriber); let mut settle = |run: &mut TrackRun| { @@ -7455,6 +7504,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(60), + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }); write_group(&mut track, 5, 5); @@ -7476,7 +7526,7 @@ mod serve_group_test { let subscriber = track.subscribe( track::Subscription::default() .with_start(5) - .with_max_delay(Duration::from_secs(60)), + .with_delay(track::Delay::default().with_max(Duration::from_secs(60))), ); let (mut run, mut writer, _log) = lite_run(Version::Lite05, SinkSession::new(Log::default()), subscriber); run.update(lite::SubscribeUpdate { @@ -7484,6 +7534,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(60), + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }); @@ -7507,7 +7558,9 @@ mod serve_group_test { "test", crate::track::Info::timed(), ); - let subscriber = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(1))); + let subscriber = track.subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(1))), + ); let (mut run, mut writer, log) = lite_run(version, SinkSession::new(Log::default()), subscriber); { let mut run = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); @@ -7541,7 +7594,7 @@ mod serve_group_test { track.request_start(Some(0)).unwrap(); let subscription = track::Subscription::default() .with_start(start_group) - .with_max_delay(Duration::from_secs(30)); + .with_delay(track::Delay::default().with_max(Duration::from_secs(30))); let subscriber = track.subscribe(subscription); let session = ScriptedSession::new(Vec::new()); @@ -7618,6 +7671,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(30), + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }); relay.settle(); @@ -7647,6 +7701,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(30), + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }); relay.settle(); @@ -7673,6 +7728,7 @@ mod serve_group_test { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(Some(7), 0, None, None)], }; relay.run.update(update); @@ -8505,6 +8561,7 @@ mod tests { track: "video".into(), priority: 0, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }; leave_request::(version, &subscribe, wait, close).await; @@ -8564,6 +8621,7 @@ mod tests { track: "video".into(), priority: 0, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], } .encode(&mut crate::coding::Encoder::new(&mut script, version.into()), version) @@ -8779,6 +8837,7 @@ mod tests { track: "video".into(), priority: 1, max_delay: Duration::ZERO, + min_delay: None, // A relay asks from its cached group, so the SUBSCRIBE is not a live-edge join. ranges: vec![lite::Range::span(Some(1), 0, None, None)], } @@ -8802,6 +8861,7 @@ mod tests { order: crate::track::Order::Descending, priority, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(Some(1), 0, None, None)], }; session.push(&update(Some(2), 3).encode_bytes(version).unwrap()); @@ -8860,6 +8920,7 @@ mod tests { order: crate::track::Order::Descending, priority: 0, max_delay: Duration::from_secs(3600), + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], } } @@ -8898,6 +8959,7 @@ mod tests { track: "video".into(), priority: 0, max_delay: Duration::from_secs(3600), + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], } .encode(&mut crate::coding::Encoder::new(&mut script, version.into()), version) @@ -9319,6 +9381,7 @@ mod tests { track: "video".into(), priority: 0, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }; let session = ScriptedSession::new(request.encode_bytes(version).unwrap().to_vec()); @@ -9430,6 +9493,7 @@ mod tests { track: "video".into(), priority: 0, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }; let session = ScriptedSession::new(request.encode_bytes(version).unwrap().to_vec()); @@ -9567,6 +9631,7 @@ mod tests { track: track.into(), priority, max_delay: Duration::ZERO, + min_delay: None, ranges: vec![lite::Range::span(None, 0, None, None)], }; let mut script = Vec::new(); diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index 80c5189867f..efabfce87ae 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -21,6 +21,9 @@ pub struct Subscribe<'a> { pub track: Cow<'a, str>, pub priority: i8, pub max_delay: std::time::Duration, + /// The subscriber's `Subscriber Min Age`, or `None` when not stated. Lite07+ only; earlier + /// wires decode as not stated and drop it on encode. + pub min_delay: Option, /// The spans to deliver, never empty. Lite07+ carries any number; earlier wires carry /// exactly one (see [`Range`]). pub ranges: Vec, @@ -39,6 +42,7 @@ impl Subscribe<'_> { track: Cow::Owned(self.track.into_owned()), priority: self.priority, max_delay: self.max_delay, + min_delay: self.min_delay, ranges: self.ranges, order: self.order, } @@ -127,12 +131,15 @@ impl<'a> Message<'a> for Subscribe<'a> { let track = Cow::Borrowed(r.str()?); let priority = super::priority::from_wire(r.u8()?); - let (order, max_delay, ranges) = match version { - Version::Lite01 | Version::Lite02 => (Order::Descending, std::time::Duration::ZERO, vec![Range::OLDEST]), + let (order, max_delay, min_delay, ranges) = match version { + Version::Lite01 | Version::Lite02 => { + (Order::Descending, std::time::Duration::ZERO, None, vec![Range::OLDEST]) + } _ => { let order = decode_order(r, version)?; let max_delay = std::time::Duration::from_millis(r.varint()?); - (order, max_delay, decode_ranges(r, version)?) + let min_delay = decode_min_delay(r, version)?; + (order, max_delay, min_delay, decode_ranges(r, version)?) } }; @@ -143,6 +150,7 @@ impl<'a> Message<'a> for Subscribe<'a> { track, priority, max_delay, + min_delay, ranges, order, }) @@ -161,6 +169,7 @@ impl<'a> Message<'a> for Subscribe<'a> { _ => { encode_order(w, version, self.order)?; w.varint(u64::try_from(self.max_delay.as_millis()).map_err(|_| EncodeError::BoundsExceeded)?)?; + encode_min_delay(w, version, self.min_delay)?; encode_ranges(w, version, &self.ranges)?; } } @@ -169,6 +178,30 @@ impl<'a> Message<'a> for Subscribe<'a> { } } +/// Decode the `Subscriber Min Age` after the max age: milliseconds plus one, zero meaning not +/// stated. A wire without the field decodes as not stated. +fn decode_min_delay(r: &mut Decoder<'_>, version: Version) -> Result, DecodeError> { + match version.has_min_delay() { + true => Ok(r.varint_opt()?.map(std::time::Duration::from_millis)), + false => Ok(None), + } +} + +/// Encode the `Subscriber Min Age`, dropping it on a wire without the field. +fn encode_min_delay( + w: &mut Encoder<'_>, + version: Version, + min_delay: Option, +) -> Result<(), EncodeError> { + if !version.has_min_delay() { + return Ok(()); + } + let min_delay = min_delay + .map(|min| u64::try_from(min.as_millis()).map_err(|_| EncodeError::BoundsExceeded)) + .transpose()?; + w.varint_opt(min_delay) +} + /// Decode the ranges SUBSCRIBE and SUBSCRIBE_UPDATE carry after the max age. /// /// Lite-07 carries a non-empty list. Older wires carry one span in `Group Start`, @@ -610,6 +643,8 @@ pub struct SubscribeUpdate { pub wanted_group: Option, pub priority: i8, pub max_delay: std::time::Duration, + /// See [`Subscribe::min_delay`]. + pub min_delay: Option, /// The replacement for every range; see [`Subscribe::ranges`]. pub ranges: Vec, /// See [`Subscribe::order`]. @@ -629,6 +664,7 @@ impl<'a> Message<'a> for SubscribeUpdate { let priority = super::priority::from_wire(r.u8()?); let order = decode_order(r, version)?; let max_delay = std::time::Duration::from_millis(r.varint()?); + let min_delay = decode_min_delay(r, version)?; let ranges = decode_ranges(r, version)?; let wanted_group = match version.has_refresh() { @@ -640,6 +676,7 @@ impl<'a> Message<'a> for SubscribeUpdate { wanted_group, priority, max_delay, + min_delay, ranges, order, }) @@ -656,6 +693,7 @@ impl<'a> Message<'a> for SubscribeUpdate { w.u8(super::priority::to_wire(self.priority)); encode_order(w, version, self.order)?; w.varint(u64::try_from(self.max_delay.as_millis()).map_err(|_| EncodeError::BoundsExceeded)?)?; + encode_min_delay(w, version, self.min_delay)?; encode_ranges(w, version, &self.ranges)?; if version.has_refresh() { @@ -1038,6 +1076,7 @@ mod test { track: Cow::Borrowed("video"), priority: 3, max_delay: std::time::Duration::from_millis(250), + min_delay: None, // Group 7 from frame 4, through frame 2 of group 9. ranges: vec![Range { start: absolute(7, 4), @@ -1218,6 +1257,7 @@ mod test { wanted_group: None, priority: 3, max_delay: std::time::Duration::from_millis(250), + min_delay: None, ranges: msg.ranges.clone(), order: crate::track::Order::Ascending, }; @@ -1312,6 +1352,7 @@ mod test { wanted_group: wanted, priority: 0, max_delay: std::time::Duration::ZERO, + min_delay: None, ranges: vec![Range::OLDEST], order: crate::track::Order::Descending, }; @@ -1323,6 +1364,39 @@ mod test { } } + /// Lite-07 carries `Subscriber Min Age` on SUBSCRIBE and SUBSCRIBE_UPDATE, keeping a stated + /// zero apart from not stated; an earlier wire drops it, so it decodes as not stated. + #[test] + fn min_delay_roundtrips_only_on_lite07() { + let stated = [ + None, + Some(std::time::Duration::ZERO), + Some(std::time::Duration::from_millis(250)), + ]; + for version in [Version::Lite03, Version::Lite06, Version::Lite07] { + for min_delay in stated { + let expected = if version.has_min_delay() { min_delay } else { None }; + let mut msg = subscribe_sample(); + msg.min_delay = min_delay; + msg.ranges = vec![Range::OLDEST]; + assert_eq!(roundtrip(&msg, version).min_delay, expected, "{version:?}"); + + let update = SubscribeUpdate { + wanted_group: None, + priority: 0, + max_delay: std::time::Duration::ZERO, + min_delay, + ranges: vec![Range::OLDEST], + order: crate::track::Order::Descending, + }; + let buf = update.encode_bytes(version).unwrap(); + let got = crate::coding::decode_buf(&mut buf.as_ref(), version, |r, v| SubscribeUpdate::decode(r, v)) + .unwrap(); + assert_eq!(got.min_delay, expected, "{version:?}"); + } + } + } + /// Lite-07 carries `Group Order` right after the priority, on SUBSCRIBE and /// SUBSCRIBE_UPDATE alike, and refuses a value it does not define. #[test] @@ -1339,6 +1413,7 @@ mod test { wanted_group: None, priority: 3, max_delay: std::time::Duration::from_millis(250), + min_delay: None, ranges: vec![Range::OLDEST], order, }; diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c202bfe075a..8a10609c858 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2426,6 +2426,7 @@ mod tests { use crate::coding::{Decode, Encode}; use crate::lite::test_transport::{Sent, SinkSession}; use crate::model::ProduceTest; + use crate::track::Delay; use futures::FutureExt; const VERSION: Version = Version::Lite05; @@ -2450,6 +2451,7 @@ mod tests { .unwrap(), id: 0, max_delay: Duration::ZERO, + min_delay: None, start: None, priority: 0, requested: None, @@ -2625,6 +2627,7 @@ mod tests { .unwrap(), id: 0, max_delay: Duration::ZERO, + min_delay: None, start: Some(floor), priority: 0, requested: Some(floor), @@ -2854,7 +2857,7 @@ mod tests { let mut reader = track.subscribe( Subscription::default() .with_range(0..) - .with_max_delay(Duration::from_secs(30)), + .with_delay(Delay::default().with_max(Duration::from_secs(30))), ); track.start_at(WIDE).unwrap(); arrive(&track, &tail, WIDE, start); @@ -3081,6 +3084,7 @@ mod tests { stream, id: 0, max_delay: Duration::ZERO, + min_delay: None, start: Some(2), priority: 0, requested: Some(2), @@ -3795,7 +3799,7 @@ mod tests { assert!(track.is_live(), "header first: {header_first}"); let subscription = crate::track::Subscription::default() - .with_max_delay(std::time::Duration::from_secs(10)) + .with_delay(crate::track::Delay::default().with_max(std::time::Duration::from_secs(10))) .with_range(0..); let mut reader = track.subscribe(subscription); let mut sequences = Vec::new(); @@ -4895,7 +4899,10 @@ mod tests { let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![info, Vec::new()]); let subscriber = pipelining(crate::driver::Transport::new(session.clone()), version); let wait = Duration::from_secs(10); - let (waiting, mut run) = subscribing(&subscriber, Subscription::default().with_max_delay(wait)); + let (waiting, mut run) = subscribing( + &subscriber, + Subscription::default().with_delay(Delay::default().with_max(wait)), + ); for _ in 0..4 { let _ = kio::Task::poll(&mut run, &kio::Waiter::noop()); } @@ -4972,7 +4979,7 @@ mod tests { let every = Subscription::default() .with_order(crate::track::Order::Ascending) .with_start(0) - .with_max_delay(Duration::from_secs(60)); + .with_delay(Delay::default().with_max(Duration::from_secs(60))); let mut consumer = track.consume().subscribe(every).await.unwrap(); let framing = lite::Framing::new(track.info(), version); subscriber.subscribes.lock().get_mut(&0).unwrap().accept( @@ -5025,7 +5032,7 @@ mod tests { let every = Subscription::default() .with_order(crate::track::Order::Ascending) .with_start(0) - .with_max_delay(Duration::from_secs(60)); + .with_delay(Delay::default().with_max(Duration::from_secs(60))); let mut consumer = track.consume().subscribe(every).await.unwrap(); let framing = lite::Framing::new(track.info(), version); entry.accept( @@ -7466,6 +7473,7 @@ struct SubStream { /// Original SUBSCRIBE params, echoed in every SUBSCRIBE_UPDATE; refreshed as the /// downstream aggregate changes. max_delay: Duration, + min_delay: Option, /// The lowest absolute start among the ranges, or `None` when one counts back from /// the latest group: the floor below which nothing is asked for. start: Option, @@ -7965,7 +7973,8 @@ impl TrackServe { let start_moved = active.start != subscription.start(); active.priority = subscription.priority; active.order = subscription.order; - active.max_delay = wire_max_delay(subscription.max_delay); + active.max_delay = wire_max_delay(subscription.delay.max); + active.min_delay = subscription.delay.min; // A lowered floor owes groups nobody asked for until now, in the session's // tail as well as in the track's arrivals, which its start declaration // restarts. @@ -8240,7 +8249,7 @@ enum Begin { Establish(Establish), } -/// The `Subscriber Max Age` a SUBSCRIBE carries for `max_delay`: `None` is the publisher's +/// The `Subscriber Max Age` a SUBSCRIBE carries for [`Delay::max`](crate::track::Delay::max): `None` is the publisher's /// max age alone, which the largest value every peer can read leaves to its clamp. fn wire_max_delay(max_delay: Option) -> std::time::Duration { max_delay.unwrap_or(std::time::Duration::from_millis(super::MAX_SAFE_DELAY_MS)) @@ -8253,6 +8262,7 @@ fn buffer_update(active: &mut SubStream) -> Result<(), Error> { wanted_group: active.wanted, priority: active.priority, max_delay: active.max_delay, + min_delay: active.min_delay, ranges: active.ranges.clone(), order: active.order, }) @@ -8294,7 +8304,8 @@ impl Establish { broadcast: self.serve.path.clone(), track: self.serve.name.as_str().into(), priority: self.subscription.priority, - max_delay: wire_max_delay(self.subscription.max_delay), + max_delay: wire_max_delay(self.subscription.delay.max), + min_delay: self.subscription.delay.min, ranges: self.ranges.clone(), order: self.subscription.order, }; @@ -8337,7 +8348,8 @@ impl Establish { unanswered: (!answered).then(|| self.validity.clone()), stream, id: self.id, - max_delay: wire_max_delay(self.subscription.max_delay), + max_delay: wire_max_delay(self.subscription.delay.max), + min_delay: self.subscription.delay.min, start: self.subscription.start(), ranges: self.ranges.clone(), priority: self.subscription.priority, diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 46db7cbcb3d..4ee7c9687c6 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -182,6 +182,15 @@ impl Version { ) } + /// Whether SUBSCRIBE and SUBSCRIBE_UPDATE carry the subscriber's optional + /// `Subscriber Min Age`. Added in lite-07; an earlier subscription decodes it as not stated. + pub(crate) fn has_min_delay(self) -> bool { + !matches!( + self, + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 + ) + } + /// Whether FETCH carries the subscriber's optional max delay. Added in lite-07; an /// earlier fetch is bounded by the publisher's retention alone. pub(crate) fn has_fetch_max_delay(self) -> bool { diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 065f0c3e1f5..cd84338be76 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -3628,7 +3628,7 @@ impl Consumer { /// /// A publisher still writing the group polls this while it waits on its own transport, /// which the group's reads cannot see: once the subscription's - /// [`max_delay`](crate::track::Subscription::max_delay) expires the group, it abandons + /// [`Delay::max`](crate::track::Delay::max) expires the group, it abandons /// the stream, even with every frame already read off. It abandons it too once /// [`Self::poll_finished`] fails. Stays `true` once set. #[cfg(any(feature = "async", test))] @@ -4733,8 +4733,10 @@ mod test { fn draining_a_parked_group_does_not_lock_the_backlog() { let broadcast = super::super::broadcast::Producer::new(Default::default()); let track = broadcast.create_track("parked", track::Info::timed()).unwrap(); - let mut subscriber = - track.subscribe(track::Subscription::default().with_max_delay(std::time::Duration::from_secs(3600))); + let mut subscriber = track.subscribe( + track::Subscription::default() + .with_delay(track::Delay::default().with_max(std::time::Duration::from_secs(3600))), + ); let groups: Vec<_> = (0..3) .map(|sequence| { let mut group = track.create_group(Info { sequence }).unwrap(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a545fa27845..bbf3a98691c 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -11622,7 +11622,8 @@ mod tests { queued(&server).await.accept(&source); let resolved = pending.await.expect("resolves"); - let budget = track::Subscription::default().with_max_delay(Duration::from_secs(3600)); + let budget = + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))); for name in ["a", "b"] { let mut subscription = resolved .track(name) @@ -13091,7 +13092,9 @@ mod tests { let mut again = resolved .track("video") .unwrap() - .subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))) + .subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ) .await .expect("resubscribe"); let mut group = kio::clock::timeout(Duration::from_secs(1), again.recv_group()) @@ -13245,7 +13248,9 @@ mod tests { let mut subscription = edge_resolved .track("video") .unwrap() - .subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))) + .subscribe( + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(3600))), + ) .await .expect("resubscribe"); kio::clock::timeout(Duration::from_secs(5), track.demand().until(|wanted| wanted.is_some())) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index e0bcfa3439c..8620b33ca12 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -2442,7 +2442,7 @@ mod test { use futures::FutureExt; use super::*; - use crate::{SessionError, Timestamp, broadcast}; + use crate::{SessionError, Timestamp, broadcast, track::Delay}; /// A route's copy of the track. fn copy() -> track::Producer { @@ -2461,7 +2461,7 @@ mod test { } fn subscribe(logical: &track::Producer, max_delay: Duration) -> track::Subscriber { - let subscription = Subscription::default().with_max_delay(max_delay); + let subscription = Subscription::default().with_delay(Delay::default().with_max(max_delay)); logical .consume() .subscribe(subscription) @@ -2978,7 +2978,7 @@ mod test { let a = copy(); routes.serve(a.consume()); let subscription = Subscription::default() - .with_max_delay(HOUR) + .with_delay(Delay::default().with_max(HOUR)) .with_ranges([track::Range::groups(0..1), track::Range::groups(2..3)]); let mut sub = logical .consume() @@ -3815,7 +3815,9 @@ mod test { let logical = logical(&routes); let a = copy(); routes.serve(a.consume()); - let subscription = Subscription::default().with_max_delay(HOUR).with_range(8..); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(HOUR)) + .with_range(8..); let mut sub = logical .consume() .subscribe(subscription) @@ -4851,7 +4853,7 @@ mod test { }, ]; let subscription = Subscription::default() - .with_max_delay(Duration::from_secs(30)) + .with_delay(Delay::default().with_max(Duration::from_secs(30))) .with_ranges(ranges); let mut sub = logical .consume() @@ -4960,10 +4962,12 @@ mod test { let logical = logical(&routes); let a = copy(); routes.serve(a.consume()); - let subscription = Subscription::default().with_max_delay(HOUR).with_ranges([track::Range { - start: track::Anchor::Absolute(5), - end: None, - }]); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(HOUR)) + .with_ranges([track::Range { + start: track::Anchor::Absolute(5), + end: None, + }]); let mut sub = logical .consume() .subscribe(subscription) @@ -5008,10 +5012,12 @@ mod test { // The latest group, past the range's end at 11, which it resolves without handing out. write(&a, 1001); routes.serve(a.consume()); - let subscription = Subscription::default().with_max_delay(HOUR).with_ranges([track::Range { - start: track::Anchor::Absolute(0), - end: Some(track::Anchor::Relative(990)), - }]); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(HOUR)) + .with_ranges([track::Range { + start: track::Anchor::Absolute(0), + end: Some(track::Anchor::Relative(990)), + }]); let mut sub = logical .consume() .subscribe(subscription) @@ -5168,7 +5174,7 @@ mod test { end: Some(track::Anchor::Time(ts(3500))), }; let subscription = Subscription::default() - .with_max_delay(Duration::from_secs(30)) + .with_delay(Delay::default().with_max(Duration::from_secs(30))) .with_ranges([range]); let sub = routes.consume().subscribe(kio::Producer::new(subscription).into()); let mut reader = sub.reader.lock().unwrap(); @@ -5417,7 +5423,9 @@ mod test { write(&a, 10); assert_eq!(recv(&mut first).sequence, 10); assert_eq!(routes.subscription(demand.clone(), 2).start(), Some(5)); - let pending = front.consume().subscribe(Subscription::default().with_max_delay(HOUR)); + let pending = front + .consume() + .subscribe(Subscription::default().with_delay(Delay::default().with_max(HOUR))); assert_eq!(routes.subscription(front.subscription().unwrap(), 3).start(), None); drop(pending); drop(second); @@ -6510,7 +6518,7 @@ mod test { write(&a, 4); write(&a, 5); let subscription = Subscription::default() - .with_max_delay(Duration::from_secs(10)) + .with_delay(Delay::default().with_max(Duration::from_secs(10))) .with_ranges([track::Range { start: Anchor::Relative(1), end: None, @@ -6567,7 +6575,7 @@ mod test { } let subscription = Subscription::default() - .with_max_delay(Duration::from_secs(10)) + .with_delay(Delay::default().with_max(Duration::from_secs(10))) .with_ranges([ track::Range::groups(0..1), track::Range { @@ -6595,10 +6603,12 @@ mod test { let a = copy(); write(&a, 0); routes.serve(a.consume()); - let subscription = Subscription::default().with_max_delay(HOUR).with_ranges([track::Range { - start, - end: Some(Anchor::Time(ts(1))), - }]); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(HOUR)) + .with_ranges([track::Range { + start, + end: Some(Anchor::Time(ts(1))), + }]); let mut sub = logical .consume() .subscribe(subscription) @@ -6625,7 +6635,7 @@ mod test { let a = copy(); routes.serve(a.consume()); let subscription = Subscription::default() - .with_max_delay(Duration::from_secs(10)) + .with_delay(Delay::default().with_max(Duration::from_secs(10))) .with_ranges([track::Range::groups(0..1), track::Range::groups(10..11)]); let mut sub = logical .consume() @@ -6786,7 +6796,7 @@ mod test { .update( Subscription::default() .with_priority(7) - .with_max_delay(Duration::from_secs(if widen { 10 } else { 2 })), + .with_delay(Delay::default().with_max(Duration::from_secs(if widen { 10 } else { 2 }))), ) .unwrap(); assert!( @@ -6818,7 +6828,7 @@ mod test { .update( Subscription::default() .with_priority(8) - .with_max_delay(Duration::from_secs(10)), + .with_delay(Delay::default().with_max(Duration::from_secs(10))), ) .unwrap(); assert!( @@ -6912,7 +6922,7 @@ mod test { .update( Subscription::default() .with_priority(7) - .with_max_delay(Duration::from_secs(2)), + .with_delay(Delay::default().with_max(Duration::from_secs(2))), ) .unwrap(), Event::Cancel => { @@ -6938,7 +6948,7 @@ mod test { ); let demand = copies[0].subscription().expect("surviving reader"); assert_eq!(demand.priority, 7, "{events:?}"); - assert_eq!(demand.max_delay, Some(Duration::from_secs(2)), "{events:?}"); + assert_eq!(demand.delay.max, Some(Duration::from_secs(2)), "{events:?}"); drop(groups); drop(subscriptions); drop(control); diff --git a/rs/moq-net/src/model/resume_model.rs b/rs/moq-net/src/model/resume_model.rs index c81a6b6ad4f..c9ba43285eb 100644 --- a/rs/moq-net/src/model/resume_model.rs +++ b/rs/moq-net/src/model/resume_model.rs @@ -306,7 +306,7 @@ impl Reader { } fn subscription(budget: Budget, range: Option<(u64, u64)>) -> track::Subscription { - let subscription = track::Subscription::default().with_max_delay(budget.duration()); + let subscription = track::Subscription::default().with_delay(track::Delay::default().with_max(budget.duration())); match range { Some((start, end)) => subscription.with_range(start..end), None => subscription, diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 1dd9ca92a4b..acd89f3a81d 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -23,6 +23,50 @@ pub struct Subscription { /// Delivery priority, higher first, defaulting to 0. Higher values preempt lower ones /// when bandwidth is constrained. pub priority: i8, + /// How far behind the live edge this subscriber plays; see [`Delay`]. + pub delay: Delay, + /// The spans of the track to deliver; see [`Range`]. + /// + /// The default is [`Range::oldest`]: everything [`Delay::max`] still considers + /// fresh. [`Range::live`] starts at the latest group instead, and an empty list asks + /// for nothing. + /// + /// 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. + /// [`crate::track::Subscriber::with_range`] narrows the reads further. See [Local cursor + /// vs wire preference](crate::track::Subscriber#local-cursor-vs-wire-preference). + pub ranges: Vec, + /// Which pending group the publisher sends first; see [`Order`]. + /// + /// Aggregated across every live subscriber: any [`Order::Descending`] subscriber makes + /// the aggregate descending, so a relay fanning one upstream request out keeps its live + /// subscribers fresh. Each subscriber's own publisher (a relay's downstream session) + /// still serves it in its own order. + pub order: Order, + /// Whether this reader needs the source's live feed, not only its history: a wire + /// publisher owes its peer an answer naming the live edge, which only the upstream's own + /// answer gives. A session holding no upstream subscription for its copy subscribes again + /// for such a reader, whatever its ranges. Aggregated as any: one such reader is enough. + pub(crate) feed: bool, +} + +/// How far behind the live edge a subscriber plays: where it starts, and how late it may fall. +/// +/// Both default to zero, which starts at the newest group and skips a superseded one at once. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Delay { + /// The target latency, the playout or jitter-buffer delay held behind the live edge, or + /// `None` when the subscriber did not state one. + /// + /// Only a peer whose wire cannot carry it leaves it unstated: moq-lite before 07 and + /// moq-transport. A media consumer reads an unstated minimum as zero. The aggregate + /// across subscribers is the smallest stated one, so an unstated minimum never pulls it + /// to zero, and a relay forwards it upstream as is. A publisher may use it, such as to + /// start a cold join that far back; nothing reads it there yet. + /// + /// This is the `Subscriber Min Age` on the moq-lite-07 wire, in milliseconds. + pub min: Option, /// How long a superseded group stays worth delivering to this subscriber, or `None` /// for as long as the publisher keeps it. /// @@ -41,7 +85,7 @@ pub struct Subscription { /// [`Info::max_age`](crate::track::Info::max_age), since waiting for a group longer /// than it is kept around cannot produce it. /// - /// It caps every one of [`Self::ranges`]: a range names what may be delivered, and this + /// It caps every one of [`Subscription::ranges`]: a range names what may be delivered, and this /// budget skips what inside it has gone stale. /// /// # Where it is enforced @@ -76,37 +120,41 @@ pub struct Subscription { /// when something collects the track's cache pool: an origin's driver does, and a /// standalone track needs [`Pool::poll`](crate::cache::Pool::poll) called, as its idle /// expiry already does. Otherwise it is judged only when something else wakes the read. - pub max_delay: Option, - /// The spans of the track to deliver; see [`Range`]. - /// - /// The default is [`Range::oldest`]: everything [`Self::max_delay`] still considers - /// fresh. [`Range::live`] starts at the latest group instead, and an empty list asks - /// for nothing. - /// - /// 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. - /// [`crate::track::Subscriber::with_range`] narrows the reads further. See [Local cursor - /// vs wire preference](crate::track::Subscriber#local-cursor-vs-wire-preference). - pub ranges: Vec, - /// Which pending group the publisher sends first; see [`Order`]. - /// - /// Aggregated across every live subscriber: any [`Order::Descending`] subscriber makes - /// the aggregate descending, so a relay fanning one upstream request out keeps its live - /// subscribers fresh. Each subscriber's own publisher (a relay's downstream session) - /// still serves it in its own order. - pub order: Order, - /// Whether this reader needs the source's live feed, not only its history: a wire - /// publisher owes its peer an answer naming the live edge, which only the upstream's own - /// answer gives. A session holding no upstream subscription for its copy subscribes again - /// for such a reader, whatever its ranges. Aggregated as any: one such reader is enough. - pub(crate) feed: bool, + pub max: Option, +} + +impl Default for Delay { + fn default() -> Self { + Self { + min: Some(Duration::ZERO), + max: Some(Duration::ZERO), + } + } +} + +impl Delay { + /// Set [`Self::min`], returning `self` for chaining. + pub fn with_min(mut self, min: impl Into>) -> Self { + self.min = min.into(); + self + } + + /// Set [`Self::max`], returning `self` for chaining. + pub fn with_max(mut self, max: impl Into>) -> Self { + self.max = max.into(); + self + } + + /// [`Self::max`] as a budget: `None` waits as long as anything could. + pub(crate) fn budget(&self) -> Duration { + self.max.unwrap_or(Duration::MAX) + } } /// Which of a subscription's pending groups the publisher sends first. /// /// A transmission order only: it never decides *whether* a group is delivered, which is -/// [`Subscription::max_delay`]'s job. An ascending subscriber that wants a backlog +/// [`Delay::max`]'s job. An ascending subscriber that wants a backlog /// therefore needs a budget that covers it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum Order { @@ -185,7 +233,7 @@ impl Range { /// Everything from the oldest group still around onward, the default. /// /// Unlike `Absolute(0)..` this names no floor: a wire whose publisher resolves the start - /// from [`Subscription::max_delay`] reaches as far back as it allows, and an older wire + /// from [`Delay::max`] reaches as far back as it allows, and an older wire /// starts at the live edge, filling nothing below it. pub fn oldest() -> Self { Self { @@ -298,7 +346,7 @@ impl Default for Subscription { fn default() -> Self { Self { priority: 0, - max_delay: Some(Duration::ZERO), + delay: Delay::default(), ranges: vec![Range::oldest()], order: Order::Descending, feed: false, @@ -313,16 +361,15 @@ impl Subscription { self } - /// Set how far a group may fall behind the live edge before it is skipped, `None` for - /// the publisher's max age alone, returning `self` for chaining. - pub fn with_max_delay(mut self, max_delay: impl Into>) -> Self { - self.max_delay = max_delay.into(); + /// Set the [`Delay`], returning `self` for chaining. + pub fn with_delay(mut self, delay: Delay) -> Self { + self.delay = delay; self } - /// [`Self::max_delay`] as a budget: `None` waits as long as anything could. + /// [`Delay::max`] as a budget: `None` waits as long as anything could. pub(crate) fn budget(&self) -> Duration { - self.max_delay.unwrap_or(Duration::MAX) + self.delay.budget() } /// Replace [`Self::ranges`], returning `self` for chaining. @@ -474,10 +521,14 @@ impl Combined { return; }; combined.priority = combined.priority.max(sub.priority); - combined.max_delay = match (combined.max_delay, sub.max_delay) { + combined.delay.max = match (combined.delay.max, sub.delay.max) { (Some(a), Some(b)) => Some(a.max(b)), _ => None, }; + combined.delay.min = match (combined.delay.min, sub.delay.min) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; combined.ranges.extend_from_slice(&sub.ranges); // Sequence-first prioritization is enabled only when every subscriber wants it. if let Order::Descending = sub.order { diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 9c1c1cf7479..e4b719f2540 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -23,7 +23,7 @@ use super::skipped::Skipped; use crate::ledger::{self, Ledger}; use super::subscription::{Accounted, Containing, Held, Holdings, Layered, Resolved, Runs, Seen, Window, later}; -pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; +pub use super::subscription::{Anchor, Delay, Order, Position, Range, Subscription}; use rangemap::{RangeInclusiveSet, RangeMap, RangeSet}; mod demand; @@ -122,12 +122,12 @@ pub struct Info { /// /// A group is stale once either clock reaches the budget: the wall-clock time since /// its successor arrived at this hop, or how far its successor's first timestamp - /// trails the live edge (see [`Subscription::max_delay`]). An untimed track is judged + /// trails the live edge (see [`Delay::max`]). An untimed track is judged /// by the wall clock alone, and a peer whose timestamps stop advancing cannot pin a /// superseded group. /// /// A retention bound rather than a delivery one, the inverse of an HTTP - /// `Cache-Control: max-age`. [`Subscription::max_delay`] is clamped to this, since a + /// `Cache-Control: max-age`. [`Delay::max`] is clamped to this, since a /// group can't be waited for longer than it's kept around. Reported in TRACK_INFO so /// relays re-serve with the same window. `None` (the default) sets no limit; /// the origin cache ceiling and pool still apply. `Some(Duration::ZERO)` keeps the live edge. @@ -135,7 +135,7 @@ pub struct Info { /// [`expiry`](crate::cache::Pool::expiry) window. /// /// This is the `Publisher Max Age` on the wire, the publisher-side half of the - /// budget [`Subscription::max_delay`] sets for a subscriber. + /// budget [`Delay::max`] sets for a subscriber. /// /// Encoded as milliseconds in a QUIC varint, so a duration of `2^62` milliseconds /// or more cannot be put on lite-07 or IETF wires. Lite05/06 map values at or above @@ -4075,7 +4075,7 @@ impl Producer { /// SUBSCRIBE_OK round trip). Pass `None` for [`Subscription::default`]. /// /// Reads only the groups the subscription's ranges cover. - /// [`Subscription::max_delay`] is what asks for data: delivery skips everything in range + /// [`Delay::max`] 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. pub fn subscribe(&self, subscription: impl Into>) -> Subscriber { @@ -4119,7 +4119,7 @@ impl Producer { /// when there are no live subscribers. Unlike [`Self::subscription`], this /// doesn't wait for a change or advance the change cursor. /// - /// The aggregate's [`Subscription::max_delay`] is the subscribers' own, capped only by + /// The aggregate's [`Delay::max`] is the subscribers' own, capped only by /// the local cache ceiling ([`broadcast::Info::cache_duration`]). The publisher /// enforces its own [`Info::max_age`], which each reader's lateness budget also /// applies, so the demand asked upstream is the same before and after the info lands. @@ -4421,7 +4421,7 @@ fn clamp_max_delay(mut max_delay: Duration, bound: Option) -> Duration fn clamp_combined(combined: Option, bound: Option) -> Option { let mut combined = combined?; if bound.is_some() { - combined.max_delay = Some(clamp_max_delay(combined.budget(), bound)); + combined.delay.max = Some(clamp_max_delay(combined.budget(), bound)); } Some(combined) } @@ -4971,7 +4971,7 @@ impl Consumer { /// [`Error::Dropped`]) if it is already closed. /// /// Reads only the groups the subscription's ranges cover. - /// [`Subscription::max_delay`] is what asks for data: delivery skips everything in range + /// [`Delay::max`] 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. pub fn subscribe(&self, subscription: impl Into>) -> kio::Pending { @@ -5898,7 +5898,7 @@ impl kio::Task for Fetching { /// publisher's demand. /// /// A group outside either is held rather than skipped past, so widening either one later -/// makes it available again. Within both, [`Subscription::max_delay`] decides what is +/// makes it available again. Within both, [`Delay::max`] decides what is /// still worth delivering, measured against the track's live edge. pub struct Subscriber { name: Arc, @@ -7530,7 +7530,7 @@ impl Cursor { let subscription = self.subscription.read(); miss::Asking { priority: subscription.priority, - max_delay: subscription.max_delay, + max_delay: subscription.delay.max, order: subscription.order, } }; @@ -8120,7 +8120,7 @@ impl Subscriber { /// rewinds. The exception is a cached group it skipped as stale, which a wider budget /// offers again. /// - /// Groups are semi-reliable, and the [`Subscription::max_delay`] budget is the other + /// Groups are semi-reliable, and the [`Delay::max`] budget is the other /// thing (alongside eviction and a moving start) that decides which of them arrive: /// one that has drifted further behind the live edge than the budget tolerates is /// skipped rather than handed over, so a single poll walks off a whole backlog. The @@ -8869,7 +8869,7 @@ impl Subscriber { /// /// # Age and skipping /// -/// [`Subscription::max_delay`] applies as this cursor reads, exactly as it does on the +/// [`Delay::max`] applies as this cursor reads, exactly as it does on the /// arrival cursor: a group is skipped once its *reach*, where its immediate successor /// begins, is that far behind the newest frame on the track. Nothing weaker convicts it, /// because the reach is the only proof that *every* frame it could still hold is past the @@ -9435,7 +9435,7 @@ mod test { fn a_missing_group_uses_the_subscription_grace() { let producer = track_producer("test", Info::default()); let budget = Duration::from_millis(50); - let _sub = producer.subscribe(Subscription::default().with_max_delay(budget)); + let _sub = producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); let feed = producer.consume(); let waiter = kio::Waiter::noop(); producer @@ -9467,7 +9467,7 @@ mod test { /// A bounded replay window for tests whose subject requires every buffered group. fn replay() -> Subscription { - Subscription::default().with_max_delay(Duration::from_secs(30)) + Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(30))) } /// Helper: count live cached groups in state. @@ -10616,7 +10616,8 @@ mod test { .unwrap(); elapse(&producer, Duration::from_secs(1)); } - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(50))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(50)))); // The edge is captured at attachment, not when the subscriber first reads. producer .append_datagram(Timestamp::from_secs(600).unwrap(), b"newer".as_slice()) @@ -10644,7 +10645,8 @@ mod test { .append_datagram(Timestamp::from_scale(units, 90_000).unwrap(), b"x".as_slice()) .unwrap(); } - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_micros(50))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_micros(50)))); for sequence in [1, 2] { assert_eq!(recv_datagram(&mut subscriber).sequence, sequence); } @@ -10661,7 +10663,8 @@ mod test { let sequence = producer .append_datagram(Timestamp::from_secs(180).unwrap(), b"new".as_slice()) .unwrap(); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::ZERO)); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::ZERO))); assert_eq!(recv_datagram(&mut subscriber).sequence, sequence); assert!(subscriber.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); } @@ -10675,7 +10678,8 @@ mod test { producer.append_datagram(None, b"x".as_slice()).unwrap(); elapse(&producer, Duration::from_secs(5)); } - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::ZERO)); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::ZERO))); for sequence in 0..3 { assert_eq!(recv_datagram(&mut subscriber).sequence, sequence); } @@ -11460,7 +11464,7 @@ mod test { #[cfg(feature = "async")] fn frozen_timestamps_still_age_out_by_wall_clock() { let mut producer = track_producer("test", Info::timed()); - let budget = Subscription::default().with_max_delay(Duration::from_secs(1)); + let budget = Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1))); append_at(&mut producer, 0); elapse(&producer, Duration::from_millis(500)); append_at(&mut producer, 0); @@ -11487,7 +11491,8 @@ mod test { // Group N reaches N + 1 seconds against the 4s edge: groups 0 and 1 are 3s and 2s // behind, past 1.5s. Their successors arrived just now. - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(1500))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500)))); assert_eq!(drain(&mut subscriber), vec![2, 3, 4]); } @@ -11506,7 +11511,8 @@ mod test { if primed { elapse(&producer, Duration::ZERO); } - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))); let mut held = producer.append_group().unwrap(); held.write_frame(at, bytes::Bytes::from_static(b"x")).unwrap(); let mut reading = subscriber.assert_group(); @@ -11550,7 +11556,8 @@ mod test { let producer = track_producer("test", info.clone()); let at = info.timescale.map(|_| Timestamp::ZERO); elapse(&producer, Duration::ZERO); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))); let mut held = producer.append_group().unwrap(); held.write_frame(at, bytes::Bytes::from_static(b"x")).unwrap(); let mut reading = subscriber.assert_group(); @@ -11584,7 +11591,7 @@ mod test { fn an_evicted_group_is_judged_by_its_evicted_successor() { let mut producer = track_producer_expiring("test", cache::DEFAULT_EXPIRY); let budget = cache::DEFAULT_EXPIRY * 2; - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(budget)); + let mut subscriber = producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); let waiter = kio::Waiter::noop(); let expired = |held: &mut [group::Consumer; 2]| held.each_mut().map(|group| group.poll_expired(&waiter)); @@ -11643,7 +11650,7 @@ mod test { fn a_reclaimed_failed_successor_hands_the_clock_on() { let mut producer = track_producer_expiring("test", cache::DEFAULT_EXPIRY); let budget = cache::DEFAULT_EXPIRY * 2; - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(budget)); + let mut subscriber = producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); let waiter = kio::Waiter::noop(); let expired = |held: &mut [group::Consumer; 2]| held.each_mut().map(|group| group.poll_expired(&waiter)); @@ -11691,7 +11698,7 @@ mod test { let pool = cache::Pool::new(cache::Config::default().with_expiry(cache::DEFAULT_EXPIRY)); let mut producer = track_producer_pooled("test", pool.clone()); let budget = cache::DEFAULT_EXPIRY * 2; - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(budget)); + let mut subscriber = producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); let waiter = kio::Waiter::noop(); append_at(&mut producer, 0); @@ -11730,7 +11737,7 @@ mod test { fn a_run_of_empty_groups_ages_out_by_wall_clock() { const RUN: u64 = 1000; let mut producer = track_producer("test", Info::timed()); - let budget = Subscription::default().with_max_delay(Duration::from_secs(1)); + let budget = Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1))); let mut subscriber = producer.subscribe(budget.clone()); append_at(&mut producer, 0); let mut open = Vec::new(); @@ -11769,23 +11776,24 @@ mod test { // A max delay budget beyond the cache is capped in the aggregate; a group can't be // waited for longer than the cache keeps it. The subscriber's own preference // is stored verbatim, so what it asked for stays readable. - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); - assert_eq!(subscriber.subscription().max_delay, Some(Duration::from_secs(10))); - assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10)))); + assert_eq!(subscriber.subscription().delay.max, Some(Duration::from_secs(10))); + assert_eq!(producer.subscription().unwrap().delay.max, Some(Duration::from_secs(2))); // A budget within the cache is left alone, and ZERO (skip immediately) stays ZERO. subscriber - .update(Subscription::default().with_max_delay(Duration::from_millis(500))) + .update(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))) .unwrap(); assert_eq!( - producer.subscription().unwrap().max_delay, + producer.subscription().unwrap().delay.max, Some(Duration::from_millis(500)) ); subscriber - .update(Subscription::default().with_max_delay(Duration::ZERO)) + .update(Subscription::default().with_delay(Delay::default().with_max(Duration::ZERO))) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::ZERO)); + assert_eq!(producer.subscription().unwrap().delay.max, Some(Duration::ZERO)); } /// Mint a track under an origin whose retention ceiling is `cap`, so the @@ -11857,18 +11865,18 @@ mod test { #[test] fn max_delay_clamped_via_every_update_path() { let producer = track_producer_capped("test", Info::timed(), Duration::from_secs(2)); - let over = Subscription::default().with_max_delay(Duration::from_secs(10)); + let over = Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10))); // The clamp lives in the aggregation, so it applies no matter which entry point // wrote the raw preference. Previously only `Subscriber::update` clamped. let mut subscriber = producer.subscribe(over.clone()); - assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); + assert_eq!(producer.subscription().unwrap().delay.max, Some(Duration::from_secs(2))); subscriber.control().update(over.clone()).unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); + assert_eq!(producer.subscription().unwrap().delay.max, Some(Duration::from_secs(2))); subscriber.update(over).unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); + assert_eq!(producer.subscription().unwrap().delay.max, Some(Duration::from_secs(2))); } #[test] @@ -11877,10 +11885,12 @@ mod test { // The aggregate takes the max, then clamps once. Equivalent to clamping each // subscriber first, since `min` distributes over `max`. - let _a = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); - let _b = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); + let _a = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))); + let _b = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10)))); - assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); + assert_eq!(producer.subscription().unwrap().delay.max, Some(Duration::from_secs(2))); } /// The publisher enforces its own max age, so the demand asked of it is the subscribers' @@ -11899,16 +11909,16 @@ mod test { consumer .track("test") .unwrap() - .subscribe(Subscription::default().with_max_delay(Duration::from_secs(60))) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(60)))) ); assert!(futures::poll!(waiting.as_mut()).is_pending()); let request = dynamic.requested_track().await.unwrap(); // Capped by the local ceiling alone. - let before = request.subscription().unwrap().max_delay; + let before = request.subscription().unwrap().delay.max; assert_eq!(before, Some(Duration::from_secs(30))); let producer = request.accept(Info::timed().with_max_age(Duration::from_secs(2))); - assert_eq!(producer.subscription().unwrap().max_delay, before); + assert_eq!(producer.subscription().unwrap().delay.max, before); assert_eq!(producer.state.read().max_age_bound(), Some(Duration::from_secs(2))); // A session's tail still waits for a late group no longer than the publisher keeps it, // even for a subscriber that defers to the max age alone. @@ -11999,7 +12009,8 @@ mod test { .write_frame(Timestamp::from_millis(533).unwrap(), bytes::Bytes::from_static(b"")) .unwrap(); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(400))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(400)))); assert_eq!(drain(&mut subscriber), vec![2]); } @@ -12072,7 +12083,8 @@ mod test { for second in 0..5 { append_at(&mut producer, second * 1000); } - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))); assert_eq!( subscriber .recv_group() @@ -12101,7 +12113,8 @@ mod test { edge.write_frame(Timestamp::from_millis(2000).unwrap(), bytes::Bytes::from_static(b"a")) .unwrap(); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(2))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2)))); assert_eq!( subscriber .recv_group() @@ -12132,7 +12145,8 @@ mod test { // edge (2s, 3s, 4s) and drops the two below it. Group 1 reaches exactly 2s behind // the edge, and the reach bound is exclusive, so every frame it could hold is // already past the budget. - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(2))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2)))); assert_eq!(drain(&mut subscriber), vec![2, 3, 4]); } @@ -12178,7 +12192,7 @@ mod test { // edge, so the same join is handed the head of what it can still use. One bound // decides both what is sent and what is expired, so a subscriber is never sent // history it would discard on arrival. - let budget = Subscription::default().with_max_delay(Duration::from_secs(2)); + let budget = Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2))); let mut subscriber = producer.subscribe(budget); assert_eq!(drain(&mut subscriber), vec![2, 3, 4]); } @@ -12201,14 +12215,14 @@ mod test { // A budget reaching further back than the floor is cut off at it. let floored = Subscription::default() .with_start(3) - .with_max_delay(Duration::from_secs(10)); + .with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut subscriber = producer.subscribe(floored); assert_eq!(drain(&mut subscriber), vec![3, 4]); // A floor below what the budget admits changes nothing. let slack = Subscription::default() .with_start(1) - .with_max_delay(Duration::from_secs(2)); + .with_delay(Delay::default().with_max(Duration::from_secs(2))); let mut subscriber = producer.subscribe(slack); assert_eq!(drain(&mut subscriber), vec![2, 3, 4]); } @@ -12225,7 +12239,7 @@ mod test { // cursor sits at the floor rather than sliding back to what is cached. let resumed = Subscription::default() .with_start(7) - .with_max_delay(Duration::from_secs(10)); + .with_delay(Delay::default().with_max(Duration::from_secs(10))); let mut subscriber = producer.subscribe(resumed); assert_eq!(drain(&mut subscriber), Vec::::new()); append_at(&mut producer, 3000); // sequence 3: still below the floor @@ -12248,7 +12262,8 @@ mod test { group.finish().unwrap(); } - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(5))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(5)))); assert_eq!(drain(&mut subscriber), vec![5, 6, 7]); // Arriving below everything already delivered is not what makes content stale: @@ -12272,7 +12287,8 @@ mod test { append_at(&mut producer, second * 1000); } - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(1500))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500)))); assert_eq!(drain(&mut subscriber), vec![1, 2, 3]); } @@ -12321,7 +12337,8 @@ mod test { #[cfg(feature = "async")] async fn a_handed_out_group_wakes_when_a_newer_group_gets_its_first_timestamp() { let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))); let mut old = producer.append_group().unwrap(); old.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"old")) .unwrap(); @@ -12369,7 +12386,8 @@ mod test { const FRAME: u64 = 2500; // micros, one Opus frame per group const PARKED: u64 = 64; let producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(200))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(200)))); // Left open, so every read parks instead of ending. let _open: Vec<_> = (0..PARKED) .map(|i| { @@ -12446,7 +12464,8 @@ mod test { #[cfg(feature = "async")] fn a_parked_read_wakes_only_once_the_edge_reaches_its_deadline() { let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10)))); let mut head = producer.append_group().unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"head")) .unwrap(); @@ -12503,7 +12522,8 @@ mod test { #[cfg(feature = "async")] fn a_reordered_batch_wakes_the_group_pts_drift_deadline() { let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10)))); let mut head = producer.append_group().unwrap(); head.write_frame(Timestamp::from_millis(0).unwrap(), bytes::Bytes::new()) .unwrap(); @@ -12541,7 +12561,8 @@ mod test { #[cfg(feature = "async")] fn an_adopted_high_pts_wakes_the_group_drift_deadline() { let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10)))); let mut old = producer.append_group().unwrap(); old.write_frame(Timestamp::from_millis(0).unwrap(), bytes::Bytes::new()) .unwrap(); @@ -12582,7 +12603,8 @@ mod test { #[cfg(feature = "async")] fn a_parked_read_watches_its_edge_abort() { let mut producer = track_producer("test", crate::track::Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(5))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(5)))); let mut head = producer.append_group().unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"head")) .unwrap(); @@ -12632,7 +12654,8 @@ mod test { #[cfg(feature = "async")] fn a_parked_read_watches_the_replacement_for_an_aborted_successor() { let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))); let mut head = producer.append_group().unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"head")) .unwrap(); @@ -12687,7 +12710,8 @@ mod test { #[cfg(feature = "async")] fn a_parked_read_watches_its_overtaken_edge_abort() { let mut producer = track_producer("test", crate::track::Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(5))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(5)))); let mut head = producer.append_group().unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"head")) .unwrap(); @@ -12735,7 +12759,8 @@ mod test { #[cfg(feature = "async")] fn revealing_a_presented_group_wakes_a_read_it_expires() { let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))); let mut head = producer.append_group().unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"head")) .unwrap(); @@ -12781,7 +12806,8 @@ mod test { #[cfg(feature = "async")] fn a_frameless_group_does_not_count_as_content() { let mut producer = track_producer("test", crate::track::Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(60))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(60)))); producer.finish_at(Position::group(2), None).unwrap(); let mut received: Vec<_> = (0..2) .map(|sequence| producer.create_group(group::Info { sequence }).unwrap()) @@ -12839,7 +12865,8 @@ mod test { async fn a_frameless_group_that_ends_empty_ends_its_readers() { for abort in [false, true] { let mut producer = track_producer("test", None); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(60))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(60)))); producer.finish_at(Position::group(1), None).unwrap(); let mut group = producer.create_group(group::Info { sequence: 0 }).unwrap(); let mut reading = subscriber.assert_group(); @@ -12945,7 +12972,8 @@ mod test { async fn a_reordered_tail_keeps_the_group_pts_drift_verdict() { for reordered in [false, true] { let producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(80))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(80)))); for ms in [0, 100] { let mut group = producer.append_group().unwrap(); group @@ -12986,7 +13014,8 @@ mod test { #[cfg(feature = "async")] async fn a_budget_is_measured_from_the_readers_position() { let producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))); let mut open = producer.append_group().unwrap(); open.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"key")) @@ -13119,7 +13148,8 @@ mod test { // Group 0 reaches 1s, a full second behind the 2s edge, so the clamped 500ms // budget drops it. An unclamped ten seconds would have kept it. - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10)))); assert_eq!(drain(&mut subscriber), vec![1, 2]); } @@ -13140,7 +13170,7 @@ mod test { let mut patient = producer.subscribe( Subscription::default() .with_start(0) - .with_max_delay(Duration::from_secs(10)), + .with_delay(Delay::default().with_max(Duration::from_secs(10))), ); patient.set_range(0..); assert_eq!(drain(&mut patient), vec![0, 1, 2, 3]); @@ -13164,7 +13194,8 @@ mod test { append_at(&mut producer, 2000); // A budget far shorter than the group's own span still keeps it. - let mut sub = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); + let mut sub = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))); assert_eq!(drain(&mut sub), vec![0, 1]); } @@ -13189,7 +13220,8 @@ mod test { append_at(&mut producer, 3000); append_at(&mut producer, 4000); - let mut sub = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); + let mut sub = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))); assert_eq!(drain(&mut sub), vec![1, 2]); } @@ -13201,7 +13233,8 @@ mod test { fn a_frameless_successor_leaves_the_next_started_group_as_the_bound() { let mut producer = track_producer("test", Info::timed()); // Any budget, so only media time judges: the wall clock stands still here. - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))); append_at(&mut producer, 0); // seq 0 let _writer_1 = producer.append_group().unwrap(); // seq 1 stalls before its first frame @@ -13258,7 +13291,8 @@ mod test { // Four seconds after group 4 arrived, groups 0 to 2 were superseded 4, 3, and 2 // seconds ago: past a 1.5s budget. Group 3 was superseded 1s ago. - let mut patient = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(1500))); + let mut patient = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500)))); assert_eq!(drain(&mut patient), vec![3, 4]); // No budget takes the newest group alone, which is never stale. let mut live = producer.subscribe(None); @@ -13443,7 +13477,7 @@ mod test { // Group 0 reaches 1s, a full 2s behind the 3s edge, so it is gone. Group 1 // reaches 2s and could still present up to it: inside a 1.5s budget. let mut subscriber = producer - .subscribe(Subscription::default().with_max_delay(Duration::from_millis(1500))) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500)))) .ordered(); assert_eq!(drain_ordered(&mut subscriber), vec![1, 2, 3]); @@ -13464,7 +13498,7 @@ mod test { // Groups 0 and 1 reach at most 10s, a second behind the edge: convicted. let mut subscriber = producer - .subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(500)))) .ordered(); assert_eq!(drain_ordered(&mut subscriber), vec![2, 3]); } @@ -13786,7 +13820,7 @@ mod test { #[cfg(feature = "async")] async fn a_held_group_is_judged_against_the_moved_run() { let at = |millis| Timestamp::from_millis(millis).unwrap(); - let budget = Subscription::default().with_max_delay(Duration::from_secs(15)); + let budget = Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(15))); let producer = track_producer("test", Info::timed()); let mut subscriber = producer.subscribe(budget.clone()); let mut zero = producer.create_group(group::Info { sequence: 0 }).unwrap(); @@ -13814,7 +13848,7 @@ mod test { for ordered in [false, true] { let mut producer = track_producer("test", Info::timed()); let subscription = Subscription::default() - .with_max_delay(Duration::ZERO) + .with_delay(Delay::default().with_max(Duration::ZERO)) .with_ranges([Range { start: Anchor::Absolute(0), end: Some(Anchor::Relative(0)), @@ -14108,7 +14142,7 @@ mod test { #[cfg(feature = "async")] async fn a_control_update_moves_the_run_a_held_group_is_judged_against() { let at = |millis| Timestamp::from_millis(millis).unwrap(); - let budget = Subscription::default().with_max_delay(Duration::from_secs(15)); + let budget = Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(15))); let producer = track_producer("test", Info::timed()); let mut subscriber = producer.subscribe(budget.clone()); let mut zero = producer.create_group(group::Info { sequence: 0 }).unwrap(); @@ -15030,7 +15064,7 @@ mod test { for (arrived, missing) in [(1, 0), (0, 1)] { let mut producer = track_producer("test", None); producer.received(); - let mut arrival = producer.subscribe(replay().with_max_delay(None)); + let mut arrival = producer.subscribe(replay().with_delay(Delay::default().with_max(None))); producer.start_at(0).unwrap(); producer.finish_at(Position::group(2), None).unwrap(); producer @@ -15479,7 +15513,8 @@ mod test { elapse(&producer, ledger::GRACE); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); - let _long = producer.subscribe(Subscription::default().with_max_delay(ledger::GRACE * 10)); + let _long = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(ledger::GRACE * 10))); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); producer.clone().abort(Error::Cancel).unwrap(); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); @@ -15793,7 +15828,7 @@ mod test { fn next_group_waits_for_an_owed_group() { let producer = received_producer(None); let mut consumer = producer - .subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))) .ordered(); put(&producer, 0, None); put(&producer, 2, None); @@ -15808,7 +15843,7 @@ mod test { fn next_group_skips_a_local_gap() { let producer = track_producer("test", None); let mut consumer = producer - .subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))) .ordered(); put(&producer, 0, None); put(&producer, 2, None); @@ -15824,7 +15859,7 @@ mod test { let budget = Duration::from_millis(50); let producer = received_producer(None); let mut consumer = producer - .subscribe(Subscription::default().with_max_delay(budget)) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))) .ordered(); put(&producer, 0, None); put(&producer, 2, None); @@ -15852,7 +15887,7 @@ mod test { fn next_group_passes_an_owed_group_past_the_media_deadline() { let producer = received_producer(Info::timed()); let mut consumer = producer - .subscribe(Subscription::default().with_max_delay(Duration::from_secs(2))) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(2)))) .ordered(); put(&producer, 5, Some(5000)); assert_eq!(drain_ordered(&mut consumer), [5]); @@ -15884,7 +15919,7 @@ mod test { fn next_group_rejudges_an_owed_group_when_a_lower_successor_lands() { let producer = received_producer(Info::timed()); let mut consumer = producer - .subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))) .ordered(); put(&producer, 0, Some(0)); assert_eq!(drain_ordered(&mut consumer), [0]); @@ -15909,7 +15944,7 @@ mod test { let budget = Duration::from_millis(50); let producer = received_producer(None); let mut consumer = producer - .subscribe(Subscription::default().with_max_delay(budget)) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))) .ordered(); put(&producer, 0, None); assert_eq!(drain_ordered(&mut consumer), [0]); @@ -15934,7 +15969,7 @@ mod test { #[test] #[cfg(feature = "async")] fn next_group_passes_what_is_not_owed() { - let subscription = Subscription::default().with_max_delay(Duration::from_secs(30)); + let subscription = Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(30))); let mut producer = received_producer(None); let mut consumer = producer.subscribe(subscription.clone()).ordered(); put(&producer, 0, None); @@ -15998,7 +16033,7 @@ mod test { let producer = received_producer(None); let mut consumer = producer - .subscribe(Subscription::default().with_max_delay(None)) + .subscribe(Subscription::default().with_delay(Delay::default().with_max(None))) .ordered(); put(&producer, 0, None); put(&producer, 2, None); @@ -21118,7 +21153,7 @@ mod test { let update = subscriber .subscription() .with_priority(-2) - .with_max_delay(budget) + .with_delay(Delay::default().with_max(budget)) .with_order(Order::Descending); subscriber.update(update).unwrap(); assert_eq!(drain_groups(&mut subscriber), [0]); @@ -21277,10 +21312,13 @@ mod test { fn a_gap_is_forgotten_once_a_group_above_it_is_past_the_budget() { let budget = Duration::from_millis(10); let readers = [ - (Info::timed(), Subscription::default().with_max_delay(budget)), + ( + Info::timed(), + Subscription::default().with_delay(Delay::default().with_max(budget)), + ), ( Info::timed().with_max_age(budget), - Subscription::default().with_max_delay(Duration::from_secs(3600)), + Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(3600))), ), ]; for (info, subscription) in readers { @@ -21312,7 +21350,8 @@ mod test { fn an_endless_track_with_gaps_stays_bounded() { let producer = track_producer("test", Info::timed()); // Groups present 1ms apart, so the budget spans ten of them. - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(10))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(10)))); let mut most = 0; for sequence in 0..3000u64 { // Every third group never arrives. @@ -21344,7 +21383,8 @@ mod test { fn an_endless_untimed_track_with_gaps_stays_bounded() { let producer = track_producer("test", None); // Groups arrive 1ms apart, so the budget spans ten of them. - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(10))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(10)))); let mut most = 0; for sequence in 0..3000u64 { elapse(&producer, Duration::from_millis(1)); @@ -21369,7 +21409,8 @@ mod test { #[test] fn an_empty_successor_still_lets_the_gaps_go() { let producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(10))); + let mut subscriber = producer + .subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(10)))); let mut most = 0; for sequence in 0..3000u64 { elapse(&producer, Duration::from_millis(1)); @@ -21397,7 +21438,8 @@ mod test { fn a_gap_below_a_frameless_run_lets_the_floor_advance() { const RUN: u64 = 100; let producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))); write_group(&producer, 0); // Group 1 never arrives. let open: Vec<_> = (2..2 + RUN) @@ -21422,7 +21464,8 @@ mod test { #[test] fn gaps_below_aborted_groups_are_forgotten_together() { let producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(1))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(1)))); let mut handed = Vec::new(); for sequence in (0..20).step_by(2) { let mut group = producer.create_group(group::Info { sequence }).unwrap(); @@ -21452,7 +21495,7 @@ mod test { fn wall_time_batches_aborted_gaps_without_forgetting_a_fresh_successor() { let budget = Duration::from_secs(1); let producer = track_producer("test", None); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(budget)); + let mut subscriber = producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); let mut open = Vec::new(); for sequence in (0..=10).step_by(2) { let mut group = producer.create_group(group::Info { sequence }).unwrap(); @@ -21649,7 +21692,11 @@ mod test { elapse(&producer, Duration::from_secs(1)); } let budget = Duration::from_millis(1500); - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(budget).with_range(0..)); + let mut subscriber = producer.subscribe( + Subscription::default() + .with_delay(Delay::default().with_max(budget)) + .with_range(0..), + ); assert_eq!(drain(&mut subscriber), vec![3, 4]); } @@ -22619,7 +22666,7 @@ mod test { #[cfg(feature = "async")] fn a_widened_budget_reoffers_what_it_skipped_as_stale() { let mut producer = track_producer("test", Info::timed()); - let narrow = Subscription::default().with_max_delay(Duration::from_millis(1500)); + let narrow = Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500))); let mut subscriber = producer.subscribe(narrow.clone()); append_at(&mut producer, 0); assert_eq!(drain(&mut subscriber), vec![0]); @@ -22630,7 +22677,7 @@ mod test { assert_eq!(drain(&mut subscriber), vec![3, 4]); subscriber - .update(narrow.with_max_delay(Duration::from_secs(10))) + .update(narrow.with_delay(Delay::default().with_max(Duration::from_secs(10)))) .unwrap(); assert_eq!( drain(&mut subscriber), @@ -22649,12 +22696,12 @@ mod test { append_at(&mut producer, millis); } let narrow = Subscription::default() - .with_max_delay(Duration::from_millis(1500)) + .with_delay(Delay::default().with_max(Duration::from_millis(1500))) .with_order(Order::Ascending); let mut subscriber = producer.subscribe(narrow.clone()); assert_eq!(drain(&mut subscriber), vec![2, 3]); subscriber - .update(narrow.with_max_delay(Duration::from_secs(10))) + .update(narrow.with_delay(Delay::default().with_max(Duration::from_secs(10)))) .unwrap(); assert_eq!(drain(&mut subscriber), vec![0, 1]); } @@ -22665,7 +22712,7 @@ mod test { #[cfg(feature = "async")] fn a_partial_widening_keeps_the_older_skip_owed() { let mut producer = track_producer("test", Info::timed()); - let narrow = Subscription::default().with_max_delay(Duration::from_millis(1500)); + let narrow = Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500))); let mut subscriber = producer.subscribe(narrow.clone()); append_at(&mut producer, 0); assert_eq!(drain(&mut subscriber), vec![0]); @@ -22680,11 +22727,15 @@ mod test { // Group 4 reaches 6s, 3s behind the 9s edge; group 1 reaches 2s, 7s behind. subscriber - .update(narrow.clone().with_max_delay(Duration::from_secs(4))) + .update( + narrow + .clone() + .with_delay(Delay::default().with_max(Duration::from_secs(4))), + ) .unwrap(); assert_eq!(drain(&mut subscriber), vec![4]); subscriber - .update(narrow.with_max_delay(Duration::from_secs(10))) + .update(narrow.with_delay(Delay::default().with_max(Duration::from_secs(10)))) .unwrap(); assert_eq!(drain(&mut subscriber), vec![1]); } @@ -22699,7 +22750,7 @@ mod test { for millis in [0, 1000, 2000, 3000, 6000] { append_at(&mut producer, millis); } - let narrow = Subscription::default().with_max_delay(Duration::from_millis(1500)); + let narrow = Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500))); let mut subscriber = producer.subscribe(narrow.clone()); subscriber.set_range(3..5); assert_eq!(drain(&mut subscriber), vec![3, 4]); @@ -22711,7 +22762,7 @@ mod test { // Groups 0 through 2 reach 3s at most, far behind the 40s edge. assert_eq!(drain(&mut subscriber), vec![6, 5]); subscriber - .update(narrow.with_max_delay(Duration::from_secs(60))) + .update(narrow.with_delay(Delay::default().with_max(Duration::from_secs(60)))) .unwrap(); assert_eq!(drain(&mut subscriber), vec![2, 1, 0]); } @@ -22731,7 +22782,7 @@ mod test { #[cfg(feature = "async")] fn a_skipped_group_outlives_the_fold_of_its_neighbours() { let mut producer = track_producer("test", Info::timed()); - let narrow = Subscription::default().with_max_delay(Duration::from_millis(1500)); + let narrow = Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500))); let mut subscriber = producer.subscribe(narrow.clone()); append_at(&mut producer, 0); assert_eq!(drain(&mut subscriber), vec![0]); @@ -22751,7 +22802,7 @@ mod test { ); subscriber - .update(narrow.with_max_delay(Duration::from_secs(60))) + .update(narrow.with_delay(Delay::default().with_max(Duration::from_secs(60)))) .unwrap(); assert_eq!(drain(&mut subscriber), vec![2, 1]); } @@ -22765,7 +22816,8 @@ mod test { for millis in [0, 1000, 2000] { append_at(&mut producer, millis); } - let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); + let mut subscriber = + producer.subscribe(Subscription::default().with_delay(Delay::default().with_max(Duration::from_secs(10)))); assert_eq!(drain(&mut subscriber), vec![1, 2]); assert_eq!(skipped_runs(&subscriber), 0); append_at(&mut producer, 3000); @@ -22779,7 +22831,7 @@ mod test { #[cfg(feature = "async")] fn an_evicted_skip_is_forgotten() { let mut producer = track_producer("test", Info::timed()); - let narrow = Subscription::default().with_max_delay(Duration::from_millis(1500)); + let narrow = Subscription::default().with_delay(Delay::default().with_max(Duration::from_millis(1500))); let mut subscriber = producer.subscribe(narrow.clone()); append_at(&mut producer, 0); assert_eq!(drain(&mut subscriber), vec![0]); @@ -22795,7 +22847,7 @@ mod test { assert_eq!(drain(&mut subscriber), vec![5]); assert_eq!(skipped_runs(&subscriber), 0); subscriber - .update(narrow.with_max_delay(Duration::from_secs(60))) + .update(narrow.with_delay(Delay::default().with_max(Duration::from_secs(60)))) .unwrap(); assert_eq!(drain(&mut subscriber), Vec::::new()); } @@ -23562,7 +23614,7 @@ mod test { } let mut stale = producer.subscribe( Subscription::default() - .with_max_delay(Duration::from_millis(10)) + .with_delay(crate::track::Delay::default().with_max(Duration::from_millis(10))) .with_order(Order::Ascending) .with_range(0..2), ); @@ -23617,7 +23669,11 @@ mod test { let (producer, _pool) = pooled_producer(1 << 40); let dynamic = producer.requests(); producer.create_group(10u64.into()).unwrap().finish().unwrap(); - let mut reader = producer.subscribe(Subscription::default().with_max_delay(None).with_range(5..6)); + let mut reader = producer.subscribe( + Subscription::default() + .with_delay(crate::track::Delay::default().with_max(None)) + .with_range(5..6), + ); assert_eq!(read_now(&mut reader, false), None, "group 5 may still arrive"); assert!(dynamic.requested_fill_group().now_or_never().is_none()); elapse(&producer, ledger::GRACE); @@ -23634,7 +23690,11 @@ mod test { // With no fill handler, the hole is given up once the grace passed. let (producer, _pool) = pooled_producer(1 << 40); producer.create_group(10u64.into()).unwrap().finish().unwrap(); - let mut reader = producer.subscribe(Subscription::default().with_max_delay(None).with_range(5..6)); + let mut reader = producer.subscribe( + Subscription::default() + .with_delay(crate::track::Delay::default().with_max(None)) + .with_range(5..6), + ); assert_eq!(read_now(&mut reader, false), None); elapse(&producer, ledger::GRACE); assert_eq!(read_now(&mut reader, false), Some(None)); @@ -23654,7 +23714,11 @@ mod test { let dynamic = producer.requests(); producer.start_at(10).unwrap(); producer.create_group(10u64.into()).unwrap().finish().unwrap(); - let mut reader = producer.subscribe(Subscription::default().with_max_delay(None).with_range(5..6)); + let mut reader = producer.subscribe( + Subscription::default() + .with_delay(crate::track::Delay::default().with_max(None)) + .with_range(5..6), + ); assert_eq!(read_now(&mut reader, false), None, "received: {received}"); let fill = dynamic .requested_fill_group() @@ -23731,7 +23795,7 @@ mod test { let (producer, _pool) = pooled_producer(1); let mut reader = producer.subscribe( Subscription::default() - .with_max_delay(Duration::from_millis(10)) + .with_delay(crate::track::Delay::default().with_max(Duration::from_millis(10))) .with_order(Order::Ascending) .with_range(0..16), ); @@ -23765,7 +23829,7 @@ mod test { let (producer, _pool) = pooled_producer(if evict { 1 } else { 1 << 40 }); let mut reader = producer.subscribe( Subscription::default() - .with_max_delay(Duration::from_millis(10)) + .with_delay(crate::track::Delay::default().with_max(Duration::from_millis(10))) .with_order(Order::Ascending) .with_range(0..), ); @@ -23782,7 +23846,7 @@ mod test { reader .update( Subscription::default() - .with_max_delay(Duration::from_millis(10)) + .with_delay(crate::track::Delay::default().with_max(Duration::from_millis(10))) .with_order(Order::Ascending) .with_range(0..2), ) @@ -23799,7 +23863,11 @@ mod test { let (mut producer, _pool) = pooled_producer(1 << 40); producer.start_at(10).unwrap(); producer.create_group(10u64.into()).unwrap().finish().unwrap(); - let mut reader = producer.subscribe(Subscription::default().with_max_delay(None).with_range(5..6)); + let mut reader = producer.subscribe( + Subscription::default() + .with_delay(crate::track::Delay::default().with_max(None)) + .with_range(5..6), + ); let (flag, waiter) = Flag::waiter(); assert!(reader.poll_group(false, &waiter).is_pending()); flag.take(); diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index 63df3990e0e..d7ed553ead5 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -9,7 +9,7 @@ use std::{ time::Duration, }; -use super::{Order, Subscription, TrackState, clamp_combined}; +use super::{Delay, Order, Subscription, TrackState, clamp_combined}; mod ranges; use ranges::Ranges; @@ -20,6 +20,8 @@ pub(super) struct Subscriptions { priority: BTreeMap, // Keyed by budget, so `None` counts as the widest of all. max_delay: BTreeMap, + // Only stated minimums count, so an unstated one never pulls the smallest to zero. + min_delay: BTreeMap, ranges: Ranges, descending: usize, /// Subscribers that need the live feed; see [`Subscription::feed`]. @@ -37,14 +39,14 @@ pub(super) struct Subscriptions { } /// Everything the aggregate holds besides its ranges. -type Scalars = (i8, Option, Order, bool); +type Scalars = (i8, Delay, Order, bool); impl Subscriptions { pub(super) fn combined(&self) -> Option { - let (priority, max_delay, order, feed) = self.scalars()?; + let (priority, delay, order, feed) = self.scalars()?; Some(Subscription { priority, - max_delay, + delay, ranges: self.ranges.union(), order, feed, @@ -62,7 +64,10 @@ impl Subscriptions { (self.count != 0).then(|| { ( *self.priority.last_key_value().unwrap().0, - Some(*self.max_delay.last_key_value().unwrap().0).filter(|budget| *budget != Duration::MAX), + Delay { + min: self.min_delay.first_key_value().map(|(min, _)| *min), + max: Some(*self.max_delay.last_key_value().unwrap().0).filter(|budget| *budget != Duration::MAX), + }, if self.descending == 0 { Order::Ascending } else { @@ -138,6 +143,9 @@ impl Subscriptions { if let Some(new) = new { add(&mut self.priority, new.priority); add(&mut self.max_delay, new.budget()); + if let Some(min) = new.delay.min { + add(&mut self.min_delay, min); + } if moved { for range in &new.ranges { reshaped |= self.ranges.add(*range); @@ -150,6 +158,9 @@ impl Subscriptions { if let Some(old) = old { remove(&mut self.priority, old.priority); remove(&mut self.max_delay, old.budget()); + if let Some(min) = old.delay.min { + remove(&mut self.min_delay, min); + } if moved { for range in &old.ranges { reshaped |= self.ranges.remove(*range); @@ -314,7 +325,12 @@ mod tests { .collect(); let next = Subscription { priority: rng.random_range(0..8), - max_delay: Some(Duration::from_millis(rng.random_range(0..8))), + delay: Delay { + min: rng + .random_bool(0.75) + .then(|| Duration::from_millis(rng.random_range(0..8))), + max: Some(Duration::from_millis(rng.random_range(0..8))), + }, ranges, order: if rng.random_bool(0.5) { Order::Ascending @@ -363,7 +379,7 @@ mod tests { let strong = producer.subscribe( Subscription::default() .with_priority(10) - .with_max_delay(Duration::from_secs(1)), + .with_delay(Delay::default().with_max(Duration::from_secs(1))), ); let count = Arc::new(Count(AtomicUsize::new(0))); let waiter = kio::Waiter::new(std::task::Waker::from(count.clone())); diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 2732671faa4..d06541d3577 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -1318,7 +1318,7 @@ fn looser(ours: Option, asked: Option) -> bool { #[cfg(test)] mod tests { - use super::super::{Servable, Subscription}; + use super::super::{Delay, Servable, Subscription}; use super::*; #[kio::test] @@ -1328,7 +1328,7 @@ mod tests { .create_track("recreated", crate::track::Info::default()) .unwrap(); let budget = Duration::from_secs(5); - let _reader = track.subscribe(Subscription::default().with_max_delay(budget)); + let _reader = track.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); for sequence in [0, 4] { track .create_group(crate::group::Info { sequence }) @@ -1470,7 +1470,7 @@ mod tests { .create_track("aborted", crate::track::Info::default()) .unwrap(); let budget = Duration::from_secs(5); - let _reader = track.subscribe(Subscription::default().with_max_delay(budget)); + let _reader = track.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); for sequence in [0, 4] { track .create_group(crate::group::Info { sequence }) @@ -1544,7 +1544,7 @@ mod tests { let broadcast = crate::broadcast::Info::default().produce(); let track = broadcast.create_track("sparse", crate::track::Info::default()).unwrap(); let budget = Duration::from_secs(5); - let _reader = track.subscribe(Subscription::default().with_max_delay(budget)); + let _reader = track.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); for sequence in [0, 2, 4] { track .create_group(crate::group::Info { sequence }) @@ -1647,7 +1647,7 @@ mod tests { let broadcast = crate::broadcast::Info::default().produce(); let track = broadcast.create_track("grace", crate::track::Info::default()).unwrap(); let budget = Duration::from_secs(5); - let _reader = track.subscribe(Subscription::default().with_max_delay(budget)); + let _reader = track.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); for sequence in 0..3 { track .create_group(crate::group::Info { sequence }) @@ -1685,7 +1685,7 @@ mod tests { drop(state); let wider = Duration::from_secs(20); - let _wide = track.subscribe(Subscription::default().with_max_delay(wider)); + let _wide = track.subscribe(Subscription::default().with_delay(Delay::default().with_max(wider))); let state = track.state.read(); assert_eq!(state.arrival_grace(), wider); assert!( @@ -1906,7 +1906,7 @@ mod tests { let broadcast = crate::broadcast::Info::default().produce(); let mut track = broadcast.create_track("moved", crate::track::Info::default()).unwrap(); let budget = Duration::from_secs(5); - let _reader = track.subscribe(Subscription::default().with_max_delay(budget)); + let _reader = track.subscribe(Subscription::default().with_delay(Delay::default().with_max(budget))); // A feed with holes, some of them under where it starts. for sequence in 0..SEQUENCES { if next(3) > 0 { @@ -2017,7 +2017,7 @@ mod tests { let ranges = [0..1, 2..3, 4..5, 6..7].map(crate::track::Range::groups); let mut reader = track.subscribe( Subscription::default() - .with_max_delay(Duration::from_secs(60)) + .with_delay(Delay::default().with_max(Duration::from_secs(60))) .with_ranges(ranges.into_iter().chain([crate::track::Range::groups(8..)])), ); let (first, second) = (Arc::new(Count::default()), Arc::new(Count::default())); diff --git a/rs/moq-net/src/stats.rs b/rs/moq-net/src/stats.rs index b784fcfb366..f3cda585b0d 100644 --- a/rs/moq-net/src/stats.rs +++ b/rs/moq-net/src/stats.rs @@ -488,7 +488,7 @@ pub struct Traffic { /// payload as stored or sent. pub inflated: u64, /// Content skipped because it fell behind a subscriber's - /// [`max_delay`](crate::track::Subscription::max_delay) budget. Disjoint from the top-level payload + /// [`Delay::max`](crate::track::Delay::max) budget. Disjoint from the top-level payload /// counters: skipped content is never handed over. A steady rate here means /// subscribers are consistently behind the live edge. pub stale: Content, diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index a73dd8d4abe..a5d6b2bb392 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -1105,7 +1105,8 @@ async fn exact_and_subtree_grants_admit_their_broadcast(version: &'static str) { .available() .await .unwrap(); - let prefs = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let prefs = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))); let mut sub = remote.track("video").unwrap().subscribe(prefs).await.unwrap(); sub.recv_group().await.unwrap().expect("a group"); @@ -1202,7 +1203,10 @@ async fn a_live_subscription_survives_union_renewal(version: &'static str) { async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { within(async { let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let prefs = || { + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))) + }; // The server publishes room/x to the client; the client publishes up/y to the server. let server_origin = produce_origin(1); @@ -1601,7 +1605,10 @@ async fn ended(sub: &mut moq_net::track::Subscriber) -> Error { async fn a_narrowing_deafens_one_path(version: &'static str) { within(async { let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let prefs = || { + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))) + }; let relay = produce_origin(1); let audio = relay.create_broadcast("room/alice/audio").unwrap(); @@ -1686,7 +1693,10 @@ async fn a_narrowing_deafens_one_path(version: &'static str) { async fn a_narrowing_aborts_what_the_peer_published(version: &'static str) { within(async { let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let prefs = || { + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))) + }; let client_origin = produce_origin(2); let mic = client_origin.create_broadcast("room/bob/mic").unwrap(); @@ -1820,7 +1830,10 @@ async fn recv_through(sub: &mut moq_net::track::Subscriber, sequence: u64) { async fn a_widening_brings_back_a_deafened_path(version: &'static str) { within(async { let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let prefs = || { + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))) + }; let relay = produce_origin(1); let audio = relay.create_broadcast("room/alice/audio").unwrap(); @@ -1889,7 +1902,10 @@ async fn a_widening_brings_back_a_deafened_path(version: &'static str) { async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { within(async { let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let prefs = || { + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))) + }; let client_origin = produce_origin(2); let mic = client_origin.create_broadcast("room/bob/mic").unwrap(); diff --git a/rs/moq-net/tests/integration/errored_end.rs b/rs/moq-net/tests/integration/errored_end.rs index fd5501e3f01..a8f8fc5caf2 100644 --- a/rs/moq-net/tests/integration/errored_end.rs +++ b/rs/moq-net/tests/integration/errored_end.rs @@ -5,6 +5,7 @@ //! Before lite-07 the end has nowhere to carry its error, so the subscription resets with it. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -27,7 +28,7 @@ fn produce_origin(hop: u64) -> moq_net::origin::Producer { async fn read(broadcast: &moq_net::broadcast::Consumer) -> (Vec<(u64, Vec>)>, Option) { let subscription = Subscription::default() .with_range(0..) - .with_max_delay(Duration::from_secs(30)); + .with_delay(Delay::default().with_max(Duration::from_secs(30))); let mut sub = kio::clock::timeout(TIMEOUT, broadcast.track("video").unwrap().subscribe(subscription)) .await .expect("subscribe timeout") diff --git a/rs/moq-net/tests/integration/fastest_reader.rs b/rs/moq-net/tests/integration/fastest_reader.rs index d4112857e9b..df0dce3b030 100644 --- a/rs/moq-net/tests/integration/fastest_reader.rs +++ b/rs/moq-net/tests/integration/fastest_reader.rs @@ -49,10 +49,10 @@ async fn relay(version: &str) -> Relay { .expect("broadcast resolves"); let subscriber = kio::clock::timeout( TIMEOUT, - remote - .track("data") - .unwrap() - .subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))), + remote.track("data").unwrap().subscribe( + track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(3600))), + ), ) .await .expect("subscribe timeout") diff --git a/rs/moq-net/tests/integration/fetch_only.rs b/rs/moq-net/tests/integration/fetch_only.rs index 253515daa44..d1da60a31e4 100644 --- a/rs/moq-net/tests/integration/fetch_only.rs +++ b/rs/moq-net/tests/integration/fetch_only.rs @@ -491,7 +491,7 @@ async fn a_resumed_bounded_subscription_reads_below_the_answered_edge() { let copy = remote.track("video").unwrap(); let subscription = moq_net::track::Subscription::default() .with_order(moq_net::track::Order::Ascending) - .with_max_delay(None) + .with_delay(moq_net::track::Delay::default().with_max(None)) .with_range(1..3); let mut sub = kio::clock::timeout(TIMEOUT, copy.subscribe(subscription)) .await @@ -548,7 +548,7 @@ async fn a_resumed_bounded_subscription_keeps_its_cached_start() { // Past the edge the live read learned, so it subscribes; the answer names group 3. let subscription = moq_net::track::Subscription::default() - .with_max_delay(None) + .with_delay(moq_net::track::Delay::default().with_max(None)) .with_range(0..1); let mut sub = kio::clock::timeout(TIMEOUT, copy.subscribe(subscription)) .await diff --git a/rs/moq-net/tests/integration/finite_relay.rs b/rs/moq-net/tests/integration/finite_relay.rs index fcce2290d75..2a0cb226c33 100644 --- a/rs/moq-net/tests/integration/finite_relay.rs +++ b/rs/moq-net/tests/integration/finite_relay.rs @@ -9,6 +9,7 @@ //! subscribes, and the publisher then resets every group still on the wire. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -83,7 +84,9 @@ async fn round(name: &str, arrival: Arrival) -> (Vec, Result<(), moq_net::E let (mut heads, mut opened) = futures::channel::mpsc::unbounded(); let reader = kio::clock::sim::spawn(async move { - let subscription = Subscription::default().with_max_delay(MAX_DELAY).with_range(0..); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(MAX_DELAY)) + .with_range(0..); let mut sub = remote .track("tail") .unwrap() diff --git a/rs/moq-net/tests/integration/group_order.rs b/rs/moq-net/tests/integration/group_order.rs index b9d12b744dd..44ab3e2aa5d 100644 --- a/rs/moq-net/tests/integration/group_order.rs +++ b/rs/moq-net/tests/integration/group_order.rs @@ -8,6 +8,7 @@ //! sender would transmit them. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -54,7 +55,7 @@ fn read_arrivals(origin: moq_net::origin::Producer, order: Order) -> kio::clock: .await .expect("broadcast resolves"); let subscription = Subscription::default() - .with_max_delay(FOREVER) + .with_delay(Delay::default().with_max(FOREVER)) .with_range(0..) .with_order(order); let mut sub = remote diff --git a/rs/moq-net/tests/integration/history_groups.rs b/rs/moq-net/tests/integration/history_groups.rs index d87b817ad03..78c7ac48ab6 100644 --- a/rs/moq-net/tests/integration/history_groups.rs +++ b/rs/moq-net/tests/integration/history_groups.rs @@ -9,6 +9,7 @@ //! releases them newest first to make that race deterministic. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -77,7 +78,9 @@ async fn round(version: &str, relay: bool, newest_first: bool) -> Vec<(u64, usiz .expect("broadcast resolves"); let reader = kio::clock::sim::spawn(async move { - let subscription = Subscription::default().with_max_delay(FOREVER).with_range(0..); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(FOREVER)) + .with_range(0..); let mut sub = remote .track("history") .unwrap() @@ -217,7 +220,9 @@ async fn a_late_subscriber_receives_the_relays_cached_history() { let first_pair = connect_mock(options).await; let consumer = first.consume(); let remote = consumer.follow("bcast", None).unwrap().available().await.unwrap(); - let subscription = Subscription::default().with_max_delay(FOREVER).with_range(0..); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(FOREVER)) + .with_range(0..); let first_subscription = subscription.clone(); let first_reader = kio::clock::sim::spawn(async move { let mut sub = remote diff --git a/rs/moq-net/tests/integration/late_lower_group.rs b/rs/moq-net/tests/integration/late_lower_group.rs index d895c1e8377..34fb1058b88 100644 --- a/rs/moq-net/tests/integration/late_lower_group.rs +++ b/rs/moq-net/tests/integration/late_lower_group.rs @@ -8,6 +8,7 @@ //! spelling of both an omitted floor and group 0; the draft says that 0 is group 0. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -120,7 +121,7 @@ async fn session(version: &str, relay: bool, start: Option) -> Vec<(u64, us .expect("broadcast resolves"); let subscription = Subscription::default() - .with_max_delay(BUDGET) + .with_delay(Delay::default().with_max(BUDGET)) .with_ranges([floored(start)]); let reader = kio::clock::sim::spawn(async move { let mut sub = remote @@ -164,7 +165,7 @@ async fn in_process(start: Option) -> Vec<(u64, usize)> { ) .unwrap(); let subscription = Subscription::default() - .with_max_delay(BUDGET) + .with_delay(Delay::default().with_max(BUDGET)) .with_ranges([floored(start)]); let mut sub = track.subscribe(subscription); let reader = kio::clock::sim::spawn(async move { read_all(&mut sub).await }); @@ -239,7 +240,9 @@ async fn merged(version: &str) -> (Vec<(u64, usize)>, Vec<(u64, usize)>) { let subscribe = |range: Range| { let track = remote.track("late").unwrap(); async move { - let subscription = Subscription::default().with_max_delay(BUDGET).with_ranges([range]); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(BUDGET)) + .with_ranges([range]); kio::clock::timeout(TIMEOUT, track.subscribe(subscription)) .await .expect("subscribe timeout") diff --git a/rs/moq-net/tests/integration/multi_cdn.rs b/rs/moq-net/tests/integration/multi_cdn.rs index 6773985936a..f2fa393dd3a 100644 --- a/rs/moq-net/tests/integration/multi_cdn.rs +++ b/rs/moq-net/tests/integration/multi_cdn.rs @@ -191,7 +191,8 @@ impl Topology { async fn subscribe(&self) -> mpsc::UnboundedReceiver { let remote = self.subscriber.consume().request("live", None).await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let prefs = + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))); read(remote.track("video").unwrap().subscribe(prefs).await.unwrap()) } diff --git a/rs/moq-net/tests/integration/partial_group.rs b/rs/moq-net/tests/integration/partial_group.rs index 8cf08502427..db8c6266368 100644 --- a/rs/moq-net/tests/integration/partial_group.rs +++ b/rs/moq-net/tests/integration/partial_group.rs @@ -5,6 +5,7 @@ //! not cache the frames it already has as the whole group. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -49,7 +50,7 @@ async fn round(version: &str) -> Option, moq_net::Error>> { remote .track("data") .unwrap() - .subscribe(Subscription::default().with_max_delay(BUDGET)), + .subscribe(Subscription::default().with_delay(Delay::default().with_max(BUDGET))), ) .await .expect("subscribe timeout") @@ -85,7 +86,7 @@ async fn round(version: &str) -> Option, moq_net::Error>> { TIMEOUT, remote.track("data").unwrap().subscribe( Subscription::default() - .with_max_delay(Duration::from_secs(3600)) + .with_delay(Delay::default().with_max(Duration::from_secs(3600))) .with_range(0..), ), ) diff --git a/rs/moq-net/tests/integration/range_anchors.rs b/rs/moq-net/tests/integration/range_anchors.rs index 6d0f4b1c707..8f3ead08c41 100644 --- a/rs/moq-net/tests/integration/range_anchors.rs +++ b/rs/moq-net/tests/integration/range_anchors.rs @@ -7,6 +7,7 @@ //! anchor instead of resolving it against the part of the track it happens to hold. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -77,7 +78,7 @@ async fn read(version: Option<&str>, range: Range) -> Result, Error> { }; let subscription = Subscription::default() - .with_max_delay(FOREVER) + .with_delay(Delay::default().with_max(FOREVER)) .with_order(Order::Ascending) .with_ranges([range]); let mut reader = kio::clock::timeout(TIMEOUT, remote.track("data").unwrap().subscribe(subscription)) diff --git a/rs/moq-net/tests/integration/received_rewind.rs b/rs/moq-net/tests/integration/received_rewind.rs index e9e43ad837b..4a8002c79b5 100644 --- a/rs/moq-net/tests/integration/received_rewind.rs +++ b/rs/moq-net/tests/integration/received_rewind.rs @@ -9,6 +9,7 @@ //! receiver checks them, so it refuses that out-of-order group. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -76,7 +77,9 @@ async fn relay( .expect("resolve timeout") .expect("broadcast resolves"); - let subscription = Subscription::default().with_max_delay(BUDGET).with_range(0..); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(BUDGET)) + .with_range(0..); let reader = kio::clock::sim::spawn(async move { let mut sub = remote .track("data") diff --git a/rs/moq-net/tests/integration/rejoin.rs b/rs/moq-net/tests/integration/rejoin.rs index 8251f2744ba..3489d8d98aa 100644 --- a/rs/moq-net/tests/integration/rejoin.rs +++ b/rs/moq-net/tests/integration/rejoin.rs @@ -58,7 +58,9 @@ async fn rejoin_recovers_the_group_reset_on_leave() { let remote = consumer.follow("bench", None).unwrap().available().await.unwrap(); let ts = |ms| Timestamp::from_millis(ms).unwrap(); - let prefs = || track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let prefs = || { + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(10))) + }; let mut group = track.append_group().unwrap(); group.write_frame(ts(0), b"a0".as_ref()).unwrap(); @@ -207,7 +209,8 @@ async fn rejoin_keeps_the_cache_when_the_backlog_lands_newest_first() { let consumer = relay.consume(); let remote = consumer.follow("bench", None).unwrap().available().await.unwrap(); let ts = |ms| Timestamp::from_millis(ms).unwrap(); - let prefs = || track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let prefs = + || track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(10))); // Held for fetches only: it keeps the relay's copy without subscribing. let _held = remote.track("video").unwrap(); diff --git a/rs/moq-net/tests/integration/relay_upstream_close.rs b/rs/moq-net/tests/integration/relay_upstream_close.rs index f0e68bec5d8..2a31afcf065 100644 --- a/rs/moq-net/tests/integration/relay_upstream_close.rs +++ b/rs/moq-net/tests/integration/relay_upstream_close.rs @@ -14,6 +14,7 @@ //! them. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -120,7 +121,9 @@ async fn round(name: &str, end: End) -> Vec { let mut readers = Vec::new(); for (index, track) in tracks.iter().enumerate() { - let subscription = Subscription::default().with_max_delay(MAX_DELAY).with_range(0..); + let subscription = Subscription::default() + .with_delay(Delay::default().with_max(MAX_DELAY)) + .with_range(0..); let sub = remote.track(&format!("track{index}")).unwrap().subscribe(subscription); let sub = kio::clock::timeout(TIMEOUT, sub) .await diff --git a/rs/moq-net/tests/integration/route_change.rs b/rs/moq-net/tests/integration/route_change.rs index 85d0e215b49..8b60195802b 100644 --- a/rs/moq-net/tests/integration/route_change.rs +++ b/rs/moq-net/tests/integration/route_change.rs @@ -130,7 +130,8 @@ impl Topology { let consumer = subscriber.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let prefs = + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))); let sub = remote.track("video").unwrap().subscribe(prefs).await.unwrap(); let topology = Self { @@ -530,7 +531,8 @@ async fn lagging_route_dies(version: Version) { async fn subscribe(origin: &origin::Producer) -> track::Subscriber { let consumer = origin.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); - let preferences = track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let preferences = + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))); remote.track("video").unwrap().subscribe(preferences).await.unwrap() } fn write(group: &mut moq_net::group::Producer, sequence: u64, frame: u64) { @@ -646,7 +648,7 @@ async fn undetected_outage(budget: Duration) -> BTreeMap> { let consumer = e.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(budget); + let prefs = track::Subscription::default().with_delay(track::Delay::default().with_max(budget)); let mut rx = read(remote.track("video").unwrap().subscribe(prefs).await.unwrap()); let m2_e = priced_link(version, &m2, &e, Some(1_000)).await; settle().await; @@ -788,7 +790,7 @@ async fn redundant_pair_follows_the_next_instance(version: &str, loss: Loss) { let mut follow = subscriber.consume().follow("live", None).unwrap(); let first = follow.available().await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let prefs = track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))); let mut rx = read(first.track("video").unwrap().subscribe(prefs.clone()).await.unwrap()); let write = |replicas: &mut Vec, sequence: u64| { for replica in replicas.iter_mut() { @@ -901,7 +903,7 @@ async fn replaced_behind_a_relay(version: &str) { let consumer = downstream.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let prefs = track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))); let mut rx = read(remote.track("video").unwrap().subscribe(prefs).await.unwrap()); let mut dead = None; @@ -998,7 +1000,7 @@ async fn compression_changes_behind_a_relay(compress: bool) { let consumer = downstream.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let prefs = track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))); let mut rx = read(remote.track("video").unwrap().subscribe(prefs).await.unwrap()); let mut dead = None; @@ -1092,7 +1094,7 @@ async fn producer_replaced(version: &str) { let consumer = subscriber.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let prefs = track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(60))); let sub = remote.track("video").unwrap().subscribe(prefs).await.unwrap(); let mut rx = read(sub); diff --git a/rs/moq-net/tests/integration/route_health.rs b/rs/moq-net/tests/integration/route_health.rs index a7a83dfdb66..38c0cb976f7 100644 --- a/rs/moq-net/tests/integration/route_health.rs +++ b/rs/moq-net/tests/integration/route_health.rs @@ -66,7 +66,7 @@ async fn silent_kill(budget: Duration, detect: Duration) -> Vec { let consumer = e.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(budget); + let prefs = track::Subscription::default().with_delay(track::Delay::default().with_max(budget)); let mut rx = read(remote.track("video").unwrap().subscribe(prefs).await.unwrap()); let m2_e = priced_link(version, &m2, &e, Some(1_000)).await; settle().await; @@ -205,7 +205,7 @@ async fn payload_stall(stall: Stall, span: Duration) -> (Observed, Observed) { async fn subscribe(origin: &origin::Producer) -> track::Subscriber { let consumer = origin.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); - let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(2)); + let prefs = track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(2))); remote.track("video").unwrap().subscribe(prefs).await.unwrap() } let mut e_sub = subscribe(&e).await; diff --git a/rs/moq-net/tests/integration/settled_end_survives_abort.rs b/rs/moq-net/tests/integration/settled_end_survives_abort.rs index 05a1f0cf8ce..8319b7f96c3 100644 --- a/rs/moq-net/tests/integration/settled_end_survives_abort.rs +++ b/rs/moq-net/tests/integration/settled_end_survives_abort.rs @@ -33,7 +33,7 @@ async fn round(abort: bool, ordered: bool) -> (Vec, Option) { // not the live edge. let subscription = moq_net::track::Subscription::default() .with_range(0..) - .with_max_delay(std::time::Duration::from_secs(30)); + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(30))); let mut consumer = track.subscribe(subscription); for _ in 0..GROUPS { diff --git a/rs/moq-net/tests/integration/slow_joiner.rs b/rs/moq-net/tests/integration/slow_joiner.rs index 7cb77328e7b..f3260ef0b3a 100644 --- a/rs/moq-net/tests/integration/slow_joiner.rs +++ b/rs/moq-net/tests/integration/slow_joiner.rs @@ -55,7 +55,7 @@ async fn stalled_read(version: Version, budget: Duration) -> BTreeSet { let consumer = viewer.consume(); crate::support::announced(&consumer, "live").await; let remote = consumer.request("live", None).await.unwrap(); - let subscription = track::Subscription::default().with_max_delay(budget); + let subscription = track::Subscription::default().with_delay(track::Delay::default().with_max(budget)); let mut reader = remote.track("audio").unwrap().subscribe(subscription).await.unwrap(); kio::clock::timeout(TIMEOUT, subscribed(&source)).await.unwrap(); diff --git a/rs/moq-net/tests/integration/stream_credit.rs b/rs/moq-net/tests/integration/stream_credit.rs index d51a3dd8d89..4e6443fad9f 100644 --- a/rs/moq-net/tests/integration/stream_credit.rs +++ b/rs/moq-net/tests/integration/stream_credit.rs @@ -41,7 +41,8 @@ async fn one_stream_credit_serves_every_backlogged_group() { .available() .await .unwrap(); - let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(30)); + let prefs = + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(30))); let mut subscribers = Vec::new(); for i in 0..tracks.len() { let track = remote.track(&format!("{i}")).unwrap(); diff --git a/rs/moq-net/tests/integration/subscribe_ranges.rs b/rs/moq-net/tests/integration/subscribe_ranges.rs index c33e815427b..c54ce34b6d6 100644 --- a/rs/moq-net/tests/integration/subscribe_ranges.rs +++ b/rs/moq-net/tests/integration/subscribe_ranges.rs @@ -4,6 +4,7 @@ //! subscription, and each still gets the groups its own range asks for. use crate::support; +use moq_net::track::Delay; use std::time::Duration; @@ -143,7 +144,7 @@ async fn sparse_ranges_reach_the_publisher_on_lite07() { .expect("broadcast resolves"); let subscription = Subscription::default() - .with_max_delay(Duration::from_secs(60)) + .with_delay(Delay::default().with_max(Duration::from_secs(60))) .with_ranges(sparse); let mut reader = subscribe(&remote, subscription).await; kio::clock::timeout(TIMEOUT, crate::support::wanted(track.demand())) @@ -262,7 +263,7 @@ async fn initial_mixed_ranges_keep_the_absolute_history() { &fixture.remote, Subscription::default() .with_ranges([Range::live()]) - .with_max_delay(None), + .with_delay(Delay::default().with_max(None)), ) .await; assert_eq!(next(&mut warm).await, Some(5)); @@ -271,7 +272,7 @@ async fn initial_mixed_ranges_keep_the_absolute_history() { } let subscription = Subscription::default() .with_ranges([Range::groups(1..2), Range::live()]) - .with_max_delay(None); + .with_delay(Delay::default().with_max(None)); let mut reader = subscribe(&fixture.remote, subscription).await; let mut got = Vec::new(); for _ in 0..2 { @@ -292,7 +293,7 @@ async fn draft22_updates_both_bounds_across_cached_groups() { let mut fixture = range_fixture(relay).await; let initial = Subscription::default() .with_range(2..4) - .with_max_delay(None) + .with_delay(Delay::default().with_max(None)) .with_order(moq_net::track::Order::Ascending); let mut reader = subscribe(&fixture.remote, initial.clone()).await; let mut seen = [next(&mut reader).await.unwrap(), next(&mut reader).await.unwrap()]; @@ -350,7 +351,7 @@ async fn draft22_resolves_cached_time_ends() { let mut fixture = range_fixture(relay).await; let initial = Subscription::default() .with_range(2..4) - .with_max_delay(None) + .with_delay(Delay::default().with_max(None)) .with_order(moq_net::track::Order::Ascending); let mut reader = subscribe(&fixture.remote, initial.clone()).await; let mut seen = [next(&mut reader).await.unwrap(), next(&mut reader).await.unwrap()]; @@ -428,7 +429,9 @@ async fn draft22_can_widen_while_the_current_group_is_quiet() { ); let mut history = subscribe( &fixture.remote, - Subscription::default().with_range(0..2).with_max_delay(None), + Subscription::default() + .with_range(0..2) + .with_delay(Delay::default().with_max(None)), ) .await; let mut got = [next(&mut history).await.unwrap(), next(&mut history).await.unwrap()]; @@ -452,7 +455,9 @@ async fn draft18_updates_narrow_then_widen() { let version = "moq-transport-18"; for relay in [false, true] { let mut fixture = range_fixture_version(version, relay).await; - let initial = Subscription::default().with_range(5..).with_max_delay(None); + let initial = Subscription::default() + .with_range(5..) + .with_delay(Delay::default().with_max(None)); let mut reader = subscribe(&fixture.remote, initial.clone()).await; let mut seen = Vec::new(); for _ in 0..1 { @@ -581,7 +586,9 @@ async fn empty_ranges_retire_the_wire_and_can_widen_again() { async fn draft22_cold_widening_finishes_the_largest_group() { for relay in [false, true] { let mut fixture = range_fixture_history("moq-transport-22", relay, 0).await; - let initial = Subscription::default().with_range(2..).with_max_delay(None); + let initial = Subscription::default() + .with_range(2..) + .with_delay(Delay::default().with_max(None)); let mut reader = subscribe(&fixture.remote, initial.clone()).await; for start in [0, 1, 2] { let future = start + 1; @@ -745,7 +752,7 @@ async fn a_covered_bounded_subscription_ends_on_lite07() { let bounded = |range: std::ops::Range| { Subscription::default() - .with_max_delay(Duration::from_secs(60)) + .with_delay(Delay::default().with_max(Duration::from_secs(60))) .with_order(moq_net::track::Order::Ascending) .with_ranges([Range::groups(range)]) }; diff --git a/rs/moq-net/tests/integration/subscriber_max_age.rs b/rs/moq-net/tests/integration/subscriber_max_age.rs index 10393544d3a..35b83a339f0 100644 --- a/rs/moq-net/tests/integration/subscriber_max_age.rs +++ b/rs/moq-net/tests/integration/subscriber_max_age.rs @@ -1,6 +1,6 @@ #![cfg(feature = "async")] -//! IETF relays forward their most tolerant subscriber's negotiated age budget. +//! Relays forward their subscribers' delay: the most tolerant negotiated max, and the smallest stated min. use std::time::Duration; @@ -19,7 +19,7 @@ async fn budget(track: &mut track::Producer, millis: u64, version: Version) { loop { if track .subscription() - .is_some_and(|subscription| subscription.max_delay == Some(Duration::from_millis(millis))) + .is_some_and(|subscription| subscription.delay.max == Some(Duration::from_millis(millis))) { return; } @@ -53,7 +53,9 @@ async fn relay_forwards_the_most_tolerant_subscriber_max_age() { let consumer = viewer.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); let cache = remote.track("catalog").unwrap(); - let prefs = |millis| track::Subscription::default().with_max_delay(Duration::from_millis(millis)); + let prefs = |millis| { + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_millis(millis))) + }; let mut short = cache.subscribe(prefs(250)).await.unwrap(); budget(&mut source, 250, version).await; let tolerant = cache.subscribe(prefs(2000)).await.unwrap(); @@ -73,3 +75,75 @@ async fn relay_forwards_the_most_tolerant_subscriber_max_age() { ); } } + +async fn min_delay(track: &mut track::Producer, min: Option) { + kio::clock::timeout(Duration::from_secs(5), async { + loop { + if track + .subscription() + .is_some_and(|subscription| subscription.delay.min == min) + { + return; + } + track.subscription_changed().await.unwrap(); + } + }) + .await + .unwrap_or_else(|_| panic!("wanted min {min:?}, publisher has {:?}", track.subscription())); +} + +/// A lite-07 relay forwards the smallest stated min delay upstream, and a lite-06 subscriber, +/// whose wire cannot state one, never pulls it to zero. +#[kio::test] +async fn relay_forwards_the_smallest_stated_min_delay() { + let lite07: Version = "moq-lite-07-wip".parse().unwrap(); + let lite06: Version = "moq-lite-06".parse().unwrap(); + let publisher = origin(1); + let relay = origin(2); + let modern = origin(3); + let legacy = origin(4); + let _upstream = peer(lite07, &publisher, &relay).await; + let _modern = peer(lite07, &relay, &modern).await; + let _legacy = peer(lite06, &relay, &legacy).await; + let broadcast = publisher.create_broadcast("live").unwrap(); + let mut source = broadcast + .create_track( + "catalog", + track::Info::default().with_timescale(moq_net::Timescale::MILLI), + ) + .unwrap(); + let mut group = source.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, b"catalog").unwrap(); + group.finish().unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let cache = |origin: &origin::Producer| { + let origin = origin.clone(); + async move { + let remote = origin + .consume() + .follow("live", None) + .unwrap() + .available() + .await + .unwrap(); + remote.track("catalog").unwrap() + } + }; + let prefs = |min: Option| { + track::Subscription::default().with_delay(track::Delay::default().with_min(min.map(Duration::from_millis))) + }; + + let unstated = cache(&legacy).await.subscribe(prefs(Some(0))).await.unwrap(); + min_delay(&mut source, None).await; + let modern = cache(&modern).await; + let slow = modern.subscribe(prefs(Some(500))).await.unwrap(); + min_delay(&mut source, Some(Duration::from_millis(500))).await; + let fast = modern.subscribe(prefs(Some(100))).await.unwrap(); + min_delay(&mut source, Some(Duration::from_millis(100))).await; + drop(fast); + min_delay(&mut source, Some(Duration::from_millis(500))).await; + drop(slow); + min_delay(&mut source, None).await; + drop(unstated); +} diff --git a/rs/moq-net/tests/integration/track_tail.rs b/rs/moq-net/tests/integration/track_tail.rs index 511472fd864..1cc939befb3 100644 --- a/rs/moq-net/tests/integration/track_tail.rs +++ b/rs/moq-net/tests/integration/track_tail.rs @@ -118,7 +118,7 @@ async fn round(version: &str, late: Late, final_sequence: u64, max_delay: Option let reader = kio::clock::sim::spawn(async move { let subscription = moq_net::track::Subscription::default() .with_range(0..) - .with_max_delay(max_delay); + .with_delay(moq_net::track::Delay::default().with_max(max_delay)); let mut sub = remote .track("video") .unwrap() @@ -477,7 +477,7 @@ async fn a_group_whose_first_frame_trails_its_header_is_delivered() { let reader = kio::clock::sim::spawn(async move { let subscription = moq_net::track::Subscription::default() .with_range(0..) - .with_max_delay(TIMEOUT); + .with_delay(moq_net::track::Delay::default().with_max(TIMEOUT)); let mut sub = remote .track("video") .unwrap() diff --git a/rs/moq-net/tests/integration/untimed.rs b/rs/moq-net/tests/integration/untimed.rs index 8beca065822..4c5e68c8a02 100644 --- a/rs/moq-net/tests/integration/untimed.rs +++ b/rs/moq-net/tests/integration/untimed.rs @@ -187,7 +187,7 @@ async fn an_explicit_start_holds_on_an_untimed_track() { let consumer = chain.nodes[1].consume().request("room", None).await.unwrap(); let subscription = track::Subscription::default() .with_range(2..) - .with_max_delay(Duration::from_secs(30)); + .with_delay(track::Delay::default().with_max(Duration::from_secs(30))); let mut subscriber = kio::clock::timeout(TIMEOUT, consumer.track("data").unwrap().subscribe(subscription)) .await .expect("subscribe timeout") diff --git a/rs/moq-net/tests/integration/warm_backfill.rs b/rs/moq-net/tests/integration/warm_backfill.rs index 4d3bc725e74..2c5f2c7251e 100644 --- a/rs/moq-net/tests/integration/warm_backfill.rs +++ b/rs/moq-net/tests/integration/warm_backfill.rs @@ -12,6 +12,7 @@ use std::collections::BTreeSet; use std::time::Duration; +use moq_net::track::Delay; use moq_net::track::{Info, Subscription}; use moq_net::{Hop, Timescale, Timestamp, Version, origin, track}; @@ -39,7 +40,11 @@ async fn subscribe(relay: &origin::Producer) -> track::Subscriber { remote .track("video") .unwrap() - .subscribe(Subscription::default().with_max_delay(BUDGET).with_range(0..)) + .subscribe( + Subscription::default() + .with_delay(Delay::default().with_max(BUDGET)) + .with_range(0..), + ) .await .unwrap() } diff --git a/rs/moq-net/tests/integration/widened_max_age.rs b/rs/moq-net/tests/integration/widened_max_age.rs index eae49bf64e9..243ac580130 100644 --- a/rs/moq-net/tests/integration/widened_max_age.rs +++ b/rs/moq-net/tests/integration/widened_max_age.rs @@ -10,6 +10,7 @@ use std::collections::BTreeSet; use std::time::Duration; +use moq_net::track::Delay; use moq_net::track::{Info, Range, Subscription}; use moq_net::{Hop, Timescale, Timestamp, Version, kio, origin, track}; @@ -111,7 +112,7 @@ impl Chain { let consumer = self.viewer.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); let subscription = Subscription::default() - .with_max_delay(budget) + .with_delay(Delay::default().with_max(budget)) .with_ranges([Range::oldest()]); remote.track("video").unwrap().subscribe(subscription).await.unwrap() } @@ -167,11 +168,11 @@ async fn a_tightened_start_leaves_history_fillable_by_an_intermediate_reader() { first .update( Subscription::default() - .with_max_delay(NARROW) + .with_delay(Delay::default().with_max(NARROW)) .with_ranges([Range::oldest()]), ) .unwrap(); - while source.subscription_changed().await.unwrap().and_then(|s| s.max_delay) != Some(NARROW) {} + while source.subscription_changed().await.unwrap().and_then(|s| s.delay.max) != Some(NARROW) {} chain.populate(); assert_eq!(received(&mut first).await, (6..GROUPS).collect()); @@ -207,7 +208,7 @@ async fn a_widened_reader_gets_what_was_skipped() { assert_eq!(first, (6..GROUPS).collect(), "{version} relay={relay}"); let wide = Subscription::default() - .with_max_delay(WIDE) + .with_delay(Delay::default().with_max(WIDE)) .with_ranges([Range::oldest()]); reader.update(wide).unwrap(); let widened = kio::clock::timeout(TIMEOUT, received(&mut reader)).await.unwrap(); @@ -319,7 +320,7 @@ async fn wider_newcomers_after_the_first_pull_nothing_upstream() { let consumer = viewer.consume(); let remote = consumer.follow("live", None).unwrap().available().await.unwrap(); let subscription = Subscription::default() - .with_max_delay(budget) + .with_delay(Delay::default().with_max(budget)) .with_ranges([Range::oldest()]); let mut reader = remote.track("video").unwrap().subscribe(subscription).await.unwrap(); let got = received(&mut reader).await; diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index dc3e973393b..a053d755c77 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -1451,7 +1451,10 @@ async fn subscriber_max_age_skips_bufferbloat(version: &str) { let mut groups = remote .track(TRACK) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_millis(100))), + ) .await .unwrap(); let mut warmup = groups.recv_group().await.unwrap().unwrap(); @@ -1463,7 +1466,7 @@ async fn subscriber_max_age_skips_bufferbloat(version: &str) { loop { if track .subscription() - .is_some_and(|subscription| subscription.max_delay == Some(Duration::from_millis(100))) + .is_some_and(|subscription| subscription.delay.max == Some(Duration::from_millis(100))) { break; } diff --git a/rs/moq-relay/tests/integration/cluster_unknown.rs b/rs/moq-relay/tests/integration/cluster_unknown.rs index b4bca9e9e12..68dffcdfe91 100644 --- a/rs/moq-relay/tests/integration/cluster_unknown.rs +++ b/rs/moq-relay/tests/integration/cluster_unknown.rs @@ -223,7 +223,8 @@ async fn read_first_frame(port: u16) -> Result, String> { // This verifies route propagation rather than real-time backlog skipping. Give // every hop enough tolerance for the next 100ms group to arrive while the // selected group's frame is still crossing the redundant mesh. - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))); let mut track = kio::clock::timeout( TIMEOUT, broadcast.track("video").expect("track handle").subscribe(subscription), diff --git a/rs/moq-relay/tests/integration/goaway_cluster.rs b/rs/moq-relay/tests/integration/goaway_cluster.rs index 18d63a910db..e9a067be4ef 100644 --- a/rs/moq-relay/tests/integration/goaway_cluster.rs +++ b/rs/moq-relay/tests/integration/goaway_cluster.rs @@ -575,9 +575,10 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { // must preserve is that every group arrives, exactly once, with its frames intact. let mut sub = within( "subscribe to the video track", - bc.track("video") - .expect("track handle") - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(60))), + bc.track("video").expect("track handle").subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))), + ), ) .await .expect("subscribe"); diff --git a/rs/moq-relay/tests/integration/upstream_links.rs b/rs/moq-relay/tests/integration/upstream_links.rs index c2cfb3a15e5..e7c4ca43cd7 100644 --- a/rs/moq-relay/tests/integration/upstream_links.rs +++ b/rs/moq-relay/tests/integration/upstream_links.rs @@ -182,7 +182,8 @@ async fn read_frame(origin: &origin::Producer, path: &str) { ) .await .unwrap_or_else(|err| panic!("{path} unroutable: {err}")); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(1)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(1))); let mut track = within( &format!("subscribe to {path}"), broadcast.track("video").expect("track handle").subscribe(subscription), diff --git a/rs/moq-room/src/chat.rs b/rs/moq-room/src/chat.rs index cc912f75038..6868d5e14cb 100644 --- a/rs/moq-room/src/chat.rs +++ b/rs/moq-room/src/chat.rs @@ -316,7 +316,8 @@ mod tests { chat.push(moq_net::Timed::from(&"second".to_owned()).at(at(1))).unwrap(); // A delay budget long enough that the backlog is still worth delivering. - let subscription = track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let subscription = + track::Subscription::default().with_delay(track::Delay::default().with_max(Duration::from_secs(10))); let mut late = Subscriber::new(remote.track(TRACK).unwrap().subscribe(subscription).await.unwrap()); assert_eq!( late.recv().await.unwrap().map(|e| (e.value, e.at)), diff --git a/rs/moq-rtsp/src/client.rs b/rs/moq-rtsp/src/client.rs index 4d8399d9a37..3a0a96c4e37 100644 --- a/rs/moq-rtsp/src/client.rs +++ b/rs/moq-rtsp/src/client.rs @@ -596,7 +596,8 @@ mod tests { broadcast: &moq_net::broadcast::Producer, (name, container): (String, moq_mux::catalog::hang::Container), ) -> moq_mux::container::Consumer { - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))); let track = broadcast .consume() .track(&name) diff --git a/rs/moq-sign/benches/sign.rs b/rs/moq-sign/benches/sign.rs index 62bf0f9c485..22fcd30b525 100644 --- a/rs/moq-sign/benches/sign.rs +++ b/rs/moq-sign/benches/sign.rs @@ -40,7 +40,8 @@ async fn roundtrip( .unwrap(); let inner = origin.consume().request(&path, None).await.unwrap(); let consumer = broadcast::Consumer::new(inner, key.public(), path).unwrap(); - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(60)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))); let consumer = consumer.subscribe("stream", subscription).unwrap().await.unwrap(); (track, consumer, publication) } diff --git a/rs/moq-sign/src/tests.rs b/rs/moq-sign/src/tests.rs index 95361113238..58ed38af99b 100644 --- a/rs/moq-sign/src/tests.rs +++ b/rs/moq-sign/src/tests.rs @@ -162,7 +162,8 @@ async fn settle() { } fn subscribe_all() -> moq_net::track::Subscription { - moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(60)) + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(60))) } /// A signed broadcast published at `path` on its own origin, announced under its instance. diff --git a/rs/moq-tokio/tests/integration/broadcast.rs b/rs/moq-tokio/tests/integration/broadcast.rs index 0c7b5cb3215..624d68976e9 100644 --- a/rs/moq-tokio/tests/integration/broadcast.rs +++ b/rs/moq-tokio/tests/integration/broadcast.rs @@ -1072,7 +1072,7 @@ async fn broadcast_route_migration() { // Resolve and subscribe: the cheaper route (A) serves the track. let subscription = moq_net::track::Subscription::default() .with_range(1..) - .with_max_delay(Duration::from_secs(10)); + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(10))); let broadcast = kio::clock::timeout(TIMEOUT, sub_consumer.request("test", None)) .await .expect("request timeout") @@ -1204,7 +1204,8 @@ async fn rejoin_skips_a_stale_warm_cache(version: &str) { .await .expect("request timeout") .expect("broadcast resolves"); - let budget = moq_net::track::Subscription::default().with_max_delay(Duration::from_millis(100)); + let budget = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_millis(100))); async fn recv(sub: &mut moq_net::track::Subscriber, version: &str) -> u64 { kio::clock::timeout(TIMEOUT, sub.recv_group()) .await diff --git a/rs/moq-tokio/tests/integration/migrate.rs b/rs/moq-tokio/tests/integration/migrate.rs index 16ff307ce1b..85ea7208174 100644 --- a/rs/moq-tokio/tests/integration/migrate.rs +++ b/rs/moq-tokio/tests/integration/migrate.rs @@ -108,7 +108,8 @@ fn view(origin: &moq_net::origin::Producer, name: &str) -> tokio::sync::mpsc::Un .await .expect("the broadcast resolves"); // A budget, so a group the move delays is late rather than skipped. - let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(5)); + let subscription = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(5))); let mut track = broadcast .track("video") .expect("track") diff --git a/rs/moq-tokio/tests/integration/receive_credit.rs b/rs/moq-tokio/tests/integration/receive_credit.rs index ef04e801e67..f1f3f899b3e 100644 --- a/rs/moq-tokio/tests/integration/receive_credit.rs +++ b/rs/moq-tokio/tests/integration/receive_credit.rs @@ -15,7 +15,10 @@ use std::{process::Stdio, sync::Arc, time::Duration}; use bytes::Bytes; -use moq_tokio::moq_net::{self, Timestamp, frame, track::Subscription}; +use moq_tokio::moq_net::{ + self, Timestamp, frame, + track::{Delay, Subscription}, +}; use moq_tokio::quic::{driver, web}; use tokio::io::AsyncWriteExt; @@ -631,7 +634,7 @@ async fn groups_waiting_for_track_info_give_way_first() { let every = Subscription::default() .with_order(moq_net::track::Order::Ascending) .with_range(0..) - .with_max_delay(Duration::from_secs(60)); + .with_delay(Delay::default().with_max(Duration::from_secs(60))); let waiting = tokio::spawn({ let remote = setup.remote.clone(); async move { remote.track("parked").unwrap().subscribe(every).await } @@ -973,7 +976,7 @@ async fn measure(cell: Cell, profile: Option, bytes: usize) let name = index.to_string(); let track = setup.broadcast.create_track(name.as_str(), timed()).unwrap(); // Groups in flight fall behind the live edge, so a zero budget would skip them. - let subscription = Subscription::default().with_max_delay(TIMEOUT); + let subscription = Subscription::default().with_delay(Delay::default().with_max(TIMEOUT)); let sub = kio::clock::timeout(TIMEOUT, setup.remote.track(&name).unwrap().subscribe(subscription)) .await .expect("subscribe timeout") diff --git a/rs/moq-tokio/tests/integration/sim.rs b/rs/moq-tokio/tests/integration/sim.rs index da2471bec53..5b1f5ac92fa 100644 --- a/rs/moq-tokio/tests/integration/sim.rs +++ b/rs/moq-tokio/tests/integration/sim.rs @@ -184,7 +184,7 @@ async fn serve(network: &Network, version: Version, seed: u64) -> Arrivals { .track("video") .unwrap() // A budget spanning the run, so recovery has to deliver every group, however late. - .subscribe(track::Subscription::default().with_max_delay(TIMEOUT)) + .subscribe(track::Subscription::default().with_delay(track::Delay::default().with_max(TIMEOUT))) .await .unwrap(); diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index ad5948fcee5..ff5fd648e4c 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -967,7 +967,7 @@ mod tests { let fills = track.requests(); let ascending = |groups| { moq_net::track::Subscription::default() - .with_max_delay(None) + .with_delay(moq_net::track::Delay::default().with_max(None)) .with_range(groups) .with_order(moq_net::track::Order::Ascending) }; diff --git a/rs/moq-video/MIGRATE.md b/rs/moq-video/MIGRATE.md index 409d76f4636..55ef582a900 100644 --- a/rs/moq-video/MIGRATE.md +++ b/rs/moq-video/MIGRATE.md @@ -4,10 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased -### `decode::Options::max_delay` is `delay` ([#7311](https://github.com/moq-dev/moq/pull/7311)) +### `decode::Options::max_delay` is `delay` ([#7311](https://github.com/moq-dev/moq/pull/7311), [#7597](https://github.com/moq-dev/moq/pull/7597)) -`decode::Options::max_delay: Duration` is `delay: decode::Delay` (`moq_mux::container::Delay`). -Replace `max_delay: d` with `delay: Delay { min: playout, max: Some(d) }`: `min` is your playout delay, where a cold join starts, and `max` (`None` for no limit) is the old max delay. +`decode::Options::max_delay: Duration` is `delay: decode::Delay` (`moq_net::track::Delay`). +Replace `max_delay: d` with `delay: Delay { min: Some(playout), max: Some(d) }`: `min` is your playout delay, where a cold join starts, and `max` (`None` for no limit) is the old max delay. ### A capture reopened onto a new identity publishes a new track ([#7110](https://github.com/moq-dev/moq/pull/7110)) diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index 70b285e6889..d2af94d4697 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -13,7 +13,7 @@ use super::select::{Lane, Next, Selector}; use super::sink::Sink; use crate::Error; use crate::Frame; -pub use moq_mux::container::Delay; +pub use moq_net::track::Delay; /// One item of a [`Consumer`] read, in delivery order: a decoded picture, or a break /// between them. @@ -181,7 +181,7 @@ impl Consumer { // so this decoder starts at a recovery point a whole sweep before the // min-delay one. let delay = Delay { - min: options.delay.min.saturating_add(warmup), + min: Some(options.delay.min.unwrap_or_default().saturating_add(warmup)), max: options.delay.max.map(|max| max.saturating_add(warmup)), }; Some(Reader::open(broadcast, catalog, &name, &options, delay, 1).await?) @@ -302,8 +302,8 @@ impl Consumer { } impl Reader { - /// Subscribe with `delay`'s max, starting `lookback` groups before the live - /// edge when the caller asked for [`Start::Latest`]. + /// Subscribe with `delay`, starting `lookback` groups before the live edge + /// when the caller asked for [`Start::Latest`]. async fn open( broadcast: &moq_net::broadcast::Consumer, catalog: &VideoConfig, @@ -319,7 +319,9 @@ impl Reader { .subscribe( moq_net::track::Subscription::default() .with_priority(hang::catalog::PRIORITY.video) - .with_max_delay(delay.max), + // The whole delay from the first request, as the container keeps it, so the + // publisher never sees a stated zero min before the container's update. + .with_delay(delay), ) .await?; // A decoder often opens on a track that is already cached: a replacement @@ -752,7 +754,7 @@ mod tests { let subscription = published.subscription().expect("the consumer subscribed"); assert_eq!( - subscription.max_delay, + subscription.delay.max, Some(max_delay), "the age budget did not reach the publisher" ); @@ -1222,7 +1224,7 @@ mod tests { ) .await; assert_eq!( - published.subscription().and_then(|sub| sub.max_delay), + published.subscription().and_then(|sub| sub.delay.max), Some(WARMUP), "the two subscriptions did not share one request at the larger budget" ); @@ -1402,6 +1404,39 @@ mod tests { assert!(consumer.lanes.iter().all(|slot| matches!(slot, Slot::Closed))); } + /// The first request already states the min delay: a latest start waits for the live edge + /// with its subscription open, before the container could update it. + #[tokio::test] + async fn the_first_subscription_states_the_min_delay() { + let (broadcast, mut track) = live_track(); + let delay = Delay { + min: Some(std::time::Duration::from_millis(300)), + max: Some(std::time::Duration::from_millis(500)), + }; + let consume = broadcast.consume(); + let catalog = warmup_catalog(None); + let mut open = Box::pin(Consumer::new( + &consume, + &catalog, + "video", + Options { + decoder: Config { + kind: Kind::Named(probe::IMMEDIATE_NAME.into()), + ..Config::new() + }, + start: Start::Latest, + delay, + }, + )); + // The open waits for the live edge with its subscription open, so the first aggregate + // the publisher sees is the request itself. + let first = tokio::select! { + opened = &mut open => panic!("the open finished with no live edge (ok: {})", opened.is_ok()), + first = track.subscription_changed() => first.unwrap(), + }; + assert_eq!(first.map(|sub| sub.delay), Some(delay)); + } + /// A rendition without `warmup` opens one subscription and one decoder. #[tokio::test] async fn no_warmup_runs_one_decoder() { @@ -1427,7 +1462,7 @@ mod tests { .await .unwrap(); assert!(matches!(consumer.lanes[Lane::Max as usize], Slot::Closed)); - assert_eq!(track.subscription().and_then(|sub| sub.max_delay), Some(max_delay)); + assert_eq!(track.subscription().and_then(|sub| sub.delay.max), Some(max_delay)); } } diff --git a/rs/moq-video/src/decode/decoder.rs b/rs/moq-video/src/decode/decoder.rs index dc5a445b7bb..61ea8f88242 100644 --- a/rs/moq-video/src/decode/decoder.rs +++ b/rs/moq-video/src/decode/decoder.rs @@ -682,7 +682,10 @@ mod tests { let track = subscriber .track(name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(30))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(std::time::Duration::from_secs(30))), + ) .await .unwrap(); let mut track = diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index 5d7a0ea958d..29d4592e78f 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -958,7 +958,9 @@ mod tests { catalog.track_info(hang::catalog::PRIORITY.video).with_max_age(replay), ) .unwrap(); - let consumer = track.subscribe(moq_net::track::Subscription::default().with_max_delay(replay)); + let consumer = track.subscribe( + moq_net::track::Subscription::default().with_delay(moq_net::track::Delay::default().with_max(replay)), + ); let mut config = Config::new(320, 240, crate::FrameRate::new(30, 1).unwrap()); config.kind = encoder::Kind::Software; @@ -1266,7 +1268,10 @@ mod tests { .consumer .track(name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(RETAIN)) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(RETAIN)), + ) .await .unwrap(); moq_mux::container::Consumer::new(track, container) @@ -1924,7 +1929,10 @@ mod tests { let mut subscriber = consumer .track(&name) .unwrap() - .subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(5))) + .subscribe( + moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(5))), + ) .await .unwrap(); for sequence in 0..2 { diff --git a/swift/Sources/Moq/Media.swift b/swift/Sources/Moq/Media.swift index 8178cc0af08..87d84360c1e 100644 --- a/swift/Sources/Moq/Media.swift +++ b/swift/Sources/Moq/Media.swift @@ -31,7 +31,7 @@ public enum Media { public typealias Dimensions = MoqFFI.MoqDimensions /// How far behind the live edge a decoder plays: `min` is the playout delay and where a /// cold join starts, and `max`, nil for no limit, is how far a stalled group may fall - /// behind before it is skipped. `min` stays on the consumer; a `min` past `max` starts at `max`. + /// behind before it is skipped. The subscription carries both; a `min` past `max` starts at `max`. public typealias Delay = MoqFFI.MoqDelay /// Where an audio or video decoder starts on a track that already holds /// groups: `.latest` for a live player. diff --git a/test/audio-quality/clients/js/src/capture.test.ts b/test/audio-quality/clients/js/src/capture.test.ts index 33409905b34..bb0e278290a 100644 --- a/test/audio-quality/clients/js/src/capture.test.ts +++ b/test/audio-quality/clients/js/src/capture.test.ts @@ -93,11 +93,11 @@ test("the observer shares playback demand and closes without ending playback", a }); try { while (!track.subscription.peek()) await track.subscription.changed(); - expect(track.subscription.peek()).toMatchObject({ priority: Catalog.PRIORITY.audio, maxDelay: 250 }); + expect(track.subscription.peek()).toMatchObject({ priority: Catalog.PRIORITY.audio, delay: { max: 250 } }); const player = published .consume() .track("audio") - .subscribe({ priority: Catalog.PRIORITY.audio, maxDelay: Milli(250) }); + .subscribe({ priority: Catalog.PRIORITY.audio, delay: { max: Milli(250) } }); await capture.close(); expect(player.closed.peek()).toBeUndefined(); player.close(); @@ -136,7 +136,7 @@ test("at auto the observer asks for the decoder's 2 s budget, not the target", a }); try { while (!track.subscription.peek()) await track.subscription.changed(); - expect(track.subscription.peek()).toMatchObject({ maxDelay: 2000 }); + expect(track.subscription.peek()).toMatchObject({ delay: { max: 2000 } }); } finally { await capture.close(); source.close(); diff --git a/test/audio-quality/clients/js/src/capture.ts b/test/audio-quality/clients/js/src/capture.ts index 9a32c6b2b2f..4524ed0cef8 100644 --- a/test/audio-quality/clients/js/src/capture.ts +++ b/test/audio-quality/clients/js/src/capture.ts @@ -52,7 +52,7 @@ export class Capture { if (!active) return; const track = active.track(name).subscribe({ priority: Catalog.PRIORITY.audio, - maxDelay: subscribed(input.delay.peek(), input.maxDelay.peek()), + delay: { max: subscribed(input.delay.peek(), input.maxDelay.peek()) }, }); const groups = new Set(); effect.cleanup(() => { @@ -62,7 +62,7 @@ export class Capture { effect.run((inner) => track.update({ priority: Catalog.PRIORITY.audio, - maxDelay: subscribed(inner.get(input.delay), inner.get(input.maxDelay)), + delay: { max: subscribed(inner.get(input.delay), inner.get(input.maxDelay)) }, }), ); effect.spawn(async () => { diff --git a/test/audio-quality/clients/js/src/recorder.ts b/test/audio-quality/clients/js/src/recorder.ts index 8d8e4d6af7d..ca908a91f72 100644 --- a/test/audio-quality/clients/js/src/recorder.ts +++ b/test/audio-quality/clients/js/src/recorder.ts @@ -88,7 +88,7 @@ async function record(): Promise { if (!name || !config) throw new Error("the catalog ended without an audio rendition"); const decoder = format(config); - const track = broadcast.track(name).subscribe({ priority: Catalog.PRIORITY.audio, maxDelay: MAX_DELAY }); + const track = broadcast.track(name).subscribe({ priority: Catalog.PRIORITY.audio, delay: { max: MAX_DELAY } }); info = { transport: connection.transport, get rtt() { diff --git a/test/drill/mutations/subscriber-max-age-ignored.patch b/test/drill/mutations/subscriber-max-age-ignored.patch index 58f45711bc8..2b4732aa115 100644 --- a/test/drill/mutations/subscriber-max-age-ignored.patch +++ b/test/drill/mutations/subscriber-max-age-ignored.patch @@ -4,12 +4,12 @@ # Preserve negotiation and decoding, but ignore the requested budget when serving. --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs -@@ -1741,9 +1741,6 @@ +@@ -2028,9 +2028,6 @@ // A live SUBSCRIBE's NEW_GROUP_REQUEST asks nothing more: subscribing is itself a join, // which the track's refresh ratio judges. let mut subscription = serving_subscription(msg.subscriber_priority).with_order(msg.group_order.to_order()); - if let Some(max_age) = msg.max_age { -- subscription.max_delay = Some(Duration::from_millis(max_age)); +- subscription.delay.max = Some(Duration::from_millis(max_age)); - } // A range that resolves without the live edge has a fixed start, which is no join: // bound the subscription before subscribing so the ratio never judges it. From the diff --git a/test/interop/clients/js-native/finish.ts b/test/interop/clients/js-native/finish.ts index 40804bf29e4..b943dae595e 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({ delay: { max: maxDelay }, groups: { start: { included: 0 } } }); const media = new Container.Consumer(subscription, { format, delay: { max: maxDelay } }); let count = 0; try { diff --git a/test/interop/clients/js-native/subscribe.ts b/test/interop/clients/js-native/subscribe.ts index 41d97c793d8..234e1e4e9aa 100644 --- a/test/interop/clients/js-native/subscribe.ts +++ b/test/interop/clients/js-native/subscribe.ts @@ -118,7 +118,7 @@ function containerFormat(config: Catalog.VideoConfig | Catalog.AudioConfig, kind } async function video(bc: Moq.Broadcast.Consumer, name: string, config: Catalog.VideoConfig): Promise { - const sub = bc.track(name).subscribe({ priority: 0, maxDelay: MAX_DELAY }); + const sub = bc.track(name).subscribe({ priority: 0, delay: { max: MAX_DELAY } }); const consumer = new Container.Consumer(sub, { format: containerFormat(config, "video"), delay: { max: MAX_DELAY }, @@ -185,7 +185,7 @@ async function audio(bc: Moq.Broadcast.Consumer, name: string, config: Catalog.A description: config.codec === "opus" || !config.description ? undefined : Hex.toBytes(config.description), }); - const sub = bc.track(name).subscribe({ priority: Catalog.PRIORITY.audio, maxDelay: AUDIO_MAX_DELAY }); + const sub = bc.track(name).subscribe({ priority: Catalog.PRIORITY.audio, delay: { max: AUDIO_MAX_DELAY } }); const consumer = new Container.Consumer(sub, { format: containerFormat(config, "audio"), delay: { max: AUDIO_MAX_DELAY }, diff --git a/test/interop/clients/js-native/tail.ts b/test/interop/clients/js-native/tail.ts index 5ea73db8fd6..ad45e4f993b 100644 --- a/test/interop/clients/js-native/tail.ts +++ b/test/interop/clients/js-native/tail.ts @@ -146,7 +146,7 @@ try { // `late` names no start, joining the latest group as a player does: group 0, whose // head predates the subscription. const track = broadcast.track("tail").subscribe({ - maxDelay: Milli(drop ? 60_000 : 5000), + delay: { max: Milli(drop ? 60_000 : 5000) }, ...(late ? {} : { groups: { start: { included: 0 } } }), }); if (compress) assert.equal((await track.info()).compress, version === "moq-lite-07-wip"); diff --git a/test/interop/clients/rust/finish.rs b/test/interop/clients/rust/finish.rs index 23df9ab5187..2e98d093d29 100644 --- a/test/interop/clients/rust/finish.rs +++ b/test/interop/clients/rust/finish.rs @@ -131,7 +131,7 @@ async fn main() -> anyhow::Result<()> { .track(name)? .subscribe(Some( moq_net::track::Subscription::default() - .with_max_delay(args.timeout) + .with_delay(moq_net::track::Delay::default().with_max(args.timeout)) .with_range(0..), )) .await?; diff --git a/test/interop/clients/rust/tail.rs b/test/interop/clients/rust/tail.rs index 75e5c47e61f..db1e2fb6307 100644 --- a/test/interop/clients/rust/tail.rs +++ b/test/interop/clients/rust/tail.rs @@ -202,7 +202,8 @@ async fn main() -> anyhow::Result<()> { if late { // No range: join the latest group as a player does, group 0, whose head predates // the subscription. - let options = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(5)); + let options = moq_net::track::Subscription::default() + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(5))); let mut track = remote.subscribe(Some(options)).await?; for sequence in 0..LATE_GROUPS { let mut group = track.recv_group().await?.context("missing group")?; @@ -224,7 +225,7 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } let options = moq_net::track::Subscription::default() - .with_max_delay(Duration::from_secs(if drop { 60 } else { 5 })) + .with_delay(moq_net::track::Delay::default().with_max(Duration::from_secs(if drop { 60 } else { 5 }))) .with_range(0..); let mut track = remote.subscribe(Some(options)).await?; if datagram { diff --git a/test/interop/compat/client.ts b/test/interop/compat/client.ts index 3acdcb784e9..82fd9a594a6 100644 --- a/test/interop/compat/client.ts +++ b/test/interop/compat/client.ts @@ -92,19 +92,27 @@ async function video(bc: Moq.Broadcast.Consumer, [name, config]: [string, Catalo // How far behind the newest audio a group may fall and still be read, in milliseconds. const AUDIO_BUDGET = 5000; -/** The subscription field holding the latency budget, which the checkout renamed from `maxAge`. */ -function budgetField(): "maxAge" | "maxDelay" { - // The checkout refuses the old name, which a throwaway local track shows without a session. +/** + * The subscription fields holding the latency budget, which releases spelled `maxAge`, then + * `maxDelay`, and the checkout spells `delay: { max }`. + */ +function budget(millis: number): Record { + // Each version refuses the older names, which a throwaway local track shows without a session. + const spellings = [{ maxAge: millis }, { maxDelay: millis }, { delay: { max: millis } }]; const broadcast = new Moq.Broadcast.Producer(); try { - broadcast - .createTrack("probe") - .subscribe({ maxAge: 0 } as Moq.Track.Subscription) - .close(); - return "maxAge"; - } catch (err) { - if (!(err instanceof TypeError)) throw err; - return "maxDelay"; + for (const spelling of spellings.slice(0, -1)) { + try { + broadcast + .createTrack(`probe-${Object.keys(spelling)[0]}`) + .subscribe(spelling as Moq.Track.Subscription) + .close(); + return spelling; + } catch (err) { + if (!(err instanceof TypeError)) throw err; + } + } + return spellings[spellings.length - 1]; } finally { broadcast.close(); } @@ -174,7 +182,7 @@ async function audio( // In sequence order, as a stateful decoder needs. An fMP4 fragment lands the tail of one audio // group and the head of the next in one burst, and the default budget of zero drops that tail // once the next group arrives, so allow a backlog; a group that never arrives still leaves a gap. - const subscription = { priority: Catalog.PRIORITY.audio, [budgetField()]: AUDIO_BUDGET }; + const subscription = { priority: Catalog.PRIORITY.audio, ...budget(AUDIO_BUDGET) }; const track = bc .track(name) .subscribe(subscription as Moq.Track.Subscription)