Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0a17881
chore(quest): claim quest/m1/subscription-delay.md
kixelated Nov 2, 2026
8b743f6
quest: record subscription minimum API decision needed
kixelated Nov 2, 2026
2c3bb85
Merge remote-tracking branch 'origin/main' into subscription-delay-re…
kixelated Nov 2, 2026
e3590ac
feat(net)!: subscriptions take a track Delay { min, max }
kixelated Nov 2, 2026
6b4cb0b
fix: finish the Delay migration across tests, benches, and interop cl…
kixelated Nov 2, 2026
99311fc
fix: carry the Delay migration into audio-quality, sign, and the dril…
kixelated Nov 2, 2026
f72acc0
test(net): lite-07 byte fixtures carry the min delay field
kixelated Nov 2, 2026
9451b41
test(interop): JS clients subscribe with delay.max
kixelated Nov 2, 2026
b074c71
Merge origin/main into quest/m1/subscription-delay
kixelated Nov 2, 2026
e190b0d
fix: drop SubscribeOk's dead minDelay and the stale 'min never reache…
kixelated Nov 2, 2026
5283982
style: format the merged Delay call sites
kixelated Nov 2, 2026
473c054
fix(media): state the whole delay on a decoder's first subscription
kixelated Nov 2, 2026
3c71c50
style: format the decoder delay fix
kixelated Nov 2, 2026
fa98b72
Merge origin/main into quest/m1/subscription-delay
kixelated Nov 2, 2026
5c92ae8
Merge origin/main into quest/m1/subscription-delay
kixelated Nov 2, 2026
6a8380f
fix(moq-cli): state the video min delay on the first subscription
kixelated Nov 2, 2026
296c9b9
Merge origin/main into quest/m1/subscription-delay
kixelated Nov 2, 2026
1fdeb62
Merge origin/main into quest/m1/subscription-delay
kixelated Nov 2, 2026
f593745
style(moq-net): format the ranges bench after the delay migration
kixelated Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bench/browser/page/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ async function consume(config: Transport): Promise<Delivery> {
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;
Expand Down
2 changes: 1 addition & 1 deletion dart/moq/lib/media.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion doc/concept/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions doc/lib/js/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion doc/lib/js/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion doc/lib/rs/moq-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ same optional capture time when opened, including when they publish on drop.

Consumers return `Timed<T>`: 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.
Expand Down
2 changes: 1 addition & 1 deletion doc/lib/rs/moq-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,7 @@ SUBSCRIBE Message {
Subscriber Priority (8)
Group Order (8)
Subscriber Max Age (i)
Subscriber Min Age (i)
Range Count (i)
Range (..) ...
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion go/wrapper/media/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion js/e2ee/bench/pump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions js/e2ee/src/e2ee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions js/e2ee/src/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions js/hang/MIGRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Container.Delay>`, where `Delay` is `{ min?: Milli; max?: Milli | null }` and an omitted field is zero.
`ConsumerProps.maxDelay` is `delay: GetterInit<Moq.Track.Delay>`, 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.

Expand Down
Loading
Loading