Skip to content

Draft: Support draft-18#176

Draft
englishm wants to merge 33 commits into
mainfrom
draft-18-dev
Draft

Draft: Support draft-18#176
englishm wants to merge 33 commits into
mainfrom
draft-18-dev

Conversation

@englishm

Copy link
Copy Markdown
Member

Work in progress; may yet be rebased or rewritten.

Updates/bugfixes for draft-18 support should target this branch (draft-18-dev), not main.

Automatically deployed to moqt://draft-18-interop.cloudflare.mediaoverquic.com:443.

itzmanish and others added 29 commits June 8, 2026 09:33
Replace Announce/Announced types with PublishNamespace/PublishedNamespace
throughout moq-transport, moq-relay-ietf, moq-pub, moq-clock-ietf, and
moq-test-client. The wire messages were already named correctly; this
aligns the session-layer API and test case names with the spec terminology.

Also replace .lock().unwrap() on mutex acquisitions with explicit poison
error handling in publisher.rs and subscriber.rs.
Key-Value-Pairs now use delta-encoded types per §1.4.2: each Delta Type
on the wire is the difference from the previous absolute type (or 0 for
the first pair in a sequence). KeyValuePairs::encode sorts by ascending
key before emitting deltas; decode accumulates the running type and
rejects overflow. ExtensionHeaders shares the same delta scheme,
threading prev through its own encode/decode loop.

The Decode/Encode trait impls on KeyValuePair are removed to prevent
context-free single-pair misuse. All callers must go through
decode_with_prev/encode_with_prev, or the KeyValuePairs/ExtensionHeaders
containers which manage the prev state correctly.

TrackNamespace now enforces §2.4.1: 1-32 non-empty fields for full
namespaces; zero-count or zero-length fields produce errors. Adds
TrackNamespacePrefix (0-32 fields, empty allowed) for SUBSCRIBE_NAMESPACE
use, and full_track_name_len() helper for the 4096-byte limit check.

New error variants added to DecodeError and EncodeError for each new
validation rule.
Version negotiation now uses ALPN only. The native QUIC ALPN identifier
changes from 'moq-00' to 'moqt-16'. CLIENT_SETUP and SERVER_SETUP no
longer carry a version list or selected version in their payloads; both
messages carry setup parameters only.

For native moqt:// connections the client now sends AUTHORITY (host:port)
and PATH (path?query) setup parameters as required by draft-16 §9.3.1.1
and §9.3.1.2. The server-side version negotiation code and
largest_common helper are removed since version is agreed at the ALPN
layer.

Adds Version::DRAFT_16 constant. Removes SessionError::Version as it is
no longer reachable. Updates mlog events to match the new setup message
shape.
…ge length

Adds three new draft-16 message types:
- REQUEST_OK (0x07): shared positive response for REQUEST_UPDATE,
  TRACK_STATUS, SUBSCRIBE_NAMESPACE, and PUBLISH_NAMESPACE
- REQUEST_ERROR (0x05): shared negative response with Request ID,
  Error Code, Retry Interval, and reason phrase; replaces per-request
  error messages from earlier drafts
- REQUEST_UPDATE (0x02): replaces SubscribeUpdate; carries
  Request ID, Existing Request ID, and parameters

Wire IDs in the message table are corrected to match draft-16 Table 1.
Legacy messages that had conflicting IDs (SubscribeError, PublishNamespaceOk,
PublishNamespaceError, TrackStatusOk/Error, etc.) are reassigned to
internal stub IDs (0x100+) so existing session dispatch compiles.

The control message decode macro now enforces the 16-bit Length field:
the payload is read into an exact-size slice and any remaining bytes
after decoding produce a PROTOCOL_VIOLATION error.
Add a session-level RequestId manager with independent send and receive state under one shared handle. Publisher and Subscriber share outbound allocation so local requests use one monotonically increasing sequence, while inbound validation checks the peer sequence separately.

Outbound allocation enforces the peer MAX_REQUEST_ID and emits REQUESTS_BLOCKED once per blocked limit. Incoming MAX_REQUEST_ID updates the outbound budget and must strictly increase. Incoming REQUESTS_BLOCKED increases the local advertised maximum and queues MAX_REQUEST_ID.

Session receive handling now validates sequenced request IDs and handles GOAWAY, MAX_REQUEST_ID, and REQUESTS_BLOCKED directly. Add SessionError variants for INVALID_REQUEST_ID, TOO_MANY_REQUESTS, and PROTOCOL_VIOLATION.
PUBLISH_NAMESPACE_DONE now carries Request ID (not namespace) per §9.22.
PUBLISH_NAMESPACE_CANCEL now carries Request ID (not namespace) per §9.24.

Acceptance of PUBLISH_NAMESPACE is now sent as REQUEST_OK (§9.7) instead
of the legacy PUBLISH_NAMESPACE_OK. Rejection is sent as REQUEST_ERROR
(§9.8) instead of the legacy PUBLISH_NAMESPACE_ERROR.

Session dispatch routes incoming REQUEST_OK and REQUEST_ERROR from the
subscriber to the PUBLISH_NAMESPACE state by request ID. RequestOk and
RequestError added to the subscriber role enum so published_namespace.rs
can send them.

PublishedNamespaceRecv now carries the request ID so PUBLISH_NAMESPACE_DONE
and CANCEL can be looked up without a namespace key. drop_publish_namespace
returns the removed recv value so callers can invoke recv_error or recv_done
directly on it.
Subscription rejections (before SUBSCRIBE_OK) now use REQUEST_ERROR
(draft-16 §9.8) instead of the legacy SUBSCRIBE_ERROR. The error code
defaults to InternalError since ServeError does not map directly to a
specific REQUEST_ERROR code at this layer.

Duplicate SUBSCRIBE (same request ID) now sends REQUEST_ERROR with
DUPLICATE_SUBSCRIPTION (0x19) per draft-16 §5.1 instead of closing the
session. This is a per-request error, not a session-level violation.

recv_subscribe_error is kept for exhaustive match but annotated as a
legacy stub — stub ID 0x100 is never produced by the wire decoder so
draft-16 subscription rejections only arrive as REQUEST_ERROR.
Add PUBLISH_DONE status code enum (§13.4.3) and map ServeError variants
to the correct codes. TrackEnded is used for normal completion,
InternalError for unexpected errors, and Closed passes through the
application-specific code.

Track the number of subgroup streams opened per subscription in
SubscribedState so PUBLISH_DONE reports an accurate stream_count.
Datagram-only subscriptions remain stream_count=0.

UNSUBSCRIBE now removes publisher-side subscription state immediately
and marks the subscription as unsubscribed. The Drop impl skips sending
PUBLISH_DONE or REQUEST_ERROR when unsubscribed is set, since the
subscriber already terminated and sending a terminal message would be
spurious.
respond_ok now sends REQUEST_OK (§9.7) with a LARGEST_OBJECT parameter
when objects have been published on the track. No Track Alias is included
since draft-16 §9.19 does not use one for TRACK_STATUS responses.

respond_error now sends REQUEST_ERROR (§9.8) instead of the legacy
TRACK_STATUS_ERROR. Callers updated to use RequestErrorCode values:
InternalError for the unknown-queue-full case, DoesNotExist for the
relay track-not-found case.
Draft-16 §10.2.1.1 only allows Normal (0x0), EndOfGroup (0x3), and
EndOfTrack (0x4). The value 0x1 existed in earlier drafts but was
removed. Any received 0x1 or other unknown value now returns
InvalidObjectStatus instead of silently succeeding.
Draft-16 §4: limited endpoints SHOULD respond with NOT_SUPPORTED rather
than ignoring incoming request types they do not implement.

Publisher-side: FETCH, REQUEST_UPDATE, and SUBSCRIBE_NAMESPACE now each
send REQUEST_ERROR NOT_SUPPORTED back to the peer instead of returning a
session-closing error. FETCH_CANCEL and UNSUBSCRIBE_NAMESPACE reference
existing requests and are logged then ignored.

Subscriber-side: PUBLISH (publisher-initiated subscription) now sends
REQUEST_ERROR NOT_SUPPORTED. Legacy response stubs that can never arrive
from a draft-16 peer are logged and ignored instead of closing the session.
Update metric description strings in moq-relay-ietf to reference
PUBLISH_NAMESPACE and REQUEST_OK instead of ANNOUNCE/ANNOUNCE_OK.
Metric names themselves are unchanged to avoid breaking existing
dashboards and alerting rules.

Update the RawQuic transport doc comment to reflect the correct
ALPN string moqt-16 instead of the old moq-00.
- Move filter_type, subscriber_priority, group_order, and forward
  from message fields into SUBSCRIBE parameters per draft-16
- Add SubscriptionFilter parameter encoding/decoding
- Introduce TrackName newtype to replace bare String track names
- Simplify SubscribeOk and TrackStatus to draft-16 field sets
- Add DeliveryFilter for applying subscription filters to objects
  and datagrams at serve time
- Reject duplicate subscriptions by FullTrackName on the publisher
- Fix subgroup object ID delta computation and defer subgroup writer
  creation until the first object is received
- Add Namespace and NamespaceDone message variants
Conflict resolution during rebase incorrectly kept the old
Subscriber::new(Queue, Arc<AtomicU64>, None) form instead of
the new Subscriber::new(Queue, Option<mlog>, RequestId) signature.
Fix the test helper and its import to match.
Replace the single hardcoded ALPN constant with a version registry
(SUPPORTED_ALPNS) and a negotiate_version() function that selects the
best mutually-supported MoQT version.

Server-side (WebTransport): instead of silently accepting connections
with no WT-Protocol header when the client's offered versions don't
match, we now select the best mutual version or reject the connection
with a clear error. This fixes interop with multi-version clients
like moxygen that rely on WT-Protocol to know which draft was
negotiated (facebookexperimental/moxygen#173).

Server-side (raw QUIC): ALPN is now validated against the full
SUPPORTED_ALPNS list instead of a single constant.

Client-side (WebTransport): all supported versions are offered via
WT-Available-Protocols instead of just one.

The ALPN constant is preserved for backward compatibility but
SUPPORTED_ALPNS is now the canonical source of truth.
Implement the new variable-length integer encoding defined in
draft-ietf-moq-transport-17 §1.4.1, replacing the QUIC-style 2-bit
tag encoding.

The new encoding counts leading 1-bits in the first byte to determine
length (1-9 bytes), supports the full u64 range (vs 2^62-1 before),
and explicitly allows non-minimal encodings.

The VarInt public API is preserved: from_u32(), into_inner(), all
From/TryFrom impls. VarInt::MAX is now u64::MAX. TryFrom<u64> can
no longer fail (all u64 values are representable).

Test vectors from the draft spec are included. Existing tests across
the codebase are updated for the new byte patterns — values 64-127
now encode as 1 byte instead of 2, which changes encoded sizes for
extension headers, datagrams, and KVP delta keys in that range.
Replace separate CLIENT_SETUP (0x20) and SERVER_SETUP (0x21) messages
with a single unified SETUP message (type 0x2F00) as defined in
draft-ietf-moq-transport-18 §10.3. Both peers now send the same
message format.

Key changes:
- New Setup struct replaces Client and Server in setup module
- KVP decode_bounded()/encode_bounded(): length-bounded KVP encoding
  without count prefix, as required by draft-18 Setup Options
- ALPN bumped from moqt-16 to moqt-18
- DRAFT_18 version constant added (0xff000012)
- Message type 0x2F00 doubles as stream type identifier for the
  control stream (used in Commit 3 for uni stream dispatch)

The control stream still uses bidirectional streams in this commit;
the switch to unidirectional streams follows in the next commit.
…ft-18)

Replace the single bidirectional control stream with a pair of
unidirectional streams as defined in draft-ietf-moq-transport-18 §3.2.

Each peer independently opens one uni stream (to send SETUP and
subsequent control messages) and accepts one uni stream (to receive
the peer's SETUP and control messages). The SETUP message type
(0x2F00) serves as the stream type identifier on the wire.

Connect flow: open_uni → write SETUP → accept_uni → read peer SETUP
Accept flow: open_uni → accept_uni → read peer SETUP → write SETUP

This is the final piece needed for a draft-18 SETUP exchange.
@englishm englishm marked this pull request as draft June 11, 2026 13:05
englishm and others added 4 commits June 12, 2026 09:44
Draft-18 removed MAX_REQUEST_ID and REQUESTS_BLOCKED entirely (spec
change #1471). This caused interop failures: third-party relays
(imquic, moqt-nr) that don't send MAX_REQUEST_ID left peer_max=0,
blocking all outbound request ID allocation with 'too many requests'.

Changes:

- session/request_id.rs: Remove peer_max tracking, Blocked variant,
  apply_max_request_id(), handle_requests_blocked(), and
  max_request_id_from_params(). RequestId::new() takes only
  local_first_id and peer_first_id. allocate() always succeeds
  (returns u64 directly, no Blocked variant).

- session/mod.rs: Stop sending MAX_REQUEST_ID in SETUP params (both
  client and server). Remove Message::MaxRequestId and
  Message::RequestsBlocked handling from run_recv().

- session/publisher.rs, session/subscriber.rs: Simplify request ID
  allocation to use the now-infallible allocate() directly.

- setup/param_types.rs: Remove MaxRequestId variant (type 0x2 is
  not in the draft-18 Setup Options registry).

- message/mod.rs: Remove MaxRequestId (0x15) and RequestsBlocked
  (0x1a) from the message type enum and re-exports.

- session/subscriber.rs: Map REQUEST_ERROR codes to semantic
  ServeError variants (DoesNotExist -> NotFound, etc.) instead of
  the opaque Closed(code). Fixes subscribe-error test producing
  'closed, code=16' instead of a 'not found' error.

Includes cargo fmt reformatting of touched and adjacent files.
…raft-18)

Draft-18 moved all request messages (SUBSCRIBE, PUBLISH_NAMESPACE, etc.)
from the control stream to individual bidirectional request streams
(#1389). Response messages (REQUEST_OK, REQUEST_ERROR, SUBSCRIBE_OK)
are sent back on the same bidi stream and omit the Request ID field
since the stream identity already associates them with the request.

Changes:

- session/subscriber.rs: Add webtransport session field. subscribe_open()
  now opens a bidi stream via open_bi(), writes SUBSCRIBE, and spawns a
  reader task that decodes draft-18 responses (no Request ID) and
  routes them through the existing recv_message dispatch.

- session/publisher.rs: publish_namespace() opens a bidi stream for
  PUBLISH_NAMESPACE and spawns a response reader with the same
  draft-18 decoding.

- session/subscribe.rs: Add new_without_send() and wire_message() so
  the caller controls transport (bidi stream vs control stream).

- session/publish_namespace.rs: Same new_without_send()/wire_message()
  pattern.

- Both publisher.rs and subscriber.rs include decode_bidi_response()
  that handles the draft-18 response framing: reads type+length
  envelope, then decodes REQUEST_ERROR (0x5), REQUEST_OK (0x7), and
  SUBSCRIBE_OK (0x4) payloads without a Request ID field.

The relay server-side still uses the control-stream path (Subscribe::new,
PublishNamespace::new) for incoming requests from peers — the relay-side
bidi request handling is a separate follow-up.
…draft-18 relay)

Draft-18 requires peers to send request messages (SUBSCRIBE,
PUBLISH_NAMESPACE, etc.) on individual bidirectional streams (#1389).
The relay accepts these bidi streams, dispatches requests through
existing handlers, and sends responses back on the same stream with
the Request ID field omitted.

Architecture:

- BidiResponseMap: HashMap<u64, mpsc::UnboundedSender<Message>> shared
  between run_send and run_bidi_requests. Responses are forwarded via
  channel (not written directly) to ensure the bidi stream Writer
  stays in the same task context that accepted the stream — required
  for Quinn to properly transmit the QUIC STREAM frames.

- handle_bidi_request: accepts bidi stream, reads request, registers
  channel, dispatches to handlers, then loops on rx.recv() writing
  responses via encode_bidi_response (no Request ID per draft-18).
  Terminal responses (REQUEST_ERROR, PUBLISH_DONE) include a zero-
  duration sleep before dropping the Writer to give Quinn's connection
  driver a scheduling opportunity to transmit the data before FIN.

- encode_bidi_response: encodes all response types without Request ID.

- RequestId::validate_incoming: relaxed to parity + duplicate check
  (no strict sequential order) for concurrent bidi stream arrival.

- Message::response_target_id: identifies response messages for
  redirect from the outgoing queue to bidi channels.

- Writer::finish: explicit FIN for clean stream closure.

Test results:
- Self-test (our client → our relay): 6/6
- imquic client → our relay: 6/6
- Our client → moqt-nr relay: 6/6
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