diff --git a/quest/m0/README.md b/quest/m0/README.md index 2183b3d335..847b9ef556 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -42,6 +42,7 @@ a published `@moq/watch` break. ## Required - [Expiry wakes](/quest/m0/expiry-wakes.md) - an appended group wakes only the parked reads it expires, so a 2.5 ms-frame publisher with 2 s of parked serves stays near idle and the Python and Go interop rows pass +- [Draft-22 LOCATION_FILTER](/quest/m0/ietf-location-filter-22.md) - moqt-22 LOCATION_FILTER carries its type instead of a Length in Rust and JS, so a draft-22 peer reads our Next Object correctly - [Request caps](/quest/m0/request-caps.md) - lite message sizes, IETF request IDs, and per-session announces and subscriptions are bounded - [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - a prefix route cannot be made to mint one front per requested path - [qmux credit](/quest/m0/qmux-credit.md) - qmux returns connection credit for dropped and stopped streams and delivers its close frame, on both lines diff --git a/quest/m0/expiry-wakes.md b/quest/m0/expiry-wakes.md index 616a6fa1eb..04df6091aa 100644 --- a/quest/m0/expiry-wakes.md +++ b/quest/m0/expiry-wakes.md @@ -6,7 +6,8 @@ A group appended to a track wakes only the parked group serves whose expiry it crosses, not every parked serve. A publisher sending one 2.5 ms Opus frame per group to a subscriber with a 2 s max age (about 800 parked serves) keeps its runtime thread near idle, and `just test interop --all` passes its -Python and Go publisher rows again. +Python and Go publisher rows again. A group held across a route switch is +woken when it goes stale, not at the next unrelated append. ## Plan @@ -32,6 +33,20 @@ backlog serves per subscription (changes delivery scheduling), and lowering the browser audio max age or using 20 ms frames in the interop clients (hides the bug). +Decided 2026-10-06, folding in #4950: the index has a second caller. +`Recover::poll` in `rs/moq-net/src/model/resume.rs` gives up a held group on +an old route through `Consumer::poll_stale`, which registers only on the +track state, so its successor's first timestamp (or abort, or a newer +group's first timestamp moving the edge) never wakes it. It is judged with +`drifted` against a copy that usually lacks the group, and its reach ends +where the successor's first timestamp lands. Recover registers the held +group in the same index as `GroupServe`, and stamping or aborting the +successor re-keys the entry. One mechanism, not a per-reader helper beside +the index. Two differences the index must carry: an entry can wait on its +successor's first stamp with no deadline yet, and Recover's entry lives in +the serving copy's index (a different track from the one holding the +group), so it moves when the route changes. + Tests: - A mocked-time regression test that counts wakes: N parked serves, one @@ -39,5 +54,13 @@ Tests: - Sweep `track_parked_read` in `rs/moq-net/benches/track.rs` over parked readers, so a per-append cost that grows with N shows up as a slope. - `just test interop --all` passes the Python and Go publisher rows on CI. +- #4950's `a_group_no_route_continues_wakes_when_its_successor_is_stamped` + in `resume.rs`: the successor's first timestamp wakes the held group, + which then reads `Error::Old`. It fails today; extend it to the successor + aborting and a newer group stamping the edge. Public API: none. Wire: none. + +## Closes + +- [#4950](https://github.com/moq-dev/moq/issues/4950) - a group held across a route switch is not woken when its successor gets its first timestamp diff --git a/quest/m0/ietf-location-filter-22.md b/quest/m0/ietf-location-filter-22.md new file mode 100644 index 0000000000..041d41e639 --- /dev/null +++ b/quest/m0/ietf-location-filter-22.md @@ -0,0 +1,56 @@ +# [S] Draft-22 LOCATION_FILTER carries its type + +## Goal + +On moqt-22, LOCATION_FILTER (0x21) is encoded and decoded the way draft-22 +section 9.20.9 defines it, in both `moq-net` and `@moq/net`: no Length, a +Location Filter Type varint, then only the fields that type names. A draft-22 +peer reads our default Next Object as Next Object, not as an absolute start +at `{0, 0}`. Drafts 20 and 21 keep the length-inferred form. + +## Plan + +Verified 2026-10-06 against draft-ietf-moq-transport-22: the changelog lists +this as the draft's only wire change (moq-wg/moq-transport#1913, #1953). Types: +0x00 none, 0x01 relative StartGroup, 0x02 absolute start, 0x03 adds +EndGroupDelta, 0x04 adds EndObject, 0x05 Next Object. Any other type is a +PROTOCOL_VIOLATION. + +Decided 2026-10-06: + +- m0, ahead of Seattle interop on 2026-10-12: today a draft-22 peer silently + reads the whole track, and every other form mis-frames the parameters + after it. +- Rust and JS in one PR, since both share the bug and only talk to each + other in `test/interop`. +- Gate on a separate draft-22 check. `is_draft20` / `isDraft20` also gate + fill, INCLUDE_PROPERTIES and joins, which did not change. + +Where: + +- Rust: `rs/moq-net/src/ietf/filter.rs`, `Param for Filter` and the + FILL_PARAMETERS scope in `Param for Fill`. Every 0x21 use goes through + `Param for Filter`. +- JS: `js/net/src/ietf/parameters.ts` frames 0x21 as length-prefixed bytes + because its id is odd, stores them raw, and `filter.decode` parses them + later. On draft-22 the decoder must read the type and its fields straight + from the stream, so the stored value changes too; `decodeInline` in + `filter.ts` is the pattern. This is the opposite of the Range Filters' + exception, which adds a Length to even ids. `encodeFill` hard-codes + `encodeLengthPrefixed` for 0x21, and `FILL_ALLOWED` maps it as `"bytes"`. +- The spec still says a "zero-length" LOCATION_FILTER inside FILL_PARAMETERS + means the whole track (section 3.4). On draft-22 that is type 0x00. + +Tests: byte vectors from the draft-22 figure for every type in both +languages, including `21 05` for Next Object and an unknown type refused. +Update the tests that pin the length-prefixed bytes on Draft22 (Rust +`fetch.rs`, which decodes `21 03 04 00 02` for drafts 20 to 22 in one loop +that Draft22 must leave, and `subscribe.rs`; JS `ietf.test.ts`, +`filter.test.ts`). +`test/interop` cannot catch this, since every client shares the codec. + +Public API: none. Wire: moqt-22 LOCATION_FILTER changes to the draft's form. + +## Closes + +- [#4847](https://github.com/moq-dev/moq/issues/4847) - draft-22 LOCATION_FILTER should drop the Length and carry the Location Filter Type diff --git a/quest/m1/README.md b/quest/m1/README.md index 98f4f6cb5a..8691442955 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -87,6 +87,7 @@ blocks. The quests that gated m0 lines moved under them. - [Draft-20 FETCH](/quest/m1/ietf-fetch-location.md) - a draft-20+ FETCH within one group is served from its LOCATION_FILTER, as older drafts are - [FETCH_OK properties](/quest/m1/fetch-ok-properties.md) - our FETCH_OK carries the track properties SUBSCRIBE_OK does, as draft 16+ requires - [Fetch without SUBSCRIBE](/quest/m1/ietf-fetch-only.md) - a relay fetches from an IETF upstream without subscribing, finished tracks included, with End of Track always reported +- [IETF object gaps](/quest/m1/ietf-object-gaps.md) - a gapped object ID is refused loudly like a subgroup in Rust and JS, and an overflowing one closes the session in Rust - [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust - [Datagrams are live-only](/quest/m1/datagram-unfetchable.md) - no FETCH, replay to a new subscriber, or cache fill ever returns a datagram group - [JavaScript FETCH](/quest/m1/js-fetch.md) - browser publishers answer IETF FETCH through the JS ranges request surface @@ -131,6 +132,7 @@ blocks. The quests that gated m0 lines moved under them. - [Catalog rendition IDs](/quest/m1/catalog-track-id.md) - catalog rendition keys become IDs unique across video and audio, with an optional `track` name, so one catalog lists renditions from several broadcasts - [Media stats](/quest/m1/stats/README.md) - publishers announce a stats track in the catalog, viewers answer a soliciting catalog through a per-catalog `.echo` broadcast, and one model turns both into a health verdict and a preflight report - [JS track handover](/quest/m1/js-group-handover.md) - a JS track subscription resumes across a route swap from the first frame it lacks, so `test/drain` passes at zero latency budget +- [JS startup hole](/quest/m1/js-startup-hole.md) - a `@moq/hang` consumer delivers its first live group at once, even when an empty group died before it - [JS GOAWAY requests](/quest/m1/js-goaway-requests.md) - after GOAWAY the JS client opens no new request on the old session, like Rust - [Drain handshakes](/quest/m1/drain-handshakes.md) - a drain GOAWAYs and waits for sessions still in their handshake instead of exiting under them - [Transport upgrade](/quest/m1/transport-upgrade/README.md) - a session that came up over WebSocket moves to QUIC once the QUIC dial lands, handing over without dropping a group diff --git a/quest/m1/catalog-enabled.md b/quest/m1/catalog-enabled.md index 704af18d4f..1847fd27c6 100644 --- a/quest/m1/catalog-enabled.md +++ b/quest/m1/catalog-enabled.md @@ -50,7 +50,15 @@ Decided (2026-10-04): Selection in `js/watch/src/video/source.ts` filters on it, as [rendition preference](/quest/m1/rendition-preference.md) expects. -Tests: disabling an audio rendition publishes a catalog with the same +- Nothing edits a rendition before its config is published (decided + 2026-10-06, #4945). Today demand on a video track before its first + keyframe lets `publish_stalled` (`rs/moq-mux/src/codec/video.rs`) call + `track.modify()` on an unpublished rendition, and `moq import ts` exits + with `NotPublished`. Deleting the detector removes that path; whatever + writes `enabled` must not bring it back. + +Tests: demand on a video track before its first keyframe does not end +the import. Disabling an audio rendition publishes a catalog with the same rendition and `enabled: false`, and a viewer deselects it and keeps one AudioContext across disable and enable; a legacy `stalled: true` changes nothing; nothing in the tree writes `stalled`. @@ -59,6 +67,7 @@ nothing; nothing in the tree writes `stalled`. - [#4772](https://github.com/moq-dev/moq/issues/4772) - close this issue when the quest finishes - [#4776](https://github.com/moq-dev/moq/issues/4776) - close this issue when the quest finishes +- [#4945](https://github.com/moq-dev/moq/issues/4945) - a replacement `moq import ts` exits with "rendition is not published"; the relay half is fixed by #4942's epochs ## Related diff --git a/quest/m1/ietf-object-gaps.md b/quest/m1/ietf-object-gaps.md new file mode 100644 index 0000000000..da389f3c4f --- /dev/null +++ b/quest/m1/ietf-object-gaps.md @@ -0,0 +1,41 @@ +# [S] A gapped IETF object ID is refused like a subgroup + +## Goal + +A moq-transport group whose object IDs skip (legal per draft-22 section +11.3.1, but not representable in the model) is refused the same way a +non-zero subgroup is: the group aborts, a warning is logged, and the stream +is stopped with the error, in Rust and JS. An object ID that overflows +closes the session with PROTOCOL_VIOLATION in Rust, as the draft requires. + +## Plan + +Decided 2026-10-06, while declining object-level identity in #4926: the +model is the product, so subgroups, gapped object IDs, and arbitrary object +properties stay unsupported. Refusals must be loud and consistent. + +Facts from `main`: + +- Rust: `next_object_id` in `rs/moq-net/src/ietf/subscriber.rs` warns and + returns `Unsupported`, but `recv_group` catches it, logs at debug, aborts + the producer, and returns `Ok(())`, so no STOP_SENDING carries the reason. + A non-zero subgroup returns `Err`, which the session's `stop_on_error` + turns into STOP_SENDING. An overflowing object ID (`BoundsExceeded`) is + aborted with the group the same way. +- A data stream's error never reaches the session: `stop_on_error` in + `rs/moq-net/src/ietf/session.rs` only aborts the stream, even for a + protocol violation. Closing the session on overflow needs a new route from + the subgroup task to session teardown, like `run_subscribe_namespace`'s. +- JS: `Frame.decode` in `js/net/src/ietf/object.ts` throws on a gap, and + `handleGroup` in `subscriber.ts` stops the stream without logging. + +Neither closes the session for a gap; that would punish a peer following +the spec. JS refuses any non-zero delta before adding it, so it has no +overflow to close on; the overflow case is Rust-only. + +Tests: a gapped group yields `Err(Unsupported)` from `recv_group` and +stops its stream, while the subscription keeps flowing (beside +`a_non_zero_subgroup_leaves_the_track_flowing`); an overflowing object ID +closes the session (Rust). + +Public API: none. Wire: none. diff --git a/quest/m1/js-startup-hole.md b/quest/m1/js-startup-hole.md new file mode 100644 index 0000000000..159cfb94e0 --- /dev/null +++ b/quest/m1/js-startup-hole.md @@ -0,0 +1,44 @@ +# [S] A JS consumer's first group is never held behind a hole + +## Goal + +`@moq/hang`'s container `Consumer` delivers the first group that carries +frames as soon as it arrives, whatever died before it. Today, if the first +group dies before a frame is read and the next arriving group is not adjacent +(16 reset, then 18), `next()` delivers nothing until a later group lets +`#checkMaxAge` skip, which can cost a whole group (2.5 s of video) before the +first picture after a reattach. + +## Plan + +Root cause (verified 2026-10-06): a hole only exists relative to presented +content, but `next()`'s promotion guard in +`js/hang/src/container/consumer.ts` needs `maxAge === 0` or a defined +`#presentedEnd` to promote a head that is not contiguous with `#active`. +Two paths reach it with nothing presented: + +- The reporter's: the first group is reset empty and `#runGroup`'s finally + moves `#active` to `sequence + 1` with nothing buffered. +- An empty group adjacent to the dead first group closes cleanly, and + `next()`'s done branch advances `#active` again (16 open, 17 empty and + closed, 16 reset, then 19). + +Decided 2026-10-06: fix it in the promotion guard, the one place every path +converges, not in each site that moves `#active` (the reporter's finally +fix misses the second path). While nothing has been presented, the head is +promotable. Keep this out of `skipHole`, so startup reports no +discontinuity, matching the "start with the first group" policy. + +Open for the implementer: which field means "nothing presented". +`#presentedEnd` can stay undefined after frames were delivered when +`#checkMaxAge` drops the delivered group without `#recordPresented`; +`#deliveredGroup === undefined` may be the sounder predicate. + +Tests: both paths above, as bun tests with mocked or flushed time, deliver +the live group from its keyframe with no warning. + +Public API: none. Wire: none. + +## Closes + +- [#4951](https://github.com/moq-dev/moq/issues/4951) - a consumer whose first group dies empty parks its cursor on a group that never comes