Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion quest/m0/expiry-wakes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -32,12 +33,34 @@ 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
append, and only the crossed serves wake. It fails today.
- 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
56 changes: 56 additions & 0 deletions quest/m0/ietf-location-filter-22.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion quest/m1/catalog-enabled.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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

Expand Down
41 changes: 41 additions & 0 deletions quest/m1/ietf-object-gaps.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions quest/m1/js-startup-hole.md
Original file line number Diff line number Diff line change
@@ -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
Loading