Skip to content

feat: MOQT draft-21 support (subscriber, player, publisher) - #20

Closed
mondain wants to merge 15 commits into
mainfrom
feature/draft-21
Closed

mondain wants to merge 15 commits into
mainfrom
feature/draft-21

Conversation

@mondain

@mondain mondain commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR adds MOQT draft 21 (0xff000015, ALPN / WebTransport protocol moqt-21) next to drafts 14, 16 and 18. Draft 21 keeps the draft-18 stream model (unified SETUP, one bidirectional stream per request, vi64 integers), so it rides on the draft-18 codec and topology with version checks where the wire or the rules differ. new MoqtConnection(21) or draftVersion: 21 opts in; a negotiated moqt-21 is detected like the other tokens.

Playback works end to end at draft 21 against red5-moq-relay with moqxr publishing (see Testing).

@moqt/transport

  • Versions: DraftVersion includes 21. isRequestStreamDraft() replaces the === 18 checks, and isDraft21() gates what is new.
  • Control codec:
    • LOCATION_FILTER (0x21) is 0-4 bare vi64 fields whose count is set by the length. SubscriptionFilter gains RelativeStart and an inclusive AbsoluteRange.endObject.
    • FETCH carries its range in LOCATION_FILTER. Draft 21 has no Joining FETCH, so encoding one throws.
    • GOAWAY has no Request ID. PUBLISH_STATE_NOTIFY (0x22) is added.
    • Draft-21 parameter scopes, and the new request and session error codes.
  • Vectors: the draft-21 wire vectors shared with red5-moq-relay and moqxr are in vectors/d21/. Every control vector decodes and re-encodes byte for byte.
  • Session:
    • fills on SUBSCRIBE and REQUEST_UPDATE (FILL_PARAMETERS 0x23), tracked by the Request ID that asked for them
    • PUBLISH_STATE_NOTIFY advances the subscription's Largest Location
    • MAX_REQUEST_UPDATES: advertised through SetupOptions.maxRequestUpdates, and the peer's limit is never exceeded
    • concurrent subscriptions to one track (draft 21 drops DUPLICATE_SUBSCRIPTION)
    • PUBLISH_DONE's unknown Stream Count is 2^64-1
  • Data plane:
    • End of Timed-Out Range (0x20C) is accepted on draft 21
    • subscriptionWindow() / windowContains() resolve a filter against the Largest Object

@moqt/webtransport

  • Fill streams:
    • A FETCH_HEADER that carries a fill's Request ID is routed as that subscription's fill. onDataStream reports it with fill: true.
    • There is no FETCH_OK: the FIN completes the fill.
    • cancelFill() stops one fill, or every fill of a subscription, without touching the subscription.
    • On the publisher side, openFillStream() serves a fill for a SUBSCRIBE or a REQUEST_UPDATE, and ending the subscription resets its fills.
  • MAX_REQUEST_UPDATES: a peer that queues more unanswered updates than we advertised closes the session with TOO_MANY_REQUEST_UPDATES.
  • Shared Track Alias (§3.1): subscribeTrack() subscriptions that share an alias each get the objects their own filter selects, once each. Ending one of them (unsubscribe, peer close or PUBLISH_DONE) leaves the others running.
  • Codecs: the adapter and the request-stream topology pick their codecs by the negotiated draft. They no longer always use draft 18.

@moqt/player

  • The MSF-01 catalog bootstrap asks for the current group (LOCATION_FILTER [1]) as a fill on the catalog SUBSCRIBE, instead of sending a Joining FETCH. The fill is the prefix.
    • If SUBSCRIBE_OK has no Largest Object, the track is empty and no fill will open (§3.4), so the first live catalog object completes the bootstrap.
    • Warm start and staged recovery use fills too.
  • On draft 21, PUBLISH_DONE 0x3 is unknown (SUBSCRIPTION_ENDED is gone).
  • LOC uses the draft-18 property wire profile.

Examples

  • The broadcast publisher accepts draft 21. It already follows the draft-18 stream model there (FIRST_OBJECT, draft-18 LOC profile).

@moqt/quic

  • connectQuic(uri, { draft: 21 }) offers moqt-21. The adapter selects the session draft from the negotiated ALPN.

Fix for all drafts: End-of-Range markers

FETCH End-of-Range markers (0x8C / 0x10C, and 0x20C on 21) end after the Object ID; they have no Object Payload Length. moxygen, LibMoQ and red5-moq-relay all encode them that way, and so does the shared draft-21 vector. Playa wrote and required a trailing zero length, so it misread their markers and they misread Playa's. Figure 28 of the draft leaves the length unbracketed, so the draft text on its own is ambiguous; this follows the other implementations. Because of this fix, the LibMoQ fetch_eor_* vectors now pass. The draft-18 fetch_eor.bin golden vector is regenerated.

Testing

  • pnpm -r build, pnpm -r typecheck, npx vitest run: 227 files, 6154 tests passing.
  • New tests:
    • draft-21 codec and vector tests
    • session tests (fills, MAX_REQUEST_UPDATES, PUBLISH_STATE_NOTIFY, GOAWAY)
    • adapter tests (fill routing on SUBSCRIBE and REQUEST_UPDATE, publisher-side fills, shared aliases, the update limit, native QUIC at 21)
    • catalog-coordinator tests
    • a draft-21 player loopback test (catalog from a fill, empty track, reset fill)
  • End to end, draft 21 only (red5-moq-relay e2e/moqxr-playa, moqxr publishing at moqt-21 through the relay to a Playa subscriber at moqt-21):
    • run.sh: moqxr -> relay -> Playa subscriber. Playa takes the catalog from the SUBSCRIBE fill when the track already has content; when it is still empty at SUBSCRIBE, the catalog comes on the subscription itself.
    • run-cluster.sh: moqxr -> origin relay -> edge relay -> Playa. The relays peer over native QUIC at moqt-21.
    • run-playa-loc.sh: Playa's broadcast LOC publisher -> relay -> Playa subscriber. Every media object is LOC-verified.
    • run-backpressure.sh: a slow-link Playa viewer is cut with PUBLISH_DONE TOO_FAR_BEHIND (0x5 on draft 21) and recovers by resubscribing.

Not in this PR

  • Range filters (0x25-0x29) are decoded and validated but cannot be sent yet, and MAX_FILTER_RANGES is not advertised.
  • The gap-probe e2e tool pins its own npm copy of @moqt/webtransport and has no draft 21 yet.

This change is Reviewable

…am model

Draft 21 keeps draft 18's unified SETUP, uni control pair and per-request
bidi streams. DraftVersion gains 21 and isRequestStreamDraft() (18 and
later) replaces the raw draft-18 checks in the session, adapter, player,
topology and examples, so draft 21 inherits the draft-18 behavior. The
draft-18 control and data codecs take their version, moqt-21 maps to 21,
and the browser, playa, player and example types accept it. The draft-21
message changes follow.
…ectors

The draft-21 codec is the draft-18 one with its own parameter table
(LOCATION_FILTER 0x21, FILL_PARAMETERS 0x23, the range filters 0x25-0x29,
INCLUDE_PROPERTIES 0x35) and the draft-21 message changes: FETCH carries
its range as a LOCATION_FILTER (a StandaloneFetch maps onto it; there is
no Joining FETCH), GOAWAY has no Request ID, and PUBLISH_STATE_NOTIFY
(0x22) is new. On draft 21 the 0x21 subscription filter is a
LOCATION_FILTER, with RelativeStart and an inclusive AbsoluteRange end
object added to the model; encodeFillParameters builds FILL_PARAMETERS.
RequestError21 and SessionError TOO_MANY_REQUEST_UPDATES are added.

vectors/d21 holds the draft-21 wire vectors shared with red5-moq-relay
and produced or verified with moqxr; every control vector round-trips.
…STATE_NOTIFY

Draft 21 validates message parameters against its own scope table (the
same matrix as red5-moq-relay): PUBLISH_OK carries only EXPIRES,
LOCATION_FILTER also rides FETCH and PUBLISH_STATE_NOTIFY, FILL_PARAMETERS,
the range filters (which may repeat) and INCLUDE_PROPERTIES are known.
A control-stream GOAWAY no longer needs a Request ID. SubscribeOptions.fill
sends FILL_PARAMETERS and marks the subscription as expecting a fill
stream. PUBLISH_STATE_NOTIFY advances the subscription's Largest Location;
one crossing our cancellation is ignored.
Route a FETCH_HEADER carrying a fill-requesting SUBSCRIBE's Request ID as
that subscription's fill stream (one per request; FIN completes it without
finishing the SUBSCRIBE). cancelFill discards a pending or open fill.
PUBLISH_STATE_NOTIFY is accepted on an established SUBSCRIBE stream and
refused elsewhere. The request-stream topology and adapter now pick the
control and data codecs by the negotiated draft instead of always 18, and
fetch flag 0x20C (End of Timed-Out Range) is accepted on draft 21.
subscribeTrack passes the fill option through.
The FETCH End of Range marker (0x8C / 0x10C, and 0x20C on draft 21) ends
after its Group ID and Object ID. moxygen, LibMoQ and red5-moq-relay all
encode it that way, as does the shared draft-21 vector
fetch_object_end_of_timed_out_range_g2_o4; Playa wrote and required a
trailing zero length, so it misread their markers and they misread its.
Applies to drafts 16, 18 and 21. The LibMoQ fetch_eor_* vectors now pass
and the draft-18 fetch_eor.bin golden vector is regenerated.
Draft 21 has no Joining FETCH. On 21 the catalog SUBSCRIBE (and a staged
recovery candidate's) carries FILL_PARAMETERS for the current group, and
the fill fetch stream, which carries the SUBSCRIBE's Request ID, is the
MSF-01 prefix: there is no FETCH_OK, so the coordinator's FETCH_OK gate is
satisfied when the fill is registered and the FIN completes it. Retiring a
fill cancels the fill, never the subscription. Warm start asks for a fill
on the media SUBSCRIBE instead of a joining FETCH.

PUBLISH_DONE 0x3 (SUBSCRIPTION_ENDED) no longer exists on 21 and is
treated as unknown. LOC uses the draft-18 property wire profile on 21.

MoqtConnection.openFillStream serves a fill on the publisher side, used by
the new draft-21 player loopback test.
…an empty catalog

A fill range never extends past Largest Object and one that starts after
it opens no fill stream (draft-21 section 3.4), so a catalog SUBSCRIBE_OK
without LARGEST_OBJECT means no fill will arrive. Resolve the attempt as
an empty track, as INVALID_RANGE does for a Joining FETCH, so the first
live head completes the bootstrap instead of the attempt timing out into
the fallback ladder.
…21 over native QUIC

MAX_REQUEST_UPDATES (section 9.1.7): SetupOptions.maxRequestUpdates is
advertised on draft 21, requestUpdate() never exceeds the peer's limit of
unanswered updates on one request, and a peer that queues more than ours
on a pending SUBSCRIBE closes the session with TOO_MANY_REQUEST_UPDATES.

Fills on REQUEST_UPDATE (section 3.4): requestUpdate() takes a fill
option. The session tracks every expected fill by the Request ID that
asked for it, so a fill stream carrying a REQUEST_UPDATE's Request ID
reaches its subscription, a refused update opens none, and cancelFill()
on a subscription stops all of its fills. The publisher side accepts
FILL_PARAMETERS on an inbound update (openFillStream with the update's
Request ID) and resets a subscription's update fills when it ends.

@moqt/quic takes draft: 18 | 21 and offers moqt-21 for 21; the adapter
selects the session draft from the negotiated ALPN.
… Aliases

Draft 21 drops the one-subscription-per-track rule (DUPLICATE_SUBSCRIPTION
is gone, section 3.1), so the session admits concurrent SUBSCRIBEs and
PUBLISHes for a track on 21. A publisher may give such subscriptions one
Track Alias; the subscriber re-applies each subscription's filter. The
adapter now keeps every subscribeTrack() sharing an alias in a group:
an object reaches each member whose window (its filter resolved against
SUBSCRIBE_OK's Largest Object) contains it, once per member, and one
member's unsubscribe, peer close or PUBLISH_DONE detaches it without
tearing down the alias the others still use.

subscriptionWindow() and windowContains() resolve and test a filter's
Location window.
The broadcast media publisher refused draft 21 before publishing. Its
draft-dependent behavior already follows the draft-18 stream model on 21
(FIRST_OBJECT subgroup bit, draft-18 LOC wire profile), so 21 joins the
supported list.
@mondain
mondain requested a review from rwl4 September 28, 2026 02:33
@rwl4

rwl4 commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

Thanks! Your commits (and some of my updates) are now in feature/draft-21.

@rwl4 rwl4 closed this Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants